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>
273 lines
9.8 KiB
Markdown
273 lines
9.8 KiB
Markdown
# 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
|
|
|
|
```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 /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:
|
|
|
|
```json
|
|
{
|
|
"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
|
|
|
|
```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 /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.)_
|