docs(form-builder): API.md, ARCH §31.10, BACKLOG

Phase 7 of S2b.

- API.md: "Form Builder" section rewritten with every new route
  (schemas / fields / submissions / values / delegations / templates /
  field library / webhooks / filter registry / public token flow).
  Calls out §22.8 typed-confirmation deletes, §6.5 binding-change guard,
  §9 signature hash on submit, §7.4–§7.5 FilterQueryBuilder contract,
  and that FormSubmissionSubmitted is the trigger for the §31.10
  TAG_PICKER sync listener.
- BACKLOG.md: FORM-02 marked done with the shipped artefacts and the
  deferred §31.9 contract tests spelled out.
- ARCH-FORM-BUILDER.md §31.10 already rewrote authoritatively in Phase 2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-17 21:28:54 +02:00
parent 6e89b0ccf7
commit 2d6d2b2991
2 changed files with 132 additions and 9 deletions

View File

@@ -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=<schema.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=<field.label>` when the field has values.
- `POST /organisations/{organisation}/forms/schemas/{form_schema}/fields/reorder`
— body: `{ field_ids: [<ulid>, ...] }`.
- `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: { <slug>: <value_or_array> } }`.
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=<ulid?>`
— 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)