Files
crewli/dev-docs/SCHEMA.md
bert.hausmans 948687f27e feat: enterprise MFA with TOTP, email codes, backup codes, and trusted devices
Three verification methods (TOTP authenticator, email code, backup codes),
trusted device management with 30-day expiry, role-based enforcement for
super_admin and org_admin, admin reset capability, and full test coverage
(46 tests). Modifies login flow to support MFA challenge/response with
temporary session tokens stored in cache.

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

106 KiB
Raw Blame History

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
  2. 3.5.1a Multi-Factor Authentication
  3. 3.5.2 Locations
  4. 3.5.3 Festival Sections, Time Slots & Shifts
  5. 3.5.4 Volunteer Profile & History
  6. 3.5.5 Crowd Types, Persons & Crowd Lists
  7. 3.5.5a Person Tags & Skills
  8. 3.5.5b Registration Form Fields & Section Preferences
  9. 3.5.5c Person Identity Matching
  10. 3.5.6 Accreditation Engine
  11. 3.5.7 Artists & Advancing
  12. 3.5.8 Communication & Briefings
  13. 3.5.9 Forms, Check-In & Operational
  14. 3.5.11 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
mfa_enabled boolean default: false
mfa_method string(20) nullable totp or email
mfa_secret text nullable encrypted TOTP secret
mfa_confirmed_at timestamp nullable null = setup not yet verified
mfa_enforced boolean default: false — forced by policy or admin
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), hasMany mfa_backup_codes, hasMany mfa_email_codes, hasMany trusted_devices
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):

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)


email_change_requests

Column Type Notes
id ULID PK
user_id ULID FK → users (cascade delete)
current_email string Email at time of request
new_email string Requested new email
token string SHA-256 hashed verification token
requested_by_user_id ULID FK null → users (null on delete) — self or admin
status string pending / verified / expired / cancelled
expires_at timestamp 24h from request
verified_at timestamp? When verification completed
created_at timestamp
updated_at timestamp

Indexes: (user_id, status), (token)


3.5.1a Multi-Factor Authentication

MFA tables supporting TOTP, email codes, backup codes, and trusted devices. See /dev-docs/AUTH_ARCHITECTURE.md section 9 for full architecture.

mfa_backup_codes

Column Type Notes
id bigint PK, auto-increment
user_id ULID FK → users
code_hash string(64) bcrypt hash of code
used boolean default: false
used_at timestamp nullable
created_at timestamp
updated_at timestamp

Relations: belongsTo User
Indexes: (user_id, used)
Soft delete: no (audit record)


mfa_email_codes

Column Type Notes
id bigint PK, auto-increment
user_id ULID FK → users
code string(6) 6-digit numeric code
expires_at timestamp 10 min from creation
used boolean default: false
created_at timestamp
updated_at timestamp

Relations: belongsTo User
Indexes: (user_id, code, used, expires_at)
Soft delete: no (audit record)


trusted_devices

Column Type Notes
id ULID PK
user_id ULID FK → users
device_hash string(64) SHA-256 of fingerprint+user_id
device_name string nullable e.g. "Chrome on macOS"
ip_address string(45) IPv4 or IPv6
trusted_until timestamp 30 days from creation
last_used_at timestamp nullable
created_at timestamp
updated_at timestamp

Relations: belongsTo User
Indexes: (user_id, device_hash, trusted_until)
Soft delete: no


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:

$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. Supports email matching (HIGH confidence), fuzzy name matching (MEDIUM confidence, upgradable to HIGH with DOB match), manual linking, and revert/unlink. PersonObserver triggers detection automatically on Person create/update.

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|name_fuzzy|manual (IdentityMatchMethod)
confidence string Enum: high|medium (IdentityMatchConfidence). high = exact email or fuzzy+DOB, medium = fuzzy name only
status string Enum: pending|confirmed|dismissed|reverted (IdentityMatchStatus), default pending
match_details JSON nullable Snapshot of matched fields, emails, names, DOB at detection time
confirmed_by_user_id ULID FK nullable → users (who confirmed). constrained()->nullOnDelete()
confirmed_at timestamp nullable When the match was confirmed
dismissed_by_user_id ULID FK nullable → users (who dismissed). constrained()->nullOnDelete()
dismissed_at timestamp nullable When the match was dismissed
reverted_by_user_id ULID FK nullable → users (who reverted/unlinked). constrained()->nullOnDelete()
reverted_at timestamp nullable When a confirmed match was reverted
resolved_by_user_id ULID FK nullable → users (legacy, set on confirm/dismiss). constrained()->nullOnDelete()
resolved_at timestamp nullable When the match was resolved (legacy)
created_at timestamp

Design notes:

  • No updated_at: status transitions tracked via specific *_at columns. Model sets const UPDATED_AT = null;.
  • Specific confirmed_by/dismissed_by/reverted_by columns track each action separately, enabling a match lifecycle of: pending → confirmed → reverted.
  • resolved_by/resolved_at retained for backward compatibility (set on confirm/dismiss).
  • Detection strategies: (1) Exact email within org → HIGH, (2) Fuzzy name (Levenshtein ≤2/3) → MEDIUM, (3) Fuzzy name + DOB match → HIGH.

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), all *_user_id → users (null on delete)

users.date_of_birth

Column Type Notes
date_of_birth date nullable Added after last_name. Used as DOB tiebreaker for fuzzy name matching

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

// 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.


3.5.10 Email Infrastructure

organisation_email_settings

Per-organisation email branding configuration. One-to-one with organisations.

Column Type Notes
id ULID PK
organisation_id ULID FK → organisations, UNIQUE, CASCADE DELETE
logo_url string(500) nullable Logo URL for email header
primary_color string(7) Hex color, default #6366F1
secondary_color string(7) Hex color, default #4F46E5
footer_text string(200) nullable Custom footer text
reply_to_email string nullable Override reply-to per org
reply_to_name string(100) nullable Reply-to display name

Relations: belongsTo organisation
Soft delete: no


organisation_email_templates

Per-organisation, per-type email text overrides. When no override exists, system defaults from EmailTemplateType enum are used.

Column Type Notes
id ULID PK
organisation_id ULID FK → organisations, CASCADE DELETE
type string(50) EmailTemplateType enum value
subject string(200) Custom subject line
heading string(200) nullable Custom heading in email body
body_text text Custom body text (supports {variable} placeholders)
button_text string(100) nullable Custom CTA button label

Unique constraint: UNIQUE(organisation_id, type)
Relations: belongsTo organisation
Soft delete: no

Template types: invitation, password_reset, email_verification, registration_approved, registration_rejected, shift_assignment


email_logs

Immutable audit record of every email sent. No soft deletes.

Column Type Notes
id ULID PK
organisation_id ULID FK nullable → organisations, NULL ON DELETE
event_id ULID FK nullable → events, NULL ON DELETE
person_id ULID nullable Person context if applicable
user_id ULID nullable User context if applicable
recipient_email string
recipient_name string nullable
mailable_class string e.g. App\Mail\TransactionalMail
template_type string(50) EmailTemplateType enum value
subject string Resolved subject (after variable substitution)
status string(20) queued|sent|failed
error_message text nullable Failure reason
queued_at timestamp
sent_at timestamp nullable
failed_at timestamp nullable
triggered_by_user_id ULID nullable Who triggered the email

Indexes: (organisation_id, created_at), (recipient_email, created_at), (template_type, status), (event_id), (person_id)
Relations: belongsTo organisation (nullable), event (nullable), person (nullable), user (nullable), triggeredBy → user
Soft delete: no — immutable audit table