Add configurable column widths (full/half) and optional descriptions for radio/select/checkbox options on registration form fields. - Migration adds display_width column to both tables - FieldDisplayWidth enum with smart defaults per field type - normalized_options accessor for backwards-compatible option format - Portal form renderer uses display_width for VRow/VCol grid layout - Radio/select/checkbox options render with descriptions - Admin field editor supports display_width toggle and description input - System templates updated with appropriate widths and descriptions Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
108 KiB
Crewli — Core Database Schema
Source: Design Document v1.3 — Section 3.5
Version: 1.8 — Updated April 2026Changelog:
- 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_exceptionstoevents. Addedevent_person_activationspivot. Changedpersons.event_idto reference festival-level event. Addedevent_type_labelfor UI terminology customisation.- v1.8: Registration Form Fields module — EAV system for dynamic event-specific registration fields, replacing queryable use of
persons.custom_fieldsJSON. 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:
HasUlidstrait- Migrations:
$table->ulid('id')->primary()- External IDs (URLs, barcodes, API): ULID
- Pure pivot tables: auto-increment integer PK for join performance
Table of Contents
- 3.5.1 Foundation
- 3.5.1a Multi-Factor Authentication
- 3.5.2 Locations
- 3.5.3 Festival Sections, Time Slots & Shifts
- 3.5.4 Volunteer Profile & History
- 3.5.5 Crowd Types, Persons & Crowd Lists
- 3.5.5a Person Tags & Skills
- 3.5.5b Registration Form Fields & Section Preferences
- 3.5.5c Person Identity Matching
- 3.5.6 Accreditation Engine
- 3.5.7 Artists & Advancing
- 3.5.8 Communication & Briefings
- 3.5.9 Forms, Check-In & Operational
- 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:
belongsToOrganisationbelongsToEvent as parent (parent_event_id)hasManyEvent as children (parent_event_id)hasManyFestivalSection, TimeSlot, Artist, Briefing (on sub-event or flat event)hasManyPerson (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_ruleandrecurrence_exceptionsare 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.mdsection 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
impersonation_sessions
| Column | Type | Notes |
|---|---|---|
id |
ULID | PK |
admin_id |
ULID FK | → users |
target_user_id |
ULID FK | → users |
reason |
string | Admin-provided reason |
mfa_method |
string(20) | totp, email, or backup_code |
ip_address |
string(45) | Admin's IP at start |
user_agent |
text nullable | Admin's user agent |
started_at |
timestamp | |
ended_at |
timestamp nullable | NULL = still active |
expires_at |
timestamp | Sliding 60-min TTL |
end_reason |
string(50) nullable | manual, expired, ip_changed, admin_kill_all |
actions_count |
unsigned int | API requests made during session |
Relations: belongsTo User (admin), belongsTo User (target)
Indexes: (admin_id, ended_at), (target_user_id, ended_at), (started_at)
Soft delete: no — immutable audit table
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_geojsonremoved — 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:
- Section (e.g. Horeca, Backstage, Entertainment) — operational area
- Location (e.g. Bar Hardstyle District) — physical spot within a section
- Shift (e.g. "Tapper" at Bar Hardstyle District) — specific role/task at a location in a time window
- 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_idbut differenttitle,actual_start_time, andslots_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_timeoverride.Cross-event sections (EHBO, verkeersregelaars): use
type = cross_event. Shifts in these sections can setallow_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: standardcrew_auto_accepts: falsecrew_invited_to_events: falseadded_to_timeline: falseresponder_self_checkin: truetimed_accreditations: falseshow_in_registration: false
Note: "Overkoepelende" sections (shared across all sub-events of a festival) are identified by
type = 'cross_event'. There is no separateis_sharedboolean column — thetypeenum 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:30–03:00, report 18:00, is_lead_role = true)
- "Tapper" (2 slots, 19:00–02:30, report 18:30)
- "Frisdrank" (2 slots, 19:00–02:30, report 18:30)
- "Tussenbuffet" (8 slots, 19:00–02:30, report 18:30)
- "Runner" (1 slot, 20:30–02: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 claimingopen— visible and claimable in portal (respectsslots_open_for_claiming)full— capacity reached, waitlist onlyin_progress— shift has started (show day)completed— shift completedcancelled— 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. Whenfestival_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.00–5.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 | 1–5 |
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 | 1–5 |
shift_rating |
tinyint | 1–5 |
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_idnow 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 viaevent_person_activationspivot 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*_atcolumns. Model setsconst UPDATED_AT = null;. - Specific
confirmed_by/dismissed_by/reverted_bycolumns track each action separately, enabling a match lifecycle of: pending → confirmed → reverted. resolved_by/resolved_atretained 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 | 1–5 |
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, NOTperson_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/syncendpoint replaces tags of the specifiedsourceonly. Syncingself_reportedtags removes self_reported tags not in the new list and adds new ones, while leaving allorganiser_assignedtags 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_fieldsJSON. 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 OR option objects {label, description?}. NULL for tag_picker (options come from person_tags). JSON OK: opaque config. Both formats accepted; normalized_options accessor always returns objects. |
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 |
display_width |
string(10) | full (default) or half — controls form layout width |
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:
optionsJSON is acceptable here: it's opaque configuration (the list of choices), not queryable data. The queryable answers are stored inperson_field_values.slugenables stable references across API calls and form submissions even iflabelchanges.- Fields scoped to event level. For festivals,
event_id= parent festival (matchingpersons.event_id). tag_pickerfields do NOT useoptions— available choices come fromperson_tagsfiltered bytag_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_optionsJSON is used ONLY for multiselect/checkbox/tag_picker fields where multiple values must be stored. For single-value fields, usevalueonly.- 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_pickerfields:selected_optionscontains 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_pickerregistration field is used, tag selections are stored inperson_field_valuesas person_tag_id ULIDs. These must be synced touser_organisation_tagswhen the person gets auser_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):
RegistrationFormFieldService::upsertPersonValues()— if person already has user_idPersonService::approve()— when account is created and user_id is setPersonIdentityService::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_fieldsto 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()+HasUlidstrait - 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_idviaOrganisationScopeEloquent 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-activitylogon: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