Files
crewli/dev-docs/discovery/S3a-public-form-api.md
bert.hausmans d67502eaec docs(api): refresh Form Builder public API surface notes
Updates API.md and S3a discovery doc to reflect submitter-details
handling and the draft/submit split.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 17:21:15 +02:00

662 lines
30 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Discovery — Public Form Builder API surface (S3a prep)
> **Superseded 2026-04-18**
> Written against commit 79d834c (S2b + S0.5) for S3a discovery.
> S2c standardised the error envelope to `{ message, code, errors? }`
> — see /dev-docs/API.md § Public Form Endpoints for the current
> contract. Route inventory and response shapes below remain
> accurate; only the error envelope changed.
> **Purpose:** ground a follow-up S3a frontend prompt in the exact shapes
> the current backend exposes. Written by inspecting code + hitting the
> live endpoints against the S0.5-seeded dev token, not from ARCH or
> `API.md`. ARCH and SCHEMA.md are directionally right; `API.md`'s
> "Form Builder" section is ahead of implementation in places — **trust
> the code, not the docs**, for anything below.
>
> **State sampled:** commit `79d834c` (S2b + S0.5 landed, 857 tests green).
> Dev server at `http://localhost:8000`. Dev org: `Stichting Feestfabriek`.
---
## 1. Route inventory
### 1.1 Authenticated `/api/v1/organisations/{organisation}/forms/*`
Full list (40 routes), middleware `api + auth:sanctum +
HandleImpersonation`:
| Method | URI |
| -------- | --- |
| GET | `organisations/{organisation}/forms/field-library` |
| POST | `organisations/{organisation}/forms/field-library` |
| GET | `organisations/{organisation}/forms/field-library/{field_library}` |
| PUT | `organisations/{organisation}/forms/field-library/{field_library}` |
| DELETE | `organisations/{organisation}/forms/field-library/{field_library}` |
| GET | `organisations/{organisation}/forms/filter-registry` |
| GET | `organisations/{organisation}/forms/schemas` |
| POST | `organisations/{organisation}/forms/schemas` |
| GET | `organisations/{organisation}/forms/schemas/{form_schema}` |
| PUT | `organisations/{organisation}/forms/schemas/{form_schema}` |
| DELETE | `organisations/{organisation}/forms/schemas/{form_schema}` |
| 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` |
| POST | `organisations/{organisation}/forms/schemas/{form_schema}/edit-lock` |
| DELETE | `organisations/{organisation}/forms/schemas/{form_schema}/edit-lock` |
| GET/POST/PUT/DELETE | `organisations/{organisation}/forms/schemas/{form_schema}/fields[/{form_field}]` |
| POST | `organisations/{organisation}/forms/schemas/{form_schema}/fields/reorder` |
| POST | `organisations/{organisation}/forms/schemas/{form_schema}/fields/insert-from-library` |
| GET/POST | `organisations/{organisation}/forms/schemas/{form_schema}/submissions` |
| GET | `organisations/{organisation}/forms/submissions/{form_submission}` |
| PUT | `organisations/{organisation}/forms/submissions/{form_submission}/field-values` |
| POST | `organisations/{organisation}/forms/submissions/{form_submission}/submit` |
| POST | `organisations/{organisation}/forms/submissions/{form_submission}/review` |
| POST | `organisations/{organisation}/forms/submissions/{form_submission}/delegate` |
| DELETE | `organisations/{organisation}/forms/submissions/{form_submission}/delegations/{delegation}` |
| DELETE | `organisations/{organisation}/forms/submissions/{form_submission}` |
| GET/POST/PUT/DELETE | `organisations/{organisation}/forms/templates[/{form_template}]` |
| GET/POST/PUT/DELETE | `organisations/{organisation}/forms/schemas/{form_schema}/webhooks[/{webhook}]` |
### 1.2 Public `/api/v1/public/forms/*`
Exactly **2 routes**, middleware `api + ThrottleRequests:30,1` (30 req/min
per IP per route — **Laravel throttle**, no Sanctum, no session, no
CSRF, no impersonation):
| Method | URI | Action |
| ------ | --- | ------ |
| GET | `public/forms/{public_token}` | `PublicFormController@show` |
| POST | `public/forms/{public_token}/submissions` | `PublicFormController@submit` |
---
## 2. Public endpoint deep-dive
### 2.1 `GET /api/v1/public/forms/{public_token}`
**Controller:** `App\Http\Controllers\Api\V1\FormBuilder\PublicFormController::show`
**FormRequest:** none (no body — just the token path segment)
**Resource:** `App\Http\Resources\FormBuilder\PublicFormSchemaResource`
**Middleware:** `api + throttle:30,1`
**Extra auth:** none. Public.
**Token resolution order** (`PublicFormController::resolveSchema`):
1. Match `form_schemas.public_token` → return as current.
2. Else match `form_schemas.public_token_previous`:
- If `public_token_rotated_at` is null → return (grace=`previous`).
- If `rotated_at + 7 days` is in the past → 410 Gone (grace=`expired`).
- Else → return (grace=`previous`). Grace window is hard-coded to
7 days, **not configurable** through `config/form_builder.php`.
3. Else → 404.
**Live response (dev token `01KPEG9D9YZ69MJRJQWXMY7XWG`):**
```json
{
"success": true,
"data": {
"id": "01kpeg9d9yz69mjrjqwxmy7xwh",
"name": "Vrijwilligersregistratie Echt Feesten 2026",
"slug": "vrijwilligersregistratie-echt-feesten-2026",
"purpose": "event_registration",
"description": "Demo-formulier voor het end-to-end doorlopen van de vrijwilligersregistratie voor Echt Feesten 2026.",
"locale": "nl",
"consent_version": null,
"submission_deadline": null,
"section_level_submit": false,
"sections": [],
"fields": [
{ "id": "...", "slug": "over-jou", "field_type": "HEADING", "label": "Over jou", "help_text": null, "options": null, "validation_rules": null, "is_required": false, "display_width": "full", "conditional_logic": null, "sort_order": 1, "form_schema_section_id": null },
{ "id": "...", "slug": "shirtmaat", "field_type": "SELECT", "label": "Shirtmaat", "help_text": null, "options": ["XS","S","M","L","XL","XXL"], "validation_rules": null, "is_required": true, "display_width": "half", "conditional_logic": null, "sort_order": 2, "form_schema_section_id": null },
{ "id": "...", "slug": "dieetwensen", "field_type": "CHECKBOX_LIST", "label": "Dieetwensen", "help_text": null, "options": ["Vegetarisch","Veganistisch","Glutenvrij","Lactosevrij","Halal","Kosher"], "validation_rules": null, "is_required": false, "display_width": "half", "conditional_logic": null, "sort_order": 3, "form_schema_section_id": null },
{ "id": "...", "slug": "vaardigheden", "field_type": "TAG_PICKER", "label": "Vaardigheden en certificaten", "help_text": null, "options": null, "validation_rules": null, "is_required": false, "display_width": "full", "conditional_logic": null, "sort_order": 4, "form_schema_section_id": null },
{ "id": "...", "slug": "opmerkingen", "field_type": "TEXTAREA", "label": "Opmerkingen", "help_text": null, "options": null, "validation_rules": null, "is_required": false, "display_width": "full", "conditional_logic": null, "sort_order": 5, "form_schema_section_id": null }
]
},
"message": "Success"
}
```
**What's included per field:** `id, slug, field_type, label, help_text,
options, validation_rules, is_required, display_width,
conditional_logic, sort_order, form_schema_section_id`.
**What's deliberately excluded per field** (see §9 Gaps):
- `available_tags` — despite being documented in ARCH + available on
the authenticated `FormFieldResource`, `PublicFormSchemaResource`
inlines its own projection that does NOT call `availableTags()`.
The TAG_PICKER in the live response above has `"options": null` and
no tag list. **Frontend blocker.**
- `binding`, `role_restrictions`, `is_admin_only`, `is_pii`,
`is_filterable`, `is_unique`, `translations`, `review_required`
correctly hidden, matches ARCH §10.
**What's deliberately excluded at schema-level** (compared to the
authenticated `FormSchemaResource`):
- `public_token` (current and previous) — fine; the client already has it.
- `version`**missing.** The authenticated resource returns it; the
public one doesn't. Combined with `schema_version_at_submit` being
in the POST response (§2.2), the frontend has no way to detect
drift **before** submitting. See Gaps.
- `fields_count`, `submissions_count`, `has_submissions`, `is_locked`,
`public_form_url`, `public_token_rotated_at`, `submission_mode`,
`snapshot_mode`, `freeze_on_submit`, `retention_days`,
`auto_save_enabled`, `max_submissions`, `organisation_id`,
`owner_type`, `owner_id` — all hidden. Matches intent of ARCH §10.
### 2.2 `POST /api/v1/public/forms/{public_token}/submissions`
**Controller:** `App\Http\Controllers\Api\V1\FormBuilder\PublicFormController::submit`
**FormRequest:** `App\Http\Requests\Api\V1\FormBuilder\PublicSubmissionRequest`
**Resource returned on success:** `FormSubmissionResource` (the
authenticated one — **not** a public-trimmed variant)
**Middleware:** `api + throttle:30,1`
**FormRequest rules (verbatim):**
```php
return [
'values' => ['required', 'array'],
'public_submitter_name' => ['nullable', 'string', 'max:150'],
'public_submitter_email' => ['nullable', 'email', 'max:255'],
'captcha_token' => ['nullable', 'string', 'max:2000'],
'idempotency_key' => ['nullable', 'string', 'max:30'],
];
```
No per-field validation. `values` is a free-form array; individual
field rules are NOT enforced at the request layer. The service layer's
`FormValueService::upsertMany` validates slug membership + write-access
only — no type-coercion on public form values before storage.
**Controller logic (order):**
1. Resolve schema via the same token-resolution path as GET (404 / 410).
2. Reject if `schema.is_published = false``410 Gone` with message
`"Form is not currently accepting submissions."`. **Same 410 as
token-expired** — the frontend can't tell them apart from status
code alone; must parse `.message`.
3. Captcha gate — only runs when
`schema.purpose ∈ config('form_builder.captcha.required_for_purposes')`.
Default config lists `['public_complaint', 'public_press_request']`
**`event_registration` and `public_rsvp` do NOT require captcha**
by default. If required and missing/invalid → `422` with message
`"Captcha validation failed."`.
4. Rate limit (in addition to the route-level throttle) via
`RateLimiter::tooManyAttempts("form-submit:{public_token}:{ip}", N)`
where `N = config('form_builder.limits.max_submissions_per_public_schema_per_ip_per_hour', 5)`.
1-hour decay. On exceed → `429` JSON
`{ success: false, message: "Too many submissions." }` + a
`Retry-After` response header with the seconds-until-available.
5. `submissionService->createDraft(schema, subject=null, submitter=request->user(), context)` — subject is always null on public flow; submitter is null unless Sanctum ever ends up authenticated.
6. If `values` non-empty → `submissionService->saveDraft(submission, values, actor)`.
7. `submissionService->submit(submission, actor)` — always runs. **There
is no public "save draft without submitting" path.** Every POST
ends as `status=submitted`. See Gaps (§9 item 4).
**Idempotency (verified against the running server):**
- Pass `idempotency_key``FormSubmissionService::createDraft` does a
`SELECT * FROM form_submissions WHERE form_schema_id=? AND idempotency_key=?`
and returns the existing row if found.
- Then `saveDraft` + `submit` are called on the returned submission.
If it was already submitted and `schema.freeze_on_submit = false`,
both calls succeed and the `submitted_at` / `submission_duration_seconds`
get refreshed. If `freeze_on_submit = true`, the retry throws
`FrozenSchemaException` which bubbles as `500`.
Probed live — replaying POST with the same `idempotency_key`
returned `201` + the **same** submission id (different status
fields untouched because nothing changed). The column has no DB-level
UNIQUE constraint, so absence of the key means Laravel will freely
create duplicates.
**Success response (201, live sample):**
```json
{
"success": true,
"data": {
"id": "01kpej4c2v0pp8tdq72528va9e",
"form_schema_id": "01kpeg9d9yz69mjrjqwxmy7xwh",
"subject_type": null,
"subject_id": null,
"submitted_by_user_id": null,
"public_submitter_name": "Anonieme Tester",
"public_submitter_email": "test@example.nl",
"status": "submitted",
"review_status": null,
"submitted_at": "2026-04-17T20:29:16+00:00",
"schema_version_at_submit": 1,
"submitted_in_locale": "nl",
"opened_at": "2026-04-17T20:29:16+00:00",
"first_interacted_at": "2026-04-17T20:29:16+00:00",
"submission_duration_seconds": 0,
"is_test": false,
"values": {
"shirtmaat": { "value": "S", "value_anonymised": false },
"dieetwensen": { "value": ["Halal"], "value_anonymised": false },
"opmerkingen": { "value": "Test inzending", "value_anonymised": false }
},
"section_statuses": [],
"delegations": [],
"created_at": "2026-04-17T20:29:16+00:00",
"updated_at": "2026-04-17T20:29:16+00:00"
},
"message": "Created"
}
```
**`schema_version_at_submit` is returned on submit** — frontend can
compare against the `id` in the GET response, but not against a
`version` field (see §9 Gaps).
### 2.3 Draft save / update endpoints
There is **no** `PUT /public/forms/{public_token}/submissions/{id}`.
There is **no** public "save draft" endpoint at all. Both the
authenticated `PUT /organisations/{org}/forms/submissions/{submission}/field-values`
and `POST .../submit` require `auth:sanctum`. See Gaps (§9 item 4) for
the frontend implication on auto-save.
### 2.4 Error-shape catalogue
| Scenario | Status | Envelope | Body |
| --- | --- | --- | --- |
| Unknown token | 404 | `{success, message}` | `{"success": false, "message": "Form not found."}` |
| Expired token (past grace) | 410 | `{success, message}` | `{"success": false, "message": "This form link has expired."}` |
| Schema not published (POST) | 410 | `{success, message}` | `{"success": false, "message": "Form is not currently accepting submissions."}` |
| Missing `values` / malformed request | 422 | **Laravel default** `{message, errors}` | `{"message": "The values field is required.", "errors": {"values": ["The values field is required."]}}` |
| Captcha required + missing/invalid | 422 | `{success, message}` | `{"success": false, "message": "Captcha validation failed."}` |
| Rate-limited (in-controller) | 429 | `{success, message}` | `{"success": false, "message": "Too many submissions."}` + `Retry-After` header |
| Rate-limited (route throttle) | 429 | Laravel default | `{"message": "Too Many Attempts."}` + `Retry-After` header |
**Frontend caveat:** the envelope is inconsistent — 404/410 and most
422s use `{success, message}` while Laravel's FormRequest-validation
422 uses `{message, errors}`. The client needs to branch on
`.success === false` vs presence of `.errors`. See Gaps (§9 item 7).
---
## 3. Dependency-data shape per field type
### 3.1 `SELECT` / `RADIO` / `MULTISELECT` / `CHECKBOX_LIST`
Options are served as `form_fields.options` JSON **directly** in the
public response. `form_value_options` is not consulted on read (that
pivot is write-side only: `FormValueObserver` rebuilds it for filter
joining on admin list endpoints).
Live fragments from the seeded schema:
```json
// SELECT
{ "slug": "shirtmaat", "field_type": "SELECT", "options": ["XS","S","M","L","XL","XXL"] }
// CHECKBOX_LIST
{ "slug": "dieetwensen", "field_type": "CHECKBOX_LIST", "options": ["Vegetarisch","Veganistisch","Glutenvrij","Lactosevrij","Halal","Kosher"] }
```
Options are flat strings. `RADIO` variants can also carry richer
objects — see `FormBuilderDevSeeder::canonicalFields()` line
4852 where `vergoedingstype` uses
`[{ label, description }, ...]`. Neither shape is documented in
`validation_rules` — the frontend has to union-type on the raw JSON.
**MULTISELECT is NOT in the seeded showcase** (only `CHECKBOX_LIST`).
Assumption: same shape, reads the same `options` JSON.
### 3.2 `TAG_PICKER`
**Not exposed on the public endpoint.** The authenticated
`FormFieldResource` exposes an `available_tags` array gated by
`validation_rules.tag_categories` (filtering against
`person_tags.category`). The public `PublicFormSchemaResource`
re-implements the field projection inline and omits
`available_tags` entirely.
Live fragment from the seeded schema (TAG_PICKER field — the shape
the frontend actually receives):
```json
{
"id": "01kpeg9da0rkq87qjvh6cfvnjy",
"slug": "vaardigheden",
"field_type": "TAG_PICKER",
"label": "Vaardigheden en certificaten",
"help_text": null,
"options": null,
"validation_rules": null,
"is_required": false,
"display_width": "full",
"conditional_logic": null,
"sort_order": 4,
"form_schema_section_id": null
}
```
For comparison — what the same field looks like via the **authenticated**
`FormFieldResource` (has `available_tags`):
```json
{
"id": "01kpeg9da0rkq87qjvh6cfvnjy",
"slug": "vaardigheden",
"field_type": "TAG_PICKER",
"available_tags": [
{ "id": "01k...", "name": "Tapper", "category": "Horeca" },
{ "id": "01k...", "name": "Barista", "category": "Horeca" },
{ "id": "01k...", "name": "EHBO", "category": "Veiligheid" },
{ "id": "01k...", "name": "BHV", "category": "Veiligheid" },
{ "id": "01k...", "name": "Rijbewijs B", "category": "Logistiek" },
{ "id": "01k...", "name": "Heftruck", "category": "Logistiek" },
{ "id": "01k...", "name": "Duits", "category": "Taal" },
{ "id": "01k...", "name": "Engels", "category": "Taal" },
{ "id": "01k...", "name": "Podiumervaring","category": "Techniek" }
],
...
}
```
The data is flat — the client needs to `group_by('category')` to
render it as a grouped picker. **Gap: this array is not in the public
response.** See Gaps (§9 item 1).
### 3.3 `AVAILABILITY_PICKER`
**Not exposed.** No time slots embedded in the response; no sub-endpoint
exists; no response field would surface them. The seeded showcase does
not include this field type. Synthesised example of what a bare
`AVAILABILITY_PICKER` field currently looks like when in a schema (same
shape as other fields — **no dependency data**):
```json
{
"slug": "beschikbaarheid",
"field_type": "AVAILABILITY_PICKER",
"options": null,
"validation_rules": null,
"conditional_logic": null
}
```
Per ARCH §3.2.1 / §5.1 the stored value is `ULID[]` of `time_slots.id`,
and per §31.3 the time-slot selection triggers
`ShiftAssignment` creation downstream. But the public client has no
way to render the picker. See Gaps (§9 item 2).
### 3.4 `SECTION_PRIORITY`
Same story as `AVAILABILITY_PICKER`. Stored value is
`{ section_id, priority }[]` over `festival_sections.id`. No embedded
list of sections in the public response; no sub-endpoint. See Gaps
(§9 item 3).
### 3.5 Field types not covered by the S0.5 seeder
The showcase seeds only: `HEADING, SELECT, CHECKBOX_LIST, TAG_PICKER,
TEXTAREA`. S3a MVP scope likely includes at least also `TEXT, EMAIL,
PHONE, NUMBER, DATE, BOOLEAN, RADIO, PARAGRAPH, URL`. The bigger
16-field `FormBuilderDevSeeder::seedEventSchema` does cover
`TEXT, BOOLEAN, RADIO, TEXTAREA, MULTISELECT, SELECT, HEADING,
TAG_PICKER` — but NOT `EMAIL, PHONE, NUMBER, DATE, DATETIME, URL,
FILE_UPLOAD, IMAGE_UPLOAD, SIGNATURE, SECTION_PRIORITY,
AVAILABILITY_PICKER, TABLE_ROWS, PARAGRAPH`.
File-upload / image-upload / signature handling is **completely
out of scope** for the current public endpoint — `PublicSubmissionRequest`
accepts `values` as a plain array (no multipart), and the controller
doesn't process uploads. Those field types need a separate
upload-then-reference flow that does not exist yet.
---
## 4. Identity-match signal
**None surfaced in the POST response.** Specifically:
- The only listener registered on `FormSubmissionSubmitted` is
`SyncTagPickerSelectionsOnSubmit` (ARCH §31.10 — tag sync).
`AppServiceProvider::boot()` does not register anything else.
- ARCH §31.1 calls for a `TriggerIdentityMatchOnRegistration` listener
that invokes `PersonIdentityService::detectMatches`. **It is not
implemented.** No file exists under `app/Listeners/FormBuilder/`
matching that name.
- `FormSubmissionResource` carries no `pending_identity_match`,
`identity_match`, or equivalent field.
So on a public `event_registration` submission the response tells the
frontend nothing about identity-match state. Known gap. See §9 item 5.
---
## 5. Conditional logic shape
`form_fields.conditional_logic` is passed through **verbatim** by
`PublicFormSchemaResource` as plain JSON on each field, no transform.
Shape per ARCH §8 (sampled from test fixtures + service code):
```json
{
"show_when": {
"all": [
{ "field_slug": "has_allergies", "operator": "equals", "value": true }
]
}
}
```
- Operators: `equals, not_equals, contains, not_contains, in, not_in,
greater_than, less_than, empty, not_empty`.
- Groups: `all` (AND), `any` (OR), nestable.
See §7 below re: slug references.
---
## 6. Draft save semantics + idempotency
- `idempotency_key` is a top-level string field on the POST body
(`max:30`). Optional — nullable per `PublicSubmissionRequest`.
- No DB-level UNIQUE constraint on
`form_submissions.idempotency_key` (the migration adds only a
composite index `(form_schema_id, idempotency_key)`). Duplicate
prevention is application-only.
- Behaviour on retry with same key: `FormSubmissionService::createDraft`
returns the existing submission (verified live — second POST with
the same key returned the same `id`, `201`).
- The full pipeline (`saveDraft` → `submit`) still runs on the
returned-existing submission. When the original submission is
already `submitted` and `schema.freeze_on_submit = false` (the S0.5
seeded schema), this is a harmless no-op that refreshes
`submitted_at`. When `freeze_on_submit = true` it throws
`FrozenSchemaException`, bubbling as 500.
Recommendation for the frontend: always send an `idempotency_key` —
ideally a client-generated ULID that survives retries — because the
server can't otherwise dedupe at POST time.
---
## 7. Conditional logic field references — by `slug` ✓
Confirmed via `FormFieldService::extractConditionSlugs` (walks the
`conditional_logic` tree collecting every `field_slug` key):
```php
if (isset($node['field_slug'])) {
$slugs[] = (string) $node['field_slug'];
}
```
and `buildConditionalAdjacency` which builds the cycle-detection graph
keyed by `$field->slug`, not `$field->id`. Frontend can resolve
references using slugs only — no ID lookup table needed.
---
## 8. Dev-seeded token + curl
Seeder: `Database\Seeders\FormBuilderDevSeeder::seedEventRegistrationShowcase()`
called from `DevSeeder::seedEchtFeesten()`, scoped to **`Stichting
Feestfabriek`** (the only dev org) and anchored to the top-level
festival event `Echt Feesten 2026`.
After `php artisan migrate:fresh --seed`:
```bash
# Grab the token
TOKEN=$(php artisan tinker --execute="echo \App\Models\FormBuilder\FormSchema::where('slug','like','vrijwilligersregistratie-%')->value('public_token');" 2>/dev/null)
echo $TOKEN # e.g. 01KPEG9D9YZ69MJRJQWXMY7XWG
# Fetch schema
curl -s "http://localhost:8000/api/v1/public/forms/$TOKEN" | jq '.'
# Extract field shapes only
curl -s "http://localhost:8000/api/v1/public/forms/$TOKEN" | jq '.data.fields[] | {slug, field_type}'
# POST a submission
curl -s -X POST "http://localhost:8000/api/v1/public/forms/$TOKEN/submissions" \
-H 'Accept: application/json' -H 'Content-Type: application/json' \
-d '{
"values": {"shirtmaat":"S","dieetwensen":["Halal"],"opmerkingen":"Test"},
"public_submitter_name": "Anonieme Tester",
"public_submitter_email": "test@example.nl",
"idempotency_key": "my-client-ulid-here"
}' | jq '.'
```
Draft person (pre-seeded): Jan de Vries. Submitted person (TAG_PICKER
sync exercised): Lisa Bakker.
---
## 9. Gaps
**Hard blockers for an end-to-end public-form frontend**
1. **`available_tags` missing from public TAG_PICKER response.**
`PublicFormSchemaResource` reimplements the field projection
inline and omits `availableTags()`. Frontend cannot render the
`vaardigheden` field from the dev token without hitting
another endpoint — which doesn't exist publicly. **Easiest fix:**
inline a public-safe `available_tags` lookup on every
TAG_PICKER field (same logic as `FormFieldResource::availableTags()`,
which already filters by `is_active` and `validation_rules.tag_categories`
— nothing PII there). Either that or expose a sibling public
endpoint like `GET /public/forms/{token}/tag-pickers/{slug}/tags`.
2. **`AVAILABILITY_PICKER` dependency data missing.** No time slots
embedded; no public endpoint to fetch them. The authenticated
API doesn't even have a dedicated "time slots for this form's
event" route. Resolution options:
- Embed per-field on public response (include
`time_slots: [{ id, name, start_time, end_time, date,
person_type }]` scoped to `owner_id` event and children).
- Ship a public sub-endpoint
`GET /public/forms/{token}/time-slots?include_children=true`.
Either needs to walk the festival hierarchy: form `owner_id` →
event → (children events if festival) → `time_slots`.
3. **`SECTION_PRIORITY` dependency data missing.** Same shape of
gap: `festival_sections` for the form's event (or its festival's
children) are not exposed. The authenticated API has
`/organisations/{org}/events/{event}/sections` but not a public
variant.
4. **No public draft-save endpoint.** Every `POST /public/forms/…/submissions`
ends at `status=submitted`. `PublicFormController::submit` always
runs `createDraft → saveDraft → submit` in one transaction. For
the S3a "save as draft" / "auto-save on blur" requirement there
is no surface at all. Options:
- Add `POST /public/forms/{token}/drafts` that stops after
`saveDraft` (status stays `draft`, returns the submission id).
- Add `PUT /public/forms/{token}/submissions/{submission_id}`
for subsequent auto-save writes (would need a private
draft-resume token — plain submission_id on the public
surface is a discovery risk).
- Keep submit-only and rely on `idempotency_key` replays (current
behaviour). **Ugly for auto-save** because each auto-save hits
`submit()` and fires `FormSubmissionSubmitted` (→ TAG_PICKER
sync runs 20× per form fill-in on a chatty form).
5. **No identity-match signal in POST response.**
`TriggerIdentityMatchOnRegistration` listener from ARCH §31.1 is
unimplemented. `PersonIdentityService::detectMatches` is never
called from the form submission pipeline. Frontend cannot know
"this email matches an existing volunteer — claim it?" from the
response. Either build the listener or defer this piece to a
later sprint — but the S3a UX should be aware the signal is
absent today.
**Soft gaps / pending polish**
6. **Schema `version` not in public GET response.** Included on
authenticated `FormSchemaResource`, dropped from
`PublicFormSchemaResource`. The `schema_version_at_submit` field
IS in the POST response, so the frontend can detect drift
**after** submitting but not **before**. If drift detection
matters (e.g. "refresh — the form changed since you opened it"),
surface `version` in the public GET.
7. **Inconsistent error envelope.** 404/410/429 (in-controller)
return `{success: false, message}`. Laravel's FormRequest
validation (422) returns `{message, errors}` without `success`.
The route-level throttle 429 returns Laravel default
`{message: "Too Many Attempts."}` (no `success`). The frontend
needs branching logic to handle both shapes.
8. **410 vs 410 ambiguity.** "Token expired past grace" and
"schema is not published" both return 410 with different
`.message` strings. Frontend must string-match to tell them
apart (or accept that both are effectively "form unavailable").
9. **Grace window hard-coded to 7 days.** The `rotatePublicToken`
admin endpoint accepts a `grace_days` request param (stored
nowhere — just passed to activity log). `PublicFormController`
unconditionally uses 7 days. If a tenant sets `grace_days=3`
they still effectively get 7. Not S3a-blocking; worth
documenting in backlog.
10. **Public POST response leaks a surprising amount of metadata.**
The submission response uses the authenticated
`FormSubmissionResource` with no public-facing trim. It
includes `opened_at`, `first_interacted_at`, `submission_duration_seconds`,
`submitted_in_locale`, `is_test`, and the full `values` array.
Nothing PII-dangerous for a submitter receiving their own
submission back, but it does echo `public_submitter_email`
— fine for a confirmation screen, worth knowing.
11. **No per-field type validation at the request layer.**
`PublicSubmissionRequest` only validates `values: required|array`.
Everything else lands in `FormValueService::upsertMany` which
doesn't honour `form_fields.validation_rules` for public
submissions either. An EMAIL field accepts `"not-an-email"`.
A SELECT field accepts any string. Client-side validation
must hold the line until this is wired up.
12. **CAPTCHA dependency is unconditional on the non-required
path too.** `PublicFormController::captchaValid()` is only
called when the purpose matches the config allowlist. That
part is correct. But if the purpose is gated and the client
doesn't send `captcha_token`, the controller returns a
generic 422 message — the frontend can't distinguish
"captcha required but missing" from "captcha present but
failed verification". Consider returning distinct error
codes / messages.
---
**Summary verdict:** the current public surface serves a linear
`TEXT / TEXTAREA / SELECT / CHECKBOX_LIST / RADIO / BOOLEAN / HEADING /
PARAGRAPH` form end-to-end (render + submit), modulo client-side
validation. **Anything that needs external-data rendering
(TAG_PICKER / AVAILABILITY_PICKER / SECTION_PRIORITY) or a separate
draft-save step is not currently possible against the public
surface.** Those are the four hard blockers to close before S3a.