Backend: - CookieBearerToken middleware reads httpOnly cookie and injects Authorization header before Sanctum validates (prepended to API middleware group) - SetAuthCookie trait provides cookie creation/expiry helpers with per-app cookie names (crewli_admin_token, crewli_app_token, crewli_portal_token) - LoginController sets token via Set-Cookie, removes it from JSON body - LogoutController expires the auth cookie on logout - AuthRefreshController (POST /auth/refresh) rotates tokens with new cookie - InvitationController accept also sets token via cookie, not JSON body - All cookies: httpOnly, SameSite=Strict, Secure (in production) Frontend (all three SPAs): - Removed all localStorage token storage (apps/app, apps/portal) - Removed all JS-readable cookie token storage (apps/admin) - Removed Authorization: Bearer header interceptors from axios - Auth stores now rely on GET /auth/me to validate httpOnly cookie - Admin app: new Pinia auth store replaces useCookie-based auth pattern - withCredentials: true ensures browser sends cookies automatically Fixes security findings A13-1 (localStorage tokens) and A13-2 (admin cookie flags). Tokens are now invisible to JavaScript. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
679 lines
28 KiB
Markdown
679 lines
28 KiB
Markdown
# Crewli API Contract
|
||
|
||
Base path: `/api/v1/`
|
||
|
||
Auth: Bearer token (Sanctum) — token delivered via httpOnly cookie, never in the JSON response body. See `/dev-docs/AUTH_ARCHITECTURE.md` for full details.
|
||
|
||
## Auth
|
||
|
||
- `POST /auth/login` — returns user data in JSON body. The Sanctum bearer token is set as an httpOnly cookie via `Set-Cookie` header (not included in response body).
|
||
- `POST /auth/logout`
|
||
- `GET /auth/me`
|
||
- `POST /auth/refresh` — rotates the Sanctum token: revokes current token, creates new one, sets new httpOnly cookie. Returns current user data.
|
||
- `POST /auth/forgot-password` — request password reset (public, rate-limited). Body: `{ email, app: "app"|"portal"|"admin" }`. Always returns 200 (no email enumeration).
|
||
- `POST /auth/reset-password` — reset password with token (public). Body: `{ token, email, password, password_confirmation }`.
|
||
|
||
## Account Management (authenticated)
|
||
|
||
- `POST /me/change-password` — change own password. Body: `{ current_password, password, password_confirmation }`. Revokes other sessions.
|
||
- `POST /me/change-email` — request email change (sends verification to new address). Body: `{ new_email, password, app: "app"|"portal"|"admin" }`.
|
||
- `POST /verify-email-change` — verify email change token (public). Body: `{ token }`. Revokes all sessions.
|
||
|
||
## 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
|
||
- `POST /organisations/{org}/members/{user}/change-email` — admin initiates email change for a member (org_admin only). Body: `{ new_email }`.
|
||
|
||
## 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 /organisations/{org}/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 /organisations/{org}/events/{event}/sections`
|
||
- `POST /organisations/{org}/events/{event}/sections`
|
||
- `PUT /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, `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 /organisations/{org}/events/{event}/sections/registration-settings` — returns unique section names across the festival with registration visibility, description, and counts
|
||
- `PUT /organisations/{org}/events/{event}/sections/registration-settings` — bulk update registration visibility for a section name across all instances in the festival
|
||
|
||
#### GET Response
|
||
|
||
```json
|
||
{
|
||
"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
|
||
|
||
```json
|
||
{
|
||
"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-slots`
|
||
- `POST /organisations/{org}/events/{event}/time-slots`
|
||
- `PUT /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-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 /organisations/{org}/events/{event}/sections/{section}/shifts`
|
||
- `POST /organisations/{org}/events/{event}/sections/{section}/shifts`
|
||
- `PUT /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}/assign`
|
||
- `POST /organisations/{org}/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 /organisations/{org}/events/{event}/shift-assignments` — list assignments for event (paginated, 50/page)
|
||
- `POST /organisations/{org}/events/{event}/shift-assignments/{shiftAssignment}/approve` — approve pending assignment
|
||
- `POST /organisations/{org}/events/{event}/shift-assignments/{shiftAssignment}/reject` — reject pending assignment
|
||
- `POST /organisations/{org}/events/{event}/shift-assignments/{shiftAssignment}/cancel` — cancel assignment
|
||
- `POST /organisations/{org}/events/{event}/shift-assignments/bulk-approve` — bulk approve multiple assignments
|
||
- `GET /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.
|
||
|
||
```json
|
||
{
|
||
"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 shift
|
||
- `person_id` — filter by person
|
||
- `section_id` — filter by festival section
|
||
|
||
### Assign Body
|
||
|
||
`POST /organisations/{org}/events/{event}/sections/{section}/shifts/{shift}/assign`
|
||
|
||
```json
|
||
{ "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`
|
||
|
||
```json
|
||
{ "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
|
||
|
||
```json
|
||
{ "reason": "Onvoldoende ervaring voor deze rol." }
|
||
```
|
||
|
||
### Bulk Approve Body
|
||
|
||
```json
|
||
{ "assignment_ids": ["ulid1", "ulid2", ...] }
|
||
```
|
||
|
||
Response includes per-assignment result: `approved` or `skipped` (with reason).
|
||
|
||
### ShiftAssignmentResource
|
||
|
||
```json
|
||
{
|
||
"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`, `cancelled`
|
||
- `approved` → `cancelled`, `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 /organisations/{org}/events/{event}/persons/{person}/availabilities` — list availabilities
|
||
- `POST /organisations/{org}/events/{event}/persons/{person}/availabilities/sync` — sync (replace all)
|
||
|
||
### Sync Body
|
||
|
||
```json
|
||
{
|
||
"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_id`s must belong to the event (or parent festival)
|
||
- Time slot `person_type` must match the person's crowd type `system_type`
|
||
|
||
## Persons
|
||
|
||
- `GET /organisations/{org}/events/{event}/persons`
|
||
- `POST /organisations/{org}/events/{event}/persons`
|
||
- `GET /organisations/{org}/events/{event}/persons/{person}`
|
||
- `PUT /organisations/{org}/events/{event}/persons/{person}`
|
||
- `POST /organisations/{org}/events/{event}/persons/{person}/approve`
|
||
- `DELETE /organisations/{org}/events/{event}/persons/{person}`
|
||
|
||
## Identity Matches
|
||
|
||
### Endpoints
|
||
|
||
- `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`, dismisses other pending matches, syncs tags)
|
||
- `POST /organisations/{org}/identity-matches/{match}/dismiss` — dismiss a match (hidden, person stays unlinked, not re-suggested)
|
||
- `POST /organisations/{org}/identity-matches/{match}/revert` — revert a confirmed match (unlinks `person.user_id`, status → `reverted`)
|
||
- `POST /organisations/{org}/identity-matches/bulk-confirm` — bulk confirm multiple matches
|
||
- `POST /organisations/{org}/events/{event}/persons/{person}/manual-link` — manually link a person to a user account (body: `{ "user_id": "ulid" }`)
|
||
- `POST /organisations/{org}/events/{event}/persons/{person}/unlink` — unlink a person from their user account
|
||
|
||
### Match Types (`IdentityMatchMethod`)
|
||
|
||
| Value | Description | Confidence |
|
||
| ------------ | ------------------------------------ | ---------- |
|
||
| `email` | Exact email match within org | `high` |
|
||
| `name_fuzzy` | Levenshtein fuzzy name match | `medium` (or `high` if DOB also matches) |
|
||
| `manual` | Organiser-initiated manual link | `high` |
|
||
|
||
### Match Confidence (`IdentityMatchConfidence`)
|
||
|
||
| Value | Description |
|
||
| -------- | -------------------------------------------------------- |
|
||
| `high` | High certainty — exact email, or fuzzy name + DOB match |
|
||
| `medium` | Moderate certainty — fuzzy name match without DOB |
|
||
|
||
### Match Status (`IdentityMatchStatus`)
|
||
|
||
| Value | Description |
|
||
| ----------- | ------------------------------------------------- |
|
||
| `pending` | Awaiting organiser review |
|
||
| `confirmed` | Organiser confirmed — `person.user_id` is linked |
|
||
| `dismissed` | Organiser dismissed — not re-suggested |
|
||
| `reverted` | Previously confirmed, then unlinked |
|
||
|
||
### Detection
|
||
|
||
Matches are detected automatically via `PersonObserver`:
|
||
- **On Person create**: if person has no `user_id` and has an email or name, `PersonIdentityService::detectMatches()` runs
|
||
- **On Person email update**: if person's email changed and person is unlinked, detection re-runs
|
||
- **On user creation**: `PersonIdentityService::detectMatchesForUser()` finds all unlinked persons with matching email
|
||
|
||
Detection strategies (in priority order):
|
||
1. **Exact email** within same organisation → `email` / `high`
|
||
2. **Fuzzy name** (Levenshtein distance ≤2 for short names, ≤3 for longer) → `name_fuzzy` / `medium`
|
||
3. **Fuzzy name + DOB match** → upgrades to `high` confidence
|
||
|
||
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 with this crowd type in this event." }] }`
|
||
|
||
### PersonResource enrichment
|
||
|
||
`GET /organisations/{org}/events/{event}/persons` now includes:
|
||
|
||
```json
|
||
{
|
||
"has_user_account": true,
|
||
"user_account": { "id": "ulid", "email": "jan@example.nl", "full_name": "Jan de Vries" },
|
||
"pending_identity_match": {
|
||
"match_id": "ulid",
|
||
"matched_user": { "id": "ulid", "first_name": "Jan", "last_name": "de Vries", "full_name": "Jan de Vries", "email": "jan@example.nl", "date_of_birth": "1990-01-01" },
|
||
"matched_on": "email",
|
||
"matched_on_label": "E-mail match",
|
||
"confidence": "high",
|
||
"confidence_label": "Hoge zekerheid",
|
||
"match_details": { "matched_fields": ["email"], "..." : "..." }
|
||
}
|
||
}
|
||
```
|
||
|
||
## Crowd Lists
|
||
|
||
- `GET /organisations/{org}/events/{event}/crowd-lists` — list all crowd lists for event (includes `persons_count`)
|
||
- `POST /organisations/{org}/events/{event}/crowd-lists` — create crowd list
|
||
- `PUT /organisations/{org}/events/{event}/crowd-lists/{list}` — update crowd list
|
||
- `DELETE /organisations/{org}/events/{event}/crowd-lists/{list}` — delete crowd list
|
||
- `GET /organisations/{org}/events/{event}/crowd-lists/{list}/persons` — list persons on a crowd list (paginated, 50/page, includes `crowd_list_pivot`)
|
||
- `POST /organisations/{org}/events/{event}/crowd-lists/{list}/persons` — add person to list
|
||
- `DELETE /organisations/{org}/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 /organisations/{org}/events/{event}/locations`
|
||
- `POST /organisations/{org}/events/{event}/locations`
|
||
- `PUT /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 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 /organisations/{org}/events/{event}/persons?tag={person_tag_id}` — filter persons by single tag
|
||
- `GET /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 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
|
||
|
||
```json
|
||
{
|
||
"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.
|
||
- `GET /portal/my-shifts` — auth:sanctum. Returns all active shift assignments across all events for the authenticated user. Finds all Person records linked via `user_id` (approved/pending status), then returns their active assignments (approved/pending_approval). Response grouped by event → date.
|
||
- `GET /portal/events/{event}/available-shifts` — auth:sanctum. Returns shifts available to claim, grouped by date → time slot. Requires approved person status.
|
||
- `GET /portal/events/{event}/my-shifts` — auth:sanctum. Returns the user's shift assignments for a specific event, categorized as upcoming/past/cancelled.
|
||
- `POST /portal/events/{event}/shifts/{shift}/claim` — auth:sanctum. Claim a shift. Returns assignment with status (pending_approval or approved based on section auto-accept).
|
||
- `POST /portal/events/{event}/assignments/{shiftAssignment}/cancel` — auth:sanctum. Cancel own assignment. Must be future and cancellable status.
|
||
|
||
### Portal My-Shifts Response
|
||
|
||
```json
|
||
{
|
||
"data": [
|
||
{
|
||
"event": { "id": "ulid", "name": "Festival X", "start_date": "2026-07-01", "end_date": "2026-07-03" },
|
||
"assignments": [
|
||
{
|
||
"date": "2026-07-01",
|
||
"date_label": "Woensdag 1 juli",
|
||
"shifts": [
|
||
{
|
||
"id": "ulid",
|
||
"status": "approved",
|
||
"shift": {
|
||
"id": "ulid",
|
||
"title": "Tapper",
|
||
"section_name": "Bar",
|
||
"section_icon": "tabler-beer",
|
||
"time_slot_name": "Avond",
|
||
"date": "2026-07-01",
|
||
"start_time": "18:00",
|
||
"end_time": "23:00",
|
||
"report_time": "17:30",
|
||
"location": { "name": "Hoofdpodium", "address": "Festivalplein 1" }
|
||
}
|
||
}
|
||
]
|
||
}
|
||
]
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
## Registration Field Templates (Organisation Settings)
|
||
|
||
- `GET /organisations/{org}/registration-field-templates` — list active templates (ordered)
|
||
- `POST /organisations/{org}/registration-field-templates` — create template
|
||
- `PUT /organisations/{org}/registration-field-templates/{template}` — update template
|
||
- `DELETE /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 template
|
||
- `PUT /organisations/{org}/events/{event}/registration-fields/{field}` — update field
|
||
- `DELETE /organisations/{org}/events/{event}/registration-fields/{field}` — delete field definition (answers preserved)
|
||
- `POST /organisations/{org}/events/{event}/registration-fields/reorder` — bulk reorder
|
||
- `POST /organisations/{org}/events/{event}/registration-fields/import-from-event` — copy fields from another event
|
||
|
||
### From-Template Body
|
||
|
||
```json
|
||
{ "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
|
||
|
||
```json
|
||
{ "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 person
|
||
- `PUT /organisations/{org}/events/{event}/persons/{person}/field-values` — bulk upsert answers
|
||
|
||
### Bulk Upsert Body
|
||
|
||
```json
|
||
{
|
||
"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 preferences
|
||
- `PUT /organisations/{org}/events/{event}/persons/{person}/section-preferences` — replace all preferences
|
||
|
||
### Replace Body
|
||
|
||
```json
|
||
{
|
||
"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_CONTAINS` for 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.)_
|