Files
crewli/dev-docs/API.md
bert.hausmans 4df668b5b8 feat: replace token-based impersonation with enterprise-grade header-based system
Replaces the insecure token-in-localStorage approach with a header-based
impersonation system backed by cache sessions and MFA verification.

Key changes:
- New impersonation_sessions audit table (immutable, ULID PK)
- MFA verification required to start impersonation (TOTP/email/backup)
- X-Impersonate-User header + HandleImpersonation middleware
- Per-request auth context swap (admin session never modified)
- IP pinning, sensitive route blocking, no nesting, sliding 60-min TTL
- Activity log auto-tagged with impersonated_by during sessions
- Frontend: sessionStorage, BroadcastChannel sync, countdown timer
- ImpersonateDialog with reason + MFA verification flow
- 26 comprehensive tests covering core, middleware, audit, lifecycle

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 02:42:53 +02:00

1131 lines
42 KiB
Markdown
Raw 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)
## Public Registration Data
- `GET /public/events/{slug}/registration-data` — public, no auth. Returns event info, available sections, and volunteer time slots for the registration form. Only returns events with status `registration_open`. Only includes sections with `show_in_registration = true` and `type = standard`. For festivals: returns child event sections only (deduplicated by name), excluding parent operational sections. Only includes time slots with `person_type = VOLUNTEER`. Resolves sub-events to parent festival.
### Response
```json
{
"data": {
"event": { "id": "01JXYZ...", "name": "Echt Feesten 2026", "start_date": "2026-07-10", "end_date": "2026-07-12", "organisation_id": "01JXYZ..." },
"sections": [{ "id": "01JXYZ...", "name": "Hoofdpodium Bar", "category": "Bar", "icon": "tabler-glass", "registration_description": "Tap bier en drankjes voor festivalgangers" }],
"time_slots": [{ "id": "01JXYZ...", "name": "Vrijdag Avond", "date": "2026-07-10", "start_time": "18:00:00", "end_time": "02:00:00", "duration_hours": 8 }]
}
}
```
### Error Responses
- `404` — Event not found or not accepting registrations
## Volunteer Registration
- `POST /events/{event}/volunteer-register` — public, auth-aware (optional Sanctum). Registers a volunteer for an event. Resolves sub-events to the parent festival. Accepts name, email, phone, tshirt_size, motivation, section_preferences, availabilities. Authenticated users have their name/email taken from the auth token. Returns `PersonResource` (201 on new, 200 on re-registration of rejected person).
## 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" }
}
}
]
}
]
}
]
}
```
## Registration Field Templates (Organisation Settings)
- `GET /organisations/{org}/registration-field-templates` — list active templates (ordered)
- `POST /organisations/{org}/registration-field-templates` — create template
- `PUT /organisations/{org}/registration-field-templates/{template}` — update template
- `DELETE /organisations/{org}/registration-field-templates/{template}` — delete (org-created) or deactivate (system)
> Templates: organisation-level reusable field definitions. System templates
> are seeded on org creation. Org-admins can customize and add their own.
## Registration Form Fields (Event Settings)
- `GET /organisations/{org}/events/{event}/registration-fields` — list all fields (ordered by sort_order)
- `POST /organisations/{org}/events/{event}/registration-fields` — create field (manually or from template)
- `POST /organisations/{org}/events/{event}/registration-fields/from-template` — create field from template
- `PUT /organisations/{org}/events/{event}/registration-fields/{field}` — update field
- `DELETE /organisations/{org}/events/{event}/registration-fields/{field}` — delete field definition (answers preserved)
- `POST /organisations/{org}/events/{event}/registration-fields/reorder` — bulk reorder
- `POST /organisations/{org}/events/{event}/registration-fields/import-from-event` — copy fields from another event
### From-Template Body
```json
{ "template_id": "ulid" }
```
Creates a COPY of the template as an event field. The copy is independent — changes don't propagate back to the template.
### Import Body
```json
{ "source_event_id": "ulid" }
```
Copies all `registration_form_fields` from the source event. Source must belong to the same organisation. Existing fields on the target event are kept.
### Tag Picker Fields
For `tag_picker` fields: the API response includes `available_tags` array (from `person_tags`, filtered by `tag_category` if set) so the frontend knows which tags to render as options.
## Person Field Values
- `GET /organisations/{org}/events/{event}/persons/{person}/field-values` — all answers for a person
- `PUT /organisations/{org}/events/{event}/persons/{person}/field-values` — bulk upsert answers
### Bulk Upsert Body
```json
{
"values": {
"field_slug": "value_or_array",
"shirtmaat": "L",
"dieetwensen": ["Vegetarisch", "Glutenvrij"],
"certificaten": ["01JXYZ...", "01JABC..."]
}
}
```
Replaces all field values for this person in one request. Used by both the registration form and the organiser backend. For `tag_picker` fields: values are arrays of `person_tag_id` ULIDs. If person has a `user_id`, tag sync is triggered automatically.
## Person Section Preferences
- `GET /organisations/{org}/events/{event}/persons/{person}/section-preferences` — list preferences
- `PUT /organisations/{org}/events/{event}/persons/{person}/section-preferences` — replace all preferences
### Replace Body
```json
{
"preferences": [
{ "festival_section_id": "01JXYZ...", "priority": 1 },
{ "festival_section_id": "01JABC...", "priority": 2 },
{ "festival_section_id": "01JDEF...", "priority": 3 }
]
}
```
## Person List Filtering (extended)
Additional filter parameters on `GET /organisations/{org}/events/{event}/persons`:
- `?field[slug]=value` — filter by registration field value (exact match for single-value, `JSON_CONTAINS` for multiselect)
- `?section_preference={section_id}` — filter by section preference (has this section as any priority)
- `?has_preference=true` — only persons who submitted section preferences
_(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..." }
}
}
```