Files
crewli/dev-docs/API.md
bert.hausmans a97922d6a4 docs(form-builder): document S3a PR 2 complex field types and update FORM-05 stub note
- Add VitePress pages for AVAILABILITY_PICKER and SECTION_PRIORITY
  and a TAG_PICKER configuration note. Wire them into the organisator
  sidebar under a new Formulieren section alongside the existing
  "Wat is een formulier" page.
- BACKLOG.md: nuance FORM-05 — the stub-shaped behaviour for public
  event_registration submissions is already shipping via the existing
  TriggerPersonIdentityMatchOnFormSubmit listener (writes 'pending').
  The real work (PersonIdentityService::detectMatchesByValues + an
  extra branch in resolveStatus) is what remains. Added a done entry
  for S3a PR 2 to the Opgeloste items list.
- API.md: add VALIDATION_FAILED to the public-form error code table
  and document the SECTION_PRIORITY shape error messages (Dutch copy
  served under errors."values.{slug}").
- COPY_CATALOGUE.md: new S3a PR 2 section capturing the seeder
  help_text, the IdentityMatchBanner copy (clearly marking the
  backend message as authoritative), all empty/error state copy for
  the three new components, and the SECTION_PRIORITY shape error
  strings.

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

52 KiB
Raw Permalink Blame History

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

{
  "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

{
  "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

{
  "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.

{
  "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:0018: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

{ "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

{ "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

{ "reason": "Onvoldoende ervaring voor deze rol." }

Bulk Approve Body

{ "assignment_ids": ["ulid1", "ulid2", ...] }

Response includes per-assignment result: approved or skipped (with reason).

ShiftAssignmentResource

{
  "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_approvalapproved, rejected, cancelled
  • approvedcancelled, 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

{
  "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: 15).

Validates:

  • All time_slot_ids 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.

{
  "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.

{
  "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:

{
  "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

{
  "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

{
  "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

{
  "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

{
  "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=<schema.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=<field.label> when the field has values.
  • POST /organisations/{organisation}/forms/schemas/{form_schema}/fields/reorder — body: { field_ids: [<ulid>, ...] }.
  • 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: { <slug>: <value_or_array> } }. 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 FormSubmissionSubmittedtriggering 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=<ulid?> — 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:

{
  "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:

{
  "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:

{
  "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:

{
  "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:

{
  "idempotency_key": "01HZ...",
  "opened_at": "2026-04-17T20:20:00+00:00",
  "submitted_in_locale": "nl",
  "public_submitter_name": "Anonieme tester",
  "public_submitter_email": "test@example.nl"
}
  • idempotency_key is REQUIRED (630 chars). Duplicate replay returns the existing draft with HTTP 200 instead of 201.
  • Response is a PublicFormSubmissionResource with status: "draft" and schema_version_at_open stamped.

PUT /public/forms/{public_token}/submissions/{submission_id} — auto-save

Body:

{
  "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:

{
  "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:

{
  "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

{
  "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

{
  "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

{
  "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

{
  "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

{
  "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

{
  "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

{
  "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

{
  "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

{
  "data": {
    "user": { "...AdminUserResource (original admin)..." }
  }
}

Status Response

{
  "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

{
  "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

{
  "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:

{
  "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

{
  "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..." }
  }
}