docs(form-builder): API.md Form Builder (Public), SCHEMA v2.1, ARCH §10.4, BACKLOG

S2c Phase 8.

- API.md: new **Form Builder (Public)** section documenting all 6
  public endpoints (GET schema + time-slots + sections; POST draft,
  PUT save, POST submit) with request/response examples, error codes,
  and the identity_match / schema_drift contracts. No PII-echo noted
  explicitly.
- SCHEMA.md bumped to v2.1:
  - changelog entry for S2c.
  - form_submissions table gains schema_version_at_open +
    identity_match_status columns; UNIQUE (form_schema_id,
    idempotency_key) replaces the composite index; a new composite
    index (form_schema_id, identity_match_status) landed for the
    organiser "pending-match" dashboard.
- ARCH-FORM-BUILDER.md bumped to v1.3 with new §10.4 "Public
  submission lifecycle — draft/save/submit split" documenting the
  three-endpoint contract, idempotency, schema-drift detection,
  access rules, the standardised error envelope, and the dependency
  data sub-endpoints.
- BACKLOG.md adds:
  - FORM-04 (grace_days configurable — current implementation still
    uses the hard-coded 7-day window)
  - DOC-01 (Scramble / OpenAPI generator for API.md to reduce the
    docs-drift effort going forward).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-17 23:07:26 +02:00
parent 9b1bf0e13d
commit 68d2c830a0
4 changed files with 325 additions and 9 deletions

View File

@@ -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 (630 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`: