Files
band-management/docs/SCHEMA.md
bert.hausmans 1cb7674d52 refactor: align codebase with EventCrew domain and trim legacy band stack
- Update API: events, users, policies, routes, resources, migrations
- Remove deprecated models/resources (customers, setlists, invitations, etc.)
- Refresh admin app and docs; remove apps/band

Made-with: Cursor
2026-03-29 23:19:06 +02:00

1155 lines
59 KiB
Markdown
Raw Permalink 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.
# EventCrew — Core Database Schema
> Source: Design Document v1.3 — Section 3.5
> All 12 findings from the database review (v1.3) are incorporated.
> Last updated: March 2026
---
## Primary Key Convention: ULID
> **All tables use ULID (Universally Unique Lexicographically Sortable Identifier) as primary key — NO UUID v4.**
>
> **Reason:** UUID v4 is random, causing B-tree index fragmentation in InnoDB on every INSERT. ULID is monotonically increasing (time-ordered) and preserves index locality.
>
> - Laravel: use `Str::ulid()` or the `HasUlids` trait
> - Migrations: `$table->ulid('id')->primary()`
>
> Externally visible IDs (URLs, barcodes, API) use ULID. Internal pivot tables may use 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.6 Accreditation Engine](#356-accreditation-engine)
7. [3.5.7 Artists & Advancing](#357-artists--advancing)
8. [3.5.8 Communication & Briefings](#358-communication--briefings)
9. [3.5.9 Forms, Check-In & Operational](#359-forms-check-in--operational)
10. [3.5.10 Database Design Rules & Index Strategy](#3510-database-design-rules--index-strategy)
---
## 3.5.1 Foundation
### `users`
| Column | Type | Notes |
| ------------------- | ------------------ | -------------------- |
| `id` | ULID | PK, `HasUlids` trait |
| `name` | string | |
| `email` | string | unique |
| `password` | string | hashed |
| `timezone` | string | |
| `locale` | string | |
| `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` | string | |
| `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
---
### `user_invitations`
| Column | Type | Notes |
| -------------------- | ---------------- | --------------------------------- |
| `id` | ULID | PK |
| `email` | string | |
| `invited_by_user_id` | ULID FK | → users |
| `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)`
**Logic:** On accept: look up existing account by email or create new one.
---
### `events`
| Column | Type | Notes |
| ----------------- | ------------------ | ------------------------------------------------------------------------- |
| `id` | ULID | PK |
| `organisation_id` | ULID FK | → organisations |
| `name` | string | |
| `slug` | string | |
| `start_date` | date | |
| `end_date` | date | |
| `timezone` | string | |
| `status` | enum | `draft\|published\|registration_open\|buildup\|showday\|teardown\|closed` |
| `deleted_at` | timestamp nullable | Soft delete |
**Relations:** `belongsTo` organisation, `hasMany` festival_sections, time_slots, persons, artists, briefings
**Indexes:** `(organisation_id, status)`
**Soft delete:** yes
---
### `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
---
## 3.5.2 Locations
> **New table (resolves review finding #3):** `locations` was referenced by `shifts` but never defined. Locations are event-scoped and reusable across sections.
### `locations`
| Column | Type | Notes |
| --------------------- | ---------------------- | -------- |
| `id` | ULID | PK |
| `event_id` | ULID FK | → events |
| `name` | string | |
| `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`
---
## 3.5.3 Festival Sections, Time Slots & Shifts
> Three-layer Crescat model. Critical improvement: `time_slot_id` denormalised onto `shift_assignments` for DB-enforceable conflict detection (finding #2). Shift swaps split into two tables (finding #10).
### `festival_sections`
| Column | Type | Notes |
| ------------ | ------------------ | ----------- |
| `id` | ULID | PK |
| `event_id` | ULID FK | → events |
| `name` | string | |
| `sort_order` | int | |
| `deleted_at` | timestamp nullable | Soft delete |
**Relations:** `hasMany` shifts
**Indexes:** `(event_id, sort_order)`
**Soft delete:** yes
---
### `time_slots`
| Column | Type | Notes |
| ---------------- | ------- | ----------------------------------------------------------------------------------- |
| `id` | ULID | PK |
| `event_id` | ULID FK | → events |
| `name` | string | |
| `person_type` | enum | `CREW\|VOLUNTEER\|PRESS\|PHOTO\|PARTNER` — controls visibility in registration form |
| `date` | date | |
| `start_time` | time | |
| `end_time` | time | |
| `duration_hours` | decimal | |
**Relations:** `hasMany` shifts
**Indexes:** `(event_id, person_type, date)`
---
### `shifts`
| 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 |
| `slots_total` | int | |
| `slots_open_for_claiming` | int | Number of slots visible & claimable in volunteer portal |
| `assigned_crew_id` | ULID FK nullable | → users |
| `events_during_shift` | JSON | Array of performance_ids — opaque reference list, no filtering needed |
| `status` | string | |
| `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
---
### `shift_assignments`
| Column | Type | Notes |
| ------------------ | ------------------ | -------------------------------------------------------------------- |
| `id` | ULID | PK |
| `shift_id` | ULID FK | → shifts |
| `person_id` | ULID FK | → persons |
| `time_slot_id` | ULID FK | Denormalised from shifts — enables 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 | |
| `deleted_at` | timestamp nullable | Soft delete |
**Unique constraint:** `UNIQUE(person_id, time_slot_id)` — DB-enforceable conflict detection
**Indexes:** `(shift_id, status)`, `(person_id, status)`, `(person_id, time_slot_id)`
**Soft delete:** yes
---
### `volunteer_availabilities`
| Column | Type | Notes |
| -------------- | --------- | ------------ |
| `id` | ULID | PK |
| `person_id` | ULID FK | → persons |
| `time_slot_id` | ULID FK | → time_slots |
| `submitted_at` | timestamp | |
**Purpose:** Volunteer selects available Time Slots — basis for shift matching
**Unique constraint:** `UNIQUE(person_id, time_slot_id)`
**Indexes:** `(time_slot_id)`
---
### `shift_absences`
> **New table (finding #10 split)**
| 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`
> **New table (finding #10 split)**
| 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 | |
**Logic:** A asks B to swap. After both agree: coordinator confirms (or auto-approve).
**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 | |
| `access_requirements` | 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)` — platform-wide, 1:1 with users
---
### `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`
> **Finding #8:** All KPIs as concrete columns instead of a JSON blob. Enables trend analysis across multiple years.
| 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
> **Finding #1 (identity fragmentation):** `persons` gets nullable `user_id` as canonical link to platform account.
> **Finding #4:** `crowd_list_persons` pivot added.
> **Finding #9:** `persons.email` as indexed deduplication key.
### `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`
| 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 |
| `crowd_type_id` | ULID FK | → crowd_types |
| `company_id` | ULID FK nullable | → companies |
| `name` | string | |
| `email` | string | Indexed deduplication key |
| `phone` | string nullable | |
| `status` | enum | `invited\|applied\|pending\|approved\|rejected\|no_show` |
| `is_blacklisted` | bool | |
| `admin_notes` | text nullable | |
| `custom_fields` | JSON | Event-specific fields — not queryable |
| `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_name` | string nullable | |
| `contact_email` | string nullable | |
| `contact_phone` | string nullable | |
| `deleted_at` | timestamp nullable | Soft delete |
**Note:** Shared across events within an organisation.
**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`
> **New pivot table (finding #4)**
| 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)`
---
## 3.5.6 Accreditation Engine
> **Finding #5:** `event_accreditation_items` activates org-level items per event. Accreditation items are now configured at org level and activated per event with event-specific limits.
### `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 | |
**Note:** Org-level items, activated per event via `event_accreditation_items`.
---
### `event_accreditation_items`
> **New table (finding #5)**
| 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 — FK to event_accreditation_items for validation |
| `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 | |
**Note:** Day-coupling via `access_zone_days` pivot.
**Indexes:** `(event_id)`
---
### `access_zone_days`
> **New table (finding #8: replaces JSON `days` column)**
| Column | Type | Notes |
| ---------------- | ------- | --------------------------------- |
| `id` | int AI | PK — integer for join performance |
| `access_zone_id` | ULID FK | → access_zones |
| `day_date` | date | |
**Purpose:** Queryable — which zones are active on date X?
**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
> **Finding #8:** `stages.active_days` JSON replaced by `stage_days` pivot. `milestone_flags` JSON remains (opaque toggle-set, never filtered).
### `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_flags` | JSON | Binary toggle-set — OK as JSON |
| `advance_open_from` | datetime nullable | |
| `advance_open_to` | datetime nullable | |
| `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` |
**Note:** B2B detection via overlap query on `stage_id + date + time window`.
**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 | |
**Note:** Day-activation via `stage_days` pivot (finding #8).
**Relations:** `hasMany` performances
**Indexes:** `(event_id)`
---
### `stage_days`
> **New table (finding #8: replaces `stages.active_days` JSON)**
| 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 | |
**Note:** Crescat section model — each section independently submittable.
**Indexes:** `(artist_id, is_open)`
---
### `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 | |
**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 — OK as JSON |
**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 | |
**Note:** Flights/hotels: Out of Scope.
**Indexes:** `(artist_id, datetime)`
---
## 3.5.8 Communication & Briefings
> **Finding #11:** `broadcast_messages` extended with polymorphic `broadcast_message_targets` for flexible audience definition.
### `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:** Track per person per briefing. No soft delete — audit record.
**Indexes:** `(status, briefing_id)` — queue processing, `(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 | |
**Note:** Bulk campaigns. SMS+WhatsApp via Zender.
**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` — determined by ZenderService |
| `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:** Volunteer replies via portal. 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 | |
**Note:** Group message. Audience defined via `broadcast_message_targets`.
**Indexes:** `(event_id, sent_at)`
---
### `broadcast_message_targets`
> **New polymorphic table (finding #11)**
| 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` (entire event) |
**Note:** Multiple targets per message possible.
**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 — not queryable |
| `submitted_at` | timestamp | |
**Indexes:** `(public_form_id, submitted_at)`, `(person_id)`
---
### `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 | |
**Note:** Visibility per crowd_type via `event_info_block_crowd_types`.
**Indexes:** `(event_id, type, is_published)`
---
### `event_info_block_crowd_types`
> **Finding #8: replaces `visible_to_crowd_types` JSON column**
| 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`
> **New table (finding #3: missing table)**
| 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.10 Database Design Rules & Index Strategy
### Rule 1 — ULID as Primary Key
- Business tables: `$table->ulid('id')->primary()` + `HasUlids` trait
- Pure pivot/link tables (no own lifecycle): `$table->id()` (auto-increment integer) for join performance
- Never UUID v4 — avoids InnoDB B-tree fragmentation
---
### Rule 2 — JSON Columns: When Yes, When No
| ✅ Use JSON for | ❌ Never JSON for |
| ----------------------------------------------- | ------------------------------------- |
| Opaque config (blocks, fields, settings, items) | Dates/periods |
| Toggle-sets (milestone_flags) | Status values |
| Free-text arrays (top_feedback) | Foreign keys |
| Unstructured rider data | Boolean flags |
| | Anything you filter/sort/aggregate on |
**Replaced in v1.3:**
- `access_zone_days` (was `days` JSON)
- `stage_days` (was `active_days` JSON)
- `broadcast_message_targets` (was `target` JSON)
- `event_info_block_crowd_types` (was `visible_to_crowd_types` JSON)
- `festival_retrospectives` columns (were in `data` JSON blob)
---
### 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`, `show_day_absence_alerts`, `briefing_sends`, `message_replies`, `audit_log`, `shift_waitlist`, `volunteer_festival_history`
> **Rationale:** Soft deleting audit records creates a false picture of reality.
---
### 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)` |
| `check_ins` | `(event_id, person_id, scanned_at)`, `(event_id, scanned_at)` |
| `briefing_sends` | `(status, briefing_id)` — queue processing |
| `shift_waitlist` | `(shift_id, position)` |
| `performances` | `(stage_id, date, start_time, end_time)` — B2B overlap detection |
> Add `EXPLAIN ANALYZE` to queries taking >100ms. Target: all list queries <50ms.
---
### Rule 5 — Multi-Tenancy Scoping
- Every query on event data **MUST** scope on `organisation_id` via Eloquent Global Scope (`OrganisationScope`)
- Use Laravel policies for authorisation: never direct id-checks in controllers
- **Audit log:** Spatie `laravel-activitylog` on: `persons`, `accreditation_assignments`, `shift_assignments`, `check_ins`, `production_requests`