Files
crewli/dev-docs/API.md
bert.hausmans c21bc085e9 feat: registration section preferences with show_in_registration filtering and deduplication
Add show_in_registration and registration_description columns to festival_sections.
Registration form now shows deduplicated sections by name (across sub-events),
filtered by show_in_registration=true, grouped by category with card-based UI.
Section preferences use section_name instead of section_id.
Add GET/PUT registration-settings endpoints for festival-level bulk management.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 20:03:54 +02:00

16 KiB
Raw Blame History

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 /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-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 /events/{event}/sections
  • POST /events/{event}/sections
  • PUT /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, 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 /events/{event}/sections/registration-settings — returns unique section names across the festival with registration visibility, description, and counts
  • PUT /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 /events/{event}/time-slots
  • POST /events/{event}/time-slots
  • PUT /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-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 /events/{event}/sections/{section}/shifts
  • POST /events/{event}/sections/{section}/shifts
  • PUT /events/{event}/sections/{section}/shifts/{shift}
  • DELETE /events/{event}/sections/{section}/shifts/{shift}
  • POST /events/{event}/sections/{section}/shifts/{shift}/assign
  • POST /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 /events/{event}/shift-assignments — list assignments for event (paginated, 50/page)
  • POST /events/{event}/shift-assignments/{shiftAssignment}/approve — approve pending assignment
  • POST /events/{event}/shift-assignments/{shiftAssignment}/reject — reject pending assignment
  • POST /events/{event}/shift-assignments/{shiftAssignment}/cancel — cancel assignment
  • POST /events/{event}/shift-assignments/bulk-approve — bulk approve multiple assignments

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 /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 /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 = 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

{ "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_approvalapproved, rejected, cancelled
  • approvedcancelled, 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 /events/{event}/persons/{person}/availabilities — list availabilities
  • POST /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: 15).

Validates:

  • All time_slot_ids must belong to the event (or parent festival)
  • Time slot person_type must match the person's crowd type system_type

Persons

  • GET /events/{event}/persons
  • POST /events/{event}/persons
  • GET /events/{event}/persons/{person}
  • PUT /events/{event}/persons/{person}
  • POST /events/{event}/persons/{person}/approve
  • DELETE /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 /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 (includes persons_count)
  • POST /events/{event}/crowd-lists — create crowd list
  • PUT /events/{event}/crowd-lists/{list} — update crowd list
  • DELETE /events/{event}/crowd-lists/{list} — delete crowd list
  • GET /events/{event}/crowd-lists/{list}/persons — list persons on a crowd list (paginated, 50/page, includes crowd_list_pivot)
  • POST /events/{event}/crowd-lists/{list}/persons — add person to list
  • DELETE /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 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

{
  "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}/locations
  • POST /events/{event}/locations
  • PUT /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 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 /events/{event}/persons?tag={person_tag_id} — filter persons by single tag
  • GET /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

{
  "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.

(Extend this contract per module as endpoints are implemented.)