Move all authenticated organiser-facing event sub-resource routes from
/events/{event}/... to /organisations/{organisation}/events/{event}/...
to enforce multi-tenancy at the routing layer.
Changes:
- Routes: restructured api.php to nest all event sub-resources under
the existing organisation prefix group
- Controllers: added Organisation parameter and VerifiesOrganisationEvent
trait to all 12 affected controllers (sections, time-slots, shifts,
persons, crowd-lists, locations, shift-assignments, registration-fields,
availabilities, field-values, section-preferences, stats)
- Tests: updated all 20 feature test files with new route paths
- Frontend: updated 8 API composables and 20 Vue components/pages
- API.md: updated documentation to reflect new route structure
Portal routes, public routes (volunteer-register), and invitation routes
remain unchanged as they operate without organisation context.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
22 KiB
Crewli API Contract
Base path: /api/v1/
Auth: Bearer token (Sanctum)
Auth
POST /auth/loginPOST /auth/logoutGET /auth/me
Organisations
GET /organisations— list (super admin)POST /organisations— createGET /organisations/{org}— showPUT /organisations/{org}— updateGET /organisations/{org}/members— membersPOST /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 responseGET /organisations/{org}/events?type=festival— filter by event_type (festival|series|event)POST /organisations/{org}/events— create (supportsparent_event_idfor sub-events)GET /organisations/{org}/events/{event}— detail (includes children and parent if loaded)PUT /organisations/{org}/events/{event}— update (does NOT acceptstatus— 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
{
"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-typesPOST /organisations/{org}/crowd-typesPUT /organisations/{org}/crowd-types/{type}DELETE /organisations/{org}/crowd-types/{type}
Companies
GET /organisations/{org}/companiesPOST /organisations/{org}/companiesPUT /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}/sectionsPOST /organisations/{org}/events/{event}/sectionsPUT /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,GETautomatically includescross_eventsections from the parent festival. Shifts on cross_event sections must use the parent festival's event_id in API calls, since the section'sevent_idpoints 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 countsPUT /organisations/{org}/events/{event}/sections/registration-settings— bulk update registration visibility for a section name across all instances in the festival
GET Response
{
"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
{
"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-slotsPOST /organisations/{org}/events/{event}/time-slotsPUT /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-slotsreturns only the specified event's own time slots by default. For sub-events, pass?include_parent=trueto also include the parent festival's time slots — each time slot is marked with asourcefield (sub_eventorfestival) and includesevent_namefor 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_idvalidation accepts time slots from the sub-event itself or its parent festival.
Shifts
GET /organisations/{org}/events/{event}/sections/{section}/shiftsPOST /organisations/{org}/events/{event}/sections/{section}/shiftsPUT /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}/assignPOST /organisations/{org}/events/{event}/sections/{section}/shifts/{shift}/claim
Festival context: When managing shifts on a
cross_eventsection, the{event}in the URL must be the parent festival's ID (matchingsection.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 assignmentPOST /organisations/{org}/events/{event}/shift-assignments/{shiftAssignment}/reject— reject pending assignmentPOST /organisations/{org}/events/{event}/shift-assignments/{shiftAssignment}/cancel— cancel assignmentPOST /organisations/{org}/events/{event}/shift-assignments/bulk-approve— bulk approve multiple assignmentsGET /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.
{
"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 shiftperson_id— filter by personsection_id— filter by festival section
Assign Body
POST /organisations/{org}/events/{event}/sections/{section}/shifts/{shift}/assign
{ "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
{ "person_id": "01JXYZ..." }
Volunteer claims a shift. Status depends on festival_section.crew_auto_accepts:
true→ status =approved,auto_approved = truefalse→ 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
{ "reason": "Onvoldoende ervaring voor deze rol." }
Bulk Approve Body
{ "assignment_ids": ["ulid1", "ulid2", ...] }
Response includes per-assignment result: approved or skipped (with reason).
ShiftAssignmentResource
{
"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,cancelledapproved→cancelled,completedrejected→ (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 availabilitiesPOST /organisations/{org}/events/{event}/persons/{person}/availabilities/sync— sync (replace all)
Sync Body
{
"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_ids must belong to the event (or parent festival) - Time slot
person_typemust match the person's crowd typesystem_type
Persons
GET /organisations/{org}/events/{event}/personsPOST /organisations/{org}/events/{event}/personsGET /organisations/{org}/events/{event}/persons/{person}PUT /organisations/{org}/events/{event}/persons/{person}POST /organisations/{org}/events/{event}/persons/{person}/approveDELETE /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 personPOST /organisations/{org}/identity-matches/{match}/confirm— confirm a match (linksperson.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:
{
"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 (includespersons_count)POST /organisations/{org}/events/{event}/crowd-lists— create crowd listPUT /organisations/{org}/events/{event}/crowd-lists/{list}— update crowd listDELETE /organisations/{org}/events/{event}/crowd-lists/{list}— delete crowd listGET /organisations/{org}/events/{event}/crowd-lists/{list}/persons— list persons on a crowd list (paginated, 50/page, includescrowd_list_pivot)POST /organisations/{org}/events/{event}/crowd-lists/{list}/persons— add person to listDELETE /organisations/{org}/events/{event}/crowd-lists/{list}/persons/{person}— remove person from list
Create/Update Body
{
"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
{
"person_id": "01JXYZ..."
}
Business rules:
max_persons: when set, adding a person beyond the limit returns 422auto_approve: when true, adding a person with statuspendingautomatically sets their status toapproved- Duplicate person on same list returns 422
CrowdListResource
{
"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}/locationsPOST /organisations/{org}/events/{event}/locationsPUT /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 tagPUT /organisations/{org}/person-tags/{tag}— update tagDELETE /organisations/{org}/person-tags/{tag}— deactivate tag (soft: setsis_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 organisationPOST /organisations/{org}/users/{user}/tags— assign a tagDELETE /organisations/{org}/users/{user}/tags/{tagAssignment}— remove assignmentPUT /organisations/{org}/users/{user}/tags/sync— sync tags by source
Sync endpoint: Replaces tags of the specified
sourceonly. Body:{ "tag_ids": ["ulid1", "ulid2"], "source": "self_reported" }Removesself_reportedtags not in the list, adds new ones, leavesorganiser_assigneduntouched (and vice versa).
Person list tag filtering
GET /organisations/{org}/events/{event}/persons?tag={person_tag_id}— filter persons by single tagGET /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 statusregistration_open. Only includes sections withshow_in_registration = trueandtype = standard. For festivals: returns child event sections only (deduplicated by name), excluding parent operational sections. Only includes time slots withperson_type = VOLUNTEER. Resolves sub-events to parent festival.
Response
{
"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. ReturnsPersonResource(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. ReturnsPersonResourcewith 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 templatePUT /organisations/{org}/registration-field-templates/{template}— update templateDELETE /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 templatePUT /organisations/{org}/events/{event}/registration-fields/{field}— update fieldDELETE /organisations/{org}/events/{event}/registration-fields/{field}— delete field definition (answers preserved)POST /organisations/{org}/events/{event}/registration-fields/reorder— bulk reorderPOST /organisations/{org}/events/{event}/registration-fields/import-from-event— copy fields from another event
From-Template Body
{ "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
{ "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 personPUT /organisations/{org}/events/{event}/persons/{person}/field-values— bulk upsert answers
Bulk Upsert Body
{
"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 preferencesPUT /organisations/{org}/events/{event}/persons/{person}/section-preferences— replace all preferences
Replace Body
{
"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_CONTAINSfor 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.)