Files
crewli/dev-docs/API.md
bert.hausmans 7bc0f1a0c7 feat: fix time slot hierarchy — seeder, API include_children, frontend dropdown, navigation
Restructure the festival hierarchy end-to-end:

Seeder: Remove duplicate festival-level VOLUNTEER time slots, keep only CREW
operational slots. Rename sub-events to "Dag 1/2/3 — ..." pattern. Change
Nachtsecurity to Security (cross_event). EHBO/Security shifts now use sub-event
time slots via cross_event exception. Add flat event "Braderie Dorpstown 2026".

API: Add ?include_children=true to TimeSlotController for festivals, returning
all sub-event time slots with source and event_name fields. Update
StoreShiftRequest and UpdateShiftRequest to accept child time slots for
cross_event sections.

Frontend: Create useTimeSlotDropdown composable with 4-scenario dropdown logic.
Replace AppSelect with VAutocomplete in CreateShiftDialog with grouped items,
dimmed festival slots, and info tooltips. Add InfoTooltip reusable component.
Show festival context labels on cross_event sections in sub-event section lists.
Add read-only festival time slots on sub-event time-slots page. Add cross_event
context banner with "Bekijk alle diensten" link.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 22:07:37 +02:00

729 lines
29 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.
>
> For festivals (events with sub-events), pass `?include_children=true` to include all
> sub-event time slots. Each time slot is marked with `source` (`own` or the sub-event's
> `event_id`) and `event_name`. Used by cross_event section shift dialogs that need access
> to all time slots across the festival. This parameter has no effect on sub-events or
> flat events.
>
> Shifts on sub-event sections may reference parent festival time slots (e.g. for build-up
> shifts). Shifts on cross_event sections may reference any time slot from the festival or
> its sub-events. The `time_slot_id` validation accepts time slots accordingly.
## 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:0018: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: 15).
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}`
- `POST /organisations/{org}/events/{event}/persons/from-member` — create person from org member
- `GET /organisations/{org}/members/available-for-event/{event}` — list members not yet added to event
### Create Person from Member
`POST /organisations/{org}/events/{event}/persons/from-member`
Creates a Person record from an existing organisation member. The person is created with `status: approved` and `user_id` pre-linked.
```json
{
"user_id": "01JXYZ...",
"crowd_type_id": "01JXYZ..."
}
```
Person data (first_name, last_name, email) is copied from the user account. Returns `PersonResource` (201).
**Validation:**
- User must belong to the organisation
- User must not already be a person at this event (422)
- crowd_type_id must belong to the organisation
### Available Members for Event
`GET /organisations/{org}/members/available-for-event/{event}`
Returns organisation members who do NOT yet have a Person record at the specified event. Used to populate the "Lid toevoegen" dialog.
```json
{
"data": [
{
"id": "01JXYZ...",
"first_name": "Jan",
"last_name": "de Vries",
"full_name": "Jan de Vries",
"email": "jan@example.nl"
}
]
}
```
Auth: org member or higher on the organisation.
## 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.)_