# Crewli API Contract Base path: `/api/v1/` Auth: Bearer token (Sanctum) — token delivered via httpOnly cookie, never in the JSON response body. See `/dev-docs/AUTH_ARCHITECTURE.md` for full details. ## Auth - `POST /auth/login` — returns user data in JSON body. The Sanctum bearer token is set as an httpOnly cookie via `Set-Cookie` header (not included in response body). - `POST /auth/logout` - `GET /auth/me` - `POST /auth/refresh` — rotates the Sanctum token: revokes current token, creates new one, sets new httpOnly cookie. Returns current user data. - `POST /auth/forgot-password` — request password reset (public, rate-limited). Body: `{ email, app: "app"|"portal"|"admin" }`. Always returns 200 (no email enumeration). - `POST /auth/reset-password` — reset password with token (public). Body: `{ token, email, password, password_confirmation }`. ## MFA (Multi-Factor Authentication) ### Login flow with MFA When MFA is enabled for a user, login becomes a two-step process: 1. `POST /auth/login` — if MFA is active, returns `{ mfa_required: true, mfa_session_token, methods, preferred_method, expires_in }` instead of the auth token 2. `POST /auth/mfa/verify` — submit the MFA code with the session token to complete login If the device is trusted (via `X-Device-Fingerprint` header), MFA is bypassed and login proceeds normally. ### MFA verification during login (public, rate-limited) - `POST /auth/mfa/verify` — verify MFA code during login. Body: `{ mfa_session_token, code, method: "totp"|"email"|"backup_code", trust_device?: bool, device_fingerprint?: string, device_name?: string }`. Returns user data + auth cookie on success. - `POST /auth/mfa/email/send` — send/resend email verification code during login. Body: `{ mfa_session_token }`. Rate-limited: 1 code per 60 seconds. ### MFA setup and management (authenticated) - `POST /auth/mfa/setup/totp` — start TOTP setup, returns `{ secret, qr_code_url, provisioning_uri }` - `POST /auth/mfa/setup/totp/confirm` — confirm TOTP with first code. Body: `{ code }`. Returns `{ mfa_enabled, method, backup_codes[] }` - `POST /auth/mfa/setup/email` — start email MFA setup (sends verification code) - `POST /auth/mfa/setup/email/confirm` — confirm email MFA. Body: `{ code }`. Returns `{ mfa_enabled, method, backup_codes[] }` - `POST /auth/mfa/disable` — disable MFA. Body: `{ code, method: "totp"|"backup_code" }`. Requires valid verification code. - `POST /auth/mfa/backup-codes` — regenerate backup codes. Body: `{ code }`. Requires valid TOTP code. - `GET /auth/mfa/status` — current MFA status: `{ mfa_enabled, method, confirmed_at, backup_codes_remaining, is_required }` ### Trusted devices (authenticated) - `GET /auth/trusted-devices` — list active trusted devices - `DELETE /auth/trusted-devices/{id}` — revoke a specific device - `DELETE /auth/trusted-devices` — revoke all devices ### Admin MFA management (super_admin) - `POST /admin/users/{user}/reset-mfa` — force-disable MFA for a user. Activity logged. ## Account Management (authenticated) - `POST /me/change-password` — change own password. Body: `{ current_password, password, password_confirmation }`. Revokes other sessions. - `POST /me/change-email` — request email change (sends verification to new address). Body: `{ new_email, password, app: "app"|"portal"|"admin" }`. - `POST /verify-email-change` — verify email change token (public). Body: `{ token }`. Revokes all sessions. ## Organisations - `GET /organisations` — list (super admin) - `POST /organisations` — create - `GET /organisations/{org}` — show - `PUT /organisations/{org}` — update - `GET /organisations/{org}/members` — members - `POST /organisations/{org}/invite` — invite user - `POST /organisations/{org}/members/{user}/change-email` — admin initiates email change for a member (org_admin only). Body: `{ new_email }`. ## Events - `GET /organisations/{org}/events` — list (top-level only by default) - `GET /organisations/{org}/events?include_children=true` — include sub-events nested in response - `GET /organisations/{org}/events?type=festival` — filter by event_type (festival|series|event) - `POST /organisations/{org}/events` — create (supports `parent_event_id` for sub-events) - `GET /organisations/{org}/events/{event}` — detail (includes children and parent if loaded) - `PUT /organisations/{org}/events/{event}` — update (does NOT accept `status` — use transition endpoint) - `POST /organisations/{org}/events/{event}/transition` — change event status via state machine (see below) - `GET /organisations/{org}/events/{event}/children` — list sub-events of a festival/series ### Event Status Transitions `POST /organisations/{org}/events/{event}/transition` Body: `{ "status": "published" }` Enforces a state machine: only valid forward (and select backward) transitions are allowed. Returns 422 with `errors`, `current_status`, `requested_status`, and `allowed_transitions` when the transition is invalid or prerequisites are missing. **Prerequisites checked:** - `→ published`: name, start_date, end_date required - `→ registration_open`: at least one time slot and one section required **Festival cascade:** Transitioning a festival parent to `showday`, `teardown`, or `closed` automatically cascades to all children in an earlier status. **EventResource** includes `allowed_transitions` (array of valid next statuses) so the frontend knows which buttons to show. ## Event Stats - `GET /organisations/{org}/events/{event}/stats` — aggregate dashboard counts for the event ### Response ```json { "data": { "persons_total": 142, "persons_approved": 98, "persons_pending": 31, "persons_rejected": 8, "persons_other": 5, "persons_approved_without_shift": 23, "pending_identity_matches": 3, "shifts_total": 45, "shifts_filled": 38, "shifts_understaffed": 7 } } ``` ## Crowd Types - `GET /organisations/{org}/crowd-types` - `POST /organisations/{org}/crowd-types` - `PUT /organisations/{org}/crowd-types/{type}` - `DELETE /organisations/{org}/crowd-types/{type}` ## Companies - `GET /organisations/{org}/companies` - `POST /organisations/{org}/companies` - `PUT /organisations/{org}/companies/{company}` - `DELETE /organisations/{org}/companies/{company}` ## Section Categories - `GET /organisations/{org}/section-categories` — distinct categories used across the organisation's events (for autocomplete). Returns `{ "data": ["Bar", "Podium", ...] }` ## Festival Sections - `GET /organisations/{org}/events/{event}/sections` - `POST /organisations/{org}/events/{event}/sections` - `PUT /organisations/{org}/events/{event}/sections/{section}` - `DELETE /organisations/{org}/events/{event}/sections/{section}` - `POST /organisations/{org}/events/{event}/sections/reorder` > **Festival context:** `{event}` can be a festival parent or a sub-event. > On a festival parent, sections are for operational planning (build-up, teardown). > For sub-events, `GET` automatically includes `cross_event` sections from the parent festival. > Shifts on cross_event sections must use the **parent festival's event_id** in API calls, > since the section's `event_id` points to the parent. ### Registration Settings (Festival-level bulk management) - `GET /organisations/{org}/events/{event}/sections/registration-settings` — returns unique section names across the festival with registration visibility, description, and counts - `PUT /organisations/{org}/events/{event}/sections/registration-settings` — bulk update registration visibility for a section name across all instances in the festival #### GET Response ```json { "data": [ { "name": "Hoofdpodium Bar", "category": "Bar", "icon": "tabler-beer", "show_in_registration": true, "registration_description": "Tap bier en drankjes voor festivalgangers", "section_count": 3, "section_ids": ["ulid1", "ulid2", "ulid3"] } ] } ``` #### PUT Body ```json { "name": "Hoofdpodium Bar", "show_in_registration": true, "registration_description": "Tap bier en drankjes voor festivalgangers" } ``` Returns the full updated registration-settings response. Creates activity log `section.registration_settings_updated`. Auth: org_admin or event_manager on the event's organisation. ## Time Slots - `GET /organisations/{org}/events/{event}/time-slots` - `POST /organisations/{org}/events/{event}/time-slots` - `PUT /organisations/{org}/events/{event}/time-slots/{timeSlot}` - `DELETE /organisations/{org}/events/{event}/time-slots/{timeSlot}` > **Festival context:** `{event}` can be a festival parent or a sub-event. > Festival-level time slots (operational: build-up, teardown, transitions) are separate > from sub-event time slots (program-specific). > > `GET /organisations/{org}/events/{event}/time-slots` returns only the specified event's own time slots by > default. For sub-events, pass `?include_parent=true` to also include the parent festival's > time slots — each time slot is marked with a `source` field (`sub_event` or `festival`) > and includes `event_name` for display grouping. This parameter has no effect on festivals > or flat events. > > For festivals (events with sub-events), pass `?include_children=true` to include all > sub-event time slots. Each time slot is marked with `source` (`own` or the sub-event's > `event_id`) and `event_name`. Used by cross_event section shift dialogs that need access > to all time slots across the festival. This parameter has no effect on sub-events or > flat events. > > Shifts on sub-event sections may reference parent festival time slots (e.g. for build-up > shifts). Shifts on cross_event sections may reference any time slot from the festival or > its sub-events. The `time_slot_id` validation accepts time slots accordingly. ## Shifts - `GET /organisations/{org}/events/{event}/sections/{section}/shifts` - `POST /organisations/{org}/events/{event}/sections/{section}/shifts` - `PUT /organisations/{org}/events/{event}/sections/{section}/shifts/{shift}` - `DELETE /organisations/{org}/events/{event}/sections/{section}/shifts/{shift}` - `POST /organisations/{org}/events/{event}/sections/{section}/shifts/{shift}/assign` - `POST /organisations/{org}/events/{event}/sections/{section}/shifts/{shift}/claim` > **Festival context:** When managing shifts on a `cross_event` section, the `{event}` > in the URL must be the parent festival's ID (matching `section.event_id`), not the > sub-event's ID. ## Shift Assignments - `GET /organisations/{org}/events/{event}/shift-assignments` — list assignments for event (paginated, 50/page) - `POST /organisations/{org}/events/{event}/shift-assignments/{shiftAssignment}/approve` — approve pending assignment - `POST /organisations/{org}/events/{event}/shift-assignments/{shiftAssignment}/reject` — reject pending assignment - `POST /organisations/{org}/events/{event}/shift-assignments/{shiftAssignment}/cancel` — cancel assignment - `POST /organisations/{org}/events/{event}/shift-assignments/bulk-approve` — bulk approve multiple assignments - `GET /organisations/{org}/events/{event}/shifts/{shift}/assignable-persons` — list approved persons with availability status ### Assignable Persons `GET /organisations/{org}/events/{event}/shifts/{shift}/assignable-persons` Returns all approved persons for the event with availability status for this shift's time slot. Persons are sorted: available first, then unavailable (conflict), then already assigned. ```json { "data": [ { "id": "ulid", "first_name": "Jan", "last_name": "de Vries", "full_name": "Jan de Vries", "email": "jan@gmail.com", "status": "approved", "crowd_type": { "id": "ulid", "name": "Vrijwilliger", "system_type": "VOLUNTEER" }, "is_available": true, "already_assigned": false, "conflict": null }, { "id": "ulid", "first_name": "Ahmed", "last_name": "Hassan", "full_name": "Ahmed Hassan", "email": "ahmed.h@gmail.com", "status": "approved", "crowd_type": { "id": "ulid", "name": "Vrijwilliger", "system_type": "VOLUNTEER" }, "is_available": false, "already_assigned": false, "conflict": { "section_name": "EHBO", "shift_title": "EHBO Post", "time_slot_name": "Zaterdag Dag", "time": "10:00–18:00" } } ] } ``` ### Query Parameters (index) - `status` — filter by assignment status (`pending_approval`, `approved`, `rejected`, `cancelled`, `completed`) - `shift_id` — filter by shift - `person_id` — filter by person - `section_id` — filter by festival section ### Assign Body `POST /organisations/{org}/events/{event}/sections/{section}/shifts/{shift}/assign` ```json { "person_id": "01JXYZ..." } ``` Organizer manually assigns a person. Assignment is pre-approved (status = `approved`). Validates: shift must be `open`, capacity not full (`slots_total`), no time slot conflict. ### Claim Body `POST /organisations/{org}/events/{event}/sections/{section}/shifts/{shift}/claim` ```json { "person_id": "01JXYZ..." } ``` Volunteer claims a shift. Status depends on `festival_section.crew_auto_accepts`: - `true` → status = `approved`, `auto_approved = true` - `false` → status = `pending_approval` Validates: shift must be `open`, person must be `approved`, claiming capacity not full (`slots_open_for_claiming`), no time slot conflict. ### Reject Body ```json { "reason": "Onvoldoende ervaring voor deze rol." } ``` ### Bulk Approve Body ```json { "assignment_ids": ["ulid1", "ulid2", ...] } ``` Response includes per-assignment result: `approved` or `skipped` (with reason). ### ShiftAssignmentResource ```json { "id": "01JXYZ...", "shift_id": "01JXYZ...", "person_id": "01JXYZ...", "time_slot_id": "01JXYZ...", "status": "pending_approval", "auto_approved": false, "assigned_by": null, "assigned_at": "2026-04-10T12:00:00+00:00", "approved_by": null, "approved_at": null, "rejection_reason": null, "hours_expected": null, "hours_completed": null, "checked_in_at": null, "checked_out_at": null, "is_cancellable": true, "is_approvable": true, "created_at": "2026-04-10T12:00:00+00:00", "person": { "..." }, "shift": { "..." } } ``` ### Status Transitions - `pending_approval` → `approved`, `rejected`, `cancelled` - `approved` → `cancelled`, `completed` - `rejected` → (terminal) - `cancelled` → (terminal) - `completed` → (terminal) ### Authorization | Action | Who | |--------|-----| | assign | org_admin, event_manager | | claim | authenticated org member | | approve / reject / bulk-approve | org_admin, event_manager | | cancel | org_admin, event_manager, or the volunteer's own user | ## Volunteer Availabilities - `GET /organisations/{org}/events/{event}/persons/{person}/availabilities` — list availabilities - `POST /organisations/{org}/events/{event}/persons/{person}/availabilities/sync` — sync (replace all) ### Sync Body ```json { "availabilities": [ { "time_slot_id": "01JXYZ...", "preference_level": 5 }, { "time_slot_id": "01JABC...", "preference_level": 2 } ] } ``` Replaces all existing availabilities for the person. `preference_level` is optional (default: 3, range: 1–5). Validates: - All `time_slot_id`s must belong to the event (or parent festival) - Time slot `person_type` must match the person's crowd type `system_type` ## Persons - `GET /organisations/{org}/events/{event}/persons` - `POST /organisations/{org}/events/{event}/persons` - `GET /organisations/{org}/events/{event}/persons/{person}` - `PUT /organisations/{org}/events/{event}/persons/{person}` - `POST /organisations/{org}/events/{event}/persons/{person}/approve` - `DELETE /organisations/{org}/events/{event}/persons/{person}` - `POST /organisations/{org}/events/{event}/persons/from-member` — create person from org member - `GET /organisations/{org}/members/available-for-event/{event}` — list members not yet added to event ### Create Person from Member `POST /organisations/{org}/events/{event}/persons/from-member` Creates a Person record from an existing organisation member. The person is created with `status: approved` and `user_id` pre-linked. ```json { "user_id": "01JXYZ...", "crowd_type_id": "01JXYZ..." } ``` Person data (first_name, last_name, email) is copied from the user account. Returns `PersonResource` (201). **Validation:** - User must belong to the organisation - User must not already be a person at this event (422) - crowd_type_id must belong to the organisation ### Available Members for Event `GET /organisations/{org}/members/available-for-event/{event}` Returns organisation members who do NOT yet have a Person record at the specified event. Used to populate the "Lid toevoegen" dialog. ```json { "data": [ { "id": "01JXYZ...", "first_name": "Jan", "last_name": "de Vries", "full_name": "Jan de Vries", "email": "jan@example.nl" } ] } ``` Auth: org member or higher on the organisation. ## Identity Matches ### Endpoints - `GET /organisations/{org}/identity-matches` — list pending matches for the organisation (paginated, 25 per page) - `GET /organisations/{org}/persons/{person}/identity-match` — show pending match for a specific person - `POST /organisations/{org}/identity-matches/{match}/confirm` — confirm a match (links `person.user_id`, dismisses other pending matches, syncs tags) - `POST /organisations/{org}/identity-matches/{match}/dismiss` — dismiss a match (hidden, person stays unlinked, not re-suggested) - `POST /organisations/{org}/identity-matches/{match}/revert` — revert a confirmed match (unlinks `person.user_id`, status → `reverted`) - `POST /organisations/{org}/identity-matches/bulk-confirm` — bulk confirm multiple matches - `POST /organisations/{org}/events/{event}/persons/{person}/manual-link` — manually link a person to a user account (body: `{ "user_id": "ulid" }`) - `POST /organisations/{org}/events/{event}/persons/{person}/unlink` — unlink a person from their user account ### Match Types (`IdentityMatchMethod`) | Value | Description | Confidence | | ------------ | ------------------------------------ | ---------- | | `email` | Exact email match within org | `high` | | `name_fuzzy` | Levenshtein fuzzy name match | `medium` (or `high` if DOB also matches) | | `manual` | Organiser-initiated manual link | `high` | ### Match Confidence (`IdentityMatchConfidence`) | Value | Description | | -------- | -------------------------------------------------------- | | `high` | High certainty — exact email, or fuzzy name + DOB match | | `medium` | Moderate certainty — fuzzy name match without DOB | ### Match Status (`IdentityMatchStatus`) | Value | Description | | ----------- | ------------------------------------------------- | | `pending` | Awaiting organiser review | | `confirmed` | Organiser confirmed — `person.user_id` is linked | | `dismissed` | Organiser dismissed — not re-suggested | | `reverted` | Previously confirmed, then unlinked | ### Detection Matches are detected automatically via `PersonObserver`: - **On Person create**: if person has no `user_id` and has an email or name, `PersonIdentityService::detectMatches()` runs - **On Person email update**: if person's email changed and person is unlinked, detection re-runs - **On user creation**: `PersonIdentityService::detectMatchesForUser()` finds all unlinked persons with matching email Detection strategies (in priority order): 1. **Exact email** within same organisation → `email` / `high` 2. **Fuzzy name** (Levenshtein distance ≤2 for short names, ≤3 for longer) → `name_fuzzy` / `medium` 3. **Fuzzy name + DOB match** → upgrades to `high` confidence No silent auto-linking. Every identity link requires explicit confirmation. ### Bulk Confirm `POST /organisations/{org}/identity-matches/bulk-confirm` Body: `{ "match_ids": ["ulid1", "ulid2", ...] }` (max 100) Response: `{ "confirmed": 2, "errors": [{ "match_id": "ulid3", "error": "User already has a person record with this crowd type in this event." }] }` ### PersonResource enrichment `GET /organisations/{org}/events/{event}/persons` now includes: ```json { "has_user_account": true, "user_account": { "id": "ulid", "email": "jan@example.nl", "full_name": "Jan de Vries" }, "pending_identity_match": { "match_id": "ulid", "matched_user": { "id": "ulid", "first_name": "Jan", "last_name": "de Vries", "full_name": "Jan de Vries", "email": "jan@example.nl", "date_of_birth": "1990-01-01" }, "matched_on": "email", "matched_on_label": "E-mail match", "confidence": "high", "confidence_label": "Hoge zekerheid", "match_details": { "matched_fields": ["email"], "..." : "..." } } } ``` ## Crowd Lists - `GET /organisations/{org}/events/{event}/crowd-lists` — list all crowd lists for event (includes `persons_count`) - `POST /organisations/{org}/events/{event}/crowd-lists` — create crowd list - `PUT /organisations/{org}/events/{event}/crowd-lists/{list}` — update crowd list - `DELETE /organisations/{org}/events/{event}/crowd-lists/{list}` — delete crowd list - `GET /organisations/{org}/events/{event}/crowd-lists/{list}/persons` — list persons on a crowd list (paginated, 50/page, includes `crowd_list_pivot`) - `POST /organisations/{org}/events/{event}/crowd-lists/{list}/persons` — add person to list - `DELETE /organisations/{org}/events/{event}/crowd-lists/{list}/persons/{person}` — remove person from list ### Create/Update Body ```json { "crowd_type_id": "01JXYZ...", "name": "VIP Gastenlijst", "type": "internal|external", "recipient_company_id": "01JXYZ... (nullable, for external lists)", "auto_approve": false, "max_persons": 50 } ``` ### Add Person Body ```json { "person_id": "01JXYZ..." } ``` **Business rules:** - `max_persons`: when set, adding a person beyond the limit returns 422 - `auto_approve`: when true, adding a person with status `pending` automatically sets their status to `approved` - Duplicate person on same list returns 422 ### CrowdListResource ```json { "id": "01JXYZ...", "event_id": "01JXYZ...", "crowd_type_id": "01JXYZ...", "name": "VIP Gastenlijst", "type": "internal", "recipient_company_id": null, "auto_approve": false, "max_persons": 50, "is_full": false, "created_at": "2026-04-10T12:00:00+00:00", "persons_count": 12 } ``` ## Locations - `GET /organisations/{org}/events/{event}/locations` - `POST /organisations/{org}/events/{event}/locations` - `PUT /organisations/{org}/events/{event}/locations/{location}` - `DELETE /organisations/{org}/events/{event}/locations/{location}` ## Person Tags (Organisation Settings) - `GET /organisations/{org}/person-tags` — list active tags (ordered) - `POST /organisations/{org}/person-tags` — create tag - `PUT /organisations/{org}/person-tags/{tag}` — update tag - `DELETE /organisations/{org}/person-tags/{tag}` — deactivate tag (soft: sets `is_active = false`) - `GET /organisations/{org}/person-tag-categories` — distinct categories for autocomplete ## User Tag Assignments - `GET /organisations/{org}/users/{user}/tags` — list all tags for user in organisation - `POST /organisations/{org}/users/{user}/tags` — assign a tag - `DELETE /organisations/{org}/users/{user}/tags/{tagAssignment}` — remove assignment - `PUT /organisations/{org}/users/{user}/tags/sync` — sync tags by source > **Sync endpoint:** Replaces tags of the specified `source` only. > Body: `{ "tag_ids": ["ulid1", "ulid2"], "source": "self_reported" }` > Removes `self_reported` tags not in the list, adds new ones, leaves `organiser_assigned` untouched (and vice versa). ### Person list tag filtering - `GET /organisations/{org}/events/{event}/persons?tag={person_tag_id}` — filter persons by single tag - `GET /organisations/{org}/events/{event}/persons?tags=ulid1,ulid2` — filter persons by multiple tags (AND logic: must have all) ## Portal - `POST /portal/token-auth` — public. Validates a portal token against artists/production_requests tables. Returns `{ context, data, event }` on success. Returns 501 if token tables don't exist yet, 401 if token is invalid. - `GET /portal/me` — auth:sanctum. Returns the authenticated user's person record for a given event. Query param: `event_id` (required, ULID). Resolves sub-events to parent festival. Returns `PersonResource` with crowdType, shiftAssignments, and volunteerAvailabilities eager-loaded. Returns 404 if no registration found. - `GET /portal/my-shifts` — auth:sanctum. Returns all active shift assignments across all events for the authenticated user. Finds all Person records linked via `user_id` (approved/pending status), then returns their active assignments (approved/pending_approval). Response grouped by event → date. - `GET /portal/events/{event}/available-shifts` — auth:sanctum. Returns shifts available to claim, grouped by date → time slot. Requires approved person status. - `GET /portal/events/{event}/my-shifts` — auth:sanctum. Returns the user's shift assignments for a specific event, categorized as upcoming/past/cancelled. - `POST /portal/events/{event}/shifts/{shift}/claim` — auth:sanctum. Claim a shift. Returns assignment with status (pending_approval or approved based on section auto-accept). - `POST /portal/events/{event}/assignments/{shiftAssignment}/cancel` — auth:sanctum. Cancel own assignment. Must be future and cancellable status. ### Portal My-Shifts Response ```json { "data": [ { "event": { "id": "ulid", "name": "Festival X", "start_date": "2026-07-01", "end_date": "2026-07-03" }, "assignments": [ { "date": "2026-07-01", "date_label": "Woensdag 1 juli", "shifts": [ { "id": "ulid", "status": "approved", "shift": { "id": "ulid", "title": "Tapper", "section_name": "Bar", "section_icon": "tabler-beer", "time_slot_name": "Avond", "date": "2026-07-01", "start_time": "18:00", "end_time": "23:00", "report_time": "17:30", "location": { "name": "Hoofdpodium", "address": "Festivalplein 1" } } } ] } ] } ] } ``` ## Form Builder Universal form builder per `/dev-docs/ARCH-FORM-BUILDER.md`. Replaces the legacy registration-form-fields / person-field-values / registration-field-templates / person-section-preferences endpoints purged in S2a. All authenticated routes are namespaced under `/organisations/{organisation}/forms/*` with `auth:sanctum` + FormBuilder policies. Public routes live at `/public/forms/*`. ### Authenticated — Form Schemas - `GET /organisations/{organisation}/forms/schemas` — paginated list (default 25). Returns `FormSchemaSummaryResource` items. - `POST /organisations/{organisation}/forms/schemas` — body: `{ name, purpose, submission_mode?, locale?, snapshot_mode?, freeze_on_submit?, retention_days?, consent_version?, ... }`. Returns `FormSchemaResource`. - `GET /organisations/{organisation}/forms/schemas/{form_schema}` — full resource with filtered `fields`. - `PUT /organisations/{organisation}/forms/schemas/{form_schema}` — updates fields; structural changes bump `version`. - `DELETE /organisations/{organisation}/forms/schemas/{form_schema}` — soft delete. If submissions exist, requires `?confirmed_name=` per §22.8 (422 without). - `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` — body: `{ grace_days?: int (default 7) }`. Moves current `public_token` to `public_token_previous`; old token returns 410 after grace window. - `POST /organisations/{organisation}/forms/schemas/{form_schema}/edit-lock` — 409 if another user holds a valid lock. - `DELETE /organisations/{organisation}/forms/schemas/{form_schema}/edit-lock` ### Authenticated — Form Fields (within a schema) - `GET /organisations/{organisation}/forms/schemas/{form_schema}/fields` - `POST /organisations/{organisation}/forms/schemas/{form_schema}/fields` — body validates `field_type` against `FormFieldType` enum + any registered `custom_field_types`. - `PUT /organisations/{organisation}/forms/schemas/{form_schema}/fields/{form_field}` — setting `force_binding_change=true` bypasses the §6.5 guard. - `DELETE /organisations/{organisation}/forms/schemas/{form_schema}/fields/{form_field}` — requires `?confirmed_name=` when the field has values. - `POST /organisations/{organisation}/forms/schemas/{form_schema}/fields/reorder` — body: `{ field_ids: [, ...] }`. - `POST /organisations/{organisation}/forms/schemas/{form_schema}/fields/insert-from-library` — body: `{ library_field_id, overrides? }`. ### Authenticated — Form Submissions - `GET /organisations/{organisation}/forms/schemas/{form_schema}/submissions` - `POST /organisations/{organisation}/forms/schemas/{form_schema}/submissions` — creates a draft. Body: `{ subject_type?, subject_id?, is_test?, opened_at?, idempotency_key? }`. - `GET /organisations/{organisation}/forms/submissions/{form_submission}` - `PUT /organisations/{organisation}/forms/submissions/{form_submission}/field-values` — bulk upsert draft values. Body: `{ values: { : } }`. 403 when `FieldAccessService::canWrite` rejects a slug. - `POST /organisations/{organisation}/forms/submissions/{form_submission}/submit` — optional `values` accepted in-place. On submit: stores `schema_version_at_submit`; when `schema.snapshot_mode != 'never'` stores `schema_snapshot`; computes SIGNATURE hashes per §9; fires `FormSubmissionSubmitted` — **triggering the §31.10 TAG_PICKER sync listener**. - `POST /organisations/{organisation}/forms/submissions/{form_submission}/review` — body: `{ status: FormSubmissionReviewStatus, review_notes? }`. - `POST /organisations/{organisation}/forms/submissions/{form_submission}/delegate` — body: `{ delegated_to_user_id (scoped to org), message? }`. - `DELETE /organisations/{organisation}/forms/submissions/{form_submission}/delegations/{delegation}` - `DELETE /organisations/{organisation}/forms/submissions/{form_submission}` ### Authenticated — Templates, Field Library, Webhooks - `GET/POST/PUT/DELETE /organisations/{organisation}/forms/templates[/{form_template}]` — system templates are read-only for non-super-admins. - `GET/POST/PUT/DELETE /organisations/{organisation}/forms/field-library[/{field_library}]` - `GET/POST/PUT/DELETE /organisations/{organisation}/forms/schemas/{form_schema}/webhooks[/{webhook}]` — responses return `url_host` + `has_secret`; the raw URL and secret never leak out. ### Authenticated — Filter Registry - `GET /organisations/{organisation}/forms/filter-registry?event_id=` — combines entity_column definitions (`config/form_filter_registry.php`) with TAG_PICKER-backed tags and every `is_filterable=true` `form_field`. Response items carry a `source` discriminator of `entity_column` / `tags` / `form_field`. Cached per `(organisation_id, event_id?)`; used by the Personen module through `FilterQueryBuilder` (ARCH §7.4–§7.5). The builder rejects filters referencing invisible fields with 403 (tied to `FieldAccessService`). ### Public (no auth, rate-limited) - `GET /public/forms/{public_token}` — returns `PublicFormSchemaResource` (portal-visible, non-admin-only fields only; no PII hints; no submissions_count; no role_restrictions bleed). `public_token` is matched against `form_schemas.public_token` first and `public_token_previous` second; if the rotated token has exceeded the 7-day grace window the response is 410 Gone. - `POST /public/forms/{public_token}/submissions` — body: `{ values, public_submitter_name?, public_submitter_email?, captcha_token?, idempotency_key? }`. Captcha (Cloudflare Turnstile) is enforced for purposes listed under `config('form_builder.captcha.required_for_purposes')`. Rate-limited per-IP per-token per-hour (`form_builder.limits.max_submissions_per_public_schema_per_ip_per_hour`). Private-IP webhook targets are rejected SSRF-style in `DeliverFormWebhookJob`. ### Response shapes `FormSchemaResource` includes `fields_count`, `submissions_count`, `has_submissions`, `is_locked`, `public_form_url` (when `public_token` is set), and a filtered `fields` collection. `FormSubmissionResource.values` is keyed by field slug and already filtered through `FieldAccessService::filterVisibleFields` so admin-only fields never leak to non-admins. `FormFieldResource` carries `available_tags` (category-filtered) for `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). ### Error envelope All public form endpoints return errors with this envelope: ```json { "message": "Human-readable message", "code": "MACHINE_READABLE_CODE", "errors": { "field_name": ["validation message"] } } ``` `errors` is only present on 422 validation failures (Laravel FormRequest shape). Codes: | Code | HTTP | Meaning | | ----------------------------- | ---- | ---------------------------------------------- | | `TOKEN_EXPIRED` | 410 | public token past grace window | | `TOKEN_REVOKED` | 410 | token rotated without grace | | `SCHEMA_UNPUBLISHED` | 410 | schema exists but not published | | `SCHEMA_NOT_FOUND` | 404 | public token does not resolve | | `SUBMISSION_ALREADY_SUBMITTED`| 409 | submit on finalised submission | | `RATE_LIMITED` | 429 | includes `Retry-After` header | | `VALIDATION_FAILED` | 422 | per-field validation — see `errors` map below | Authoritative source: `api/app/Exceptions/FormBuilder/PublicFormApiException.php` and its concrete subclasses. `VALIDATION_FAILED` returns the `errors` field keyed by `values.{slug}` with an array of Dutch messages. Field-shape triggers include: - **SECTION_PRIORITY** — values must be `{ section_id, priority }[]` with unique section_ids and priorities in `1..5`, max 5 entries, and section_ids scoped to the schema's owner event tree. Specific messages land under `errors."values.{slug}"`: - `"Dezelfde sectie mag slechts één keer worden opgegeven."` - `"Elke prioriteit mag slechts één keer worden toegekend."` - `"priority moet tussen 1 en 5 liggen (positie {n})."` - `"Je kunt maximaal 5 voorkeuren opgeven."` - `"Eén of meer secties horen niet bij dit evenement."` - `"Ongeldig formaat voor sectievoorkeuren."` / `"Ongeldig voorkeur-element op positie {n}."` / `"section_id ontbreekt op positie {n}."` / `"priority ontbreekt of is ongeldig op positie {n}."` Authoritative source for shape rules: `api/app/Services/FormBuilder/FormValueService::validateSectionPriorityShape`. ### `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`: - `?section_preference={section_id}` — filter by section preference (has this section as any priority) - `?has_preference=true` — only persons who submitted section preferences Form-field-value filtering (`?field[slug]=value`) was served by the legacy endpoints that were purged in S2a. It returns in S2b on top of `form_values` + `form_value_options` via the FilterQueryBuilder described in `/dev-docs/ARCH-FORM-BUILDER.md` §7. _(Extend this contract per module as endpoints are implemented.)_ ## Platform Admin All admin endpoints require `auth:sanctum` + `role:super_admin`. They bypass OrganisationScope and query across all organisations. Base path: `/api/v1/admin/` ### Admin Organisations - `GET /admin/organisations` — list all organisations (paginated) - `GET /admin/organisations/{organisation}` — show with counts and total persons - `POST /admin/organisations` — not supported (405), use regular endpoint - `PUT /admin/organisations/{organisation}` — update name, slug, billing_status, settings - `DELETE /admin/organisations/{organisation}` — soft delete #### Query Parameters (index) - `search` — filter by name or slug (partial match) - `billing_status` — filter by billing status (`trial`, `active`, `suspended`, `cancelled`) - `sort` — sort field: `name` (default), `created_at` - `direction` — sort direction: `asc` (default), `desc` #### AdminOrganisationResource ```json { "id": "01JXYZ...", "name": "Festival Corp", "slug": "festival-corp", "billing_status": "active", "billing_status_label": "Active", "settings": {}, "events_count": 5, "users_count": 12, "total_persons": 342, "created_at": "2026-01-15T10:00:00+00:00", "updated_at": "2026-04-10T12:00:00+00:00", "deleted_at": null } ``` #### Update Body ```json { "name": "Festival Corp", "slug": "festival-corp", "billing_status": "suspended", "settings": {} } ``` ### Admin Users - `GET /admin/users` — list all users with organisation memberships (paginated) - `GET /admin/users/{user}` — show with organisations and roles - `PUT /admin/users/{user}` — update name, email, timezone, locale, platform roles - `DELETE /admin/users/{user}` — soft delete + revoke all tokens #### Query Parameters (index) - `search` — filter by first_name, last_name, or email (partial match) - `organisation_id` — filter by organisation membership - `role` — filter by Spatie role name #### AdminUserResource ```json { "id": "01JXYZ...", "first_name": "Jan", "last_name": "de Vries", "full_name": "Jan de Vries", "email": "jan@example.nl", "avatar": null, "timezone": "Europe/Amsterdam", "locale": "nl", "email_verified_at": "2026-01-15T10:00:00+00:00", "created_at": "2026-01-15T10:00:00+00:00", "roles": ["super_admin"], "is_super_admin": true, "organisations": [ { "id": "01JXYZ...", "name": "Festival Corp", "slug": "festival-corp", "role": "org_admin" } ] } ``` #### Update Body ```json { "first_name": "Jan", "last_name": "de Vries", "email": "jan@example.nl", "timezone": "Europe/Amsterdam", "locale": "nl", "roles": ["super_admin"] } ``` `roles` accepts platform-level roles only: `super_admin`, `support_agent`. Organisation/event roles are managed via the regular endpoints. ### Admin Stats - `GET /admin/stats` — platform-wide aggregate counts #### Response ```json { "data": { "organisations": { "total": 15, "by_billing_status": { "trial": 3, "active": 10, "suspended": 1, "cancelled": 1 } }, "events": { "total": 42, "by_status": { "draft": 10, "published": 8, "registration_open": 12, "showday": 5, "closed": 7 } }, "users": { "total": 156, "verified": 142 }, "persons": { "total": 2340 } } } ``` ### Admin Activity Log - `GET /admin/activity-log` — paginated activity log (25 per page) #### Query Parameters - `causer_id` — filter by user who caused the action - `subject_type` — filter by subject model type - `log_name` — filter by log name (e.g. `admin`, `default`) - `from` — filter from date (ISO 8601) - `to` — filter to date (ISO 8601) #### Response ```json { "data": [ { "id": 1, "log_name": "admin", "description": "Updated organisation Festival Corp", "event": "admin.organisation.updated", "causer": { "id": "01JXYZ...", "name": "Super Admin", "email": "admin@crewli.app" }, "subject_type": "App\\Models\\Organisation", "subject_id": "01JXYZ...", "properties": { "billing_status": "suspended" }, "created_at": "2026-04-14T10:00:00+00:00" } ], "meta": { "current_page": 1, "last_page": 1, "per_page": 25, "total": 1 } } ``` ### Admin Impersonation Header-based impersonation with MFA verification. See `AUTH_ARCHITECTURE.md` section 10 for full details. **Endpoints:** - `POST /admin/impersonate/{user}` — start impersonation (requires `role:super_admin` + MFA) - `POST /admin/stop-impersonation` — stop impersonation (requires `auth:sanctum` only — admin calls without `X-Impersonate-User` header) - `GET /admin/impersonate/status` — check active session (requires `role:super_admin`) - `POST /admin/impersonate/send-mfa-code` — send email verification code to admin (requires `role:super_admin`) #### Start Request ```json { "reason": "Investigating user issue with shift assignments", "mfa_code": "123456", "mfa_method": "totp" } ``` | Field | Type | Rules | |-------|------|-------| | `reason` | string | required, min:5, max:500 | | `mfa_code` | string | required | | `mfa_method` | string | required, in: `totp`, `email`, `backup_code` | #### Start Response ```json { "data": { "session": { "id": "01JXYZ...", "admin_id": "01JXYZ...", "target_user_id": "01JXYZ...", "reason": "Investigating user issue", "mfa_method": "totp", "started_at": "2026-04-16T12:00:00+00:00", "expires_at": "2026-04-16T13:00:00+00:00", "ended_at": null, "end_reason": null, "actions_count": 0 }, "user": { "...AdminUserResource..." } } } ``` #### Stop Response ```json { "data": { "user": { "...AdminUserResource (original admin)..." } } } ``` #### Status Response ```json { "data": { "active": true, "session": { "...ImpersonationSessionResource..." } } } ``` #### Impersonation Header During an active session, the frontend sends `X-Impersonate-User: {target_user_id}` on every request. The `HandleImpersonation` middleware validates the header against the cached session and swaps auth context. #### Business Rules - Admin must have MFA enabled (403 if not) - Cannot impersonate another super_admin (403) - Cannot nest impersonation sessions (403) - Cannot impersonate a user already being impersonated (403) - MFA code verified against admin's own MFA (TOTP, email, or backup code) - Sessions expire after 60 minutes (sliding TTL, extended on each request) - IP pinning: session terminated if admin's IP changes - Sensitive routes blocked during impersonation (auth/*, me/*, admin/impersonate/*) - All activity during session tagged with `impersonated_by` in properties - Immutable audit trail in `impersonation_sessions` table ## Email Settings (org admin) - `GET /organisations/{org}/email-settings` — get current email branding settings (returns defaults if none configured) - `PUT /organisations/{org}/email-settings` — create or update email branding settings ### PUT body ```json { "logo_url": "https://example.com/logo.png", "primary_color": "#FF5500", "secondary_color": "#CC4400", "footer_text": "© 2026 Stichting Feestfabriek", "reply_to_email": "info@festival.nl", "reply_to_name": "Festival Team" } ``` All fields are optional/nullable. Colors must match `#[0-9A-Fa-f]{6}`. ## Email Templates (org admin) - `GET /organisations/{org}/email-templates` — list all template types with current content (custom or default) - `GET /organisations/{org}/email-templates/{type}` — get single template with both custom content and defaults - `PUT /organisations/{org}/email-templates/{type}` — create or update custom template for this type - `DELETE /organisations/{org}/email-templates/{type}` — reset to system default (deletes custom override) - `POST /organisations/{org}/email-templates/{type}/preview` — render email HTML with sample data - `POST /organisations/{org}/email-templates/{type}/send-test` — send test email. Body: `{ "email": "test@example.com" }` ### Template types `invitation`, `password_reset`, `email_verification`, `registration_approved`, `registration_rejected`, `shift_assignment` ### PUT body ```json { "subject": "Aangepaste uitnodiging voor {organisation_name}", "heading": "Welkom!", "body_text": "Aangepaste tekst met {organisation_name} variabelen.", "button_text": "Klik hier" } ``` ### GET response (index) Returns array of all template types with resolved content: ```json { "data": [ { "type": "invitation", "label": "Uitnodiging", "is_custom": false, "subject": "Je bent uitgenodigd voor {organisation_name}", "heading": "Welkom bij {organisation_name}!", "body_text": "...", "button_text": "Uitnodiging accepteren", "defaults": { "subject": "...", "heading": "...", "body_text": "...", "button_text": "..." } } ] } ``` ### Template variables - `{organisation_name}` — available in all templates - `{event_name}` — available in registration and shift templates - `{shift_title}`, `{shift_date}`, `{shift_start}`, `{shift_end}`, `{section_name}` — shift assignment only ## Email Logs (org admin, read-only) - `GET /organisations/{org}/email-logs` — paginated email log ### Query parameters | Param | Type | Description | | --------------- | ------ | ------------------------------------ | | `search` | string | Search by recipient_email | | `status` | string | Filter: `queued\|sent\|failed` | | `template_type` | string | Filter by EmailTemplateType | | `event_id` | ULID | Filter by event | | `person_id` | ULID | Filter by person | | `from` | date | Start of date range | | `to` | date | End of date range | | `per_page` | int | Results per page (default 15) | ### Response ```json { "data": { "data": [ { "id": "01JXYZ...", "recipient_email": "volunteer@test.nl", "recipient_name": "Jan Janssen", "template_type": "registration_approved", "template_label": "Registratie goedgekeurd", "subject": "Je registratie voor Festival X is goedgekeurd!", "status": "sent", "error_message": null, "queued_at": "2026-04-15T12:00:00+00:00", "sent_at": "2026-04-15T12:00:05+00:00", "failed_at": null, "triggered_by": { "id": "01JXYZ...", "name": "Admin User" }, "event_id": "01JXYZ...", "person_id": "01JXYZ...", "created_at": "2026-04-15T12:00:00+00:00" } ], "links": { "...pagination..." }, "meta": { "...pagination..." } } } ```