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:
213
dev-docs/API.md
213
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`:
|
||||
|
||||
@@ -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: { <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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user