refactor: align codebase with EventCrew domain and trim legacy band stack

- Update API: events, users, policies, routes, resources, migrations
- Remove deprecated models/resources (customers, setlists, invitations, etc.)
- Refresh admin app and docs; remove apps/band

Made-with: Cursor
This commit is contained in:
2026-03-29 23:19:06 +02:00
parent 34e12e00b3
commit 1cb7674d52
1034 changed files with 7453 additions and 8743 deletions

View File

@@ -1,223 +1,168 @@
---
description: Core workspace rules for Laravel + Vue/TypeScript full-stack application
description: Core workspace rules for EventCrew multi-tenant SaaS platform
globs: ["**/*"]
alwaysApply: true
---
# Workspace Rules
You are an expert full-stack developer working on a Laravel API backend with a Vue 3/TypeScript frontend using Vuexy admin template. This is an API-first architecture where the backend and frontend are completely separated.
You are an expert full-stack developer working on EventCrew, a multi-tenant SaaS platform for event and festival management. The backend is a Laravel 12 REST API (JSON only, no Blade), and three Vue 3 SPA frontends communicate via CORS + Sanctum tokens.
## Tech Stack
### Backend (Laravel)
- PHP 8.3+
- Laravel 12+
- Laravel Sanctum for SPA authentication (token-based)
- MySQL 8.0 database
- Redis for cache and queues
- Pest for testing
- PHP 8.2+
- Laravel 12
- Laravel Sanctum (SPA token auth)
- Spatie laravel-permission (three-level roles)
- Spatie laravel-activitylog (audit log)
- Spatie laravel-medialibrary (file management)
- MySQL 8 (primary), Redis (cache, queues, sessions)
- Laravel Horizon (queue monitoring)
- PHPUnit for testing
### Frontend (Vue)
- Vue 3 with TypeScript (strict mode)
- Vite as build tool
- Vuexy Admin Template
- TanStack Query (Vue Query) for server state
- Pinia for client state
- Vue Router for routing
- Axios for HTTP client
- TypeScript 5.9+
- Vue 3.5+ (Composition API, `<script setup>` only)
- Vite 7+
- Vuexy 9.5 + Vuetify 3.10
- Pinia 3 (client state)
- TanStack Query / Vue Query (server state)
- Axios (HTTP client)
- VeeValidate + Zod (form validation)
- VueDraggable (drag-and-drop)
- Vue I18n (internationalization)
## Project Structure
```
band-management/
├── api/ # Laravel 12 API
event-crew/
├── api/ # Laravel 12 REST API (JSON only)
│ ├── app/
│ │ ├── Actions/ # Single-purpose business logic
│ │ ├── Enums/ # PHP enums
│ │ ├── Http/
│ │ │ ├── Controllers/Api/V1/
│ │ │ ├── Middleware/
│ │ │ ├── Requests/ # Form Request validation
│ │ │ └── Resources/ # API Resources
│ │ ├── Models/ # Eloquent models
│ │ ├── Policies/ # Authorization
│ │ ├── Services/ # Complex business logic
│ │ ── Traits/ # Shared traits
│ │ │ ├── Middleware/ # OrganisationRoleMiddleware, EventRoleMiddleware, PortalTokenMiddleware
│ │ │ ├── Requests/Api/V1/ # Form Request validation
│ │ │ └── Resources/Api/V1/ # API Resources
│ │ ├── Models/ # Eloquent models with HasUlids
│ │ ├── Policies/ # Authorization (never hardcode roles)
│ │ ├── Services/ # Complex business logic
│ │ ── Events/ + Listeners/
│ │ └── Jobs/ # Queue jobs (briefings, PDF, notifications)
│ ├── database/
│ │ ├── factories/
│ │ ├── migrations/
│ │ ├── factories/
│ │ └── seeders/
── routes/
│ │ └── api.php # API routes
│ └── tests/
│ ├── Feature/Api/
│ └── Unit/
── tests/Feature/Api/V1/
├── apps/
│ ├── admin/ # Admin Dashboard (Vuexy full)
│ │ ── src/
│ │ ├── @core/ # Vuexy core (don't modify)
│ │ ├── @layouts/ # Vuexy layouts (don't modify)
│ │ ├── components/ # Custom components
│ │ ├── composables/ # Vue composables
│ │ ├── layouts/ # App layouts
│ │ ├── lib/ # Utilities (api-client, etc.)
│ │ ├── navigation/ # Menu configuration
│ │ ├── pages/ # Page components
│ │ ── plugins/ # Vue plugins
│ │ │ ├── router/ # Vue Router
│ │ │ ├── stores/ # Pinia stores
│ │ │ └── types/ # TypeScript types
│ │ └── ...
│ ├── admin/ # Super Admin SPA (Vuexy full)
│ │ ── src/
│ │ ├── @core/ # Vuexy core (NEVER modify)
│ │ ├── @layouts/ # Vuexy layouts (NEVER modify)
│ │ ├── components/
│ │ ├── composables/ # useModule.ts composables
│ │ ├── lib/ # axios.ts (single instance)
│ │ ├── pages/
│ │ ├── plugins/ # vue-query, casl, vuetify
│ │ ├── stores/ # Pinia stores
│ │ ── types/ # TypeScript interfaces
│ │
│ ├── band/ # Band Portal (Vuexy starter)
│ └── customers/ # Customer Portal (Vuexy starter)
│ ├── app/ # Organizer SPA (Vuexy full) - MAIN APP
│ └── src/ # Same structure as admin/
│ │
│ └── portal/ # External Portal SPA (Vuexy stripped)
│ └── src/ # No sidebar, no customizer, top-bar only
├── docker/ # Docker configurations
── docs/ # Documentation
└── .cursor/ # Cursor AI configuration
├── resources/design/ # Design documents (source of truth)
── .cursor/ # Cursor AI configuration
```
## Multi-Tenancy Rules (CRITICAL)
1. **EVERY query on event-data MUST scope on `organisation_id`** via `OrganisationScope` Eloquent Global Scope.
2. **Never use direct id-checks in controllers** - always use Policies.
3. **Never use `Model::all()` without a where-clause** - always scope.
4. **Never hardcode role strings** like `$user->role === 'admin'` - use `$user->hasRole()` and Policies.
## Naming Conventions
### PHP (Laravel)
| Type | Convention | Example |
|------|------------|---------|
| Models | Singular PascalCase | `Event`, `MusicNumber` |
| Controllers | PascalCase + Controller | `EventController` |
| Form Requests | Action + Resource + Request | `StoreEventRequest` |
| Resources | Resource + Resource | `EventResource` |
| Actions | Verb + Resource + Action | `CreateEventAction` |
| Models | Singular PascalCase | `Event`, `FestivalSection`, `ShiftAssignment` |
| Controllers | PascalCase + Controller | `EventController`, `ShiftController` |
| Form Requests | Action + Resource + Request | `StoreEventRequest`, `UpdateShiftRequest` |
| Resources | Resource + Resource | `EventResource`, `PersonResource` |
| Services | PascalCase + Service | `ZenderService`, `BriefingService` |
| Migrations | snake_case with timestamp | `create_events_table` |
| Tables | Plural snake_case | `events`, `music_numbers` |
| Columns | snake_case | `event_date`, `created_at` |
| Enums | Singular PascalCase | `EventStatus` |
| Tables | Plural snake_case | `events`, `festival_sections`, `shift_assignments` |
| Columns | snake_case | `organisation_id`, `slots_total`, `created_at` |
| Enums | Singular PascalCase | `EventStatus`, `BookingStatus` |
### TypeScript (Vue)
| Type | Convention | Example |
|------|------------|---------|
| Components | PascalCase | `EventCard.vue` |
| Pages | PascalCase + Page | `EventsPage.vue` |
| Composables | camelCase with "use" | `useEvents.ts` |
| Stores | camelCase | `authStore.ts` |
| Types/Interfaces | PascalCase | `Event`, `ApiResponse` |
| Files | kebab-case or camelCase | `api-client.ts` |
| Components | PascalCase | `ShiftAssignPanel.vue`, `PersonCard.vue` |
| Composables | use-prefix camelCase | `useShifts.ts`, `usePersons.ts` |
| Pinia Stores | use-prefix + Store suffix | `useEventStore.ts`, `useAuthStore.ts` |
| Types/Interfaces | PascalCase | `Event`, `Person`, `ShiftAssignment` |
| Variables | camelCase | `slotsFilled`, `fillRate` |
## Code Style
### General Principles
1. **Explicit over implicit** - Be clear about types, returns, and intentions
2. **Small, focused units** - Each file/function does one thing well
3. **Consistent formatting** - Use automated formatters
4. **Descriptive names** - Names should explain purpose
5. **No magic** - Avoid hidden behavior
### PHP
- Use `declare(strict_types=1);` in all files
- Use `final` for classes that shouldn't be extended
- Use readonly properties where applicable
- Prefer named arguments for clarity
- Use enums instead of string constants
### TypeScript
- Enable strict mode in tsconfig
- No `any` types - use `unknown` if truly unknown
- Use interface for objects, type for unions/primitives
- Prefer `const` over `let`
- Use optional chaining and nullish coalescing
### Database
- Primary keys: ULID via `HasUlids` trait (NOT UUID v4, NOT auto-increment for business tables)
- Pure pivot tables: auto-increment integer PK for join performance
- DB columns: `snake_case`
## Environment Configuration
### Development URLs
| Service | URL |
|---------|-----|
| API | http://localhost:8000/api/v1 |
| Admin SPA | http://localhost:5173 |
| Band SPA | http://localhost:5174 |
| Customer SPA | http://localhost:5175 |
| MySQL | localhost:3306 |
| Redis | localhost:6379 |
| Mailpit | http://localhost:8025 |
| Service | URL | Env Variable |
|---------|-----|--------------|
| API | `http://localhost:8000/api/v1` | - |
| Admin SPA | `http://localhost:5173` | `FRONTEND_ADMIN_URL` |
| Organizer SPA | `http://localhost:5174` | `FRONTEND_APP_URL` |
| Portal SPA | `http://localhost:5175` | `FRONTEND_PORTAL_URL` |
| MySQL | `localhost:3306` | - |
| Redis | `localhost:6379` | - |
| Mailpit | `http://localhost:8025` | - |
### Database Credentials (Development)
```
Host: 127.0.0.1
Port: 3306
Database: band_management
Username: band_management
Password: secret
```
### Production URLs
| Service | URL |
|---------|-----|
| API | https://api.bandmanagement.nl |
| Admin | https://admin.bandmanagement.nl |
| Band | https://band.bandmanagement.nl |
| Customers | https://customers.bandmanagement.nl |
### CORS
Three frontend origins configured in `config/cors.php` via env variables. Each Vite dev server gets its own port for CORS isolation.
## Git Conventions
### Branch Names
- `feature/event-management`
- `fix/rsvp-validation`
- `refactor/auth-system`
- `feature/shift-planning`
- `fix/organisation-scoping`
- `refactor/accreditation-engine`
### Commit Messages
```
feat: add event RSVP functionality
fix: correct date validation in events
refactor: extract event creation to action class
docs: update API documentation
test: add event controller tests
feat: add shift claiming with approval flow
fix: enforce organisation scope on persons query
refactor: extract briefing logic to BriefingService
test: add accreditation assignment tests
```
## Dependencies
## Forbidden Patterns
### PHP (api/composer.json)
- PHP 8.3+
- Laravel 12
- Laravel Sanctum
- Laravel Pint (formatting)
- Pest PHP (testing)
### Node (apps/*/package.json)
- Vue 3.4+
- TypeScript 5.3+
- Vite 5+
- Pinia
- @tanstack/vue-query
- axios
- NEVER: `$user->role === 'admin'` (use Policies + Spatie roles)
- NEVER: `Model::all()` without where-clause (always scope)
- NEVER: `dd()` or `var_dump()` left in code
- NEVER: Hardcode `.env` values in code
- NEVER: JSON columns for queryable/filterable data
- NEVER: UUID v4 as primary key (use HasUlids for ULID)
- NEVER: Blade views or Inertia (API-only backend)
- NEVER: Business logic in controllers without Policy authorization
## Code Style Principles
1. **Readability over cleverness** - Write code that is easy to understand
1. **Explicit over implicit** - Be clear about types, returns, and intentions
2. **Single Responsibility** - Each class/function does one thing well
3. **Type Safety** - Leverage TypeScript and PHP type hints everywhere
4. **Testability** - Write code that is easy to test
5. **API Consistency** - Follow RESTful conventions
## Response Format
When generating code:
1. Always include proper type hints/annotations
2. Add brief comments for complex logic only
3. Follow the established patterns in the codebase
4. Consider error handling and edge cases
5. Suggest tests for new functionality
## Communication Style
- Be concise and direct
- Provide working code examples
- Explain architectural decisions briefly
- Ask clarifying questions only when truly ambiguous
3. **Type Safety** - PHP type hints and TypeScript strict mode everywhere
4. **Multi-tenant first** - Every feature must respect organisation boundaries
5. **Mobile-first** - Responsive design, minimum 375px width

View File

@@ -1,5 +1,5 @@
---
description: Laravel API development guidelines
description: Laravel API development guidelines for EventCrew multi-tenant platform
globs: ["api/**/*.php"]
alwaysApply: true
---
@@ -8,52 +8,25 @@ alwaysApply: true
## PHP Conventions
- Use PHP 8.3+ features: constructor property promotion, readonly properties, match expressions
- Use `match` operator over `switch` wherever possible
- Import all classes with `use` statements; avoid fully-qualified class names inline
- Use named arguments for functions with 3+ parameters
- Use PHP 8.2+ features: constructor property promotion, readonly properties, match expressions, enums
- Use `declare(strict_types=1);` in all files
- Use `match` over `switch` wherever possible
- Import all classes with `use` statements
- Prefer early returns over nested conditionals
```php
// ✅ Good - constructor property promotion
public function __construct(
private readonly UserRepository $users,
private readonly Mailer $mailer,
) {}
// ✅ Good - early return
public function handle(Request $request): Response
{
if (!$request->user()) {
return response()->json(['error' => 'Unauthorized'], 401);
}
// Main logic here
}
// ❌ Avoid - nested conditionals
public function handle(Request $request): Response
{
if ($request->user()) {
// Nested logic
} else {
return response()->json(['error' => 'Unauthorized'], 401);
}
}
```
## Core Principles
1. **API-only** - No Blade views, no web routes
2. **Thin controllers** - Business logic in Actions
3. **Consistent responses** - Use API Resources and response trait
4. **Validate everything** - Use Form Requests
5. **Authorize properly** - Use Policies
6. **Test thoroughly** - Feature tests for all endpoints
1. **API-only** - No Blade views, no web routes. Every response is JSON.
2. **Multi-tenant** - Every query scoped on `organisation_id` via Global Scope.
3. **Resource Controllers** - Use index/show/store/update/destroy.
4. **Validate via Form Requests** - Never inline `validate()`.
5. **Authorize via Policies** - Never hardcode role strings in controllers.
6. **Respond via API Resources** - Never return model attributes directly.
7. **ULID primary keys** - Via HasUlids trait on all business models.
## File Templates
### Model
### Model (with OrganisationScope)
```php
<?php
@@ -68,83 +41,78 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Builder;
final class Event extends Model
class Event extends Model
{
use HasFactory;
use HasUlids;
use SoftDeletes;
protected $fillable = [
'title',
'description',
'location_id',
'customer_id',
'setlist_id',
'event_date',
'start_time',
'end_time',
'fee',
'currency',
'organisation_id',
'name',
'slug',
'start_date',
'end_date',
'timezone',
'status',
'visibility',
'rsvp_deadline',
'notes',
'internal_notes',
'created_by',
];
protected $casts = [
'event_date' => 'date',
'start_time' => 'datetime:H:i',
'end_time' => 'datetime:H:i',
'fee' => 'decimal:2',
'start_date' => 'date',
'end_date' => 'date',
'status' => EventStatus::class,
'rsvp_deadline' => 'datetime',
];
// Global Scope: always scope on organisation
protected static function booted(): void
{
static::addGlobalScope('organisation', function (Builder $builder) {
if ($organisationId = auth()->user()?->current_organisation_id) {
$builder->where('organisation_id', $organisationId);
}
});
}
// Relationships
public function location(): BelongsTo
public function organisation(): BelongsTo
{
return $this->belongsTo(Location::class);
return $this->belongsTo(Organisation::class);
}
public function customer(): BelongsTo
public function festivalSections(): HasMany
{
return $this->belongsTo(Customer::class);
return $this->hasMany(FestivalSection::class);
}
public function setlist(): BelongsTo
public function timeSlots(): HasMany
{
return $this->belongsTo(Setlist::class);
return $this->hasMany(TimeSlot::class);
}
public function creator(): BelongsTo
public function persons(): HasMany
{
return $this->belongsTo(User::class, 'created_by');
return $this->hasMany(Person::class);
}
public function invitations(): HasMany
public function artists(): HasMany
{
return $this->hasMany(EventInvitation::class);
return $this->hasMany(Artist::class);
}
// Scopes
public function scopeUpcoming($query)
public function scopeWithStatus(Builder $query, EventStatus $status): Builder
{
return $query->where('event_date', '>=', now()->toDateString())
->orderBy('event_date');
}
public function scopeConfirmed($query)
{
return $query->where('status', EventStatus::Confirmed);
return $query->where('status', $status);
}
}
```
### Enum
### Enum (EventStatus)
```php
<?php
@@ -156,30 +124,36 @@ namespace App\Enums;
enum EventStatus: string
{
case Draft = 'draft';
case Pending = 'pending';
case Confirmed = 'confirmed';
case Completed = 'completed';
case Cancelled = 'cancelled';
case Published = 'published';
case RegistrationOpen = 'registration_open';
case BuildUp = 'buildup';
case ShowDay = 'showday';
case TearDown = 'teardown';
case Closed = 'closed';
public function label(): string
{
return match ($this) {
self::Draft => 'Draft',
self::Pending => 'Pending Confirmation',
self::Confirmed => 'Confirmed',
self::Completed => 'Completed',
self::Cancelled => 'Cancelled',
self::Published => 'Published',
self::RegistrationOpen => 'Registration Open',
self::BuildUp => 'Build-Up',
self::ShowDay => 'Show Day',
self::TearDown => 'Tear-Down',
self::Closed => 'Closed',
};
}
public function color(): string
{
return match ($this) {
self::Draft => 'gray',
self::Pending => 'yellow',
self::Confirmed => 'green',
self::Completed => 'blue',
self::Cancelled => 'red',
self::Draft => 'secondary',
self::Published => 'info',
self::RegistrationOpen => 'primary',
self::BuildUp => 'warning',
self::ShowDay => 'success',
self::TearDown => 'warning',
self::Closed => 'secondary',
};
}
}
@@ -202,28 +176,17 @@ return new class extends Migration
{
Schema::create('events', function (Blueprint $table) {
$table->ulid('id')->primary();
$table->string('title');
$table->text('description')->nullable();
$table->foreignUlid('location_id')->nullable()->constrained()->nullOnDelete();
$table->foreignUlid('customer_id')->nullable()->constrained()->nullOnDelete();
$table->foreignUlid('setlist_id')->nullable()->constrained()->nullOnDelete();
$table->date('event_date');
$table->time('start_time');
$table->time('end_time')->nullable();
$table->time('load_in_time')->nullable();
$table->time('soundcheck_time')->nullable();
$table->decimal('fee', 10, 2)->nullable();
$table->string('currency', 3)->default('EUR');
$table->enum('status', ['draft', 'pending', 'confirmed', 'completed', 'cancelled'])->default('draft');
$table->enum('visibility', ['private', 'members', 'public'])->default('members');
$table->dateTime('rsvp_deadline')->nullable();
$table->text('notes')->nullable();
$table->text('internal_notes')->nullable();
$table->boolean('is_public_setlist')->default(false);
$table->foreignUlid('created_by')->constrained('users');
$table->foreignUlid('organisation_id')->constrained()->cascadeOnDelete();
$table->string('name');
$table->string('slug');
$table->date('start_date');
$table->date('end_date');
$table->string('timezone')->default('Europe/Amsterdam');
$table->string('status')->default('draft');
$table->timestamps();
$table->softDeletes();
$table->index(['event_date', 'status']);
$table->index(['organisation_id', 'status']);
});
}
@@ -234,7 +197,7 @@ return new class extends Migration
};
```
### Controller
### Controller (Resource Controller with Policy)
```php
<?php
@@ -243,60 +206,59 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Actions\Events\CreateEventAction;
use App\Actions\Events\UpdateEventAction;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\V1\StoreEventRequest;
use App\Http\Requests\Api\V1\UpdateEventRequest;
use App\Http\Resources\Api\V1\EventCollection;
use App\Http\Resources\Api\V1\EventResource;
use App\Models\Event;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
final class EventController extends Controller
class EventController extends Controller
{
public function index(): EventCollection
public function __construct()
{
$events = Event::query()
->with(['location', 'customer'])
->latest('event_date')
->paginate();
return new EventCollection($events);
$this->authorizeResource(Event::class, 'event');
}
public function store(StoreEventRequest $request, CreateEventAction $action): JsonResponse
public function index(): AnonymousResourceCollection
{
$event = $action->execute($request->validated());
$events = Event::query()
->with(['organisation', 'festivalSections'])
->latest('start_date')
->paginate();
return $this->created(
new EventResource($event->load(['location', 'customer'])),
'Event created successfully'
);
return EventResource::collection($events);
}
public function store(StoreEventRequest $request): JsonResponse
{
$event = Event::create($request->validated());
return (new EventResource($event))
->response()
->setStatusCode(201);
}
public function show(Event $event): EventResource
{
return new EventResource(
$event->load(['location', 'customer', 'setlist', 'invitations.user'])
$event->load(['organisation', 'festivalSections', 'timeSlots', 'persons'])
);
}
public function update(UpdateEventRequest $request, Event $event, UpdateEventAction $action): JsonResponse
public function update(UpdateEventRequest $request, Event $event): EventResource
{
$event = $action->execute($event, $request->validated());
$event->update($request->validated());
return $this->success(
new EventResource($event),
'Event updated successfully'
);
return new EventResource($event);
}
public function destroy(Event $event): JsonResponse
{
$event->delete();
return $this->success(null, 'Event deleted successfully');
return response()->json(null, 204);
}
}
```
@@ -311,45 +273,26 @@ declare(strict_types=1);
namespace App\Http\Requests\Api\V1;
use App\Enums\EventStatus;
use App\Enums\EventVisibility;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
final class StoreEventRequest extends FormRequest
class StoreEventRequest extends FormRequest
{
public function authorize(): bool
{
return true; // Or use policy
return true; // Handled by Policy via authorizeResource
}
public function rules(): array
{
return [
'title' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string', 'max:5000'],
'location_id' => ['nullable', 'ulid', 'exists:locations,id'],
'customer_id' => ['nullable', 'ulid', 'exists:customers,id'],
'setlist_id' => ['nullable', 'ulid', 'exists:setlists,id'],
'event_date' => ['required', 'date', 'after_or_equal:today'],
'start_time' => ['required', 'date_format:H:i'],
'end_time' => ['nullable', 'date_format:H:i', 'after:start_time'],
'load_in_time' => ['nullable', 'date_format:H:i'],
'soundcheck_time' => ['nullable', 'date_format:H:i'],
'fee' => ['nullable', 'numeric', 'min:0', 'max:999999.99'],
'currency' => ['sometimes', 'string', 'size:3'],
'organisation_id' => ['required', 'ulid', 'exists:organisations,id'],
'name' => ['required', 'string', 'max:255'],
'slug' => ['required', 'string', 'max:255', Rule::unique('events')->where('organisation_id', $this->organisation_id)],
'start_date' => ['required', 'date'],
'end_date' => ['required', 'date', 'after_or_equal:start_date'],
'timezone' => ['sometimes', 'string', 'timezone'],
'status' => ['sometimes', Rule::enum(EventStatus::class)],
'visibility' => ['sometimes', Rule::enum(EventVisibility::class)],
'rsvp_deadline' => ['nullable', 'date', 'before:event_date'],
'notes' => ['nullable', 'string', 'max:5000'],
'internal_notes' => ['nullable', 'string', 'max:5000'],
];
}
public function messages(): array
{
return [
'event_date.after_or_equal' => 'The event date must be today or a future date.',
'end_time.after' => 'The end time must be after the start time.',
];
}
}
@@ -367,35 +310,30 @@ namespace App\Http\Resources\Api\V1;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
final class EventResource extends JsonResource
class EventResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'description' => $this->description,
'event_date' => $this->event_date->toDateString(),
'start_time' => $this->start_time?->format('H:i'),
'end_time' => $this->end_time?->format('H:i'),
'load_in_time' => $this->load_in_time?->format('H:i'),
'soundcheck_time' => $this->soundcheck_time?->format('H:i'),
'fee' => $this->fee,
'currency' => $this->currency,
'organisation_id' => $this->organisation_id,
'name' => $this->name,
'slug' => $this->slug,
'start_date' => $this->start_date->toDateString(),
'end_date' => $this->end_date->toDateString(),
'timezone' => $this->timezone,
'status' => $this->status->value,
'status_label' => $this->status->label(),
'visibility' => $this->visibility,
'rsvp_deadline' => $this->rsvp_deadline?->toIso8601String(),
'notes' => $this->notes,
'internal_notes' => $this->when(
$request->user()?->isAdmin(),
$this->internal_notes
'status_color' => $this->status->color(),
'festival_sections' => FestivalSectionResource::collection(
$this->whenLoaded('festivalSections')
),
'location' => new LocationResource($this->whenLoaded('location')),
'customer' => new CustomerResource($this->whenLoaded('customer')),
'setlist' => new SetlistResource($this->whenLoaded('setlist')),
'invitations' => EventInvitationResource::collection(
$this->whenLoaded('invitations')
'time_slots' => TimeSlotResource::collection(
$this->whenLoaded('timeSlots')
),
'persons_count' => $this->when(
$this->persons_count !== null,
$this->persons_count
),
'created_at' => $this->created_at->toIso8601String(),
'updated_at' => $this->updated_at->toIso8601String(),
@@ -404,147 +342,7 @@ final class EventResource extends JsonResource
}
```
### Resource Collection
```php
<?php
declare(strict_types=1);
namespace App\Http\Resources\Api\V1;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\ResourceCollection;
final class EventCollection extends ResourceCollection
{
public $collects = EventResource::class;
public function toArray(Request $request): array
{
return [
'data' => $this->collection,
];
}
public function with(Request $request): array
{
return [
'success' => true,
'meta' => [
'pagination' => [
'current_page' => $this->currentPage(),
'per_page' => $this->perPage(),
'total' => $this->total(),
'last_page' => $this->lastPage(),
'from' => $this->firstItem(),
'to' => $this->lastItem(),
],
],
];
}
}
```
### Action Class
```php
<?php
declare(strict_types=1);
namespace App\Actions\Events;
use App\Models\Event;
use Illuminate\Support\Facades\Auth;
final class CreateEventAction
{
public function execute(array $data): Event
{
$data['created_by'] = Auth::id();
return Event::create($data);
}
}
```
### API Response Trait
```php
<?php
declare(strict_types=1);
namespace App\Traits;
use Illuminate\Http\JsonResponse;
trait ApiResponse
{
protected function success(mixed $data = null, string $message = 'Success', int $code = 200): JsonResponse
{
return response()->json([
'success' => true,
'data' => $data,
'message' => $message,
], $code);
}
protected function created(mixed $data = null, string $message = 'Created'): JsonResponse
{
return $this->success($data, $message, 201);
}
protected function error(string $message, int $code = 400, array $errors = []): JsonResponse
{
$response = [
'success' => false,
'message' => $message,
];
if (!empty($errors)) {
$response['errors'] = $errors;
}
return response()->json($response, $code);
}
protected function notFound(string $message = 'Resource not found'): JsonResponse
{
return $this->error($message, 404);
}
protected function unauthorized(string $message = 'Unauthorized'): JsonResponse
{
return $this->error($message, 401);
}
protected function forbidden(string $message = 'Forbidden'): JsonResponse
{
return $this->error($message, 403);
}
}
```
### Base Controller
```php
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Traits\ApiResponse;
abstract class Controller
{
use ApiResponse;
}
```
### Policy
### Policy (with Spatie Roles)
```php
<?php
@@ -556,31 +354,33 @@ namespace App\Policies;
use App\Models\Event;
use App\Models\User;
final class EventPolicy
class EventPolicy
{
public function viewAny(User $user): bool
{
return true;
return $user->hasAnyRole(['super_admin', 'org_admin', 'org_member', 'org_readonly']);
}
public function view(User $user, Event $event): bool
{
return true;
return $user->belongsToOrganisation($event->organisation_id);
}
public function create(User $user): bool
{
return $user->isAdmin() || $user->isBookingAgent();
return $user->hasAnyRole(['super_admin', 'org_admin']);
}
public function update(User $user, Event $event): bool
{
return $user->isAdmin() || $user->isBookingAgent();
return $user->hasAnyRole(['super_admin', 'org_admin'])
&& $user->belongsToOrganisation($event->organisation_id);
}
public function delete(User $user, Event $event): bool
{
return $user->isAdmin();
return $user->hasAnyRole(['super_admin', 'org_admin'])
&& $user->belongsToOrganisation($event->organisation_id);
}
}
```
@@ -592,195 +392,90 @@ final class EventPolicy
declare(strict_types=1);
use App\Http\Controllers\Api\V1\AuthController;
use App\Http\Controllers\Api\V1\EventController;
use App\Http\Controllers\Api\V1\LocationController;
use App\Http\Controllers\Api\V1\MemberController;
use App\Http\Controllers\Api\V1\MusicController;
use App\Http\Controllers\Api\V1\SetlistController;
use App\Http\Controllers\Api\V1\CustomerController;
use App\Http\Controllers\Api\V1;
use Illuminate\Support\Facades\Route;
Route::prefix('v1')->group(function () {
// Public routes
Route::post('auth/login', [AuthController::class, 'login']);
Route::post('auth/register', [AuthController::class, 'register']);
Route::post('auth/forgot-password', [AuthController::class, 'forgotPassword']);
Route::post('auth/reset-password', [AuthController::class, 'resetPassword']);
Route::post('auth/login', [V1\AuthController::class, 'login']);
Route::post('portal/token-auth', [V1\PortalAuthController::class, 'tokenAuth']);
// Protected routes
// Protected routes (login-based)
Route::middleware('auth:sanctum')->group(function () {
// Auth
Route::get('auth/user', [AuthController::class, 'user']);
Route::post('auth/logout', [AuthController::class, 'logout']);
Route::post('auth/logout', [V1\AuthController::class, 'logout']);
Route::get('auth/me', [V1\AuthController::class, 'me']);
// Resources
Route::apiResource('events', EventController::class);
Route::post('events/{event}/invite', [EventController::class, 'invite']);
Route::post('events/{event}/rsvp', [EventController::class, 'rsvp']);
// Organisations
Route::apiResource('organisations', V1\OrganisationController::class);
Route::post('organisations/{organisation}/invite', [V1\OrganisationController::class, 'invite']);
Route::apiResource('members', MemberController::class);
Route::apiResource('music', MusicController::class);
Route::apiResource('setlists', SetlistController::class);
Route::apiResource('locations', LocationController::class);
Route::apiResource('customers', CustomerController::class);
// Events (nested under organisations)
Route::apiResource('organisations.events', V1\EventController::class)->shallow();
// Festival Sections (nested under events)
Route::apiResource('events.festival-sections', V1\FestivalSectionController::class)->shallow();
// Time Slots
Route::apiResource('events.time-slots', V1\TimeSlotController::class)->shallow();
// Shifts (nested under sections)
Route::apiResource('festival-sections.shifts', V1\ShiftController::class)->shallow();
Route::post('shifts/{shift}/assign', [V1\ShiftController::class, 'assign']);
Route::post('shifts/{shift}/claim', [V1\ShiftController::class, 'claim']);
// Persons
Route::apiResource('events.persons', V1\PersonController::class)->shallow();
Route::post('persons/{person}/approve', [V1\PersonController::class, 'approve']);
Route::post('persons/{person}/checkin', [V1\PersonController::class, 'checkin']);
// Artists
Route::apiResource('events.artists', V1\ArtistController::class)->shallow();
// Accreditation
Route::apiResource('events.accreditation-items', V1\AccreditationItemController::class)->shallow();
Route::apiResource('events.access-zones', V1\AccessZoneController::class)->shallow();
// Briefings
Route::apiResource('events.briefings', V1\BriefingController::class)->shallow();
Route::post('briefings/{briefing}/send', [V1\BriefingController::class, 'send']);
});
// Token-based portal routes
Route::middleware('portal.token')->prefix('portal')->group(function () {
Route::get('artist', [V1\PortalArtistController::class, 'show']);
Route::post('advancing', [V1\PortalArtistController::class, 'submitAdvance']);
Route::get('supplier', [V1\PortalSupplierController::class, 'show']);
Route::post('production-request', [V1\PortalSupplierController::class, 'submit']);
});
});
```
## Soft Delete Strategy
**Soft delete ON**: Organisation, Event, FestivalSection, Shift, ShiftAssignment, Person, Artist, Company, ProductionRequest.
**Soft delete OFF** (immutable audit records): CheckIn, BriefingSend, MessageReply, ShiftWaitlist, VolunteerFestivalHistory.
## Best Practices
### Always Use
- `declare(strict_types=1)` at the top of every file
- `final` keyword for Action classes, Form Requests, Resources
- `declare(strict_types=1)` at top of every file
- HasUlids trait for ULID primary keys on business models
- OrganisationScope for multi-tenant data isolation
- Type hints for all parameters and return types
- Named arguments for better readability
- Enums for status fields and fixed options
- ULIDs for all primary keys
- Eager loading to prevent N+1 queries
- API Resources for all responses
- API Resources for all responses (never raw models)
- Spatie roles and Policies for authorization
- Composite indexes as documented in design document
### Avoid
- Business logic in controllers
- String constants (use enums)
- Auto-increment IDs
- Direct model creation in controllers
- Business logic in controllers (use Services for complex logic)
- String constants for statuses (use enums)
- Auto-increment IDs for business tables (use ULIDs)
- Returning raw models (use Resources)
- Hardcoded strings for error messages
## DTOs (Data Transfer Objects)
Use DTOs for complex data passing between layers:
```php
<?php
declare(strict_types=1);
namespace App\DTOs;
readonly class CreateEventDTO
{
public function __construct(
public string $title,
public string $eventDate,
public string $startTime,
public ?string $description = null,
public ?string $locationId = null,
public ?string $customerId = null,
public ?string $endTime = null,
public ?float $fee = null,
) {}
public static function from(array $data): self
{
return new self(
title: $data['title'],
eventDate: $data['event_date'],
startTime: $data['start_time'],
description: $data['description'] ?? null,
locationId: $data['location_id'] ?? null,
customerId: $data['customer_id'] ?? null,
endTime: $data['end_time'] ?? null,
fee: $data['fee'] ?? null,
);
}
}
// Usage
$dto = CreateEventDTO::from($request->validated());
$event = $action->execute($dto);
```
## Helpers
Use Laravel helpers instead of facades:
```php
// ✅ Good
auth()->id()
auth()->user()
now()
str($string)->slug()
collect($array)->filter()
cache()->remember('key', 3600, fn() => $value)
// ❌ Avoid
Auth::id()
Carbon::now()
Str::slug($string)
Cache::remember(...)
```
## Error Handling
Create domain-specific exceptions:
```php
<?php
declare(strict_types=1);
namespace App\Exceptions;
use Exception;
use Illuminate\Http\JsonResponse;
class EventNotFoundException extends Exception
{
public function render(): JsonResponse
{
return response()->json([
'success' => false,
'message' => 'Event not found',
], 404);
}
}
class EventAlreadyConfirmedException extends Exception
{
public function render(): JsonResponse
{
return response()->json([
'success' => false,
'message' => 'Event has already been confirmed and cannot be modified',
], 422);
}
}
// Usage in Action
if ($event->isConfirmed()) {
throw new EventAlreadyConfirmedException();
}
```
## Query Scopes
Add reusable query scopes to models:
```php
// In Event model
public function scopeUpcoming(Builder $query): Builder
{
return $query->where('event_date', '>=', now()->toDateString())
->orderBy('event_date');
}
public function scopeForUser(Builder $query, User $user): Builder
{
return $query->whereHas('invitations', fn ($q) =>
$q->where('user_id', $user->id)
);
}
public function scopeConfirmed(Builder $query): Builder
{
return $query->where('status', EventStatus::Confirmed);
}
// Usage
Event::upcoming()->confirmed()->get();
Event::forUser($user)->upcoming()->get();
```
- Hardcoded role checks in controllers (use Policies)
- JSON columns for data that needs to be filtered/sorted
- `Model::all()` without organisation scoping

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,221 @@
---
description: Multi-tenancy and portal architecture rules for EventCrew
globs: ["api/**/*.php", "apps/portal/**/*.{vue,ts}"]
alwaysApply: true
---
# Multi-Tenancy & Portal Rules
## Organisation Scoping (CRITICAL)
Every query on event-related data MUST be scoped to the current organisation. This is enforced via Eloquent Global Scopes, NOT manual where-clauses in controllers.
### OrganisationScope Implementation
```php
// Applied in model's booted() method
protected static function booted(): void
{
static::addGlobalScope('organisation', function (Builder $builder) {
if ($organisationId = auth()->user()?->current_organisation_id) {
$builder->where('organisation_id', $organisationId);
}
});
}
```
### Models That Need OrganisationScope
- Event (direct `organisation_id`)
- CrowdType (direct `organisation_id`)
- AccreditationCategory (direct `organisation_id`)
- Company (direct `organisation_id`)
### Models Scoped via Parent
These don't have `organisation_id` directly but inherit scope through their parent:
- FestivalSection → via Event
- TimeSlot → via Event
- Shift → via FestivalSection → Event
- Person → via Event
- Artist → via Event
- All other event-child models
### Rules
1. NEVER use `Model::all()` without a scope
2. NEVER pass organisation_id in URL params for filtering — always derive from authenticated user
3. ALWAYS use Policies to verify the user belongs to the organisation
4. ALWAYS test that users from Organisation A cannot see Organisation B's data
## Three-Level Authorization
### Level 1: App Level (Spatie Roles)
```php
// super_admin, support_agent
$user->hasRole('super_admin');
```
### Level 2: Organisation Level (Spatie Team Permissions)
```php
// org_admin, org_member, org_readonly
// Organisation acts as Spatie "team"
$user->hasRole('org_admin'); // within current organisation context
```
### Level 3: Event Level (Custom Pivot)
```php
// event_manager, artist_manager, staff_coordinator, volunteer_coordinator, accreditation_officer
// Stored in event_user_roles pivot table
$user->eventRoles()->where('event_id', $event->id)->pluck('role');
```
### Middleware Stack
```php
// routes/api.php
Route::middleware(['auth:sanctum', 'organisation.role:org_admin,org_member'])->group(...);
Route::middleware(['auth:sanctum', 'event.role:event_manager'])->group(...);
```
## User Invitation Flow
### Internal Staff (login-based)
1. Organiser enters email + selects role
2. System checks if account exists
3. If no: invitation email with activation link (24h valid), account created on activation
4. If yes: invitation email, existing user linked to new org/event role on acceptance
5. User can switch between organisations via org-switcher
### Volunteer Registration
1. Volunteer fills public registration form (multi-step)
2. Person record created with `status = 'pending'`
3. Organiser approves/rejects → `status = 'approved'`
4. On approval: check if email has platform account → link or create
5. Volunteer logs in to portal
## Portal Architecture
### Two Access Modes in One App (`apps/portal/`)
| Mode | Middleware | Users | Token Source |
|------|-----------|-------|-------------|
| Login | `auth:sanctum` | Volunteers, Crew | Bearer token from login |
| Token | `portal.token` | Artists, Suppliers, Press | URL token param: `?token=ULID` |
### Token-Based Authentication Flow
```
1. Artist/supplier receives email with link: https://portal.eventcrew.app/advance?token=01HQ3K...
2. Portal detects token in URL query parameter
3. POST /api/v1/portal/token-auth { token: '01HQ3K...' }
4. Backend validates token against artists.portal_token or production_requests.token
5. Returns person context (name, event, crowd_type, permissions)
6. Portal stores context in Pinia, shows relevant portal view
```
### Login-Based Authentication Flow
```
1. Volunteer navigates to https://portal.eventcrew.app/login
2. Enters email + password
3. POST /api/v1/auth/login (same endpoint as apps/app/)
4. Returns user + organisations + event roles
5. Portal shows volunteer-specific views (My Shifts, Claim Shifts, Messages, Profile)
```
### Backend Route Structure
```php
// Public
Route::post('auth/login', ...);
Route::post('portal/token-auth', ...);
Route::post('portal/form-submit', ...); // Public form submission
// Login-based portal (auth:sanctum)
Route::middleware('auth:sanctum')->prefix('portal')->group(function () {
Route::get('my-shifts', ...);
Route::post('shifts/{shift}/claim', ...);
Route::get('messages', ...);
Route::get('profile', ...);
});
// Token-based portal (portal.token)
Route::middleware('portal.token')->prefix('portal')->group(function () {
Route::get('artist', ...);
Route::post('advancing', ...);
Route::get('supplier', ...);
Route::post('production-request', ...);
});
```
### PortalTokenMiddleware
```php
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use App\Models\Artist;
use App\Models\ProductionRequest;
use Closure;
use Illuminate\Http\Request;
class PortalTokenMiddleware
{
public function handle(Request $request, Closure $next)
{
$token = $request->bearerToken() ?? $request->query('token');
if (!$token) {
return response()->json(['message' => 'Token required'], 401);
}
// Try artist token
$artist = Artist::where('portal_token', $token)->first();
if ($artist) {
$request->merge(['portal_context' => 'artist', 'portal_entity' => $artist]);
return $next($request);
}
// Try production request token
$productionRequest = ProductionRequest::where('token', $token)->first();
if ($productionRequest) {
$request->merge(['portal_context' => 'supplier', 'portal_entity' => $productionRequest]);
return $next($request);
}
return response()->json(['message' => 'Invalid token'], 401);
}
}
```
## CORS Configuration
```php
// config/cors.php
'allowed_origins' => [
env('FRONTEND_ADMIN_URL', 'http://localhost:5173'),
env('FRONTEND_APP_URL', 'http://localhost:5174'),
env('FRONTEND_PORTAL_URL', 'http://localhost:5175'),
],
'supports_credentials' => true,
```
## Shift Claiming & Approval Flow
### Three Assignment Strategies per Shift
1. **Fully controlled**: `slots_open_for_claiming = 0`. Organiser assigns manually.
2. **Fully self-service**: `slots_open_for_claiming = slots_total`. Volunteers fill all spots.
3. **Hybrid**: `slots_open_for_claiming < slots_total`. Some reserved for manual.
### Claim Flow
```
1. Volunteer claims shift → POST /shifts/{id}/claim
2. Backend checks: slot availability, time_slot conflict (UNIQUE person_id + time_slot_id)
3. Creates ShiftAssignment with status = 'pending_approval' (or 'approved' if auto_approve)
4. Dispatches NotifyCoordinatorOfClaimJob (queued, WhatsApp via Zender)
5. Coordinator approves/rejects via Organizer app
6. Volunteer receives confirmation email
```
### Status Machine
```
pending_approval → approved → completed
→ rejected (final)
→ cancelled (by volunteer or organiser)
```

File diff suppressed because it is too large Load Diff