Files
crewli/dev-docs/API.md
bert.hausmans 0cdc192239 feat: shift assignment workflow with claim, approve, reject, cancel, and bulk approve
Implements the complete ShiftAssignment lifecycle:
- ShiftAssignmentStatus enum with allowed transitions
- ShiftAssignmentService with claim/assign/approve/reject/cancel/bulkApprove
- ShiftAssignmentController with event-scoped endpoints
- ShiftAssignmentPolicy (organizer + volunteer self-cancel)
- VolunteerAvailability model, controller, and sync endpoint
- Refactored ShiftController to delegate to service layer
- 31 workflow tests covering all paths and multi-tenancy

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 17:00:56 +02:00

13 KiB
Raw Blame History

Crewli API Contract

Base path: /api/v1/

Auth: Bearer token (Sanctum)

Auth

  • POST /auth/login
  • POST /auth/logout
  • GET /auth/me

Organisations

  • GET /organisations — list (super admin)
  • POST /organisations — create
  • GET /organisations/{org} — show
  • PUT /organisations/{org} — update
  • GET /organisations/{org}/members — members
  • POST /organisations/{org}/invite — invite user

Events

  • GET /organisations/{org}/events — list (top-level only by default)
  • GET /organisations/{org}/events?include_children=true — include sub-events nested in response
  • GET /organisations/{org}/events?type=festival — filter by event_type (festival|series|event)
  • POST /organisations/{org}/events — create (supports parent_event_id for sub-events)
  • GET /organisations/{org}/events/{event} — detail (includes children and parent if loaded)
  • PUT /organisations/{org}/events/{event} — update (does NOT accept status — use transition endpoint)
  • POST /organisations/{org}/events/{event}/transition — change event status via state machine (see below)
  • GET /organisations/{org}/events/{event}/children — list sub-events of a festival/series

Event Status Transitions

POST /organisations/{org}/events/{event}/transition

Body: { "status": "published" }

Enforces a state machine: only valid forward (and select backward) transitions are allowed. Returns 422 with errors, current_status, requested_status, and allowed_transitions when the transition is invalid or prerequisites are missing.

Prerequisites checked:

  • → published: name, start_date, end_date required
  • → registration_open: at least one time slot and one section required

Festival cascade: Transitioning a festival parent to showday, teardown, or closed automatically cascades to all children in an earlier status.

EventResource includes allowed_transitions (array of valid next statuses) so the frontend knows which buttons to show.

Event Stats

  • GET /events/{event}/stats — aggregate dashboard counts for the event

Response

{
  "data": {
    "persons_total": 142,
    "persons_approved": 98,
    "persons_pending": 31,
    "persons_rejected": 8,
    "persons_other": 5,
    "persons_approved_without_shift": 23,
    "pending_identity_matches": 3,
    "shifts_total": 45,
    "shifts_filled": 38,
    "shifts_understaffed": 7
  }
}

Crowd Types

  • GET /organisations/{org}/crowd-types
  • POST /organisations/{org}/crowd-types
  • PUT /organisations/{org}/crowd-types/{type}
  • DELETE /organisations/{org}/crowd-types/{type}

Companies

  • GET /organisations/{org}/companies
  • POST /organisations/{org}/companies
  • PUT /organisations/{org}/companies/{company}
  • DELETE /organisations/{org}/companies/{company}

Section Categories

  • GET /organisations/{org}/section-categories — distinct categories used across the organisation's events (for autocomplete). Returns { "data": ["Bar", "Podium", ...] }

Festival Sections

  • GET /events/{event}/sections
  • POST /events/{event}/sections
  • PUT /events/{event}/sections/{section}
  • DELETE /events/{event}/sections/{section}
  • POST /events/{event}/sections/reorder

Festival context: {event} can be a festival parent or a sub-event. On a festival parent, sections are for operational planning (build-up, teardown). For sub-events, GET automatically includes cross_event sections from the parent festival. Shifts on cross_event sections must use the parent festival's event_id in API calls, since the section's event_id points to the parent.

Time Slots

  • GET /events/{event}/time-slots
  • POST /events/{event}/time-slots
  • PUT /events/{event}/time-slots/{timeSlot}
  • DELETE /events/{event}/time-slots/{timeSlot}

Festival context: {event} can be a festival parent or a sub-event. Festival-level time slots (operational: build-up, teardown, transitions) are separate from sub-event time slots (program-specific).

GET /events/{event}/time-slots returns only the specified event's own time slots by default. For sub-events, pass ?include_parent=true to also include the parent festival's time slots — each time slot is marked with a source field (sub_event or festival) and includes event_name for display grouping. This parameter has no effect on festivals or flat events.

Shifts on sub-event sections may reference parent festival time slots (e.g. for build-up shifts). The time_slot_id validation accepts time slots from the sub-event itself or its parent festival.

Shifts

  • GET /events/{event}/sections/{section}/shifts
  • POST /events/{event}/sections/{section}/shifts
  • PUT /events/{event}/sections/{section}/shifts/{shift}
  • DELETE /events/{event}/sections/{section}/shifts/{shift}
  • POST /events/{event}/sections/{section}/shifts/{shift}/assign
  • POST /events/{event}/sections/{section}/shifts/{shift}/claim

Festival context: When managing shifts on a cross_event section, the {event} in the URL must be the parent festival's ID (matching section.event_id), not the sub-event's ID.

Shift Assignments

  • GET /events/{event}/shift-assignments — list assignments for event (paginated, 50/page)
  • POST /events/{event}/shift-assignments/{shiftAssignment}/approve — approve pending assignment
  • POST /events/{event}/shift-assignments/{shiftAssignment}/reject — reject pending assignment
  • POST /events/{event}/shift-assignments/{shiftAssignment}/cancel — cancel assignment
  • POST /events/{event}/shift-assignments/bulk-approve — bulk approve multiple assignments

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 /events/{event}/sections/{section}/shifts/{shift}/assign

{ "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 /events/{event}/sections/{section}/shifts/{shift}/claim

{ "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

{ "reason": "Onvoldoende ervaring voor deze rol." }

Bulk Approve Body

{ "assignment_ids": ["ulid1", "ulid2", ...] }

Response includes per-assignment result: approved or skipped (with reason).

ShiftAssignmentResource

{
  "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_approvalapproved, rejected, cancelled
  • approvedcancelled, 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 /events/{event}/persons/{person}/availabilities — list availabilities
  • POST /events/{event}/persons/{person}/availabilities/sync — sync (replace all)

Sync Body

{
  "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_ids must belong to the event (or parent festival)
  • Time slot person_type must match the person's crowd type system_type

Persons

  • GET /events/{event}/persons
  • POST /events/{event}/persons
  • GET /events/{event}/persons/{person}
  • PUT /events/{event}/persons/{person}
  • POST /events/{event}/persons/{person}/approve
  • DELETE /events/{event}/persons/{person}

Identity Matches

  • GET /organisations/{org}/identity-matches — list pending matches for the organisation (paginated, 25 per page)
  • GET /organisations/{org}/persons/{person}/identity-match — show pending match for a specific person
  • POST /organisations/{org}/identity-matches/{match}/confirm — confirm a match (links person.user_id)
  • POST /organisations/{org}/identity-matches/{match}/dismiss — dismiss a match (hidden, person stays unlinked)
  • POST /organisations/{org}/identity-matches/bulk-confirm — bulk confirm multiple matches

Detection

Matches are created automatically:

  • When a person is created (via POST /events/{event}/persons) with an email matching an existing user → pending match created
  • When a new user account is created (invitation acceptance) with an email matching unlinked persons → pending matches created

No silent auto-linking. Every identity link requires explicit confirmation.

Bulk Confirm

POST /organisations/{org}/identity-matches/bulk-confirm

Body: { "match_ids": ["ulid1", "ulid2", ...] } (max 100)

Response: { "confirmed": 2, "errors": [{ "match_id": "ulid3", "error": "User already has a person record in this event." }] }

PersonResource enrichment

GET /events/{event}/persons includes pending_identity_match inline when a pending match exists:

{
  "pending_identity_match": {
    "match_id": "ulid",
    "matched_user": { "id": "ulid", "name": "Jan", "email": "jan@example.nl" },
    "matched_on": "email",
    "confidence": "exact"
  }
}

Crowd Lists

  • GET /events/{event}/crowd-lists — list all crowd lists for event (includes persons_count)
  • POST /events/{event}/crowd-lists — create crowd list
  • PUT /events/{event}/crowd-lists/{list} — update crowd list
  • DELETE /events/{event}/crowd-lists/{list} — delete crowd list
  • GET /events/{event}/crowd-lists/{list}/persons — list persons on a crowd list (paginated, 50/page, includes crowd_list_pivot)
  • POST /events/{event}/crowd-lists/{list}/persons — add person to list
  • DELETE /events/{event}/crowd-lists/{list}/persons/{person} — remove person from list

Create/Update Body

{
  "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

{
  "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

{
  "id": "01JXYZ...",
  "event_id": "01JXYZ...",
  "crowd_type_id": "01JXYZ...",
  "name": "VIP Gastenlijst",
  "type": "internal",
  "recipient_company_id": null,
  "auto_approve": false,
  "max_persons": 50,
  "is_full": false,
  "created_at": "2026-04-10T12:00:00+00:00",
  "persons_count": 12
}

Locations

  • GET /events/{event}/locations
  • POST /events/{event}/locations
  • PUT /events/{event}/locations/{location}
  • DELETE /events/{event}/locations/{location}

Person Tags (Organisation Settings)

  • GET /organisations/{org}/person-tags — list active tags (ordered)
  • POST /organisations/{org}/person-tags — create tag
  • PUT /organisations/{org}/person-tags/{tag} — update tag
  • DELETE /organisations/{org}/person-tags/{tag} — deactivate tag (soft: sets is_active = false)
  • GET /organisations/{org}/person-tag-categories — distinct categories for autocomplete

User Tag Assignments

  • GET /organisations/{org}/users/{user}/tags — list all tags for user in organisation
  • POST /organisations/{org}/users/{user}/tags — assign a tag
  • DELETE /organisations/{org}/users/{user}/tags/{tagAssignment} — remove assignment
  • PUT /organisations/{org}/users/{user}/tags/sync — sync tags by source

Sync endpoint: Replaces tags of the specified source only. Body: { "tag_ids": ["ulid1", "ulid2"], "source": "self_reported" } Removes self_reported tags not in the list, adds new ones, leaves organiser_assigned untouched (and vice versa).

Person list tag filtering

  • GET /events/{event}/persons?tag={person_tag_id} — filter persons by single tag
  • GET /events/{event}/persons?tags=ulid1,ulid2 — filter persons by multiple tags (AND logic: must have all)

(Extend this contract per module as endpoints are implemented.)