Files
crewli/.cursor/rules/103_database.mdc
bert.hausmans 5e2ede14b4 fix(admin): index redirect uses auth cookies and Spatie roles
- Gate redirect on userData + accessToken; map org roles to events route
- Keep legacy admin/client role redirects for compatibility
- Rename organizer app HTML title to Event Crew - App
- Add Cursor database rules (ULID, JSON, indexes, soft deletes)

Made-with: Cursor
2026-03-30 10:32:42 +02:00

284 lines
7.3 KiB
Plaintext

---
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
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('shifts', function (Blueprint $table) {
// ULID primary key
$table->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)