--- description: Database schema conventions, ULID primary keys, JSON column rules, soft delete strategy, and index requirements for EventCrew globs: ["api/database/**/*.php", "api/app/Models/**/*.php"] alwaysApply: true --- # Database & Schema Rules ## Primary Keys: ULID (NOT UUID v4) All business tables use ULID (Universally Unique Lexicographically Sortable Identifier). UUID v4 is forbidden because it is random, causing B-tree index fragmentation in InnoDB. ULID is monotonically increasing (time-ordered). ### Migration Pattern ```php $table->ulid('id')->primary(); ``` ### Model Pattern ```php use Illuminate\Database\Eloquent\Concerns\HasUlids; class Event extends Model { use HasUlids; } ``` ### Foreign Keys ```php $table->foreignUlid('organisation_id')->constrained()->cascadeOnDelete(); $table->foreignUlid('event_id')->constrained()->cascadeOnDelete(); $table->foreignUlid('location_id')->nullable()->constrained()->nullOnDelete(); ``` ### Exception: Pure Pivot Tables Pure pivot tables (no business logic, only joins) use auto-increment integer PK for join performance: ```php // organisation_user, event_user_roles, crowd_list_persons, access_zone_days, etc. $table->id(); // auto-increment integer ``` ## JSON Column Rules ### ALLOWED for JSON - Opaque configuration: `settings`, `blocks`, `fields` - Toggle-sets and item lists: `items`, `milestone_flags` - Free-text arrays: `top_feedback` - Conditional logic definitions: `conditional_logic` ### NEVER use JSON for - Dates or periods (use separate columns or pivot tables) - Status values (use enum columns) - Foreign keys or references (use FK columns) - Boolean flags (use bool columns) - Anything that needs to be filtered, sorted, or aggregated in queries ### Example: Access Zone Days ```php // ❌ WRONG: JSON array of dates $table->json('active_days'); // Can't index or query efficiently // ✅ CORRECT: Separate pivot table Schema::create('access_zone_days', function (Blueprint $table) { $table->id(); $table->foreignUlid('access_zone_id')->constrained()->cascadeOnDelete(); $table->date('day_date'); $table->unique(['access_zone_id', 'day_date']); $table->index('day_date'); }); ``` ## Soft Delete Strategy ### Soft Delete ON (recoverable business records) - `organisations` - `events` - `festival_sections` - `shifts` - `shift_assignments` - `persons` - `artists` - `companies` - `production_requests` ```php use Illuminate\Database\Eloquent\SoftDeletes; class Event extends Model { use SoftDeletes; } // Migration: $table->softDeletes(); ``` ### Soft Delete OFF (immutable audit records) These are historical records that must never be modified or hidden: - `check_ins` (who checked in, when, by whom) - `briefing_sends` (delivery tracking) - `message_replies` (communication audit) - `shift_waitlist` (position tracking) - `volunteer_festival_history` (coordinator assessments) - `show_day_absence_alerts` (operational audit) ## Required Composite Indexes All list queries must target < 50ms. These composite indexes are mandatory: ### persons ```php $table->index(['event_id', 'crowd_type_id', 'status']); $table->index(['email', 'event_id']); $table->index(['user_id', 'event_id']); ``` ### shift_assignments ```php $table->index(['shift_id', 'status']); $table->index(['person_id', 'status']); $table->unique(['person_id', 'time_slot_id']); // DB-enforced conflict detection ``` ### check_ins ```php $table->index(['event_id', 'person_id', 'scanned_at']); $table->index(['event_id', 'scanned_at']); ``` ### briefing_sends ```php $table->index(['status', 'briefing_id']); $table->index(['person_id']); ``` ### shift_waitlist ```php $table->unique(['shift_id', 'person_id']); $table->index(['shift_id', 'position']); ``` ### performances (B2B detection via overlap query) ```php $table->index(['stage_id', 'date', 'start_time', 'end_time']); ``` ### events ```php $table->index(['organisation_id', 'status']); ``` ### festival_sections ```php $table->index(['event_id', 'sort_order']); ``` ### time_slots ```php $table->index(['event_id', 'person_type', 'date']); ``` ### shifts ```php $table->index(['festival_section_id', 'time_slot_id']); $table->index(['time_slot_id', 'status']); ``` ## Migration Ordering Migrations must be created in dependency order. Foundation tables first, then dependent tables: 1. `users` (update: add timezone, locale, deleted_at) 2. `organisations` 3. `organisation_user` (pivot) 4. `user_invitations` 5. `events` 6. `event_user_roles` (pivot) 7. `locations` 8. `crowd_types` 9. `companies` 10. `festival_sections` 11. `time_slots` 12. `persons` 13. `shifts` 14. `shift_assignments` 15. `volunteer_availabilities` 16. `shift_waitlist` 17. `accreditation_categories` 18. `accreditation_items` 19. `event_accreditation_items` 20. `accreditation_assignments` 21. `access_zones` + `access_zone_days` + `person_access_zones` 22. `stages` + `stage_days` 23. `artists` 24. `performances` 25. `advance_sections` + `advance_submissions` 26. `briefing_templates` + `briefings` + `briefing_sends` 27. `public_forms` + `form_submissions` 28. `check_ins` 29. `scanners` ## Migration Template ```php ulid('id')->primary(); // Foreign keys with constraints $table->foreignUlid('festival_section_id')->constrained()->cascadeOnDelete(); $table->foreignUlid('time_slot_id')->constrained()->cascadeOnDelete(); $table->foreignUlid('location_id')->nullable()->constrained()->nullOnDelete(); // Business columns $table->unsignedSmallInteger('slots_total'); $table->unsignedSmallInteger('slots_open_for_claiming')->default(0); $table->string('status')->default('open'); $table->json('events_during_shift')->nullable(); // opaque config = OK for JSON // Timestamps + soft delete $table->timestamps(); $table->softDeletes(); // Composite indexes (from design document section 3.5) $table->index(['festival_section_id', 'time_slot_id']); $table->index(['time_slot_id', 'status']); }); } public function down(): void { Schema::dropIfExists('shifts'); } }; ``` ## Audit Logging Use Spatie laravel-activitylog on these models: - `persons` - `accreditation_assignments` - `shift_assignments` - `check_ins` - `production_requests` ```php use Spatie\Activitylog\Traits\LogsActivity; use Spatie\Activitylog\LogOptions; class Person extends Model { use LogsActivity; public function getActivitylogOptions(): LogOptions { return LogOptions::defaults() ->logOnly(['status', 'is_blacklisted', 'crowd_type_id']) ->logOnlyDirty(); } } ``` ## Out of Scope (Do NOT create tables for) These features are explicitly excluded from v1.x. No placeholders, no empty JSON fields, no migrations: - Travel management (flights, buses) - Accommodation management (hotels) - Travel Party management - CO2/sustainability reporting - Native mobile apps - Ticket sales (own ticketing system)