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`:

View File

@@ -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, 630 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: { <slug>: <value>|<array>, ... }, 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

View File

@@ -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.

View File

@@ -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