diff --git a/dev-docs/API.md b/dev-docs/API.md index b3457dce..ec8508f7 100644 --- a/dev-docs/API.md +++ b/dev-docs/API.md @@ -776,6 +776,219 @@ fields never leak to non-admins. `TAG_PICKER` fields and resolves `label` / `help_text` / `options` through `FormLocaleResolver` + the `translations` JSON column. +## Form Builder (Public) + +S2c contract for the unauthenticated portal. Six endpoints at +`/api/v1/public/forms/*`, every one rate-limited via the +`throttle:30,1` middleware and served behind +`PublicFormTokenResolver` (7-day grace window on +`public_token_previous`; BACKLOG FORM-04 makes this configurable). + +All errors use the standardised envelope: + +```json +{ "message": "Human text", "code": "MACHINE_CODE", "errors"?: {"values.slug": ["..."]} } +``` + +Codes: `SCHEMA_NOT_FOUND` (404), `TOKEN_EXPIRED` (410), +`TOKEN_REVOKED` (410), `SCHEMA_UNPUBLISHED` (410), +`SUBMISSION_ALREADY_SUBMITTED` (409), `RATE_LIMITED` (429, carries +`Retry-After`), `VALIDATION_FAILED` (422). + +### `GET /public/forms/{public_token}` + +Returns `PublicFormSchemaResource`. Shape: + +```json +{ + "success": true, + "data": { + "id": "01HZ...", "name": "...", "slug": "...", + "purpose": "event_registration", + "locale": "nl", + "version": 3, + "opened_at": "2026-04-17T20:29:16+00:00", + "consent_version": null, + "submission_deadline": null, + "section_level_submit": false, + "sections": [], + "fields": [ + { + "id": "01HZ...", + "slug": "shirtmaat", + "field_type": "SELECT", + "label": "Shirtmaat", + "help_text": null, + "options": ["XS","S","M","L","XL","XXL"], + "available_tags": null, + "validation_rules": null, + "is_required": true, + "display_width": "half", + "conditional_logic": null, + "sort_order": 2, + "form_schema_section_id": null + }, + { + "slug": "vaardigheden", + "field_type": "TAG_PICKER", + "available_tags": [ + {"id": "01HZ...", "name": "EHBO", "category": "Veiligheid"}, + {"id": "01HZ...", "name": "Tapper", "category": "Horeca"} + ] + } + ] + } +} +``` + +Notes: +- `available_tags` is populated on `TAG_PICKER` fields only. Filter + via `form_fields.validation_rules.tag_categories` when set, else + returns every active `person_tag` for the org. +- `conditional_logic` references peers by `field_slug`. +- Admin-only / non-portal-visible fields are filtered out entirely. + +### `GET /public/forms/{public_token}/time-slots` + +`AVAILABILITY_PICKER` dependency data. Response: + +```json +{ + "data": [ + { + "id": "01HZ...", + "name": "Zaterdag ochtend", + "date": "2026-07-11", + "start_time": "08:00:00", + "end_time": "13:00:00", + "duration_hours": 5.0, + "event_id": "01HZ...", + "event_name": "Echt Feesten 2026 — Dag 2" + } + ] +} +``` + +- Volunteer-only: `person_type = 'VOLUNTEER'`. +- Festival-aware: parent + children surfaced; deduplication is by id. + +### `GET /public/forms/{public_token}/sections` + +`SECTION_PRIORITY` dependency data. Response: + +```json +{ + "data": [ + { + "id": "01HZ...", + "name": "Bar", + "category": "Horeca", + "icon": "tabler-beer", + "registration_description": "Tappen en serveren" + } + ] +} +``` + +- `show_in_registration = true AND type = 'standard'`. +- Festival-aware: children surfaced, deduplicated by name. + +### `POST /public/forms/{public_token}/submissions` — create draft + +Body: + +```json +{ + "idempotency_key": "01HZ...", + "opened_at": "2026-04-17T20:20:00+00:00", + "submitted_in_locale": "nl", + "public_submitter_name": "Anonieme tester", + "public_submitter_email": "test@example.nl" +} +``` + +- `idempotency_key` is REQUIRED (6–30 chars). Duplicate replay + returns the existing draft with `HTTP 200` instead of `201`. +- Response is a `PublicFormSubmissionResource` with + `status: "draft"` and `schema_version_at_open` stamped. + +### `PUT /public/forms/{public_token}/submissions/{submission_id}` — auto-save + +Body: + +```json +{ + "values": {"shirtmaat": "L", "vaardigheden": ["01HZ...", "01HZ..."]}, + "first_interacted_at": "2026-04-17T20:21:05+00:00" +} +``` + +- Partial updates allowed. Only slugs present in the body are + written; unrelated saved values stay intact. +- Relaxed rule set at the request layer (nullable + type check). The + service layer still enforces `validation_rules.min/max/regex/unique`. +- Every PUT increments `auto_save_count` and fires + `FormSubmissionDraftUpdated` on the domain event bus. +- 409 if the submission is not status=draft. 404 if + `submission_id` belongs to a different schema. + +### `POST /public/forms/{public_token}/submissions/{submission_id}/submit` — finalize + +Body: + +```json +{ + "values": {"opmerkingen": "final remark"}, + "captcha_token": "..." +} +``` + +- Merges the body with already-saved values and runs the strict rule + set against the merged map. Required fields must be present + somewhere (saved or in the body) or the server returns + `VALIDATION_FAILED`. +- Fires `FormSubmissionSubmitted` — triggers the §31.10 TAG_PICKER + sync and §31.1 identity-match listeners. +- Rate-limited per `(public_token, ip)` per hour. Exceed returns + `RATE_LIMITED` with `Retry-After` header. + +Response: + +```json +{ + "success": true, + "data": { + "id": "01HZ...", + "form_schema_id": "01HZ...", + "status": "submitted", + "auto_save_count": 4, + "submitted_in_locale": "nl", + "schema_version_at_submit": 3, + "schema_drift": false, + "values": { "shirtmaat": {"value": "L", "value_anonymised": false} }, + "identity_match": { + "status": "pending", + "message": "We controleren of je al bekend bent bij de organisator. Je gegevens worden gekoppeld zodra zij dit bevestigen." + }, + "opened_at": "...", + "first_interacted_at": "...", + "submitted_at": "...", + "submission_duration_seconds": 120, + "created_at": "...", + "updated_at": "..." + } +} +``` + +- `schema_drift` is true when `schema_version_at_open != + schema_version_at_submit` (organiser edited the schema during the + draft). +- `identity_match.status` is one of `null | pending | matched | none` + per ARCH §31.1. +- **No PII echo.** `public_submitter_name`, `public_submitter_email`, + `public_submitter_ip`, and `submitted_by_user_id` are never + included in the response. + ## Person List Filtering (extended) Additional filter parameters on `GET /organisations/{org}/events/{event}/persons`: diff --git a/dev-docs/ARCH-FORM-BUILDER.md b/dev-docs/ARCH-FORM-BUILDER.md index e3a01938..5f3e1f85 100644 --- a/dev-docs/ARCH-FORM-BUILDER.md +++ b/dev-docs/ARCH-FORM-BUILDER.md @@ -1,14 +1,15 @@ -# ARCH — Universal Form Builder (v1.2) +# ARCH — Universal Form Builder (v1.3) > **Source of truth** for Crewli's universal Form Builder architecture. > Any discrepancy with SCHEMA.md is resolved in favour of this document > during the refactor. SCHEMA.md is updated at the end of the refactor. > -> **Status:** Approved — S2b in progress (service + API layer) -> **Version:** 1.2.1 (§31.10 FORM-02 contract rewritten authoritatively) -> **Previous version:** 1.2 April 2026 — per-purpose lifecycles, integration -> contracts, user guidance principles, documentation coverage requirements, -> in-app copy catalogue, and concrete gap fills from v1.1 review +> **Status:** Approved — S2c landed (public API completion) +> **Version:** 1.3 (§10.4 public submission lifecycle — draft/save/submit +> split with error envelope and drift detection) +> **Previous version:** 1.2.1 April 2026 (§31.10 FORM-02 contract), +> 1.2 April 2026 (per-purpose lifecycles, integration contracts, user +> guidance principles, documentation coverage, in-app copy catalogue) > **Created:** April 2026 > **Owner:** Architecture doc; every session reads this before starting > @@ -1388,6 +1389,77 @@ When anonymised: IP replaced with `"[anonymised]"` and Exception: if submission is flagged for investigation (organiser marks it), anonymisation is paused until flag is cleared. +### 10.4 [v1.3] Public submission lifecycle — draft / save / submit split + +S2c split the atomic "one-POST does everything" flow into three +REST endpoints so the portal can auto-save drafts without firing +submit-events on every keystroke: + +``` +POST /api/v1/public/forms/{public_token}/submissions + Body: { idempotency_key (required, 6–30 chars), + opened_at?, submitted_in_locale?, + public_submitter_name?, public_submitter_email? } + Creates a draft. Returns PublicFormSubmissionResource. + Duplicate POST with the same idempotency_key returns the existing + draft as HTTP 200 (vs 201 for fresh). Race-safe via UNIQUE + (form_schema_id, idempotency_key). + +PUT /api/v1/public/forms/{public_token}/submissions/{submission_id} + Body: { values: { : |, ... }, first_interacted_at? } + Auto-save — partial updates allowed, only provided slugs are + written. Status stays 'draft'. auto_save_count increments per call; + FormSubmissionDraftUpdated event fires. Rule layer is relaxed + (nullable + type checks); service layer enforces + form_fields.validation_rules (min/max/regex/unique). + +POST /api/v1/public/forms/{public_token}/submissions/{submission_id}/submit + Body: { values?: {...}, captcha_token? } + Final submit. Merges body values with already-saved values, runs + STRICT rule set (required, in:options, types, min/max) against the + merged map. On success: status draft → submitted, schema_snapshot + stored per form_schemas.snapshot_mode, schema_version_at_submit set, + fires FormSubmissionSubmitted (→ §31.10 tag sync, §31.1 identity + match). Rate-limited per + (public_token, ip) per hour via RateLimiter 'form-submit:TOKEN:IP'. +``` + +**Schema drift detection.** +`form_submissions.schema_version_at_open` is stamped at draft-create +time; `schema_version_at_submit` is stamped at submit. Any difference +(or — for active drafts — a difference against current `schema.version`) +surfaces in `PublicFormSubmissionResource.schema_drift: true` so the +portal can warn "this form has changed since you started". + +**Access rules.** +- Submission must belong to the resolved schema (URL + `(public_token, submission_id)` pair must match). +- Submission must be `status = draft` on both PUT and POST-submit — + 409 `SUBMISSION_ALREADY_SUBMITTED` otherwise. +- Rotated-token grace window applies the same way as the GET: resolve + via `public_token` first, then `public_token_previous` (rejected + with 410 `TOKEN_EXPIRED` if past the hard-coded 7-day grace). + Making the window configurable is BACKLOG FORM-04. + +**Error envelope (D6).** +Every public form endpoint responds with the shared shape: + +```json +{ "message": "...", "code": "...", "errors"?: {"values.slug": ["..."]} } +``` + +Codes: `SCHEMA_NOT_FOUND`, `TOKEN_EXPIRED`, `TOKEN_REVOKED`, +`SCHEMA_UNPUBLISHED`, `SUBMISSION_ALREADY_SUBMITTED`, `RATE_LIMITED` +(carries `Retry-After` header), `VALIDATION_FAILED`. + +**Dependency-data endpoints.** The public GET `/{public_token}` embeds +`available_tags` per TAG_PICKER field. `AVAILABILITY_PICKER` and +`SECTION_PRIORITY` use sibling read endpoints: +- `GET /{public_token}/time-slots` — VOLUNTEER person_type only, + festival-parent query surfaces parent + children time slots. +- `GET /{public_token}/sections` — `show_in_registration=true + + type=standard`, dedup by name across festival children. + --- ## 11. Migration plan diff --git a/dev-docs/BACKLOG.md b/dev-docs/BACKLOG.md index a1d3ccf3..643aa9c9 100644 --- a/dev-docs/BACKLOG.md +++ b/dev-docs/BACKLOG.md @@ -319,6 +319,26 @@ shifts claimen zonder toegang tot de Organizer app. --- +### FORM-04 — `grace_days` configurable on public_token rotation + +**Aanleiding:** S2c §10.4 opgeleverd met een hardgecodeerd 7-daagse grace window in `PublicFormTokenResolver`. `rotatePublicToken` endpoint accepteert wel een `grace_days` request param maar schrijft die nergens naartoe; `form_schemas` heeft geen `grace_days` kolom. +**Wat:** +- Kolom `form_schemas.public_token_grace_days` (unsignedSmallInteger nullable, default null). +- `rotatePublicToken` service persisteert de ontvangen `grace_days` value (fallback: config default). +- `PublicFormTokenResolver::GRACE_DAYS` leest uit `form_schemas.public_token_grace_days ?? config('form_builder.public_token.default_grace_days', 7)`. +- Test: rotatie met grace_days=3 levert 410 na 4 dagen. +**Prioriteit:** Laag — operationele tuning, niet frontend-blocking. + +--- + +### DOC-01 — Scramble / OpenAPI generator voor API.md + +**Aanleiding:** `dev-docs/API.md` wordt met de hand bijgehouden per sprint — bij snelle iteratie landt hij altijd een slag achter de code. Scramble (of equivalent) genereert OpenAPI uit FormRequest + Resource introspectie zonder annotaties. +**Wat:** Scramble installeren, publieke form endpoints een dedicated `public` tag geven, CI-hook die de generated spec vergelijkt met een checked-in `dev-docs/api.openapi.yaml`, README link naar de live viewer. +**Prioriteit:** Middel — verlaagt docs-drift substantieel; past in een "developer-experience" sprint. + +--- + ### SUP-01 — Leveranciersportal + productieverzoeken **Aanleiding:** Leveranciers moeten productie-informatie kunnen indienen. diff --git a/dev-docs/SCHEMA.md b/dev-docs/SCHEMA.md index 72e33533..309ffe07 100644 --- a/dev-docs/SCHEMA.md +++ b/dev-docs/SCHEMA.md @@ -1,7 +1,7 @@ # Crewli — Core Database Schema > Source: Design Document v1.3 — Section 3.5 -> **Version: 2.0** — Updated April 2026 +> **Version: 2.1** — Updated April 2026 > > **Changelog:** > @@ -26,6 +26,15 @@ > Removed minimum volunteer hours threshold concept. Removed hardcoded > motivation form step. Moved payment status from fixed admin field to > dynamic registration field. +> - v2.1: Public Form Builder API completion (S2c). Added columns on +> `form_submissions`: `identity_match_status` (null|pending|matched|none, +> populated by `TriggerPersonIdentityMatchOnFormSubmit` per ARCH §31.1) +> and `schema_version_at_open` (stamped at draft-create for drift +> detection). Replaced the composite index on +> `(form_schema_id, idempotency_key)` with a UNIQUE constraint so the +> DB is the race-safe backstop behind application-level idempotency +> replay. Full public API contract: `/dev-docs/ARCH-FORM-BUILDER.md` +> §10.4. > - v2.0: Universal Form Builder replaces event-scoped registration EAV > (S1 + S2a + S2b landed). Full architecture: > `/dev-docs/ARCH-FORM-BUILDER.md` v1.2. @@ -2034,6 +2043,7 @@ that aggregates the user's submitted, non-test `form_submissions`. | `reviewed_at` | timestamp nullable | | | `review_notes` | text nullable | | | `submitted_at` | timestamp nullable | | +| `schema_version_at_open` | int unsigned null | **v2.1** `form_schemas.version` at draft-create; compared against `schema_version_at_submit` for drift detection (ARCH §10.4) | | `schema_version_at_submit` | int unsigned null | `form_schemas.version` at submit time | | `schema_snapshot` | JSON nullable | Full snapshot when policy dictates (ARCH §4.6.1 shape) | | `is_test` | bool | default: false — excluded from reporting & retention | @@ -2042,14 +2052,15 @@ that aggregates the user's submitted, non-test `form_submissions`. | `first_interacted_at` | timestamp nullable | First field focus | | `submission_duration_seconds` | int unsigned null | opened_at → submitted_at | | `auto_save_count` | int unsigned | default: 0 | -| `idempotency_key` | ULID nullable | Duplicate-submit guard | +| `idempotency_key` | ULID nullable | Duplicate-submit guard — UNIQUE `(form_schema_id, idempotency_key)` since v2.1 | | `anonymised_at` | timestamp nullable | | +| `identity_match_status` | string(20) null | **v2.1** null\|pending\|matched\|none — written by `TriggerPersonIdentityMatchOnFormSubmit` (ARCH §31.1) | | `search_index` | mediumText null | Concatenated text of text-type values; FULLTEXT-indexed on MySQL when supported | | `created_at`, `updated_at` | timestamps | | | `deleted_at` | timestamp nullable | Soft delete | **Relations:** `belongsTo` schema, submittedBy / reviewedBy (User); `morphsTo` subject; `hasMany` values, section statuses, delegations -**Indexes:** `(form_schema_id, status)`, `(subject_type, subject_id)`, `(submitted_by_user_id)`, `(form_schema_id, review_status)`, `(form_schema_id, idempotency_key)`, `FULLTEXT(search_index)` (MySQL/InnoDB — best-effort, skipped gracefully on SQLite) +**Indexes:** `(form_schema_id, status)`, `(subject_type, subject_id)`, `(submitted_by_user_id)`, `(form_schema_id, review_status)`, **UNIQUE** `(form_schema_id, idempotency_key)` (v2.1; replaced the non-unique composite index from v2.0), `(form_schema_id, identity_match_status)` (v2.1), `FULLTEXT(search_index)` (MySQL/InnoDB — best-effort, skipped gracefully on SQLite) **Events fired:** `FormSubmissionCreated`, `FormSubmissionDraftUpdated`, `FormSubmissionSubmitted`, `FormSubmissionReviewed`, `FormSubmissionSectionSubmitted`, `FormSubmissionSectionReviewed`, `FormSubmissionAnonymised`, `FormSubmissionArchived`, `FormSubmissionDeleted` **Soft delete:** yes