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>
662 lines
30 KiB
Markdown
662 lines
30 KiB
Markdown
# 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
|
||
48–52 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.
|