Files
crewli/dev-docs/SCHEMA.md
bert.hausmans fcff3b0344 docs: registration form fields, section preferences & form redesign
Update SCHEMA.md (v1.8), design-document.md (v1.9), and API.md with
EAV system for dynamic event-specific registration fields, section
preferences, tag picker sync architecture, and field templates.

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

1728 lines
96 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 — Core Database Schema
> Source: Design Document v1.3 — Section 3.5
> **Version: 1.8** — Updated April 2026
>
> **Changelog:**
>
> - v1.3: Original — 12 database review findings incorporated
> - v1.4: Competitor analysis amendments (Crescat, WeezCrew, In2Event)
> - v1.5: Concept Event Structure review + final decisions
> - v1.6: Removed `festival_sections.shift_follows_events`
> - v1.7: Festival/Event architecture — universal event model supporting
> single events, multi-day festivals, multi-location events, event series
> and periodic operations (recurrence). Added `parent_event_id`,
> `event_type`, `sub_event_label`, `is_recurring`, `recurrence_rule`,
> `recurrence_exceptions` to `events`. Added `event_person_activations`
> pivot. Changed `persons.event_id` to reference festival-level event.
> Added `event_type_label` for UI terminology customisation.
> - v1.8: Registration Form Fields module — EAV system for dynamic event-specific
> registration fields, replacing queryable use of `persons.custom_fields` JSON.
> Added tables: `registration_form_fields`, `person_field_values`,
> `person_section_preferences`. Added columns: `persons.remarks`,
> `persons.date_of_birth`, `events.registration_show_section_preferences`,
> `events.registration_show_availability`. Added TAG_PICKER field type for
> tag selection during registration with deferred sync via TagSyncService.
> Removed minimum volunteer hours threshold concept. Removed hardcoded
> motivation form step. Moved payment status from fixed admin field to
> dynamic registration field.
---
## Primary Key Convention: ULID
> **All tables use ULID as primary key — NO UUID v4.**
>
> - Laravel: `HasUlids` trait
> - Migrations: `$table->ulid('id')->primary()`
> - External IDs (URLs, barcodes, API): ULID
> - Pure pivot tables: auto-increment integer PK for join performance
---
## Table of Contents
1. [3.5.1 Foundation](#351-foundation)
2. [3.5.2 Locations](#352-locations)
3. [3.5.3 Festival Sections, Time Slots & Shifts](#353-festival-sections-time-slots--shifts)
4. [3.5.4 Volunteer Profile & History](#354-volunteer-profile--history)
5. [3.5.5 Crowd Types, Persons & Crowd Lists](#355-crowd-types-persons--crowd-lists)
6. [3.5.5a Person Tags & Skills](#355a-person-tags--skills)
7. [3.5.5b Registration Form Fields & Section Preferences](#355b-registration-form-fields--section-preferences)
8. [3.5.5c Person Identity Matching](#355c-person-identity-matching)
9. [3.5.6 Accreditation Engine](#356-accreditation-engine)
10. [3.5.7 Artists & Advancing](#357-artists--advancing)
11. [3.5.8 Communication & Briefings](#358-communication--briefings)
12. [3.5.9 Forms, Check-In & Operational](#359-forms-check-in--operational)
13. [3.5.11 Database Design Rules & Index Strategy](#3511-database-design-rules--index-strategy)
---
## 3.5.1 Foundation
### `users`
| Column | Type | Notes |
| ------------------- | ------------------ | ------------------------- |
| `id` | ULID | PK, `HasUlids` trait |
| `first_name` | string | |
| `last_name` | string | |
| `email` | string | unique |
| `password` | string | hashed |
| `timezone` | string | default: Europe/Amsterdam |
| `locale` | string | default: nl |
| `avatar` | string nullable | |
| `email_verified_at` | timestamp nullable | |
| `deleted_at` | timestamp nullable | Soft delete |
**Relations:** `belongsToMany` organisations (via `organisation_user`), `belongsToMany` events (via `event_user_roles`)
**Soft delete:** yes
---
### `organisations`
| Column | Type | Notes |
| ---------------- | ------------------ | -------------------------------------- |
| `id` | ULID | PK |
| `name` | string | |
| `slug` | string | unique |
| `billing_status` | enum | `trial\|active\|suspended\|cancelled` |
| `settings` | JSON | Display prefs only — no queryable data |
| `created_at` | timestamp | |
| `deleted_at` | timestamp nullable | Soft delete |
**Relations:** `hasMany` events, crowd_types, accreditation_categories
**Soft delete:** yes
---
### `organisation_user`
| Column | Type | Notes |
| ----------------- | ------- | --------------------------------- |
| `id` | int AI | PK — integer for join performance |
| `user_id` | ULID FK | → users |
| `organisation_id` | ULID FK | → organisations |
| `role` | string | Spatie role via pivot |
**Type:** Pivot table — integer PK
**Unique constraint:** `UNIQUE(user_id, organisation_id)`
---
### `user_invitations`
| Column | Type | Notes |
| -------------------- | ---------------- | --------------------------------- |
| `id` | ULID | PK |
| `email` | string | |
| `invited_by_user_id` | ULID FK nullable | → users (nullOnDelete) |
| `organisation_id` | ULID FK | → organisations |
| `event_id` | ULID FK nullable | → events |
| `role` | string | |
| `token` | ULID | unique — sent in invitation email |
| `status` | enum | `pending\|accepted\|expired` |
| `expires_at` | timestamp | |
**Indexes:** `(token)`, `(email, status)`
---
### `events`
> **v1.7:** Universal event model supporting all event types:
> single events, multi-day festivals, multi-location events,
> event series, and periodic operations (schaatsbaan use case).
>
> **Architecture:**
>
> - A **flat event** has no parent and no children → behaves as a normal single event
> - A **festival/series** has no parent but has children → container level
> - A **sub-event** has a `parent_event_id` → operational unit within a festival
> - A **single event** = flat event where festival and operational unit are the same
>
> **UI behaviour:** If an event has no children, all tabs are shown at the
> event level (flat mode). Once children are added, the event becomes a
> festival container and children get the operational tabs.
| Column | Type | Notes |
| ----------------------- | ------------------ | ------------------------------------------------------------------------------------------ |
| `id` | ULID | PK |
| `organisation_id` | ULID FK | → organisations |
| `parent_event_id` | ULID FK nullable | **v1.7** → events (nullOnDelete). NULL = top-level event or festival |
| `name` | string | |
| `slug` | string | |
| `start_date` | date | |
| `end_date` | date | |
| `timezone` | string | default: Europe/Amsterdam |
| `status` | enum | `draft\|published\|registration_open\|buildup\|showday\|teardown\|closed` |
| `event_type` | enum | **v1.7** `event\|festival\|series` — default: event |
| `event_type_label` | string nullable | **v1.7** UI label chosen by organiser: "Festival", "Evenement", "Serie" |
| `sub_event_label` | string nullable | **v1.7** How to call children: "Dag", "Programmaonderdeel", "Editie" |
| `is_recurring` | bool | **v1.7** default: false. True = generated from recurrence rule |
| `recurrence_rule` | string nullable | **v1.7** RRULE (RFC 5545): "FREQ=WEEKLY;BYDAY=SA,SU;UNTIL=20270126" |
| `recurrence_exceptions` | JSON nullable | **v1.7** Array of {date, type: cancelled\|modified, overrides: {}}. JSON OK: opaque config |
| `registration_show_section_preferences` | bool | **v1.8** Default: true. Toggle section preferences step in registration form |
| `registration_show_availability` | bool | **v1.8** Default: true. Toggle time slot availability step in registration form |
| `deleted_at` | timestamp nullable | Soft delete |
**Relations:**
- `belongsTo` Organisation
- `belongsTo` Event as parent (`parent_event_id`)
- `hasMany` Event as children (`parent_event_id`)
- `hasMany` FestivalSection, TimeSlot, Artist, Briefing (on sub-event or flat event)
- `hasMany` Person (on festival/top-level event)
**Indexes:** `(organisation_id, status)`, `(parent_event_id)`, `UNIQUE(organisation_id, slug)`
**Soft delete:** yes
**Status state machine:**
```
draft → published → registration_open → buildup → showday → teardown → closed
↑ ↓ ↑
└── (back) ────┘ │
(back) ────┘
```
Allowed transitions:
| From | To |
| ------------------- | --------------------------- |
| `draft` | `published` |
| `published` | `registration_open`, `draft`|
| `registration_open` | `buildup`, `published` |
| `buildup` | `showday` |
| `showday` | `teardown` |
| `teardown` | `closed` |
| `closed` | _(terminal — no transitions)_ |
**Prerequisites:**
- `→ published`: name, start_date, and end_date must be set
- `→ registration_open`: at least one time slot and one section must exist
**Festival cascade:** When a festival parent transitions to `showday`, `teardown`, or `closed`, all children in an earlier status are automatically updated to the same status. Earlier statuses (`draft → published`) do NOT cascade.
**Helper scopes (Laravel):**
```php
scopeTopLevel() // WHERE parent_event_id IS NULL
scopeChildren() // WHERE parent_event_id IS NOT NULL
scopeWithChildren() // includes self + all children
scopeFestivals() // WHERE event_type IN ('festival', 'series')
```
**Event type behaviour:**
| event_type | Has parent? | Description |
|---|---|---|
| `event` | No | Flat single event — all modules at this level |
| `event` | Yes | Sub-event (operational unit within festival) |
| `festival` | No | Multi-day festival — children are the days |
| `series` | No | Recurring series — children are the editions |
> **Recurrence note (BACKLOG ARCH-01):** `recurrence_rule` and
> `recurrence_exceptions` are reserved for the future recurrence generator.
> For now, sub-events are created manually. The generator will auto-create
> sub-events from the RRULE when built.
---
### `event_user_roles`
| Column | Type | Notes |
| ---------- | ------- | --------------------------------- |
| `id` | int AI | PK — integer for join performance |
| `user_id` | ULID FK | → users |
| `event_id` | ULID FK | → events |
| `role` | string | |
**Type:** Pivot table — integer PK
**Unique constraint:** `UNIQUE(user_id, event_id, role)`
---
## 3.5.2 Locations
> Locations are event-scoped and reusable across sections within an event.
> Maps/route integration is out of scope for Crewli.
### `locations`
| Column | Type | Notes |
| --------------------- | ---------------------- | --------------------------------------------------- |
| `id` | ULID | PK |
| `event_id` | ULID FK | → events |
| `name` | string | e.g. "Bar Hardstyle District", "Stage Silent Disco" |
| `address` | string nullable | |
| `lat` | decimal(10,8) nullable | |
| `lng` | decimal(11,8) nullable | |
| `description` | text nullable | |
| `access_instructions` | text nullable | |
**Indexes:** `(event_id)`
**Usage:** Referenced by `shifts.location_id`
> **v1.5 note:** `route_geojson` removed — maps/routes out of scope.
---
## 3.5.3 Festival Sections, Time Slots & Shifts
> **Architecture overview (based on Concept Event Structure review):**
>
> The planning model has 4 levels:
>
> 1. **Section** (e.g. Horeca, Backstage, Entertainment) — operational area
> 2. **Location** (e.g. Bar Hardstyle District) — physical spot within a section
> 3. **Shift** (e.g. "Tapper" at Bar Hardstyle District) — specific role/task at a location in a time window
> 4. **Time Slot** — the event-wide time framework that shifts reference
>
> Each row in the shift planning document = one Shift record.
> Multiple shifts at the same location = multiple records with the same `location_id` but different `title`, `actual_start_time`, and `slots_total`.
>
> **Generic shifts across sections:** Use a shared Time Slot. All sections reference the same Time Slot, ensuring the same time framework. Exceptions use `actual_start_time` override.
>
> **Cross-event sections** (EHBO, verkeersregelaars): use `type = cross_event`. Shifts in these sections can set `allow_overlap = true`.
---
### `festival_sections`
> **v1.5:** Added `type`, 7 Crescat-derived section settings (excl. `shift_follows_events` — removed in v1.6), `crew_need`, and accreditation level columns.
| Column | Type | Notes |
| --------------------------------- | ------------------ | ----------------------------------------------------------------- |
| `id` | ULID | PK |
| `event_id` | ULID FK | → events |
| `name` | string | e.g. Horeca, Backstage, Overig, Entertainment |
| `category` | string nullable | **v1.8** Free-text grouping label (e.g. Bar, Podium, Operationeel)|
| `icon` | string nullable | **v1.8** Tabler icon name (e.g. tabler-beer, tabler-microphone-2) |
| `type` | enum | `standard\|cross_event` — cross_event for EHBO, verkeersregelaars |
| `sort_order` | int | default: 0 |
| `crew_need` | int nullable | **v1.5** Total crew needed for this section (Crescat: Crew need) |
| `crew_auto_accepts` | bool | **v1.5** Crew assignments auto-approved without explicit approval |
| `crew_invited_to_events` | bool | **v1.5** Crew automatically gets event invitations |
| `added_to_timeline` | bool | **v1.5** Section visible in event timeline overview |
| `responder_self_checkin` | bool | **v1.5** Volunteers can self check-in via QR in portal |
| `crew_accreditation_level` | string nullable | **v1.5** Default accreditation level for crew (e.g. AAA, AA, A) |
| `public_form_accreditation_level` | string nullable | **v1.5** Accreditation level for public form registrants |
| `timed_accreditations` | bool | **v1.5** Accreditations are time-limited for this section |
| `show_in_registration` | bool | **v1.8** Show this section in the volunteer registration form |
| `registration_description` | text nullable | **v1.8** Description shown to volunteers in the registration form |
| `deleted_at` | timestamp nullable | Soft delete |
**Relations:** `hasMany` shifts
**Indexes:** `(event_id, sort_order)`, `(event_id, category)`
**Soft delete:** yes
**Default values:**
- `type`: standard
- `crew_auto_accepts`: false
- `crew_invited_to_events`: false
- `added_to_timeline`: false
- `responder_self_checkin`: true
- `timed_accreditations`: false
- `show_in_registration`: false
> **Note:** "Overkoepelende" sections (shared across all sub-events of a festival)
> are identified by `type = 'cross_event'`. There is no separate `is_shared` boolean
> column — the `type` enum distinguishes standard sections from cross-event sections.
**Festival context:** On a festival parent event, `standard` type sections are festival-only
operational sections (e.g. Terreinploeg, Bouwploeg). `cross_event` sections appear in all
sub-events (e.g. EHBO, Security, Backstage). On sub-events, all sections are `standard`
(program-specific). When querying sections for a sub-event, the API automatically includes
`cross_event` sections from the parent festival.
---
### `time_slots`
> Time Slots are defined centrally at event level. All sections reference the same Time Slots.
> This naturally handles "generic shifts" — multiple sections referencing one Time Slot share the same time framework.
> Per-shift time overrides are handled by `shifts.actual_start_time` / `actual_end_time`.
**Festival context:** Time slots on a festival parent event are for operational scheduling
(build-up, teardown, transitions). Time slots on sub-events are for program-specific
scheduling. When querying time slots for a sub-event with `?include_parent=true`, the parent
festival's time slots are included (marked with `source='festival'`) so that local sections
can create build-up, teardown, and transition shifts. Without this parameter, only the
sub-event's own time slots are returned. Festival-level queries never include sub-event
time slots. The `Event::getAllRelevantTimeSlots()` method can retrieve time slots across
levels for planning purposes.
| Column | Type | Notes |
| ---------------- | --------------------- | --------------------------------------------------------------------- |
| `id` | ULID | PK |
| `event_id` | ULID FK | → events |
| `name` | string | Descriptive, e.g. "DAY 1 - AVOND - VRIJWILLIGER", "KIDS - OCHTEND" |
| `person_type` | enum | `CREW\|VOLUNTEER\|PRESS\|PHOTO\|PARTNER` — controls portal visibility |
| `date` | date | |
| `start_time` | time | |
| `end_time` | time | |
| `duration_hours` | decimal(4,2) nullable | |
**Relations:** `hasMany` shifts
**Indexes:** `(event_id, person_type, date)`
---
### `shifts`
> **Architecture note:**
> One shift = one role at one location in one time window.
> Example from Concept Event Structure — Bar Hardstyle District has 5 shifts:
>
> - "Barhoofd" (1 slot, 18:3003:00, report 18:00, is_lead_role = true)
> - "Tapper" (2 slots, 19:0002:30, report 18:30)
> - "Frisdrank" (2 slots, 19:0002:30, report 18:30)
> - "Tussenbuffet" (8 slots, 19:0002:30, report 18:30)
> - "Runner" (1 slot, 20:3002:30, report 20:00)
>
> v1.4: added title, description, instructions, coordinator_notes, actual_start_time, actual_end_time, end_date, explicit status enum
> v1.5: added report_time (aanwezig-tijd), allow_overlap (Overlap Toegestaan), is_lead_role
| Column | Type | Notes |
| ------------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------- |
| `id` | ULID | PK |
| `festival_section_id` | ULID FK | → festival_sections |
| `time_slot_id` | ULID FK | → time_slots |
| `location_id` | ULID FK nullable | → locations |
| `title` | string nullable | Role/task name, e.g. "Tapper", "Barhoofd", "Stage Manager" |
| `description` | text nullable | Brief description of the task |
| `instructions` | text nullable | Shown to volunteer after assignment: what to bring, where to report |
| `coordinator_notes` | text nullable | Internal only — never visible to volunteers |
| `slots_total` | int | |
| `slots_open_for_claiming` | int | Slots visible & claimable in volunteer portal |
| `is_lead_role` | bool | **v1.5** Marks this as the lead/head role at a location (Barhoofd, Stage Manager, etc.) |
| `report_time` | time nullable | **v1.5** "Aanwezig" time — when to arrive. Displayed in briefing and portal. |
| `actual_start_time` | time nullable | Overrides `time_slot.start_time` for this shift. NULL = use time_slot time |
| `actual_end_time` | time nullable | Overrides `time_slot.end_time` for this shift. NULL = use time_slot time |
| `end_date` | date nullable | For multi-day assignments. NULL = single day (via time_slot.date) |
| `allow_overlap` | bool | **v1.5** When true: skip UNIQUE(person_id, time_slot_id) conflict check. For Stage Managers covering multiple stages, cross-event sections. |
| `events_during_shift` | JSON | Array of performance_ids — opaque reference, no filtering needed |
| `status` | enum | `draft\|open\|full\|in_progress\|completed\|cancelled` |
| `deleted_at` | timestamp nullable | Soft delete |
**Relations:** `belongsTo` festival_section, time_slot, location; `hasMany` shift_assignments
**Indexes:** `(festival_section_id, time_slot_id)`, `(time_slot_id, status)`
**Soft delete:** yes
**Status lifecycle:**
- `draft` — created but not yet published for claiming
- `open` — visible and claimable in portal (respects `slots_open_for_claiming`)
- `full` — capacity reached, waitlist only
- `in_progress` — shift has started (show day)
- `completed` — shift completed
- `cancelled` — shift cancelled
**Time resolution:**
```php
$effectiveReportTime = $shift->report_time; // shown in briefing
$effectiveStart = $shift->actual_start_time ?? $shift->timeSlot->start_time;
$effectiveEnd = $shift->actual_end_time ?? $shift->timeSlot->end_time;
$effectiveDate = $shift->end_date ?? $shift->timeSlot->date;
```
---
### `shift_assignments`
> v1.4: added hours_expected, hours_completed, checked_in_at, checked_out_at
>
> Conflict detection: UNIQUE(person_id, time_slot_id) is enforced at DB level.
> Exception: when `shifts.allow_overlap = true`, the application skips this check before inserting.
> The DB constraint remains — use a conditional unique index or handle in application layer.
| Column | Type | Notes |
| ------------------ | --------------------- | ------------------------------------------------------------ |
| `id` | ULID | PK |
| `shift_id` | ULID FK | → shifts |
| `person_id` | ULID FK | → persons |
| `time_slot_id` | ULID FK | Denormalised from shifts — DB-enforceable conflict detection |
| `status` | enum | `pending_approval\|approved\|rejected\|cancelled\|completed` |
| `auto_approved` | bool | |
| `assigned_by` | ULID FK nullable | → users |
| `assigned_at` | timestamp nullable | |
| `approved_by` | ULID FK nullable | → users |
| `approved_at` | timestamp nullable | |
| `rejection_reason` | text nullable | |
| `cancelled_by` | ULID FK nullable | → users (who performed the cancellation) |
| `cancellation_source` | enum nullable | `organiser\|volunteer\|system` |
| `cancelled_at` | timestamp nullable | |
| `hours_expected` | decimal(4,2) nullable | Planned hours for this assignment |
| `hours_completed` | decimal(4,2) nullable | Actual hours worked — set after shift completion |
| `checked_in_at` | timestamp nullable | Shift-level check-in (when reported at section) |
| `checked_out_at` | timestamp nullable | When volunteer completed the shift |
| `deleted_at` | timestamp nullable | Soft delete |
**Unique constraint:** `UNIQUE(person_id, time_slot_id)` — bypassed in application when `shift.allow_overlap = true`
**Indexes:** `(shift_id, status)`, `(person_id, status)`, `(person_id, time_slot_id)`
**Soft delete:** yes
---
### `shift_check_ins`
> Separate from terrain `check_ins`. Records when a volunteer physically reported at their section for duty.
> Enables per-shift no-show detection independent of gate access.
> When `festival_sections.responder_self_checkin = true`, volunteers trigger this via QR in portal.
| Column | Type | Notes |
| ----------------------- | ------------------ | ---------------------------------------------- |
| `id` | ULID | PK |
| `shift_assignment_id` | ULID FK | → shift_assignments |
| `person_id` | ULID FK | → persons (denormalised for query performance) |
| `shift_id` | ULID FK | → shifts (denormalised for query performance) |
| `checked_in_at` | timestamp | |
| `checked_out_at` | timestamp nullable | |
| `checked_in_by_user_id` | ULID FK nullable | → users — coordinator who confirmed check-in |
| `method` | enum | `qr\|manual` |
**Note:** Immutable audit record — NO soft delete.
**Indexes:** `(shift_assignment_id)`, `(shift_id, checked_in_at)`, `(person_id, checked_in_at)`
---
### `volunteer_availabilities`
> v1.4: added preference_level for future auto-matching algorithm.
| Column | Type | Notes |
| ------------------ | --------- | ---------------------------------------------------------------- |
| `id` | ULID | PK |
| `person_id` | ULID FK | → persons |
| `time_slot_id` | ULID FK | → time_slots |
| `preference_level` | tinyint | 1 (low) 5 (high). Default: 3. Used for auto-matching priority. |
| `submitted_at` | timestamp | |
**Unique constraint:** `UNIQUE(person_id, time_slot_id)`
**Indexes:** `(time_slot_id)`
---
### `shift_absences`
| Column | Type | Notes |
| --------------------- | ------------------ | ----------------------- |
| `id` | ULID | PK |
| `shift_assignment_id` | ULID FK | → shift_assignments |
| `person_id` | ULID FK | → persons |
| `reason` | enum | `sick\|personal\|other` |
| `reported_at` | timestamp | |
| `status` | enum | `open\|filled\|closed` |
| `closed_at` | timestamp nullable | |
**Purpose:** Volunteer reports absence — shift slot becomes available. Triggers waitlist notification.
**Indexes:** `(shift_assignment_id)`, `(status)`
---
### `shift_swap_requests`
| Column | Type | Notes |
| -------------------- | ------------------ | --------------------------------------------------- |
| `id` | ULID | PK |
| `from_assignment_id` | ULID FK | → shift_assignments |
| `to_person_id` | ULID FK | → persons |
| `message` | text nullable | |
| `status` | enum | `pending\|accepted\|rejected\|cancelled\|completed` |
| `reviewed_by` | ULID FK nullable | → users |
| `reviewed_at` | timestamp nullable | |
| `auto_approved` | bool | |
**Indexes:** `(from_assignment_id)`, `(to_person_id, status)`
---
### `shift_waitlist`
| Column | Type | Notes |
| ------------- | ------------------ | --------- |
| `id` | ULID | PK |
| `shift_id` | ULID FK | → shifts |
| `person_id` | ULID FK | → persons |
| `position` | int | |
| `added_at` | timestamp | |
| `notified_at` | timestamp nullable | |
**Unique constraint:** `UNIQUE(shift_id, person_id)`
**Logic:** On vacancy: position 1 is automatically notified.
**Indexes:** `(shift_id, position)`
---
## 3.5.4 Volunteer Profile & History
### `volunteer_profiles`
| Column | Type | Notes |
| ------------------------- | --------------- | ------------------------------------- |
| `id` | ULID | PK |
| `user_id` | ULID FK unique | → users — 1:1 |
| `bio` | text nullable | |
| `photo_url` | string nullable | |
| `tshirt_size` | string nullable | |
| `first_aid` | bool | |
| `driving_licence` | bool | |
| `allergies` | text nullable | |
| `emergency_contact_name` | string nullable | |
| `emergency_contact_phone` | string nullable | |
| `reliability_score` | decimal(3,2) | 0.005.00, computed via scheduled job |
| `is_ambassador` | bool | |
**Unique constraint:** `UNIQUE(user_id)`
---
### `volunteer_festival_history`
| Column | Type | Notes |
| -------------------- | ---------------- | --------------- |
| `id` | ULID | PK |
| `user_id` | ULID FK | → users |
| `event_id` | ULID FK | → events |
| `organisation_id` | ULID FK | → organisations |
| `hours_planned` | decimal nullable | |
| `hours_completed` | decimal nullable | |
| `no_show_count` | int | |
| `coordinator_rating` | tinyint | 15 |
| `coordinator_notes` | text nullable | |
| `would_reinvite` | bool | |
**Note:** Never visible to the volunteer themselves.
**Unique constraint:** `UNIQUE(user_id, event_id)`
**Indexes:** `(user_id, event_id)`
---
### `post_festival_evaluations`
| Column | Type | Notes |
| ------------------------ | ---------------- | --------- |
| `id` | ULID | PK |
| `event_id` | ULID FK | → events |
| `person_id` | ULID FK | → persons |
| `shift_id` | ULID FK nullable | → shifts |
| `overall_rating` | tinyint | 15 |
| `shift_rating` | tinyint | 15 |
| `would_return` | bool | |
| `feedback_text` | text nullable | |
| `improvement_suggestion` | text nullable | |
| `submitted_at` | timestamp | |
| `is_anonymous` | bool | |
**Indexes:** `(event_id, is_anonymous)`, `(person_id)`
---
### `festival_retrospectives`
| Column | Type | Notes |
| -------------------------- | -------------- | ------------------------------------- |
| `id` | ULID | PK |
| `event_id` | ULID FK unique | → events |
| `generated_at` | timestamp | |
| `volunteers_planned` | int | |
| `volunteers_completed` | int | |
| `no_show_count` | int | |
| `no_show_pct` | decimal(5,2) | |
| `avg_overall_satisfaction` | decimal(3,2) | |
| `avg_shift_satisfaction` | decimal(3,2) | |
| `would_return_pct` | decimal(5,2) | |
| `sections_understaffed` | int | |
| `sections_overstaffed` | int | |
| `top_feedback` | JSON | Array of strings — free-text feedback |
| `notes` | text nullable | |
---
## 3.5.5 Crowd Types, Persons & Crowd Lists
### `crowd_types`
| Column | Type | Notes |
| ----------------- | --------------- | ---------------------------------------------------------- |
| `id` | ULID | PK |
| `organisation_id` | ULID FK | → organisations |
| `name` | string | |
| `system_type` | enum | `CREW\|GUEST\|ARTIST\|VOLUNTEER\|PRESS\|PARTNER\|SUPPLIER` |
| `color` | string | hex |
| `icon` | string nullable | |
| `is_active` | bool | |
**Indexes:** `(organisation_id, system_type)`
---
### `persons`
> **v1.7:** `event_id` now always references the top-level event (festival or
> flat event). For sub-events, persons register at the festival level.
> Activation per sub-event is tracked via `event_person_activations` pivot
> and/or derived from shift assignments.
| Column | Type | Notes |
| ---------------- | ------------------ | ------------------------------------------------------------------------------ |
| `id` | ULID | PK |
| `user_id` | ULID FK nullable | → users — nullable: external guests/artists have no platform account |
| `event_id` | ULID FK | → events — **v1.7** always references top-level event (festival or flat event) |
| `crowd_type_id` | ULID FK | → crowd_types |
| `company_id` | ULID FK nullable | → companies |
| `first_name` | string | |
| `last_name` | string | |
| `date_of_birth` | date nullable | |
| `email` | string | Indexed deduplication key |
| `phone` | string nullable | |
| `status` | enum | `invited\|applied\|pending\|approved\|rejected\|no_show` |
| `is_blacklisted` | bool | |
| `admin_notes` | text nullable | Organiser-only notes |
| `remarks` | text nullable | **v1.8** Volunteer-editable notes (distinct from admin_notes which is organiser-only) |
| `custom_fields` | JSON | Backward compat + truly opaque event-specific data. For queryable registration data, use `person_field_values` via `registration_form_fields` instead. |
| `deleted_at` | timestamp nullable | Soft delete |
**Unique constraint:** `UNIQUE(event_id, user_id) WHERE user_id IS NOT NULL`
**Indexes:** `(event_id, crowd_type_id, status)`, `(email, event_id)`, `(user_id, event_id)`
**Soft delete:** yes
---
### `companies`
| Column | Type | Notes |
| ----------------- | ------------------ | ----------------------------------------- |
| `id` | ULID | PK |
| `organisation_id` | ULID FK | → organisations |
| `name` | string | |
| `type` | enum | `supplier\|partner\|agency\|venue\|other` |
| `contact_first_name` | string nullable | |
| `contact_last_name` | string nullable | |
| `contact_email` | string nullable | |
| `contact_phone` | string nullable | |
| `deleted_at` | timestamp nullable | Soft delete |
**Indexes:** `(organisation_id)`
**Soft delete:** yes
---
### `crowd_lists`
| Column | Type | Notes |
| ---------------------- | ---------------- | -------------------- |
| `id` | ULID | PK |
| `event_id` | ULID FK | → events |
| `crowd_type_id` | ULID FK | → crowd_types |
| `name` | string | |
| `type` | enum | `internal\|external` |
| `recipient_company_id` | ULID FK nullable | → companies |
| `auto_approve` | bool | |
| `max_persons` | int nullable | |
**Relations:** `hasMany` persons via `crowd_list_persons` pivot
**Indexes:** `(event_id, type)`
---
### `crowd_list_persons`
| Column | Type | Notes |
| ------------------ | ---------------- | --------------------------------- |
| `id` | int AI | PK — integer for join performance |
| `crowd_list_id` | ULID FK | → crowd_lists |
| `person_id` | ULID FK | → persons |
| `added_at` | timestamp | |
| `added_by_user_id` | ULID FK nullable | → users |
**Unique constraint:** `UNIQUE(crowd_list_id, person_id)`
**Indexes:** `(person_id)`
---
### `event_person_activations`
> **v1.7 New table.** Tracks which sub-events a person is active on,
> independent of shift assignments. Used for:
>
> - Suppliers/crew present at all sub-events without shifts
> - Festival-wide crew who need accreditation per day
> - Persons manually activated on specific sub-events by coordinator
>
> For volunteers: activation is derived from shift assignments (no manual entry needed).
> For fixed crew and suppliers: use this pivot for explicit activation.
| Column | Type | Notes |
| ----------- | ------- | --------------------------------- |
| `id` | int AI | PK — integer for join performance |
| `event_id` | ULID FK | → events (the sub-event) |
| `person_id` | ULID FK | → persons |
**Unique constraint:** `UNIQUE(event_id, person_id)`
**Indexes:** `(person_id)`, `(event_id)`
---
## 3.5.5c Person Identity Matching
> **v1.8:** Enterprise-grade identity resolution with three steps: detect → suggest → confirm.
> No silent auto-linking. When a person is created with an email matching an existing user,
> or when a new user account is created with an email matching unlinked persons, the system
> creates pending match records for organisers to review.
### `person_identity_matches`
| Column | Type | Notes |
| ---------------------- | ------------------ | ---------------------------------------------------------------------------- |
| `id` | ULID | PK — `HasUlids` trait. Entity with its own lifecycle, not a pure pivot |
| `person_id` | ULID FK | → persons. `constrained()->cascadeOnDelete()` |
| `matched_user_id` | ULID FK | → users. Named `matched_user_id` (not `user_id`) to avoid confusion with `persons.user_id`. `constrained()->cascadeOnDelete()` |
| `matched_on` | string | Enum: `email\|phone\|manual` (`IdentityMatchMethod`) |
| `confidence` | string | Enum: `exact\|fuzzy` (`IdentityMatchConfidence`). `exact` = deterministic match, `fuzzy` = algorithmic |
| `status` | string | Enum: `pending\|confirmed\|dismissed` (`IdentityMatchStatus`), default `pending` |
| `resolved_by_user_id` | ULID FK nullable | → users (who confirmed or dismissed). `constrained()->nullOnDelete()` |
| `resolved_at` | timestamp nullable | When the match was confirmed or dismissed |
| `created_at` | timestamp | |
**Design notes:**
- No `updated_at`: status transitions are captured by `resolved_at`. Model sets `const UPDATED_AT = null;`.
- Single `resolved_by`/`resolved_at` pair: status enum is exclusive (pending → confirmed OR pending → dismissed). Spatie activity log records the full audit trail.
**Unique constraint:** `UNIQUE(person_id, matched_user_id)` — prevent duplicate match records
**Indexes:** `(person_id, status)`, `(matched_user_id, status)`, `(status)`
**Foreign keys:** `person_id` → persons (cascade delete), `matched_user_id` → users (cascade delete), `resolved_by_user_id` → users (null on delete)
---
## 3.5.6 Accreditation Engine
### `accreditation_categories`
| Column | Type | Notes |
| ----------------- | --------------- | -------------------------------------------------------- |
| `id` | ULID | PK |
| `organisation_id` | ULID FK | → organisations |
| `name` | string | e.g. Wristband, Food & Beverage, Communication, Clothing |
| `sort_order` | int | |
| `icon` | string nullable | |
**Indexes:** `(organisation_id)`
---
### `accreditation_items`
| Column | Type | Notes |
| --------------------------- | --------------------- | -------------------------- |
| `id` | ULID | PK |
| `accreditation_category_id` | ULID FK | → accreditation_categories |
| `name` | string | |
| `is_date_dependent` | bool | |
| `barcode_type` | enum | `qr\|code128\|ean13` |
| `ticket_visual_url` | string nullable | |
| `cost_price` | decimal(8,2) nullable | |
| `sort_order` | int | |
---
### `event_accreditation_items`
| Column | Type | Notes |
| ------------------------- | ------------- | --------------------- |
| `id` | ULID | PK |
| `event_id` | ULID FK | → events |
| `accreditation_item_id` | ULID FK | → accreditation_items |
| `max_quantity_per_person` | int nullable | |
| `total_budget_quantity` | int nullable | |
| `is_active` | bool | |
| `notes` | text nullable | |
**Unique constraint:** `UNIQUE(event_id, accreditation_item_id)`
**Indexes:** `(event_id, is_active)`
---
### `accreditation_assignments`
| Column | Type | Notes |
| ----------------------- | ------------------ | ------------------------ |
| `id` | ULID | PK |
| `person_id` | ULID FK | → persons |
| `accreditation_item_id` | ULID FK | → accreditation_items |
| `event_id` | ULID FK | → events |
| `date` | date nullable | For date-dependent items |
| `quantity` | int | |
| `is_handed_out` | bool | |
| `handed_out_at` | timestamp nullable | |
| `handed_out_by_user_id` | ULID FK nullable | → users |
**Indexes:** `(person_id, event_id)`, `(accreditation_item_id, is_handed_out)`
---
### `access_zones`
| Column | Type | Notes |
| ------------- | ------------- | ------------------------------- |
| `id` | ULID | PK |
| `event_id` | ULID FK | → events |
| `name` | string | e.g. Backstage, VIP, Main Stage |
| `zone_code` | varchar(20) | unique per event |
| `description` | text nullable | |
**Indexes:** `(event_id)`
---
### `access_zone_days`
| Column | Type | Notes |
| ---------------- | ------- | --------------------------------- |
| `id` | int AI | PK — integer for join performance |
| `access_zone_id` | ULID FK | → access_zones |
| `day_date` | date | |
**Unique constraint:** `UNIQUE(access_zone_id, day_date)`
**Indexes:** `(day_date)`
---
### `person_access_zones`
| Column | Type | Notes |
| ---------------- | ----------------- | --------------------------------- |
| `id` | int AI | PK — integer for join performance |
| `person_id` | ULID FK | → persons |
| `access_zone_id` | ULID FK | → access_zones |
| `valid_from` | datetime | |
| `valid_to` | datetime nullable | |
**Indexes:** `(person_id)`, `(access_zone_id)`
---
## 3.5.7 Artists & Advancing
### `artists`
| Column | Type | Notes |
| ------------------------------ | ------------------ | -------------------------------------------------------------- |
| `id` | ULID | PK |
| `event_id` | ULID FK | → events |
| `name` | string | |
| `booking_status` | enum | `concept\|requested\|option\|confirmed\|contracted\|cancelled` |
| `star_rating` | tinyint | 15 |
| `project_leader_id` | ULID FK nullable | → users |
| `milestone_offer_in` | bool | Default: false |
| `milestone_offer_agreed` | bool | Default: false |
| `milestone_confirmed` | bool | Default: false |
| `milestone_announced` | bool | Default: false |
| `milestone_schedule_confirmed` | bool | Default: false |
| `milestone_itinerary_sent` | bool | Default: false |
| `milestone_advance_sent` | bool | Default: false |
| `milestone_advance_received` | bool | Default: false |
| `advance_open_from` | datetime nullable | |
| `advance_open_to` | datetime nullable | |
| `show_advance_share_page` | bool | Default: true |
| `portal_token` | ULID unique | Access to artist portal without account |
| `deleted_at` | timestamp nullable | Soft delete |
**Relations:** `hasMany` performances, advance_sections, artist_contacts, artist_riders
**Soft delete:** yes
---
### `performances`
| Column | Type | Notes |
| ----------------- | ------- | ------------------------------- |
| `id` | ULID | PK |
| `artist_id` | ULID FK | → artists |
| `stage_id` | ULID FK | → stages |
| `date` | date | |
| `start_time` | time | |
| `end_time` | time | |
| `booking_status` | string | |
| `check_in_status` | enum | `expected\|checked_in\|no_show` |
**Indexes:** `(stage_id, date, start_time, end_time)`
---
### `stages`
| Column | Type | Notes |
| ---------- | ------------ | -------- |
| `id` | ULID | PK |
| `event_id` | ULID FK | → events |
| `name` | string | |
| `color` | string | hex |
| `capacity` | int nullable | |
**Relations:** `hasMany` performances
**Indexes:** `(event_id)`
---
### `stage_days`
| Column | Type | Notes |
| ---------- | ------- | --------------------------------- |
| `id` | int AI | PK — integer for join performance |
| `stage_id` | ULID FK | → stages |
| `day_date` | date | |
**Unique constraint:** `UNIQUE(stage_id, day_date)`
---
### `advance_sections`
| Column | Type | Notes |
| ------------------- | ------------------ | -------------------------------------------------------------- |
| `id` | ULID | PK |
| `artist_id` | ULID FK | → artists |
| `name` | string | |
| `type` | enum | `guest_list\|contacts\|production\|custom` |
| `is_open` | bool | |
| `open_from` | datetime nullable | |
| `open_to` | datetime nullable | |
| `sort_order` | int | |
| `submission_status` | enum | `open\|pending\|submitted\|approved\|declined` |
| `last_submitted_at` | timestamp nullable | |
| `last_submitted_by` | string nullable | |
| `submission_diff` | JSON nullable | `{created, updated, untouched, deleted}` counts per submission |
**Indexes:** `(artist_id, is_open)`, `(artist_id, submission_status)`
---
### `advance_submissions`
| Column | Type | Notes |
| -------------------- | ------------------ | ------------------------------ |
| `id` | ULID | PK |
| `advance_section_id` | ULID FK | → advance_sections |
| `submitted_by_name` | string | |
| `submitted_by_email` | string | |
| `submitted_at` | timestamp | |
| `status` | enum | `pending\|accepted\|declined` |
| `reviewed_by` | ULID FK nullable | → users |
| `reviewed_at` | timestamp nullable | |
| `data` | JSON | Free form data — not queryable |
**Indexes:** `(advance_section_id, status)`
---
### `artist_contacts`
| Column | Type | Notes |
| -------------------- | --------------- | -------------------------------- |
| `id` | ULID | PK |
| `artist_id` | ULID FK | → artists |
| `name` | string | |
| `email` | string nullable | |
| `phone` | string nullable | |
| `role` | string | e.g. tour manager, agent, booker |
| `receives_briefing` | bool | |
| `receives_infosheet` | bool | |
| `is_travel_party` | bool | |
**Indexes:** `(artist_id)`
---
### `artist_riders`
| Column | Type | Notes |
| ----------- | ------- | ------------------------ |
| `id` | ULID | PK |
| `artist_id` | ULID FK | → artists |
| `category` | enum | `technical\|hospitality` |
| `items` | JSON | Unstructured rider data |
**Indexes:** `(artist_id, category)`
---
### `itinerary_items`
| Column | Type | Notes |
| --------------- | --------------- | -------------------------------------------------- |
| `id` | ULID | PK |
| `artist_id` | ULID FK | → artists |
| `type` | enum | `transfer\|pickup\|delivery\|checkin\|performance` |
| `datetime` | datetime | |
| `from_location` | string nullable | |
| `to_location` | string nullable | |
| `notes` | text nullable | |
**Indexes:** `(artist_id, datetime)`
---
## 3.5.8 Communication & Briefings
### `briefing_templates`
| Column | Type | Notes |
| ------------ | ------- | ------------------------------------------- |
| `id` | ULID | PK |
| `event_id` | ULID FK | → events |
| `name` | string | |
| `type` | enum | `crowd\|artist\|volunteer\|supplier` |
| `blocks` | JSON | Drag-and-drop block config — never filtered |
| `is_default` | bool | |
**Indexes:** `(event_id, type)`
---
### `briefings`
| Column | Type | Notes |
| ---------------------- | ----------------- | -------------------------------------- |
| `id` | ULID | PK |
| `event_id` | ULID FK | → events |
| `briefing_template_id` | ULID FK nullable | → briefing_templates |
| `name` | string | |
| `target_crowd_types` | JSON | Array of crowd_type IDs |
| `send_from` | datetime nullable | |
| `send_until` | datetime nullable | |
| `status` | enum | `draft\|queued\|sending\|sent\|paused` |
**Indexes:** `(event_id, status)`
---
### `briefing_sends`
| Column | Type | Notes |
| ------------- | ------------------ | ---------------------------------- |
| `id` | ULID | PK |
| `briefing_id` | ULID FK | → briefings |
| `person_id` | ULID FK | → persons |
| `status` | enum | `queued\|sent\|opened\|downloaded` |
| `sent_at` | timestamp nullable | |
| `opened_at` | timestamp nullable | |
**Note:** No soft delete — audit record.
**Indexes:** `(status, briefing_id)`, `(person_id)`
---
### `communication_campaigns`
| Column | Type | Notes |
| ----------------- | ----------------- | -------------------------------------------- |
| `id` | ULID | PK |
| `event_id` | ULID FK | → events |
| `type` | enum | `email\|sms\|whatsapp` |
| `name` | string | |
| `body` | text | |
| `recipient_group` | JSON | Target filter description |
| `status` | enum | `draft\|scheduled\|sending\|sent\|cancelled` |
| `scheduled_at` | datetime nullable | |
| `sent_at` | datetime nullable | |
| `sent_count` | int | |
| `failed_count` | int | |
**Indexes:** `(event_id, type, status)`
---
### `messages`
| Column | Type | Notes |
| --------------------- | ------------------ | --------------------------- |
| `id` | ULID | PK |
| `event_id` | ULID FK | → events |
| `sender_user_id` | ULID FK | → users |
| `recipient_person_id` | ULID FK | → persons |
| `body` | text | |
| `urgency` | enum | `normal\|urgent\|emergency` |
| `channel_used` | enum | `email\|sms\|whatsapp` |
| `read_at` | timestamp nullable | |
| `replied_at` | timestamp nullable | |
| `created_at` | timestamp | |
**Indexes:** `(event_id, recipient_person_id)`, `(recipient_person_id, read_at)`
---
### `message_replies`
| Column | Type | Notes |
| --------------- | ------------- | --------------------------------- |
| `id` | ULID | PK |
| `message_id` | ULID FK | → messages |
| `person_id` | ULID FK | → persons |
| `body` | text | |
| `status_update` | enum nullable | `on_my_way\|arrived\|sick\|other` |
| `created_at` | timestamp | |
**Note:** No soft delete — audit record.
**Indexes:** `(message_id)`
---
### `broadcast_messages`
| Column | Type | Notes |
| ----------------- | --------- | --------------------------- |
| `id` | ULID | PK |
| `event_id` | ULID FK | → events |
| `sender_user_id` | ULID FK | → users |
| `body` | text | |
| `urgency` | enum | `normal\|urgent\|emergency` |
| `channel_used` | enum | `email\|sms\|whatsapp` |
| `sent_at` | timestamp | |
| `recipient_count` | int | |
| `read_count` | int | |
**Indexes:** `(event_id, sent_at)`
---
### `broadcast_message_targets`
| Column | Type | Notes |
| ---------------------- | ------------- | ------------------------------------------------ |
| `id` | int AI | PK — integer for join performance |
| `broadcast_message_id` | ULID FK | → broadcast_messages |
| `target_type` | enum | `event\|section\|shift\|crowd_type\|custom_list` |
| `target_id` | ULID nullable | NULL when `target_type = event` |
**Indexes:** `(broadcast_message_id)`
---
## 3.5.9 Forms, Check-In & Operational
### `public_forms`
| Column | Type | Notes |
| ----------------------------- | ------------- | -------------------------- |
| `id` | ULID | PK |
| `event_id` | ULID FK | → events |
| `name` | string | |
| `crowd_type_id` | ULID FK | → crowd_types |
| `fields` | JSON | Form config — not filtered |
| `conditional_logic` | JSON | Form config — not filtered |
| `iframe_token` | ULID unique | |
| `confirmation_email_template` | text nullable | |
| `is_active` | bool | |
**Indexes:** `(event_id, crowd_type_id, is_active)`
---
### `form_submissions`
| Column | Type | Notes |
| ---------------- | --------- | ----------------- |
| `id` | ULID | PK |
| `public_form_id` | ULID FK | → public_forms |
| `person_id` | ULID FK | → persons |
| `data` | JSON | Free form results |
| `submitted_at` | timestamp | |
**Indexes:** `(public_form_id, submitted_at)`, `(person_id)`
---
### `check_ins`
> Terrain check-in at access gates. Separate from `shift_check_ins`.
| Column | Type | Notes |
| -------------------- | ---------------- | ----------- |
| `id` | ULID | PK |
| `event_id` | ULID FK | → events |
| `person_id` | ULID FK | → persons |
| `scanned_by_user_id` | ULID FK nullable | → users |
| `scanner_id` | ULID FK nullable | → scanners |
| `scanned_at` | timestamp | |
| `location_id` | ULID FK nullable | → locations |
**Note:** Immutable audit record — NO soft delete.
**Indexes:** `(event_id, person_id, scanned_at)`, `(event_id, scanned_at)`
---
### `show_day_absence_alerts`
| Column | Type | Notes |
| ----------------- | ------------------ | -------------------------------------- |
| `id` | ULID | PK |
| `event_id` | ULID FK | → events |
| `shift_id` | ULID FK | → shifts |
| `person_id` | ULID FK | → persons |
| `alert_sent_at` | timestamp | |
| `response_status` | enum | `no_response\|confirmed\|absent\|late` |
| `resolved_at` | timestamp nullable | |
**Note:** Immutable audit record — NO soft delete.
**Indexes:** `(shift_id, response_status)`, `(event_id, alert_sent_at)`
---
### `scanners`
| Column | Type | Notes |
| ---------------- | ------------------ | ---------------------------- |
| `id` | ULID | PK |
| `event_id` | ULID FK | → events |
| `name` | string | |
| `type` | enum | `crowd\|zone\|accreditation` |
| `scope` | JSON | Scanner configuration |
| `pairing_code` | varchar(8) unique | |
| `last_active_at` | timestamp nullable | |
**Indexes:** `(event_id)`, `(pairing_code)`
---
### `inventory_items`
| Column | Type | Notes |
| ----------------------- | ------------------ | ----------------------------- |
| `id` | ULID | PK |
| `event_id` | ULID FK | → events |
| `name` | string | e.g. walkie-talkie, vest, key |
| `item_code` | varchar(50) | |
| `assigned_to_person_id` | ULID FK nullable | → persons |
| `assigned_at` | timestamp nullable | |
| `returned_at` | timestamp nullable | |
| `returned_by_user_id` | ULID FK nullable | → users |
**Indexes:** `(event_id, assigned_to_person_id)`, `(item_code)`
---
### `event_info_blocks`
| Column | Type | Notes |
| -------------- | ------- | ---------------------------------------------------------- |
| `id` | ULID | PK |
| `event_id` | ULID FK | → events |
| `type` | enum | `description\|route\|parking\|contacts\|marketing\|custom` |
| `title` | string | |
| `content` | text | |
| `files` | JSON | Array of file paths |
| `sort_order` | int | |
| `is_published` | bool | |
**Indexes:** `(event_id, type, is_published)`
---
### `event_info_block_crowd_types`
| Column | Type | Notes |
| --------------------- | ------- | --------------------------------- |
| `id` | int AI | PK — integer for join performance |
| `event_info_block_id` | ULID FK | → event_info_blocks |
| `crowd_type_id` | ULID FK | → crowd_types |
**Unique constraint:** `UNIQUE(event_info_block_id, crowd_type_id)`
---
### `production_requests`
| Column | Type | Notes |
| -------------- | ------------------ | --------------------------------------------------------- |
| `id` | ULID | PK |
| `event_id` | ULID FK | → events |
| `company_id` | ULID FK | → companies |
| `title` | string | |
| `status` | enum | `draft\|sent\|in_progress\|submitted\|approved\|rejected` |
| `token` | ULID unique | Portal access without account |
| `sent_at` | timestamp nullable | |
| `submitted_at` | timestamp nullable | |
| `reviewed_by` | ULID FK nullable | → users |
| `reviewed_at` | timestamp nullable | |
| `deleted_at` | timestamp nullable | Soft delete |
**Relations:** `hasMany` material_requests
**Indexes:** `(event_id, status)`, `(company_id)`
**Soft delete:** yes
---
### `material_requests`
| Column | Type | Notes |
| ----------------------- | ----------------- | ------------------------------------------ |
| `id` | ULID | PK |
| `production_request_id` | ULID FK | → production_requests |
| `category` | enum | `heavy_equipment\|tools\|vehicles\|other` |
| `name` | string | |
| `description` | text nullable | |
| `quantity` | int | |
| `period_from` | datetime nullable | |
| `period_to` | datetime nullable | |
| `status` | enum | `requested\|approved\|rejected\|fulfilled` |
| `notes` | text nullable | |
**Indexes:** `(production_request_id, status)`
---
## 3.5.5a Person Tags & Skills
> Tag-based skills/competencies system for volunteers and crew.
> Tags are defined per organisation, assigned to users at the organisation level
> (persistent across events), and come from two sources: self-reported by
> volunteers during registration, or assigned by organisers based on experience.
### `person_tags`
| Column | Type | Notes |
| ----------------- | --------------- | ---------------------------------------- |
| `id` | ULID | PK, `HasUlids` trait |
| `organisation_id` | ULID FK | → organisations |
| `name` | string(50) | e.g. "Tapper", "EHBO", "Duits" |
| `category` | string(50) null | e.g. "Vaardigheid", "Taal", "Certificaat"|
| `icon` | string(50) null | Tabler icon name |
| `color` | string(7) null | Hex color |
| `is_active` | bool | default: true |
| `sort_order` | int | default: 0 |
| `created_at` | timestamp | |
| `updated_at` | timestamp | |
**Relations:** `belongsTo` Organisation
**Indexes:** `(organisation_id, is_active, sort_order)`
**Unique constraint:** `UNIQUE(organisation_id, name)`
**No soft deletes** — tags are deactivated via `is_active = false`
---
### `user_organisation_tags`
> Tag assignments linking a user to a tag within an organisation.
> Persistent across events — tags live on user+org level, not event level.
> Users without a `user_id` (external guests, artists) cannot have tags.
| Column | Type | Notes |
| -------------------- | --------------- | ---------------------------------------------- |
| `id` | int AI | PK — integer for join performance (pivot table) |
| `user_id` | ULID FK | → users |
| `organisation_id` | ULID FK | → organisations |
| `person_tag_id` | ULID FK | → person_tags |
| `source` | enum | `self_reported\|organiser_assigned` |
| `assigned_by_user_id`| ULID FK null | → users (who assigned, for organiser_assigned) |
| `proficiency` | enum null | `beginner\|experienced\|expert` |
| `notes` | text null | Organiser-only notes, never shown to volunteer |
| `assigned_at` | timestamp | |
**Relations:** `belongsTo` User, Organisation, PersonTag, AssignedBy (→ User)
**Unique constraint:** `UNIQUE(user_id, organisation_id, person_tag_id, source)`
— allows both `self_reported` AND `organiser_assigned` for the same tag
**Indexes:** `(user_id, organisation_id)`, `(person_tag_id)`, `(organisation_id, person_tag_id, proficiency)`
**Design notes:**
- Tags are scoped to `organisation_id` — organisation A's "Tapper" tag is independent of organisation B's.
- Tags link to `user_id`, NOT `person_id` — this makes them persistent across events.
- The unique constraint includes `source` — a volunteer can self-report "Tapper" AND the organiser can independently assign "Tapper expert". Both coexist.
- **Sync behaviour:** The `PUT .../tags/sync` endpoint replaces tags of the specified `source` only. Syncing `self_reported` tags removes self_reported tags not in the new list and adds new ones, while leaving all `organiser_assigned` tags untouched.
---
## 3.5.5b Registration Form Fields & Section Preferences
### `registration_form_fields`
> Event-level dynamic field definitions for registration forms.
> Replaces the need for queryable data in `persons.custom_fields` JSON.
> Organisers configure these per event to collect additional information
> during volunteer/crew registration (shirt size, dietary needs,
> compensation preference, consent, emergency contact, etc.)
>
> Special field type TAG_PICKER: renders the organisation's person_tags
> as selectable options. Answers are stored in person_field_values as
> tag IDs. When the person gets a user_id (account creation or identity
> matching), TagSyncService syncs the selections to user_organisation_tags
> with source=self_reported.
| Column | Type | Notes |
| ------------------ | ------------------ | -------------------------------------------------- |
| `id` | ULID | PK, `HasUlids` trait |
| `event_id` | ULID FK | → events (festival-level for festivals) |
| `label` | string | Display label, e.g. "Heb je voedselallergiëen?" |
| `slug` | string(100) | Auto-generated from label, used as stable key |
| `field_type` | enum | `text\|textarea\|select\|multiselect\|checkbox\|radio\|boolean\|number\|tag_picker` |
| `options` | JSON nullable | For select/multiselect/radio/checkbox: array of option strings. NULL for tag_picker (options come from person_tags). JSON OK: opaque config. |
| `tag_category` | string(50) null | Only for tag_picker: filter tags by this category. NULL = show all active tags. |
| `is_required` | bool | Field must be filled in |
| `is_portal_visible`| bool | Shown to person in registration form |
| `is_admin_only` | bool | Only visible in organiser backend |
| `is_filterable` | bool | Available as filter in person list / shift assignment |
| `section` | string(100) null | Form section grouping (e.g. "Vergoeding", "Toestemming") |
| `help_text` | text nullable | Explanatory text shown below the field |
| `sort_order` | int | Display order in form |
| `created_at` | timestamp | |
| `updated_at` | timestamp | |
**Unique constraint:** `UNIQUE(event_id, slug)`
**Indexes:** `(event_id, sort_order)`, `(event_id, is_portal_visible, sort_order)`
**No soft delete** — deactivation by deleting the field; existing answers remain for history.
Design notes:
- `options` JSON is acceptable here: it's opaque configuration (the list of choices),
not queryable data. The queryable answers are stored in `person_field_values`.
- `slug` enables stable references across API calls and form submissions even if
`label` changes.
- Fields scoped to event level. For festivals, `event_id` = parent festival
(matching `persons.event_id`).
- `tag_picker` fields do NOT use `options` — available choices come from
`person_tags` filtered by `tag_category` (or all active tags if null).
---
### `person_field_values`
> Stores each person's answers to registration form fields.
> One row per person per field. Queryable via standard SQL.
| Column | Type | Notes |
| ----------------------------- | ------------- | ----------------------------------------------------- |
| `id` | int AI | PK — high volume, pivot-like |
| `person_id` | ULID FK | → persons |
| `registration_form_field_id` | ULID FK | → registration_form_fields |
| `value` | text nullable | For text/textarea/select/radio/boolean/number |
| `selected_options` | JSON nullable | For multiselect/checkbox: array of selected option strings. For tag_picker: array of person_tag_id ULIDs. |
**Unique constraint:** `UNIQUE(person_id, registration_form_field_id)`
**Indexes:** `(registration_form_field_id, value(191))` — for filtering on field values
**No soft delete** — immutable answers. If field definition is deleted, answers remain.
Design notes:
- `selected_options` JSON is used ONLY for multiselect/checkbox/tag_picker fields
where multiple values must be stored. For single-value fields, use `value` only.
- Filtering on multiselect: use MySQL `JSON_CONTAINS()`. Acceptable because
multiselect filtering is a low-frequency organiser query, not a hot path.
- Integer PK for join performance (high volume table).
- For `tag_picker` fields: `selected_options` contains person_tag_id ULIDs,
not tag names. This ensures referential integrity.
---
### `person_section_preferences`
> Volunteer's preferred sections for shift assignment. Soft hints for the
> organiser, NOT promises. The organiser retains full flexibility to assign
> anyone anywhere. Captured during registration (configurable per event
> via `events.registration_show_section_preferences`).
| Column | Type | Notes |
| --------------------- | ------- | --------------------------- |
| `id` | int AI | PK — pivot table |
| `person_id` | ULID FK | → persons |
| `festival_section_id` | ULID FK | → festival_sections |
| `priority` | tinyint | 1 (first choice) 5 |
**Unique constraint:** `UNIQUE(person_id, festival_section_id)`
**Indexes:** `(festival_section_id, priority)`, `(person_id)`
Design notes:
- Priority is a ranking, not a score. 1 = first choice.
- Accompanying text in form: "We proberen hier zoveel mogelijk rekening mee te
houden, maar de uiteindelijke indeling wordt bepaald door de organisatie."
- For festivals: shown sections = sub-event sections + parent's cross_event sections.
- Only shown when `events.registration_show_section_preferences = true`.
---
### Tag Sync Architecture
> When a `tag_picker` registration field is used, tag selections are stored
> in `person_field_values` as person_tag_id ULIDs. These must be synced to
> `user_organisation_tags` when the person gets a `user_id`.
**Service:** `TagSyncService::syncFromRegistration(Person $person): void`
Single responsibility: reads tag_picker field values for this person, syncs
them to `user_organisation_tags` with `source = self_reported`. Uses the
existing sync behaviour: replaces only `self_reported` tags, never touches
`organiser_assigned` tags.
**Trigger points (callers):**
1. `RegistrationFormFieldService::upsertPersonValues()` — if person already has user_id
2. `PersonService::approve()` — when account is created and user_id is set
3. `PersonIdentityService::confirmMatch()` — when user_id is linked via identity matching
**Idempotent:** Safe to call multiple times. If tags already exist, no action.
If self_reported tags were removed by organiser, they are re-created from the
latest registration data (the volunteer still claims them).
---
### `registration_field_templates`
> Organisation-level reusable field templates. Pre-populated with system
> defaults when an organisation is created (same pattern as crowd_types).
> Organisers can customize system templates and add their own.
> When adding a field to an event's registration form, the organiser picks
> from templates — a COPY is created as a registration_form_field on the event.
> The event field is independent; changes don't propagate back to the template.
| Column | Type | Notes |
| ------------------ | ------------------ | -------------------------------------------------- |
| `id` | ULID | PK, `HasUlids` trait |
| `organisation_id` | ULID FK | → organisations |
| `label` | string | e.g. "Shirtmaat" |
| `slug` | string(100) | Auto-generated from label |
| `field_type` | enum | Same RegistrationFieldType enum |
| `options` | JSON nullable | Predefined choices for select/multiselect/etc. |
| `tag_category` | string(50) null | Only for tag_picker |
| `is_required` | bool | Suggested default when creating event field |
| `is_filterable` | bool | Suggested default |
| `is_portal_visible`| bool | Suggested default |
| `is_admin_only` | bool | Suggested default |
| `section` | string(100) null | Suggested form section |
| `help_text` | text nullable | Suggested help text |
| `sort_order` | int | |
| `is_system` | bool | true = shipped with Crewli, false = org-created |
| `is_active` | bool | Deactivate without deleting |
| `created_at` | timestamp | |
| `updated_at` | timestamp | |
**Unique constraint:** `UNIQUE(organisation_id, slug)`
**Indexes:** `(organisation_id, is_active, sort_order)`
**No soft delete** — deactivation via `is_active = false`
Design notes:
- Follows the same pattern as `crowd_types`: org-level definitions, seeded
with system defaults on organisation creation.
- System templates (`is_system = true`) can be customized per org (label,
options, etc.) but cannot be deleted — only deactivated.
- Org-created templates (`is_system = false`) can be fully deleted.
- No FK from `registration_form_fields` to templates — the copy is independent.
- System templates seeded: Shirtmaat, Dieetwensen, Vergoeding, Toestemming
gegevensverwerking, Noodcontact naam, Noodcontact telefoon, EHBO/BHV,
Rijbewijs, Eerder vrijwilliger geweest, Certificaten & vaardigheden
(tag_picker), Opmerkingen.
---
## 3.5.11 Database Design Rules & Index Strategy
### Rule 1 — ULID as Primary Key
- Business tables: `$table->ulid('id')->primary()` + `HasUlids` trait
- Pure pivot/link tables: `$table->id()` (auto-increment integer)
- Never UUID v4
---
### Rule 2 — JSON Columns: When Yes, When No
| ✅ Use JSON for | ❌ Never JSON for |
| ----------------------------------------------- | ------------------------------------- |
| Opaque config (blocks, fields, settings, items) | Dates/periods |
| Free-text arrays (top_feedback) | Status values |
| Unstructured rider data | Foreign keys |
| Submission diff snapshots | Boolean flags |
| events_during_shift (opaque reference list) | Anything you filter/sort/aggregate on |
---
### Rule 3 — Soft Delete Strategy
**Soft delete YES:** `organisations`, `events`, `festival_sections`, `shifts`, `shift_assignments`, `persons`, `artists`, `companies`, `production_requests`
**Soft delete NO (immutable audit records):** `check_ins`, `shift_check_ins`, `show_day_absence_alerts`, `briefing_sends`, `message_replies`, `shift_waitlist`, `volunteer_festival_history`
---
### Rule 4 — Required Indexes (minimum set)
| Table | Indexes |
| ------------------- | ---------------------------------------------------------------------------------- |
| `persons` | `(event_id, crowd_type_id, status)`, `(email, event_id)`, `(user_id, event_id)` |
| `shift_assignments` | `UNIQUE(person_id, time_slot_id)`, `(shift_id, status)`, `(person_id, status)` |
| `shift_check_ins` | `(shift_assignment_id)`, `(shift_id, checked_in_at)`, `(person_id, checked_in_at)` |
| `check_ins` | `(event_id, person_id, scanned_at)`, `(event_id, scanned_at)` |
| `briefing_sends` | `(status, briefing_id)` |
| `shift_waitlist` | `(shift_id, position)` |
| `performances` | `(stage_id, date, start_time, end_time)` |
| `advance_sections` | `(artist_id, is_open)`, `(artist_id, submission_status)` |
| `registration_form_fields` | `UNIQUE(event_id, slug)`, `(event_id, sort_order)` |
| `person_field_values` | `UNIQUE(person_id, registration_form_field_id)`, `(registration_form_field_id, value(191))` |
| `person_section_preferences` | `UNIQUE(person_id, festival_section_id)`, `(festival_section_id, priority)` |
| `registration_field_templates` | `UNIQUE(organisation_id, slug)`, `(organisation_id, is_active, sort_order)` |
---
### Rule 5 — Multi-Tenancy Scoping
- Every query on event data **MUST** scope on `organisation_id` via `OrganisationScope` Eloquent Global Scope
- Use Laravel policies — never direct id-checks in controllers
- **v1.7:** For festival queries, use `scopeWithChildren()` to include parent + all sub-events
- **Audit log:** Spatie `laravel-activitylog` on: `persons`, `accreditation_assignments`, `shift_assignments`, `check_ins`, `production_requests`
---
### Rule 8 — Festival/Event Model (v1.7)
```
Registration level → top-level event (festival or flat event)
Operational level → sub-event (child event)
Planning level → festival_section + shift
A person:
- Registers once at festival/top-level event
- Is active on 1 or more sub-events
- Has shifts within those sub-events
Determined by:
- Volunteer: via shift assignments (automatic)
- Fixed crew: via event_person_activations (manual)
- Supplier crew: via event_person_activations (manual)
- Artist: always linked to one sub-event
Flat event (no children):
- All modules at event level
- persons.event_id = the event itself
- No sub-event navigation shown in UI
Festival/series (has children):
- persons.event_id = the parent (festival) event
- festival_sections, shifts, artists = on child events
- UI shows festival overview + child event tabs
```
---
### Rule 6 — Shift Time Resolution
```php
// Effective times — shift overrides take precedence over time slot
$reportTime = $shift->report_time; // arrival time (aanwezig)
$effectiveStart = $shift->actual_start_time ?? $shift->timeSlot->start_time;
$effectiveEnd = $shift->actual_end_time ?? $shift->timeSlot->end_time;
$effectiveDate = $shift->end_date ?? $shift->timeSlot->date;
```
---
### Rule 7 — Overlap / Conflict Detection
Default: `UNIQUE(person_id, time_slot_id)` on `shift_assignments` prevents double-booking.
Exception: when `shifts.allow_overlap = true`, the **application layer** skips this constraint check before inserting. Use cases:
- `festival_sections.type = cross_event` (EHBO, verkeersregelaars)
- Stage Managers covering multiple stages simultaneously
- Any role explicitly marked as overlap-allowed in the planning document
The DB constraint remains as a safety net for all other cases.