Files
crewli/dev-docs/API.md
bert.hausmans 65978104d8 feat: complete email infrastructure with queue, templates, logging, and API
Adds the full transactional email system:
- Redis queue (QUEUE_CONNECTION=redis), SES config in .env.example
- 3 migrations: organisation_email_settings, organisation_email_templates, email_logs
- EmailTemplateType and EmailLogStatus enums with Dutch defaults
- EmailService as central entry point for all email sending
- SendTransactionalEmail queued job with retries and idempotency
- TransactionalMail mailable with responsive HTML + plain text templates
- Organisation-level branding (colors, logo, footer, reply-to)
- Per-type template overrides with {variable} substitution
- Email log with filtering by status, type, date range, recipient
- Preview and send-test endpoints for template management
- API endpoints: email-settings, email-templates (CRUD), email-logs (read-only)
- Integrated into existing flows: invitations, password reset, email
  verification, registration approval/rejection
- 37 new tests across 4 test files, all existing tests updated

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

1043 lines
38 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.)_
## Platform Admin
All admin endpoints require `auth:sanctum` + `role:super_admin`. They bypass OrganisationScope and query across all organisations.
Base path: `/api/v1/admin/`
### Admin Organisations
- `GET /admin/organisations` — list all organisations (paginated)
- `GET /admin/organisations/{organisation}` — show with counts and total persons
- `POST /admin/organisations` — not supported (405), use regular endpoint
- `PUT /admin/organisations/{organisation}` — update name, slug, billing_status, settings
- `DELETE /admin/organisations/{organisation}` — soft delete
#### Query Parameters (index)
- `search` — filter by name or slug (partial match)
- `billing_status` — filter by billing status (`trial`, `active`, `suspended`, `cancelled`)
- `sort` — sort field: `name` (default), `created_at`
- `direction` — sort direction: `asc` (default), `desc`
#### AdminOrganisationResource
```json
{
"id": "01JXYZ...",
"name": "Festival Corp",
"slug": "festival-corp",
"billing_status": "active",
"billing_status_label": "Active",
"settings": {},
"events_count": 5,
"users_count": 12,
"total_persons": 342,
"created_at": "2026-01-15T10:00:00+00:00",
"updated_at": "2026-04-10T12:00:00+00:00",
"deleted_at": null
}
```
#### Update Body
```json
{
"name": "Festival Corp",
"slug": "festival-corp",
"billing_status": "suspended",
"settings": {}
}
```
### Admin Users
- `GET /admin/users` — list all users with organisation memberships (paginated)
- `GET /admin/users/{user}` — show with organisations and roles
- `PUT /admin/users/{user}` — update name, email, timezone, locale, platform roles
- `DELETE /admin/users/{user}` — soft delete + revoke all tokens
#### Query Parameters (index)
- `search` — filter by first_name, last_name, or email (partial match)
- `organisation_id` — filter by organisation membership
- `role` — filter by Spatie role name
#### AdminUserResource
```json
{
"id": "01JXYZ...",
"first_name": "Jan",
"last_name": "de Vries",
"full_name": "Jan de Vries",
"email": "jan@example.nl",
"avatar": null,
"timezone": "Europe/Amsterdam",
"locale": "nl",
"email_verified_at": "2026-01-15T10:00:00+00:00",
"created_at": "2026-01-15T10:00:00+00:00",
"roles": ["super_admin"],
"is_super_admin": true,
"organisations": [
{ "id": "01JXYZ...", "name": "Festival Corp", "slug": "festival-corp", "role": "org_admin" }
]
}
```
#### Update Body
```json
{
"first_name": "Jan",
"last_name": "de Vries",
"email": "jan@example.nl",
"timezone": "Europe/Amsterdam",
"locale": "nl",
"roles": ["super_admin"]
}
```
`roles` accepts platform-level roles only: `super_admin`, `support_agent`. Organisation/event roles are managed via the regular endpoints.
### Admin Stats
- `GET /admin/stats` — platform-wide aggregate counts
#### Response
```json
{
"data": {
"organisations": {
"total": 15,
"by_billing_status": { "trial": 3, "active": 10, "suspended": 1, "cancelled": 1 }
},
"events": {
"total": 42,
"by_status": { "draft": 10, "published": 8, "registration_open": 12, "showday": 5, "closed": 7 }
},
"users": {
"total": 156,
"verified": 142
},
"persons": {
"total": 2340
}
}
}
```
### Admin Activity Log
- `GET /admin/activity-log` — paginated activity log (25 per page)
#### Query Parameters
- `causer_id` — filter by user who caused the action
- `subject_type` — filter by subject model type
- `log_name` — filter by log name (e.g. `admin`, `default`)
- `from` — filter from date (ISO 8601)
- `to` — filter to date (ISO 8601)
#### Response
```json
{
"data": [
{
"id": 1,
"log_name": "admin",
"description": "Updated organisation Festival Corp",
"event": "admin.organisation.updated",
"causer": { "id": "01JXYZ...", "name": "Super Admin", "email": "admin@crewli.app" },
"subject_type": "App\\Models\\Organisation",
"subject_id": "01JXYZ...",
"properties": { "billing_status": "suspended" },
"created_at": "2026-04-14T10:00:00+00:00"
}
],
"meta": { "current_page": 1, "last_page": 1, "per_page": 25, "total": 1 }
}
```
### Admin Impersonation
- `POST /admin/impersonate/{user}` — start impersonating a user (requires `role:super_admin`)
- `POST /admin/stop-impersonation` — stop impersonation (requires `auth:sanctum` only, callable by impersonated user)
#### Start Response
```json
{
"data": {
"token": "1|abc123...",
"user": { "...AdminUserResource..." },
"admin_id": "01JXYZ..."
}
}
```
#### Stop Response
```json
{
"data": {
"user": { "...AdminUserResource (original admin)..." }
}
}
```
#### Business Rules
- Cannot impersonate another super_admin (403)
- Impersonation token has name `impersonation-by-{admin_id}`
- Admin ID is cached for 4 hours at key `impersonation:{token_id}`
- Activity log records both start (`admin.impersonation.started`) and stop (`admin.impersonation.stopped`)
## Email Settings (org admin)
- `GET /organisations/{org}/email-settings` — get current email branding settings (returns defaults if none configured)
- `PUT /organisations/{org}/email-settings` — create or update email branding settings
### PUT body
```json
{
"logo_url": "https://example.com/logo.png",
"primary_color": "#FF5500",
"secondary_color": "#CC4400",
"footer_text": "© 2026 Stichting Feestfabriek",
"reply_to_email": "info@festival.nl",
"reply_to_name": "Festival Team"
}
```
All fields are optional/nullable. Colors must match `#[0-9A-Fa-f]{6}`.
## Email Templates (org admin)
- `GET /organisations/{org}/email-templates` — list all template types with current content (custom or default)
- `GET /organisations/{org}/email-templates/{type}` — get single template with both custom content and defaults
- `PUT /organisations/{org}/email-templates/{type}` — create or update custom template for this type
- `DELETE /organisations/{org}/email-templates/{type}` — reset to system default (deletes custom override)
- `POST /organisations/{org}/email-templates/{type}/preview` — render email HTML with sample data
- `POST /organisations/{org}/email-templates/{type}/send-test` — send test email. Body: `{ "email": "test@example.com" }`
### Template types
`invitation`, `password_reset`, `email_verification`, `registration_approved`, `registration_rejected`, `shift_assignment`
### PUT body
```json
{
"subject": "Aangepaste uitnodiging voor {organisation_name}",
"heading": "Welkom!",
"body_text": "Aangepaste tekst met {organisation_name} variabelen.",
"button_text": "Klik hier"
}
```
### GET response (index)
Returns array of all template types with resolved content:
```json
{
"data": [
{
"type": "invitation",
"label": "Uitnodiging",
"is_custom": false,
"subject": "Je bent uitgenodigd voor {organisation_name}",
"heading": "Welkom bij {organisation_name}!",
"body_text": "...",
"button_text": "Uitnodiging accepteren",
"defaults": { "subject": "...", "heading": "...", "body_text": "...", "button_text": "..." }
}
]
}
```
### Template variables
- `{organisation_name}` — available in all templates
- `{event_name}` — available in registration and shift templates
- `{shift_title}`, `{shift_date}`, `{shift_start}`, `{shift_end}`, `{section_name}` — shift assignment only
## Email Logs (org admin, read-only)
- `GET /organisations/{org}/email-logs` — paginated email log
### Query parameters
| Param | Type | Description |
| --------------- | ------ | ------------------------------------ |
| `search` | string | Search by recipient_email |
| `status` | string | Filter: `queued\|sent\|failed` |
| `template_type` | string | Filter by EmailTemplateType |
| `event_id` | ULID | Filter by event |
| `person_id` | ULID | Filter by person |
| `from` | date | Start of date range |
| `to` | date | End of date range |
| `per_page` | int | Results per page (default 15) |
### Response
```json
{
"data": {
"data": [
{
"id": "01JXYZ...",
"recipient_email": "volunteer@test.nl",
"recipient_name": "Jan Janssen",
"template_type": "registration_approved",
"template_label": "Registratie goedgekeurd",
"subject": "Je registratie voor Festival X is goedgekeurd!",
"status": "sent",
"error_message": null,
"queued_at": "2026-04-15T12:00:00+00:00",
"sent_at": "2026-04-15T12:00:05+00:00",
"failed_at": null,
"triggered_by": { "id": "01JXYZ...", "name": "Admin User" },
"event_id": "01JXYZ...",
"person_id": "01JXYZ...",
"created_at": "2026-04-15T12:00:00+00:00"
}
],
"links": { "...pagination..." },
"meta": { "...pagination..." }
}
}
```