Files
crewli/dev-docs/API.md
bert.hausmans 874eeee770 feat: event dashboard metric cards with stats endpoint (UX-02)
Add GET /events/{event}/stats endpoint returning aggregate counts for
persons (by status, approved without shift), pending identity matches,
and shift fill rates. Frontend metric cards component shows four
actionable KPIs on the event overview tab.

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

9.8 KiB

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.

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.

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)

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