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
This commit is contained in:
283
.cursor/rules/103_database.mdc
Normal file
283
.cursor/rules/103_database.mdc
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
---
|
||||||
|
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)
|
||||||
@@ -10,16 +10,32 @@ export const redirects: RouteRecordRaw[] = [
|
|||||||
path: '/',
|
path: '/',
|
||||||
name: 'index',
|
name: 'index',
|
||||||
redirect: to => {
|
redirect: to => {
|
||||||
// TODO: Get type from backend
|
|
||||||
const userData = useCookie<Record<string, unknown> | null | undefined>('userData')
|
const userData = useCookie<Record<string, unknown> | null | undefined>('userData')
|
||||||
const userRole = userData.value?.role
|
const accessToken = useCookie<string | null | undefined>('accessToken')
|
||||||
|
const isLoggedIn = !!(userData.value && accessToken.value)
|
||||||
|
|
||||||
if (userRole === 'admin')
|
if (!isLoggedIn)
|
||||||
|
return { name: 'login', query: to.query }
|
||||||
|
|
||||||
|
// Laravel API + Spatie: `roles` is string[] (e.g. super_admin, org_admin)
|
||||||
|
const roles = Array.isArray(userData.value?.roles)
|
||||||
|
? (userData.value!.roles as string[])
|
||||||
|
: []
|
||||||
|
const legacyRole = userData.value?.role as string | undefined
|
||||||
|
|
||||||
|
if (legacyRole === 'admin')
|
||||||
return { name: 'dashboards-crm' }
|
return { name: 'dashboards-crm' }
|
||||||
if (userRole === 'client')
|
if (legacyRole === 'client')
|
||||||
return { name: 'access-control' }
|
return { name: 'access-control' }
|
||||||
|
|
||||||
return { name: 'login', query: to.query }
|
const isOrgUser = roles.some(r =>
|
||||||
|
['super_admin', 'org_admin', 'org_member', 'org_readonly'].includes(r),
|
||||||
|
)
|
||||||
|
if (isOrgUser)
|
||||||
|
return { name: 'events' }
|
||||||
|
|
||||||
|
// Authenticated but unexpected role payload — avoid redirect loop back to login
|
||||||
|
return { name: 'events' }
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<link rel="icon" href="/favicon.ico" />
|
<link rel="icon" href="/favicon.ico" />
|
||||||
<meta name="robots" content="noindex, nofollow" />
|
<meta name="robots" content="noindex, nofollow" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Event Crew - Band Portal</title>
|
<title>Event Crew - App</title>
|
||||||
<link rel="stylesheet" type="text/css" href="/loader.css" />
|
<link rel="stylesheet" type="text/css" href="/loader.css" />
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user