- 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>
1409 lines
52 KiB
Markdown
1409 lines
52 KiB
Markdown
# 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=<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
|
||
`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=<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:
|
||
|
||
```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..." }
|
||
}
|
||
}
|
||
```
|