diff --git a/dev-docs/API.md b/dev-docs/API.md index f5060ad1..b3457dce 100644 --- a/dev-docs/API.md +++ b/dev-docs/API.md @@ -653,12 +653,128 @@ Response: `{ "confirmed": 2, "errors": [{ "match_id": "ulid3", "error": "User al ## Form Builder -The legacy registration-form-fields / person-field-values / -registration-field-templates / person-section-preferences endpoints were -purged in S2a. Their replacements (new Form Builder CRUD, public form -submission endpoints, tag-sync listener) land in S2b+. This section will -be filled in then — see `/dev-docs/ARCH-FORM-BUILDER.md` §4 for the -target schema and §31 for integration contracts. +Universal form builder per `/dev-docs/ARCH-FORM-BUILDER.md`. Replaces the +legacy registration-form-fields / person-field-values / +registration-field-templates / person-section-preferences endpoints +purged in S2a. All authenticated routes are namespaced under +`/organisations/{organisation}/forms/*` with `auth:sanctum` + FormBuilder +policies. Public routes live at `/public/forms/*`. + +### Authenticated — Form Schemas + +- `GET /organisations/{organisation}/forms/schemas` — paginated list + (default 25). Returns `FormSchemaSummaryResource` items. +- `POST /organisations/{organisation}/forms/schemas` — body: + `{ name, purpose, submission_mode?, locale?, snapshot_mode?, + freeze_on_submit?, retention_days?, consent_version?, ... }`. Returns + `FormSchemaResource`. +- `GET /organisations/{organisation}/forms/schemas/{form_schema}` — full + resource with filtered `fields`. +- `PUT /organisations/{organisation}/forms/schemas/{form_schema}` — + updates fields; structural changes bump `version`. +- `DELETE /organisations/{organisation}/forms/schemas/{form_schema}` — + soft delete. If submissions exist, requires + `?confirmed_name=` per §22.8 (422 without). +- `POST /organisations/{organisation}/forms/schemas/{form_schema}/duplicate` +- `POST /organisations/{organisation}/forms/schemas/{form_schema}/publish` +- `POST /organisations/{organisation}/forms/schemas/{form_schema}/unpublish` +- `POST /organisations/{organisation}/forms/schemas/{form_schema}/rotate-public-token` + — body: `{ grace_days?: int (default 7) }`. Moves current + `public_token` to `public_token_previous`; old token returns 410 after + grace window. +- `POST /organisations/{organisation}/forms/schemas/{form_schema}/edit-lock` + — 409 if another user holds a valid lock. +- `DELETE /organisations/{organisation}/forms/schemas/{form_schema}/edit-lock` + +### Authenticated — Form Fields (within a schema) + +- `GET /organisations/{organisation}/forms/schemas/{form_schema}/fields` +- `POST /organisations/{organisation}/forms/schemas/{form_schema}/fields` + — body validates `field_type` against `FormFieldType` enum + any + registered `custom_field_types`. +- `PUT /organisations/{organisation}/forms/schemas/{form_schema}/fields/{form_field}` + — setting `force_binding_change=true` bypasses the §6.5 guard. +- `DELETE /organisations/{organisation}/forms/schemas/{form_schema}/fields/{form_field}` + — requires `?confirmed_name=` when the field has values. +- `POST /organisations/{organisation}/forms/schemas/{form_schema}/fields/reorder` + — body: `{ field_ids: [, ...] }`. +- `POST /organisations/{organisation}/forms/schemas/{form_schema}/fields/insert-from-library` + — body: `{ library_field_id, overrides? }`. + +### Authenticated — Form Submissions + +- `GET /organisations/{organisation}/forms/schemas/{form_schema}/submissions` +- `POST /organisations/{organisation}/forms/schemas/{form_schema}/submissions` + — creates a draft. Body: + `{ subject_type?, subject_id?, is_test?, opened_at?, idempotency_key? }`. +- `GET /organisations/{organisation}/forms/submissions/{form_submission}` +- `PUT /organisations/{organisation}/forms/submissions/{form_submission}/field-values` + — bulk upsert draft values. Body: `{ values: { : } }`. + 403 when `FieldAccessService::canWrite` rejects a slug. +- `POST /organisations/{organisation}/forms/submissions/{form_submission}/submit` + — optional `values` accepted in-place. On submit: stores + `schema_version_at_submit`; when `schema.snapshot_mode != 'never'` + stores `schema_snapshot`; computes SIGNATURE hashes per §9; fires + `FormSubmissionSubmitted` — **triggering the §31.10 TAG_PICKER sync + listener**. +- `POST /organisations/{organisation}/forms/submissions/{form_submission}/review` + — body: `{ status: FormSubmissionReviewStatus, review_notes? }`. +- `POST /organisations/{organisation}/forms/submissions/{form_submission}/delegate` + — body: `{ delegated_to_user_id (scoped to org), message? }`. +- `DELETE /organisations/{organisation}/forms/submissions/{form_submission}/delegations/{delegation}` +- `DELETE /organisations/{organisation}/forms/submissions/{form_submission}` + +### Authenticated — Templates, Field Library, Webhooks + +- `GET/POST/PUT/DELETE /organisations/{organisation}/forms/templates[/{form_template}]` + — system templates are read-only for non-super-admins. +- `GET/POST/PUT/DELETE /organisations/{organisation}/forms/field-library[/{field_library}]` +- `GET/POST/PUT/DELETE /organisations/{organisation}/forms/schemas/{form_schema}/webhooks[/{webhook}]` + — responses return `url_host` + `has_secret`; the raw URL and secret + never leak out. + +### Authenticated — Filter Registry + +- `GET /organisations/{organisation}/forms/filter-registry?event_id=` + — combines entity_column definitions (`config/form_filter_registry.php`) + with TAG_PICKER-backed tags and every `is_filterable=true` + `form_field`. Response items carry a `source` discriminator of + `entity_column` / `tags` / `form_field`. Cached per + `(organisation_id, event_id?)`; used by the Personen module through + `FilterQueryBuilder` (ARCH §7.4–§7.5). The builder rejects filters + referencing invisible fields with 403 (tied to `FieldAccessService`). + +### Public (no auth, rate-limited) + +- `GET /public/forms/{public_token}` — returns + `PublicFormSchemaResource` (portal-visible, non-admin-only fields + only; no PII hints; no submissions_count; no role_restrictions bleed). + `public_token` is matched against `form_schemas.public_token` first + and `public_token_previous` second; if the rotated token has exceeded + the 7-day grace window the response is 410 Gone. +- `POST /public/forms/{public_token}/submissions` — body: + `{ values, public_submitter_name?, public_submitter_email?, + captcha_token?, idempotency_key? }`. Captcha (Cloudflare Turnstile) is + enforced for purposes listed under + `config('form_builder.captcha.required_for_purposes')`. Rate-limited + per-IP per-token per-hour + (`form_builder.limits.max_submissions_per_public_schema_per_ip_per_hour`). + Private-IP webhook targets are rejected SSRF-style in + `DeliverFormWebhookJob`. + +### Response shapes + +`FormSchemaResource` includes `fields_count`, `submissions_count`, +`has_submissions`, `is_locked`, `public_form_url` (when `public_token` +is set), and a filtered `fields` collection. + +`FormSubmissionResource.values` is keyed by field slug and already +filtered through `FieldAccessService::filterVisibleFields` so admin-only +fields never leak to non-admins. + +`FormFieldResource` carries `available_tags` (category-filtered) for +`TAG_PICKER` fields and resolves `label` / `help_text` / `options` +through `FormLocaleResolver` + the `translations` JSON column. ## Person List Filtering (extended) diff --git a/dev-docs/BACKLOG.md b/dev-docs/BACKLOG.md index f89f6078..a1d3ccf3 100644 --- a/dev-docs/BACKLOG.md +++ b/dev-docs/BACKLOG.md @@ -303,12 +303,19 @@ shifts claimen zonder toegang tot de Organizer app. --- -### FORM-02 — TAG_PICKER → user_organisation_tags sync rebuild +### FORM-02 — TAG_PICKER → user_organisation_tags sync rebuild ✅ Done in S2b (2026-04-17) **Aanleiding:** TagSyncService verwijderd in S2a Form Builder legacy purge. Semantiek (TAG_PICKER-antwoorden syncen naar user_organisation_tags bij registratie-goedkeuring) blijft valide. -**Wat:** Herbouwen als listener op FormSubmissionSubmitted of FormSubmissionApproved tegen de nieuwe FormValue + TAG_PICKER field_type. Integreren met PersonController::approve() workflow zonder directe service-injection. +**Wat:** Herbouwen als listener op FormSubmissionSubmitted tegen de nieuwe FormValue + TAG_PICKER field_type. Integreren via PersonIdentityService::confirmMatch zonder directe service-injection in PersonController. **Eerdere call-sites (nu verwijderd):** PersonController::approve(), PersonIdentityService::syncRegistrationTags(). -**Prioriteit:** Hoog — moet landen in S2b of S2c, vóór de frontend in S5 opnieuw op het registratieformulier aansluit. +**Landed artefacts:** + +- `App\Services\FormBuilder\FormTagSyncService::rebuildForPerson` — idempotent union-of-TAG_PICKER-values rebuild, only mutates `source=self_reported` rows, no-op when `person.user_id IS NULL`. +- `App\Listeners\FormBuilder\SyncTagPickerSelectionsOnSubmit` — ShouldQueue listener on `FormSubmissionSubmitted`, filters to `event_registration` purpose with `subject_type=person` + at least one `TAG_PICKER` value. Logs + swallows errors so sibling listeners (§31.1/§31.3/§31.8) keep running. +- `App\Services\PersonIdentityService::confirmMatch` — calls `FormTagSyncService::rebuildForPerson` after setting `person.user_id` (deferred-sync path for person who submitted before the user account existed). +- Contract frozen in ARCH-FORM-BUILDER.md §31.10 (authoritative block) and covered by `tests/Feature/FormBuilder/Integration/TagPickerSyncListenerTest`. + +**Deferred integration tests (move under FORM-03 if needed):** GdprDeleteCascadeTest, EmailNotificationFlowTest, CodeOfConductGatingTest, SupplierIntakeFlowTest, CrowdListAutoAddTest (§31.9). Only §31.10 ships with S2b; other contracts wait until their feature arrives. ---