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:
@@ -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
|
||||
|
||||
@@ -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
221
.cursor/rules/102_multi_tenancy.mdc
Normal file
221
.cursor/rules/102_multi_tenancy.mdc
Normal 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
Reference in New Issue
Block a user