Audit and complete the Crowd Lists module: - Add CrowdListType enum (internal/external) with proper casts - Create CrowdListService for business logic (add/remove person, max_persons enforcement, auto_approve, activity logging) - Create CrowdListFactory with Dutch names and states - Create AddPersonToCrowdListRequest form request - Fix FormRequests to use Rule::enum instead of hardcoded strings - Fix CrowdListResource to use enum->value and add is_full field - Refactor controller to be thin (delegates to service) - Add eager loading for crowdType and recipientCompany - Write 18 comprehensive tests (CRUD, auth, edge cases) - Update API.md with request/response documentation Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
9.2 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.
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 /events/{event}/sectionsPOST /events/{event}/sectionsPUT /events/{event}/sections/{section}DELETE /events/{event}/sections/{section}POST /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.
Time Slots
GET /events/{event}/time-slotsPOST /events/{event}/time-slotsPUT /events/{event}/time-slots/{timeSlot}DELETE /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 /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 /events/{event}/sections/{section}/shiftsPOST /events/{event}/sections/{section}/shiftsPUT /events/{event}/sections/{section}/shifts/{shift}DELETE /events/{event}/sections/{section}/shifts/{shift}POST /events/{event}/sections/{section}/shifts/{shift}/assignPOST /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.
Persons
GET /events/{event}/personsPOST /events/{event}/personsGET /events/{event}/persons/{person}PUT /events/{event}/persons/{person}POST /events/{event}/persons/{person}/approveDELETE /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 /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 /events/{event}/persons includes pending_identity_match inline when a pending match exists:
{
"pending_identity_match": {
"match_id": "ulid",
"matched_user": { "id": "ulid", "name": "Jan", "email": "jan@example.nl" },
"matched_on": "email",
"confidence": "exact"
}
}
Crowd Lists
GET /events/{event}/crowd-lists— list all crowd lists for event (includespersons_count)POST /events/{event}/crowd-lists— create crowd listPUT /events/{event}/crowd-lists/{list}— update crowd listDELETE /events/{event}/crowd-lists/{list}— delete crowd listPOST /events/{event}/crowd-lists/{list}/persons— add person to listDELETE /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 /events/{event}/locationsPOST /events/{event}/locationsPUT /events/{event}/locations/{location}DELETE /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 /events/{event}/persons?tag={person_tag_id}— filter persons by single tagGET /events/{event}/persons?tags=ulid1,ulid2— filter persons by multiple tags (AND logic: must have all)
(Extend this contract per module as endpoints are implemented.)