# EventCrew - Architecture > Multi-tenant SaaS platform for event- and festival management. > Source of truth: `/resources/design/EventCrew_Design_Document_v1.3.docx` ## System Overview ``` ┌─────────────────────────────────────────────────────────────────────────┐ │ INTERNET │ └─────────────────────────────────────────────────────────────────────────┘ │ ┌───────────────────────────┼───────────────────────────┐ │ │ │ ▼ ▼ ▼ ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ │ Admin SPA │ │ Organizer │ │ Portal SPA │ │ (Super Admin)│ │ SPA (Main) │ │ (External) │ │ :5173 │ │ :5174 │ │ :5175 │ └───────┬───────┘ └───────┬───────┘ └───────┬───────┘ │ │ │ └───────────────────────────┼───────────────────────────┘ │ CORS + Sanctum tokens ▼ ┌───────────────────────┐ │ Laravel 12 REST API │ │ (JSON only, no │ │ Blade views) │ │ :8000 │ └───────────┬───────────┘ │ ┌───────────────┼───────────────┐ │ │ │ ▼ ▼ ▼ ┌───────────┐ ┌───────────┐ ┌───────────┐ │ MySQL 8 │ │ Redis │ │ Mailpit │ │ :3306 │ │ :6379 │ │ :8025 │ └───────────┘ └───────────┘ └───────────┘ ``` **Golden Rule:** Laravel is exclusively a JSON REST API. No Blade views, no Mix, no Inertia. Every response is `application/json`. Vue handles ALL UI via three SPAs. --- ## Applications ### Admin Dashboard (`apps/admin/`) **Purpose**: Super Admin platform management. **Users**: Platform owner only (super_admin role). **Features**: - Organisation management (CRUD, billing status) - Platform user management - Global settings **Vuexy Version**: `typescript-version/full-version` --- ### Organizer App (`apps/app/`) **Purpose**: Main application for event management per organisation. **Users**: Organisation Admins, Event Managers, Staff Coordinators, Artist Managers, Volunteer Coordinators. **Features**: - Event lifecycle management (Draft through Closed) - Festival Sections, Time Slots, Shift planning - Person & Crowd management (Crew, Volunteers, Artists, Guests, Press, Partners, Suppliers) - Accreditation engine (categories, items, access zones) - Artist booking & advancing - Timetable & stage management - Briefing builder & communication hub - Mission Control (show day operations) - Form builder with conditional logic - Supplier & production management - Reporting & insights **Vuexy Version**: `typescript-version/full-version` (customized navigation) --- ### Portal (`apps/portal/`) **Purpose**: External-facing portal with two access modes. **Users**: Volunteers, Crew (login-based), Artists, Suppliers, Press (token-based). **Access Modes**: | User | Access Mode | Rationale | |------|-------------|-----------| | Volunteer / Crew | Login (`auth:sanctum`) | Long-term relationship, festival passport, shift history | | Artist / Tour Manager | Token (`portal.token` middleware) | Per-event, advance portal via signed URL | | Supplier / Partner | Token (`portal.token` middleware) | Per-event, production request via token | | Press / Media | Token (`portal.token` middleware) | Per-event accreditation, no recurring relationship | **Router guard logic**: If `route.query.token` -> token mode. If `authStore.isAuthenticated` -> login mode. Otherwise -> redirect to `/login`. **Vuexy Version**: `typescript-version/starter-kit` (stripped: no sidebar, no customizer, no dark mode toggle; uses Vuetify components + Vuexy SCSS) --- ## Multi-Tenant Data Model Shared database schema with organisation scoping on all tables. No row-level security at DB level; scoping enforced via Laravel Policies and Eloquent Global Scopes. **Scoping Rule**: EVERY query on event data MUST have an `organisation_id` scope via `OrganisationScope` Global Scope. **Tenancy Hierarchy**: ``` Platform (Super Admin) └─ Organisation (client A) └─ Event (event 1) └─ Festival Section (Bar, Hospitality, Technical, ...) ├─ Time Slots (DAY1-EARLY-CREW, DAY1-EARLY-VOLUNTEER, ...) └─ Shifts (Bar x DAY1-EARLY-VOLUNTEER, 5 slots) ``` --- ## Three-Level Role & Permission Model Managed via Spatie `laravel-permission` with team-based permissions. | Level | Scope | Roles | Implementation | |-------|-------|-------|----------------| | App Level | Whole platform | `super_admin`, `support_agent` | Spatie role | | Organisation Level | Within one org | `org_admin`, `org_member`, `org_readonly` | Spatie team = organisation | | Event Level | Within one event | `event_manager`, `artist_manager`, `staff_coordinator`, `volunteer_coordinator`, `accreditation_officer` | `event_user_roles` pivot table | **Middleware**: `OrganisationRoleMiddleware` and `EventRoleMiddleware` check per route. --- ## Event Lifecycle | Phase | Description | |-------|-------------| | `draft` | Created but not published. Only admin sees it. | | `published` | Active in planning. Internal modules available. External portals closed. | | `registration_open` | Volunteer registration and artist advance portals open. | | `buildup` | Setup days. Crew shifts begin. Accreditation distribution starts. | | `showday` | Active event days. Mission Control active. Real-time check-in. | | `teardown` | Breakdown days. Inventory return. Shift closure. | | `closed` | Event completed. Read-only. Reports available. | --- ## API Structure ### Base URL - Development: `http://localhost:8000/api/v1` ### Route Groups ``` # Public (no auth) POST /auth/login POST /portal/token-auth Token-based portal access POST /portal/form-submit Public form submission # Protected (auth:sanctum) POST /auth/logout GET /auth/me Returns user + organisations + event roles # Organisations GET/POST /organisations GET/PUT /organisations/{id} POST /organisations/{id}/invite GET /organisations/{id}/members # Events (nested under organisations) GET/POST /organisations/{org}/events GET/PUT/DELETE /events/{id} PUT /events/{id}/status # Festival Sections GET/POST /events/{event}/sections GET/PUT/DELETE /sections/{id} GET /sections/{id}/dashboard # Time Slots GET/POST /events/{event}/time-slots PUT/DELETE /time-slots/{id} # Shifts GET/POST /sections/{section}/shifts PUT/DELETE /shifts/{id} POST /shifts/{id}/assign POST /shifts/{id}/claim Volunteer self-service # Persons GET/POST /events/{event}/persons GET/PUT /persons/{id} POST /persons/{id}/approve POST /persons/{id}/checkin # Crowd Types & Lists GET/POST /organisations/{org}/crowd-types GET/POST /events/{event}/crowd-lists # Artists & Advancing GET/POST /events/{event}/artists GET/PUT /artists/{id} GET/POST /artists/{id}/sections Advance sections POST /sections/{id}/submit Advance submission # Accreditation GET/POST /events/{event}/accreditation-items POST /persons/{id}/accreditations GET/POST /events/{event}/access-zones # Briefings & Communication GET/POST /events/{event}/briefings POST /briefings/{id}/send GET/POST /events/{event}/campaigns POST /campaigns/{id}/send # Mission Control GET /events/{event}/mission-control POST /persons/{id}/checkin-item # Scanners & Inventory GET/POST /events/{event}/scanners POST /scan GET/POST /events/{event}/inventory # Reports GET /events/{event}/reports/{type} # Portal (token-based, portal.token middleware) GET /portal/artist POST /portal/advancing GET /portal/supplier POST /portal/production-request ``` ### API Response Format ```json { "data": { ... }, "meta": { "pagination": { "current_page": 1, "per_page": 15, "total": 100, "last_page": 7 } } } ``` --- ## Core Database Schema **Primary Keys**: ULID on all business tables via `HasUlids` trait. Pure pivot tables use auto-increment integer PK. **Soft Deletes ON**: organisations, events, festival_sections, shifts, shift_assignments, persons, artists, companies, production_requests. **Soft Deletes OFF** (audit records): check_ins, briefing_sends, message_replies, shift_waitlist, volunteer_festival_history. **JSON Columns**: ONLY for opaque config (blocks, fields, settings, items). NEVER for dates, status values, foreign keys, booleans, or anything filtered/sorted/aggregated. ### 1. Foundation | Table | Key Columns | Notes | |-------|-------------|-------| | `users` | id (ULID), name, email, password, timezone, locale, avatar, deleted_at | Platform-wide, unique per email. | | `organisations` | id (ULID), name, slug, billing_status, settings (JSON: display prefs only), deleted_at | hasMany events, crowd_types. | | `organisation_user` | id (int AI), user_id, organisation_id, role | Pivot. Integer PK. | | `user_invitations` | id (ULID), email, invited_by_user_id, organisation_id, event_id (nullable), role, token (ULID unique), status, expires_at | INDEX: (token), (email, status). | | `events` | id (ULID), organisation_id, name, slug, start_date, end_date, timezone, status (enum), deleted_at | INDEX: (organisation_id, status). | | `event_user_roles` | id (int AI), user_id, event_id, role | Pivot. Integer PK. | ### 2. Locations | Table | Key Columns | Notes | |-------|-------------|-------| | `locations` | id (ULID), event_id, name, address, lat, lng, description, access_instructions | INDEX: (event_id). | ### 3. Festival Sections, Time Slots & Shifts | Table | Key Columns | Notes | |-------|-------------|-------| | `festival_sections` | id (ULID), event_id, name, sort_order, deleted_at | INDEX: (event_id, sort_order). | | `time_slots` | id (ULID), event_id, name, person_type (CREW/VOLUNTEER/PRESS/...), date, start_time, end_time | INDEX: (event_id, person_type, date). | | `shifts` | id (ULID), festival_section_id, time_slot_id, location_id, slots_total, slots_open_for_claiming, status, deleted_at | INDEX: (festival_section_id, time_slot_id). | | `shift_assignments` | id (ULID), shift_id, person_id, time_slot_id (denormalized), status (pending_approval/approved/rejected/cancelled/completed), auto_approved, deleted_at | UNIQUE(person_id, time_slot_id). | | `volunteer_availabilities` | id (ULID), person_id, time_slot_id, submitted_at | UNIQUE(person_id, time_slot_id). | | `shift_waitlist` | id (ULID), shift_id, person_id, position, added_at | UNIQUE(shift_id, person_id). | | `shift_swap_requests` | id (ULID), from_assignment_id, to_person_id, status, auto_approved | | | `shift_absences` | id (ULID), shift_assignment_id, person_id, reason, status | | ### 4. Volunteer Profile & History | Table | Key Columns | Notes | |-------|-------------|-------| | `volunteer_profiles` | id (ULID), user_id (unique), bio, tshirt_size, first_aid, driving_licence, reliability_score (0.00-5.00) | Platform-wide, 1:1 with users. | | `volunteer_festival_history` | id (ULID), user_id, event_id, hours_planned, hours_completed, no_show_count, coordinator_rating, would_reinvite | UNIQUE(user_id, event_id). Never visible to volunteer. | | `post_festival_evaluations` | id (ULID), event_id, person_id, overall_rating, would_return, feedback_text | | | `festival_retrospectives` | id (ULID), event_id (unique), KPI columns, top_feedback (JSON) | | ### 5. Crowd Types, Persons & Crowd Lists | Table | Key Columns | Notes | |-------|-------------|-------| | `crowd_types` | id (ULID), organisation_id, name, system_type (CREW/GUEST/ARTIST/VOLUNTEER/PRESS/PARTNER/SUPPLIER), color, icon | Org-level config. | | `persons` | id (ULID), user_id (nullable), event_id, crowd_type_id, company_id (nullable), name, email, phone, status, is_blacklisted, custom_fields (JSON), deleted_at | user_id nullable for externals. UNIQUE(event_id, user_id) WHERE user_id IS NOT NULL. | | `companies` | id (ULID), organisation_id, name, type, contact_*, deleted_at | Shared across events within org. | | `crowd_lists` | id (ULID), event_id, crowd_type_id, name, type (internal/external), auto_approve, max_persons | | | `crowd_list_persons` | id (int AI), crowd_list_id, person_id | Pivot. | ### 6. Accreditation Engine | Table | Key Columns | Notes | |-------|-------------|-------| | `accreditation_categories` | id (ULID), organisation_id, name, sort_order, icon | Org-level. | | `accreditation_items` | id (ULID), accreditation_category_id, name, is_date_dependent, barcode_type, cost_price | Org-level items. | | `event_accreditation_items` | id (ULID), event_id, accreditation_item_id, max_quantity_per_person, is_active | Activates item per event. UNIQUE(event_id, accreditation_item_id). | | `accreditation_assignments` | id (ULID), person_id, accreditation_item_id, event_id, date, quantity, is_handed_out | | | `access_zones` | id (ULID), event_id, name, zone_code (unique per event) | | | `access_zone_days` | id (int AI), access_zone_id, day_date | UNIQUE(access_zone_id, day_date). | | `person_access_zones` | id (int AI), person_id, access_zone_id, valid_from, valid_to | | ### 7. Artists & Advancing | Table | Key Columns | Notes | |-------|-------------|-------| | `artists` | id (ULID), event_id, name, booking_status (concept/requested/option/confirmed/contracted/cancelled), portal_token (ULID unique), deleted_at | | | `performances` | id (ULID), artist_id, stage_id, date, start_time, end_time, check_in_status | INDEX: (stage_id, date, start_time). | | `stages` | id (ULID), event_id, name, color, capacity | | | `stage_days` | id (int AI), stage_id, day_date | UNIQUE(stage_id, day_date). | | `advance_sections` | id (ULID), artist_id, name, type, is_open, sort_order | | | `advance_submissions` | id (ULID), advance_section_id, data (JSON), status | | | `artist_contacts` | id (ULID), artist_id, name, email, role | | | `artist_riders` | id (ULID), artist_id, category (technical/hospitality), items (JSON) | | ### 8. Communication & Briefings | Table | Key Columns | Notes | |-------|-------------|-------| | `briefing_templates` | id (ULID), event_id, name, type, blocks (JSON) | | | `briefings` | id (ULID), event_id, briefing_template_id, name, target_crowd_types (JSON), status | | | `briefing_sends` | id (ULID), briefing_id, person_id, status (queued/sent/opened/downloaded) | NO soft delete. | | `communication_campaigns` | id (ULID), event_id, type (email/sms/whatsapp), status | | | `messages` | id (ULID), event_id, sender_user_id, recipient_person_id, urgency (normal/urgent/emergency) | | | `broadcast_messages` | id (ULID), event_id, sender_user_id, body, urgency | | ### 9. Forms, Check-In & Operational | Table | Key Columns | Notes | |-------|-------------|-------| | `public_forms` | id (ULID), event_id, crowd_type_id, fields (JSON), conditional_logic (JSON), iframe_token | | | `form_submissions` | id (ULID), public_form_id, person_id, data (JSON) | | | `check_ins` | id (ULID), event_id, person_id, scanned_by_user_id, scanned_at | NO soft delete. Immutable audit record. | | `scanners` | id (ULID), event_id, name, type, pairing_code | | | `inventory_items` | id (ULID), event_id, name, item_code, assigned_to_person_id | | | `production_requests` | id (ULID), event_id, company_id, title, status, token (ULID unique) | | | `material_requests` | id (ULID), production_request_id, category, name, quantity, status | | --- ## Model Relationships **User** - belongsToMany Organisations (via `organisation_user`) - belongsToMany Events (via `event_user_roles`) **Organisation** - hasMany Events - hasMany CrowdTypes - hasMany AccreditationCategories - hasMany Companies - belongsToMany Users (via `organisation_user`) **Event** - belongsTo Organisation - hasMany FestivalSections - hasMany TimeSlots - hasMany Persons - hasMany Artists - hasMany Briefings - hasMany Locations - hasMany AccessZones - hasMany PublicForms **FestivalSection** - belongsTo Event - hasMany Shifts **TimeSlot** - belongsTo Event - hasMany Shifts - hasMany ShiftAssignments (denormalized) **Shift** - belongsTo FestivalSection - belongsTo TimeSlot - belongsTo Location (nullable) - hasMany ShiftAssignments **Person** - belongsTo Event - belongsTo CrowdType - belongsTo User (nullable) - belongsTo Company (nullable) - hasMany ShiftAssignments - hasMany AccreditationAssignments - hasMany CheckIns **Artist** - belongsTo Event - hasMany Performances - hasMany AdvanceSections - hasMany ArtistContacts --- ## Security & CORS Three frontend origins in `config/cors.php` (via env): | App | Dev URL | Env Variable | |-----|---------|--------------| | Admin | `http://localhost:5173` | `FRONTEND_ADMIN_URL` | | App | `http://localhost:5174` | `FRONTEND_APP_URL` | | Portal | `http://localhost:5175` | `FRONTEND_PORTAL_URL` | --- ## Real-time Events (WebSocket) Via Laravel Echo + Pusher/Soketi: - `PersonCheckedIn` - `ShiftFillRateChanged` - `ArtistCheckInStatusChanged` - `AdvanceSectionSubmitted` - `AccreditationItemHandedOut` - `BriefingSendQueued` --- *Source: EventCrew Design Document v1.3, March 2026*