# Crewli API Contract Base path: `/api/v1/` Auth: Bearer token (Sanctum) ## Auth - `POST /auth/login` - `POST /auth/logout` - `GET /auth/me` ## 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 ## 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. > > Shifts on sub-event sections may reference parent festival time slots (e.g. for build-up > shifts). The `time_slot_id` validation accepts time slots from the sub-event itself or > its parent festival. ## 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}` ## Identity Matches - `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`) - `POST /organisations/{org}/identity-matches/{match}/dismiss` — dismiss a match (hidden, person stays unlinked) - `POST /organisations/{org}/identity-matches/bulk-confirm` — bulk confirm multiple matches ### Detection Matches are created automatically: - When a person is created (via `POST /organisations/{org}/events/{event}/persons`) with an email matching an existing user → pending match created - When a new user account is created (invitation acceptance) with an email matching unlinked persons → pending matches created 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 in this event." }] }` ### PersonResource enrichment `GET /organisations/{org}/events/{event}/persons` includes `pending_identity_match` inline when a pending match exists: ```json { "pending_identity_match": { "match_id": "ulid", "matched_user": { "id": "ulid", "first_name": "Jan", "last_name": "", "full_name": "Jan", "email": "jan@example.nl" }, "matched_on": "email", "confidence": "exact" } } ``` ## 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. ## 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.)_