feat: fase 2 backend — crowd types, persons, sections, shifts, invite flow

- Crowd Types + Persons CRUD (73 tests)
- Festival Sections + Time Slots + Shifts CRUD met assign/claim flow (84 tests)
- Invite Flow + Member Management met InvitationService (109 tests)
- Schema v1.6 migraties volledig uitgevoerd
- DevSeeder bijgewerkt met crowd types voor testorganisatie
This commit is contained in:
2026-04-08 01:34:46 +02:00
parent c417a6647a
commit 9acb27af3a
114 changed files with 6916 additions and 984 deletions

View File

@@ -26,22 +26,41 @@ Auth: Bearer token (Sanctum)
- `GET /organisations/{org}/events/{event}`
- `PUT /organisations/{org}/events/{event}`
## Festival sections
## Crowd Types
- `GET /organisations/{org}/crowd-types`
- `POST /organisations/{org}/crowd-types`
- `PUT /organisations/{org}/crowd-types/{type}`
- `DELETE /organisations/{org}/crowd-types/{type}`
## Companies
- `GET /organisations/{org}/companies`
- `POST /organisations/{org}/companies`
- `PUT /organisations/{org}/companies/{company}`
- `DELETE /organisations/{org}/companies/{company}`
## Festival Sections
- `GET /events/{event}/sections`
- `POST /events/{event}/sections`
- `GET /events/{event}/sections/{section}`
- `PUT /events/{event}/sections/{section}`
- `DELETE /events/{event}/sections/{section}`
- `POST /events/{event}/sections/reorder`
## Time slots
## Time Slots
- `GET /events/{event}/time-slots`
- `POST /events/{event}/time-slots`
- `PUT /events/{event}/time-slots/{timeSlot}`
- `DELETE /events/{event}/time-slots/{timeSlot}`
## Shifts
- `GET /events/{event}/sections/{section}/shifts`
- `POST /events/{event}/sections/{section}/shifts`
- `PUT /events/{event}/sections/{section}/shifts/{shift}`
- `DELETE /events/{event}/sections/{section}/shifts/{shift}`
- `POST /events/{event}/sections/{section}/shifts/{shift}/assign`
- `POST /events/{event}/sections/{section}/shifts/{shift}/claim`
@@ -52,5 +71,22 @@ Auth: Bearer token (Sanctum)
- `GET /events/{event}/persons/{person}`
- `PUT /events/{event}/persons/{person}`
- `POST /events/{event}/persons/{person}/approve`
- `DELETE /events/{event}/persons/{person}`
## Crowd Lists
- `GET /events/{event}/crowd-lists`
- `POST /events/{event}/crowd-lists`
- `PUT /events/{event}/crowd-lists/{list}`
- `DELETE /events/{event}/crowd-lists/{list}`
- `POST /events/{event}/crowd-lists/{list}/persons`
- `DELETE /events/{event}/crowd-lists/{list}/persons/{person}`
## Locations
- `GET /events/{event}/locations`
- `POST /events/{event}/locations`
- `PUT /events/{event}/locations/{location}`
- `DELETE /events/{event}/locations/{location}`
_(Extend this contract per module as endpoints are implemented.)_

View File

@@ -1,21 +1,25 @@
# Crewli — 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
> **Version: 1.6** — 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` — feature does not fit Crewli's vision (staff planning is independent of artist/timetable planning)
---
## Primary Key Convention: ULID
> **All tables use ULID (Universally Unique Lexicographically Sortable Identifier) as primary key — NO UUID v4.**
> **All tables use ULID 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
> - Laravel: `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.
> - External IDs (URLs, barcodes, API): ULID
> - Pure pivot tables: auto-increment integer PK for join performance
---
@@ -38,17 +42,17 @@
### `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 |
| Column | Type | Notes |
| ------------------- | ------------------ | ------------------------- |
| `id` | ULID | PK, `HasUlids` trait |
| `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
@@ -62,7 +66,7 @@
| `id` | ULID | PK |
| `name` | string | |
| `slug` | string | unique |
| `billing_status` | string | |
| `billing_status` | enum | `trial\|active\|suspended\|cancelled` |
| `settings` | JSON | Display prefs only — no queryable data |
| `created_at` | timestamp | |
| `deleted_at` | timestamp nullable | Soft delete |
@@ -81,7 +85,8 @@
| `organisation_id` | ULID FK | → organisations |
| `role` | string | Spatie role via pivot |
**Type:** Pivot table — integer PK
**Type:** Pivot table — integer PK
**Unique constraint:** `UNIQUE(user_id, organisation_id)`
---
@@ -91,7 +96,7 @@
| -------------------- | ---------------- | --------------------------------- |
| `id` | ULID | PK |
| `email` | string | |
| `invited_by_user_id` | ULID FK | → users |
| `invited_by_user_id` | ULID FK nullable | → users (nullOnDelete) |
| `organisation_id` | ULID FK | → organisations |
| `event_id` | ULID FK nullable | → events |
| `role` | string | |
@@ -99,8 +104,7 @@
| `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.
**Indexes:** `(token)`, `(email, status)`
---
@@ -114,14 +118,16 @@
| `slug` | string | |
| `start_date` | date | |
| `end_date` | date | |
| `timezone` | string | |
| `timezone` | string | default: Europe/Amsterdam |
| `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)`
**Indexes:** `(organisation_id, status)`, `UNIQUE(organisation_id, slug)`
**Soft delete:** yes
> **v1.5 note:** `volunteer_min_hours_for_pass` removed — not applicable for Crewli use cases.
---
### `event_user_roles`
@@ -133,64 +139,108 @@
| `event_id` | ULID FK | → events |
| `role` | string | |
**Type:** Pivot table — integer PK
**Type:** Pivot table — integer PK
**Unique constraint:** `UNIQUE(user_id, event_id, role)`
---
## 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 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 | |
| `address` | string nullable | |
| `lat` | decimal(10,8) nullable | |
| `lng` | decimal(11,8) nullable | |
| `description` | text nullable | |
| `access_instructions` | text nullable | |
| 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
> 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).
> **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`
| Column | Type | Notes |
| ------------ | ------------------ | ----------- |
| `id` | ULID | PK |
| `event_id` | ULID FK | → events |
| `name` | string | |
| `sort_order` | int | |
| `deleted_at` | timestamp nullable | Soft delete |
> **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 |
| `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 |
| `deleted_at` | timestamp nullable | Soft delete |
**Relations:** `hasMany` shifts
**Indexes:** `(event_id, sort_order)`
**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
---
### `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 | |
> 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`.
| 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)`
@@ -199,58 +249,132 @@
### `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 |
> **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`
| 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 |
> 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.
**Unique constraint:** `UNIQUE(person_id, time_slot_id)` — DB-enforceable conflict detection
| 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 | |
| `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`
| Column | Type | Notes |
| -------------- | --------- | ------------ |
| `id` | ULID | PK |
| `person_id` | ULID FK | → persons |
| `time_slot_id` | ULID FK | → time_slots |
| `submitted_at` | timestamp | |
> 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 | |
**Purpose:** Volunteer selects available Time Slots — basis for shift matching
**Unique constraint:** `UNIQUE(person_id, time_slot_id)`
**Indexes:** `(time_slot_id)`
@@ -258,8 +382,6 @@
### `shift_absences`
> **New table (finding #10 split)**
| Column | Type | Notes |
| --------------------- | ------------------ | ----------------------- |
| `id` | ULID | PK |
@@ -277,8 +399,6 @@
### `shift_swap_requests`
> **New table (finding #10 split)**
| Column | Type | Notes |
| -------------------- | ------------------ | --------------------------------------------------- |
| `id` | ULID | PK |
@@ -290,7 +410,6 @@
| `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)`
---
@@ -332,7 +451,7 @@
| `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
**Unique constraint:** `UNIQUE(user_id)`
---
@@ -379,8 +498,6 @@
### `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 |
@@ -402,10 +519,6 @@
## 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 |
@@ -459,7 +572,6 @@
| `contact_phone` | string nullable | |
| `deleted_at` | timestamp nullable | Soft delete |
**Note:** Shared across events within an organisation.
**Indexes:** `(organisation_id)`
**Soft delete:** yes
@@ -485,8 +597,6 @@
### `crowd_list_persons`
> **New pivot table (finding #4)**
| Column | Type | Notes |
| ------------------ | ---------------- | --------------------------------- |
| `id` | int AI | PK — integer for join performance |
@@ -502,8 +612,6 @@
## 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 |
@@ -531,14 +639,10 @@
| `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 |
@@ -556,17 +660,17 @@
### `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 |
| 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)`
@@ -582,22 +686,18 @@
| `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)`
@@ -619,23 +719,29 @@
## 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 |
| 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
@@ -655,7 +761,6 @@
| `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)`
---
@@ -670,7 +775,6 @@
| `color` | string | hex |
| `capacity` | int nullable | |
**Note:** Day-activation via `stage_days` pivot (finding #8).
**Relations:** `hasMany` performances
**Indexes:** `(event_id)`
@@ -678,8 +782,6 @@
### `stage_days`
> **New table (finding #8: replaces `stages.active_days` JSON)**
| Column | Type | Notes |
| ---------- | ------- | --------------------------------- |
| `id` | int AI | PK — integer for join performance |
@@ -692,19 +794,22 @@
### `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 | |
| 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 |
**Note:** Crescat section model — each section independently submittable.
**Indexes:** `(artist_id, is_open)`
**Indexes:** `(artist_id, is_open)`, `(artist_id, submission_status)`
---
@@ -738,6 +843,7 @@
| `role` | string | e.g. tour manager, agent, booker |
| `receives_briefing` | bool | |
| `receives_infosheet` | bool | |
| `is_travel_party` | bool | |
**Indexes:** `(artist_id)`
@@ -745,12 +851,12 @@
### `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 |
| Column | Type | Notes |
| ----------- | ------- | ------------------------ |
| `id` | ULID | PK |
| `artist_id` | ULID FK | → artists |
| `category` | enum | `technical\|hospitality` |
| `items` | JSON | Unstructured rider data |
**Indexes:** `(artist_id, category)`
@@ -768,15 +874,12 @@
| `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 |
@@ -820,8 +923,8 @@
| `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)`
**Note:** No soft delete — audit record.
**Indexes:** `(status, briefing_id)`, `(person_id)`
---
@@ -841,25 +944,24 @@
| `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 | |
| 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)`
@@ -876,7 +978,7 @@
| `status_update` | enum nullable | `on_my_way\|arrived\|sick\|other` |
| `created_at` | timestamp | |
**Note:** Volunteer replies via portal. No soft delete — audit record.
**Note:** No soft delete — audit record.
**Indexes:** `(message_id)`
---
@@ -895,23 +997,19 @@
| `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) |
| `target_id` | ULID nullable | NULL when `target_type = event` |
**Note:** Multiple targets per message possible.
**Indexes:** `(broadcast_message_id)`
---
@@ -938,13 +1036,13 @@
### `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 | |
| 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)`
@@ -952,6 +1050,8 @@
### `check_ins`
> Terrain check-in at access gates. Separate from `shift_check_ins`.
| Column | Type | Notes |
| -------------------- | ---------------- | ----------- |
| `id` | ULID | PK |
@@ -1030,15 +1130,12 @@
| `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 |
@@ -1051,8 +1148,6 @@
### `production_requests`
> **New table (finding #3: missing table)**
| Column | Type | Notes |
| -------------- | ------------------ | --------------------------------------------------------- |
| `id` | ULID | PK |
@@ -1097,8 +1192,8 @@
### 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
- Pure pivot/link tables: `$table->id()` (auto-increment integer)
- Never UUID v4
---
@@ -1107,18 +1202,10 @@
| ✅ 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)
| 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 |
---
@@ -1126,29 +1213,53 @@
**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.
**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)` |
| `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.
| 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)` |
---
### 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
- Every query on event data **MUST** scope on `organisation_id` via `OrganisationScope` Eloquent Global Scope
- Use Laravel policies never direct id-checks in controllers
- **Audit log:** Spatie `laravel-activitylog` on: `persons`, `accreditation_assignments`, `shift_assignments`, `check_ins`, `production_requests`
---
### 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.

View File

@@ -110,3 +110,13 @@ Voer dit uit na elke Fase 2 module om regressie te voorkomen.
- [ ] Organisatie switcher werkt nog
- [ ] Events lijst laadt zonder errors
- [ ] php artisan test → alle tests groen
## Openstaande FK constraints (worden toegevoegd bij persons module)
- shift_assignments.person_id → persons
- shift_check_ins.person_id → persons
- volunteer_availabilities.person_id → persons
person_id kolommen zonder FK constraint in:
- shift_assignments
- shift_check_ins
- volunteer_availabilities