Files
crewli/dev-docs/API.md
bert.hausmans 53100d4f6d feat: portal cross-event my-shifts endpoint and dashboard page
GET /portal/my-shifts aggregates shift assignments across all events
the logged-in user is linked to via Person records. Groups by event
then date, showing only active assignments (approved/pending_approval)
for approved/pending persons.

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

669 lines
26 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)
## 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 /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: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}`
## 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.)_