# Crewli API Contract Base path: `/api/v1/` Auth: Bearer token (Sanctum) — token delivered via httpOnly cookie, never in the JSON response body. See `/dev-docs/AUTH_ARCHITECTURE.md` for full details. ## Auth - `POST /auth/login` — returns user data in JSON body. The Sanctum bearer token is set as an httpOnly cookie via `Set-Cookie` header (not included in response body). - `POST /auth/logout` - `GET /auth/me` - `POST /auth/refresh` — rotates the Sanctum token: revokes current token, creates new one, sets new httpOnly cookie. Returns current user data. - `POST /auth/forgot-password` — request password reset (public, rate-limited). Body: `{ email, app: "app"|"portal"|"admin" }`. Always returns 200 (no email enumeration). - `POST /auth/reset-password` — reset password with token (public). Body: `{ token, email, password, password_confirmation }`. ## MFA (Multi-Factor Authentication) ### Login flow with MFA When MFA is enabled for a user, login becomes a two-step process: 1. `POST /auth/login` — if MFA is active, returns `{ mfa_required: true, mfa_session_token, methods, preferred_method, expires_in }` instead of the auth token 2. `POST /auth/mfa/verify` — submit the MFA code with the session token to complete login If the device is trusted (via `X-Device-Fingerprint` header), MFA is bypassed and login proceeds normally. ### MFA verification during login (public, rate-limited) - `POST /auth/mfa/verify` — verify MFA code during login. Body: `{ mfa_session_token, code, method: "totp"|"email"|"backup_code", trust_device?: bool, device_fingerprint?: string, device_name?: string }`. Returns user data + auth cookie on success. - `POST /auth/mfa/email/send` — send/resend email verification code during login. Body: `{ mfa_session_token }`. Rate-limited: 1 code per 60 seconds. ### MFA setup and management (authenticated) - `POST /auth/mfa/setup/totp` — start TOTP setup, returns `{ secret, qr_code_url, provisioning_uri }` - `POST /auth/mfa/setup/totp/confirm` — confirm TOTP with first code. Body: `{ code }`. Returns `{ mfa_enabled, method, backup_codes[] }` - `POST /auth/mfa/setup/email` — start email MFA setup (sends verification code) - `POST /auth/mfa/setup/email/confirm` — confirm email MFA. Body: `{ code }`. Returns `{ mfa_enabled, method, backup_codes[] }` - `POST /auth/mfa/disable` — disable MFA. Body: `{ code, method: "totp"|"backup_code" }`. Requires valid verification code. - `POST /auth/mfa/backup-codes` — regenerate backup codes. Body: `{ code }`. Requires valid TOTP code. - `GET /auth/mfa/status` — current MFA status: `{ mfa_enabled, method, confirmed_at, backup_codes_remaining, is_required }` ### Trusted devices (authenticated) - `GET /auth/trusted-devices` — list active trusted devices - `DELETE /auth/trusted-devices/{id}` — revoke a specific device - `DELETE /auth/trusted-devices` — revoke all devices ### Admin MFA management (super_admin) - `POST /admin/users/{user}/reset-mfa` — force-disable MFA for a user. Activity logged. ## Account Management (authenticated) - `POST /me/change-password` — change own password. Body: `{ current_password, password, password_confirmation }`. Revokes other sessions. - `POST /me/change-email` — request email change (sends verification to new address). Body: `{ new_email, password, app: "app"|"portal"|"admin" }`. - `POST /verify-email-change` — verify email change token (public). Body: `{ token }`. Revokes all sessions. ## Organisations - `GET /organisations` — list (super admin) - `POST /organisations` — create - `GET /organisations/{org}` — show - `PUT /organisations/{org}` — update - `GET /organisations/{org}/members` — members - `POST /organisations/{org}/invite` — invite user - `POST /organisations/{org}/members/{user}/change-email` — admin initiates email change for a member (org_admin only). Body: `{ new_email }`. ## Events - `GET /organisations/{org}/events` — list (top-level only by default) - `GET /organisations/{org}/events?include_children=true` — include sub-events nested in response - `GET /organisations/{org}/events?type=festival` — filter by event_type (festival|series|event) - `POST /organisations/{org}/events` — create (supports `parent_event_id` for sub-events) - `GET /organisations/{org}/events/{event}` — detail (includes children and parent if loaded) - `PUT /organisations/{org}/events/{event}` — update (does NOT accept `status` — use transition endpoint) - `POST /organisations/{org}/events/{event}/transition` — change event status via state machine (see below) - `GET /organisations/{org}/events/{event}/children` — list sub-events of a festival/series ### Event Status Transitions `POST /organisations/{org}/events/{event}/transition` Body: `{ "status": "published" }` Enforces a state machine: only valid forward (and select backward) transitions are allowed. Returns 422 with `errors`, `current_status`, `requested_status`, and `allowed_transitions` when the transition is invalid or prerequisites are missing. **Prerequisites checked:** - `→ published`: name, start_date, end_date required - `→ registration_open`: at least one time slot and one section required **Festival cascade:** Transitioning a festival parent to `showday`, `teardown`, or `closed` automatically cascades to all children in an earlier status. **EventResource** includes `allowed_transitions` (array of valid next statuses) so the frontend knows which buttons to show. ## Event Stats - `GET /organisations/{org}/events/{event}/stats` — aggregate dashboard counts for the event ### Response ```json { "data": { "persons_total": 142, "persons_approved": 98, "persons_pending": 31, "persons_rejected": 8, "persons_other": 5, "persons_approved_without_shift": 23, "pending_identity_matches": 3, "shifts_total": 45, "shifts_filled": 38, "shifts_understaffed": 7 } } ``` ## Crowd Types - `GET /organisations/{org}/crowd-types` - `POST /organisations/{org}/crowd-types` - `PUT /organisations/{org}/crowd-types/{type}` - `DELETE /organisations/{org}/crowd-types/{type}` ## Companies - `GET /organisations/{org}/companies` - `POST /organisations/{org}/companies` - `PUT /organisations/{org}/companies/{company}` - `DELETE /organisations/{org}/companies/{company}` ## Section Categories - `GET /organisations/{org}/section-categories` — distinct categories used across the organisation's events (for autocomplete). Returns `{ "data": ["Bar", "Podium", ...] }` ## Festival Sections - `GET /organisations/{org}/events/{event}/sections` - `POST /organisations/{org}/events/{event}/sections` - `PUT /organisations/{org}/events/{event}/sections/{section}` - `DELETE /organisations/{org}/events/{event}/sections/{section}` - `POST /organisations/{org}/events/{event}/sections/reorder` > **Festival context:** `{event}` can be a festival parent or a sub-event. > On a festival parent, sections are for operational planning (build-up, teardown). > For sub-events, `GET` automatically includes `cross_event` sections from the parent festival. > Shifts on cross_event sections must use the **parent festival's event_id** in API calls, > since the section's `event_id` points to the parent. ### Registration Settings (Festival-level bulk management) - `GET /organisations/{org}/events/{event}/sections/registration-settings` — returns unique section names across the festival with registration visibility, description, and counts - `PUT /organisations/{org}/events/{event}/sections/registration-settings` — bulk update registration visibility for a section name across all instances in the festival #### GET Response ```json { "data": [ { "name": "Hoofdpodium Bar", "category": "Bar", "icon": "tabler-beer", "show_in_registration": true, "registration_description": "Tap bier en drankjes voor festivalgangers", "section_count": 3, "section_ids": ["ulid1", "ulid2", "ulid3"] } ] } ``` #### PUT Body ```json { "name": "Hoofdpodium Bar", "show_in_registration": true, "registration_description": "Tap bier en drankjes voor festivalgangers" } ``` Returns the full updated registration-settings response. Creates activity log `section.registration_settings_updated`. Auth: org_admin or event_manager on the event's organisation. ## Time Slots - `GET /organisations/{org}/events/{event}/time-slots` - `POST /organisations/{org}/events/{event}/time-slots` - `PUT /organisations/{org}/events/{event}/time-slots/{timeSlot}` - `DELETE /organisations/{org}/events/{event}/time-slots/{timeSlot}` > **Festival context:** `{event}` can be a festival parent or a sub-event. > Festival-level time slots (operational: build-up, teardown, transitions) are separate > from sub-event time slots (program-specific). > > `GET /organisations/{org}/events/{event}/time-slots` returns only the specified event's own time slots by > default. For sub-events, pass `?include_parent=true` to also include the parent festival's > time slots — each time slot is marked with a `source` field (`sub_event` or `festival`) > and includes `event_name` for display grouping. This parameter has no effect on festivals > or flat events. > > For festivals (events with sub-events), pass `?include_children=true` to include all > sub-event time slots. Each time slot is marked with `source` (`own` or the sub-event's > `event_id`) and `event_name`. Used by cross_event section shift dialogs that need access > to all time slots across the festival. This parameter has no effect on sub-events or > flat events. > > Shifts on sub-event sections may reference parent festival time slots (e.g. for build-up > shifts). Shifts on cross_event sections may reference any time slot from the festival or > its sub-events. The `time_slot_id` validation accepts time slots accordingly. ## Shifts - `GET /organisations/{org}/events/{event}/sections/{section}/shifts` - `POST /organisations/{org}/events/{event}/sections/{section}/shifts` - `PUT /organisations/{org}/events/{event}/sections/{section}/shifts/{shift}` - `DELETE /organisations/{org}/events/{event}/sections/{section}/shifts/{shift}` - `POST /organisations/{org}/events/{event}/sections/{section}/shifts/{shift}/assign` - `POST /organisations/{org}/events/{event}/sections/{section}/shifts/{shift}/claim` > **Festival context:** When managing shifts on a `cross_event` section, the `{event}` > in the URL must be the parent festival's ID (matching `section.event_id`), not the > sub-event's ID. ## Shift Assignments - `GET /organisations/{org}/events/{event}/shift-assignments` — list assignments for event (paginated, 50/page) - `POST /organisations/{org}/events/{event}/shift-assignments/{shiftAssignment}/approve` — approve pending assignment - `POST /organisations/{org}/events/{event}/shift-assignments/{shiftAssignment}/reject` — reject pending assignment - `POST /organisations/{org}/events/{event}/shift-assignments/{shiftAssignment}/cancel` — cancel assignment - `POST /organisations/{org}/events/{event}/shift-assignments/bulk-approve` — bulk approve multiple assignments - `GET /organisations/{org}/events/{event}/shifts/{shift}/assignable-persons` — list approved persons with availability status ### Assignable Persons `GET /organisations/{org}/events/{event}/shifts/{shift}/assignable-persons` Returns all approved persons for the event with availability status for this shift's time slot. Persons are sorted: available first, then unavailable (conflict), then already assigned. ```json { "data": [ { "id": "ulid", "first_name": "Jan", "last_name": "de Vries", "full_name": "Jan de Vries", "email": "jan@gmail.com", "status": "approved", "crowd_type": { "id": "ulid", "name": "Vrijwilliger", "system_type": "VOLUNTEER" }, "is_available": true, "already_assigned": false, "conflict": null }, { "id": "ulid", "first_name": "Ahmed", "last_name": "Hassan", "full_name": "Ahmed Hassan", "email": "ahmed.h@gmail.com", "status": "approved", "crowd_type": { "id": "ulid", "name": "Vrijwilliger", "system_type": "VOLUNTEER" }, "is_available": false, "already_assigned": false, "conflict": { "section_name": "EHBO", "shift_title": "EHBO Post", "time_slot_name": "Zaterdag Dag", "time": "10:00–18:00" } } ] } ``` ### Query Parameters (index) - `status` — filter by assignment status (`pending_approval`, `approved`, `rejected`, `cancelled`, `completed`) - `shift_id` — filter by shift - `person_id` — filter by person - `section_id` — filter by festival section ### Assign Body `POST /organisations/{org}/events/{event}/sections/{section}/shifts/{shift}/assign` ```json { "person_id": "01JXYZ..." } ``` Organizer manually assigns a person. Assignment is pre-approved (status = `approved`). Validates: shift must be `open`, capacity not full (`slots_total`), no time slot conflict. ### Claim Body `POST /organisations/{org}/events/{event}/sections/{section}/shifts/{shift}/claim` ```json { "person_id": "01JXYZ..." } ``` Volunteer claims a shift. Status depends on `festival_section.crew_auto_accepts`: - `true` → status = `approved`, `auto_approved = true` - `false` → status = `pending_approval` Validates: shift must be `open`, person must be `approved`, claiming capacity not full (`slots_open_for_claiming`), no time slot conflict. ### Reject Body ```json { "reason": "Onvoldoende ervaring voor deze rol." } ``` ### Bulk Approve Body ```json { "assignment_ids": ["ulid1", "ulid2", ...] } ``` Response includes per-assignment result: `approved` or `skipped` (with reason). ### ShiftAssignmentResource ```json { "id": "01JXYZ...", "shift_id": "01JXYZ...", "person_id": "01JXYZ...", "time_slot_id": "01JXYZ...", "status": "pending_approval", "auto_approved": false, "assigned_by": null, "assigned_at": "2026-04-10T12:00:00+00:00", "approved_by": null, "approved_at": null, "rejection_reason": null, "hours_expected": null, "hours_completed": null, "checked_in_at": null, "checked_out_at": null, "is_cancellable": true, "is_approvable": true, "created_at": "2026-04-10T12:00:00+00:00", "person": { "..." }, "shift": { "..." } } ``` ### Status Transitions - `pending_approval` → `approved`, `rejected`, `cancelled` - `approved` → `cancelled`, `completed` - `rejected` → (terminal) - `cancelled` → (terminal) - `completed` → (terminal) ### Authorization | Action | Who | |--------|-----| | assign | org_admin, event_manager | | claim | authenticated org member | | approve / reject / bulk-approve | org_admin, event_manager | | cancel | org_admin, event_manager, or the volunteer's own user | ## Volunteer Availabilities - `GET /organisations/{org}/events/{event}/persons/{person}/availabilities` — list availabilities - `POST /organisations/{org}/events/{event}/persons/{person}/availabilities/sync` — sync (replace all) ### Sync Body ```json { "availabilities": [ { "time_slot_id": "01JXYZ...", "preference_level": 5 }, { "time_slot_id": "01JABC...", "preference_level": 2 } ] } ``` Replaces all existing availabilities for the person. `preference_level` is optional (default: 3, range: 1–5). Validates: - All `time_slot_id`s must belong to the event (or parent festival) - Time slot `person_type` must match the person's crowd type `system_type` ## Persons - `GET /organisations/{org}/events/{event}/persons` - `POST /organisations/{org}/events/{event}/persons` - `GET /organisations/{org}/events/{event}/persons/{person}` - `PUT /organisations/{org}/events/{event}/persons/{person}` - `POST /organisations/{org}/events/{event}/persons/{person}/approve` - `DELETE /organisations/{org}/events/{event}/persons/{person}` - `POST /organisations/{org}/events/{event}/persons/from-member` — create person from org member - `GET /organisations/{org}/members/available-for-event/{event}` — list members not yet added to event ### Create Person from Member `POST /organisations/{org}/events/{event}/persons/from-member` Creates a Person record from an existing organisation member. The person is created with `status: approved` and `user_id` pre-linked. ```json { "user_id": "01JXYZ...", "crowd_type_id": "01JXYZ..." } ``` Person data (first_name, last_name, email) is copied from the user account. Returns `PersonResource` (201). **Validation:** - User must belong to the organisation - User must not already be a person at this event (422) - crowd_type_id must belong to the organisation ### Available Members for Event `GET /organisations/{org}/members/available-for-event/{event}` Returns organisation members who do NOT yet have a Person record at the specified event. Used to populate the "Lid toevoegen" dialog. ```json { "data": [ { "id": "01JXYZ...", "first_name": "Jan", "last_name": "de Vries", "full_name": "Jan de Vries", "email": "jan@example.nl" } ] } ``` Auth: org member or higher on the organisation. ## Identity Matches ### Endpoints - `GET /organisations/{org}/identity-matches` — list pending matches for the organisation (paginated, 25 per page) - `GET /organisations/{org}/persons/{person}/identity-match` — show pending match for a specific person - `POST /organisations/{org}/identity-matches/{match}/confirm` — confirm a match (links `person.user_id`, dismisses other pending matches, syncs tags) - `POST /organisations/{org}/identity-matches/{match}/dismiss` — dismiss a match (hidden, person stays unlinked, not re-suggested) - `POST /organisations/{org}/identity-matches/{match}/revert` — revert a confirmed match (unlinks `person.user_id`, status → `reverted`) - `POST /organisations/{org}/identity-matches/bulk-confirm` — bulk confirm multiple matches - `POST /organisations/{org}/events/{event}/persons/{person}/manual-link` — manually link a person to a user account (body: `{ "user_id": "ulid" }`) - `POST /organisations/{org}/events/{event}/persons/{person}/unlink` — unlink a person from their user account ### Match Types (`IdentityMatchMethod`) | Value | Description | Confidence | | ------------ | ------------------------------------ | ---------- | | `email` | Exact email match within org | `high` | | `name_fuzzy` | Levenshtein fuzzy name match | `medium` (or `high` if DOB also matches) | | `manual` | Organiser-initiated manual link | `high` | ### Match Confidence (`IdentityMatchConfidence`) | Value | Description | | -------- | -------------------------------------------------------- | | `high` | High certainty — exact email, or fuzzy name + DOB match | | `medium` | Moderate certainty — fuzzy name match without DOB | ### Match Status (`IdentityMatchStatus`) | Value | Description | | ----------- | ------------------------------------------------- | | `pending` | Awaiting organiser review | | `confirmed` | Organiser confirmed — `person.user_id` is linked | | `dismissed` | Organiser dismissed — not re-suggested | | `reverted` | Previously confirmed, then unlinked | ### Detection Matches are detected automatically via `PersonObserver`: - **On Person create**: if person has no `user_id` and has an email or name, `PersonIdentityService::detectMatches()` runs - **On Person email update**: if person's email changed and person is unlinked, detection re-runs - **On user creation**: `PersonIdentityService::detectMatchesForUser()` finds all unlinked persons with matching email Detection strategies (in priority order): 1. **Exact email** within same organisation → `email` / `high` 2. **Fuzzy name** (Levenshtein distance ≤2 for short names, ≤3 for longer) → `name_fuzzy` / `medium` 3. **Fuzzy name + DOB match** → upgrades to `high` confidence No silent auto-linking. Every identity link requires explicit confirmation. ### Bulk Confirm `POST /organisations/{org}/identity-matches/bulk-confirm` Body: `{ "match_ids": ["ulid1", "ulid2", ...] }` (max 100) Response: `{ "confirmed": 2, "errors": [{ "match_id": "ulid3", "error": "User already has a person record with this crowd type in this event." }] }` ### PersonResource enrichment `GET /organisations/{org}/events/{event}/persons` now includes: ```json { "has_user_account": true, "user_account": { "id": "ulid", "email": "jan@example.nl", "full_name": "Jan de Vries" }, "pending_identity_match": { "match_id": "ulid", "matched_user": { "id": "ulid", "first_name": "Jan", "last_name": "de Vries", "full_name": "Jan de Vries", "email": "jan@example.nl", "date_of_birth": "1990-01-01" }, "matched_on": "email", "matched_on_label": "E-mail match", "confidence": "high", "confidence_label": "Hoge zekerheid", "match_details": { "matched_fields": ["email"], "..." : "..." } } } ``` ## Crowd Lists - `GET /organisations/{org}/events/{event}/crowd-lists` — list all crowd lists for event (includes `persons_count`) - `POST /organisations/{org}/events/{event}/crowd-lists` — create crowd list - `PUT /organisations/{org}/events/{event}/crowd-lists/{list}` — update crowd list - `DELETE /organisations/{org}/events/{event}/crowd-lists/{list}` — delete crowd list - `GET /organisations/{org}/events/{event}/crowd-lists/{list}/persons` — list persons on a crowd list (paginated, 50/page, includes `crowd_list_pivot`) - `POST /organisations/{org}/events/{event}/crowd-lists/{list}/persons` — add person to list - `DELETE /organisations/{org}/events/{event}/crowd-lists/{list}/persons/{person}` — remove person from list ### Create/Update Body ```json { "crowd_type_id": "01JXYZ...", "name": "VIP Gastenlijst", "type": "internal|external", "recipient_company_id": "01JXYZ... (nullable, for external lists)", "auto_approve": false, "max_persons": 50 } ``` ### Add Person Body ```json { "person_id": "01JXYZ..." } ``` **Business rules:** - `max_persons`: when set, adding a person beyond the limit returns 422 - `auto_approve`: when true, adding a person with status `pending` automatically sets their status to `approved` - Duplicate person on same list returns 422 ### CrowdListResource ```json { "id": "01JXYZ...", "event_id": "01JXYZ...", "crowd_type_id": "01JXYZ...", "name": "VIP Gastenlijst", "type": "internal", "recipient_company_id": null, "auto_approve": false, "max_persons": 50, "is_full": false, "created_at": "2026-04-10T12:00:00+00:00", "persons_count": 12 } ``` ## Locations - `GET /organisations/{org}/events/{event}/locations` - `POST /organisations/{org}/events/{event}/locations` - `PUT /organisations/{org}/events/{event}/locations/{location}` - `DELETE /organisations/{org}/events/{event}/locations/{location}` ## Person Tags (Organisation Settings) - `GET /organisations/{org}/person-tags` — list active tags (ordered) - `POST /organisations/{org}/person-tags` — create tag - `PUT /organisations/{org}/person-tags/{tag}` — update tag - `DELETE /organisations/{org}/person-tags/{tag}` — deactivate tag (soft: sets `is_active = false`) - `GET /organisations/{org}/person-tag-categories` — distinct categories for autocomplete ## User Tag Assignments - `GET /organisations/{org}/users/{user}/tags` — list all tags for user in organisation - `POST /organisations/{org}/users/{user}/tags` — assign a tag - `DELETE /organisations/{org}/users/{user}/tags/{tagAssignment}` — remove assignment - `PUT /organisations/{org}/users/{user}/tags/sync` — sync tags by source > **Sync endpoint:** Replaces tags of the specified `source` only. > Body: `{ "tag_ids": ["ulid1", "ulid2"], "source": "self_reported" }` > Removes `self_reported` tags not in the list, adds new ones, leaves `organiser_assigned` untouched (and vice versa). ### Person list tag filtering - `GET /organisations/{org}/events/{event}/persons?tag={person_tag_id}` — filter persons by single tag - `GET /organisations/{org}/events/{event}/persons?tags=ulid1,ulid2` — filter persons by multiple tags (AND logic: must have all) ## 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. ### Response Fields Each registration form field response includes: - `options` — raw stored format (string array or object array, for backwards compatibility) - `normalized_options` — always `[{label, description}]` format (null when field has no options). Descriptions are null when not set. Use this for rendering. - `display_width` — `"full"` or `"half"`, controls form layout column width. Auto-set based on field type when not explicitly provided. ### 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..." } } } ```