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

1409 lines
52 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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: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`
```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: 15).
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 (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:
```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..." }
}
}
```