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:
File diff suppressed because it is too large
Load Diff
@@ -1,28 +1,30 @@
|
||||
# Band Management - Cursor AI Instructions
|
||||
# EventCrew - Cursor AI Instructions
|
||||
|
||||
> This document provides AI assistants with comprehensive context about the project.
|
||||
> Update this file as the project evolves.
|
||||
> Multi-tenant SaaS platform for event- and festival management.
|
||||
> Design Document: `/resources/design/EventCrew_Design_Document_v1.3.docx`
|
||||
> Dev Guide: `/resources/design/EventCrew_Dev_Guide_v1.0.docx`
|
||||
> Start Guide: `/resources/design/EventCrew_Start_Guide_v1.0.docx`
|
||||
|
||||
## Project Overview
|
||||
|
||||
**Name**: Band Management Platform
|
||||
**Type**: Full-stack web application (API-first architecture)
|
||||
**Name**: EventCrew
|
||||
**Type**: Multi-tenant SaaS platform (API-first architecture)
|
||||
**Status**: Development
|
||||
|
||||
### Description
|
||||
|
||||
Band Management is a full-stack web application designed to streamline band operations by centralizing member coordination, gig management, music cataloging, and setlist planning. The platform serves as the single source of truth for all band-related activities.
|
||||
EventCrew is a multi-tenant SaaS platform for professional event and festival management. It supports the full operational cycle: artist booking and advancing, staff planning and volunteer management, accreditation, briefings, and real-time show-day operations (Mission Control). Built for a professional volunteer organisation, with SaaS expansion potential.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Component | Technology | Location | Port |
|
||||
|-----------|------------|----------|------|
|
||||
| API | Laravel 12 + Sanctum | `api/` | 8000 |
|
||||
| Admin Dashboard | Vue 3 + Vuexy (full) | `apps/admin/` | 5173 |
|
||||
| Band Portal | Vue 3 + Vuexy (starter) | `apps/band/` | 5174 |
|
||||
| Customer Portal | Vue 3 + Vuexy (starter) | `apps/customers/` | 5175 |
|
||||
| Database | MySQL 8.0 | Docker | 3306 |
|
||||
| Cache | Redis | Docker | 6379 |
|
||||
| API | Laravel 12 + Sanctum + Spatie Permission | `api/` | 8000 |
|
||||
| Admin (Super Admin) | Vue 3 + Vuexy (full) | `apps/admin/` | 5173 |
|
||||
| Organizer App (Main) | Vue 3 + Vuexy (full) | `apps/app/` | 5174 |
|
||||
| Portal (External) | Vue 3 + Vuexy (stripped) | `apps/portal/` | 5175 |
|
||||
| Database | MySQL 8 | Docker | 3306 |
|
||||
| Cache / Queues | Redis | Docker | 6379 |
|
||||
| Mail | Mailpit | Docker | 8025 |
|
||||
|
||||
## Documentation Structure
|
||||
@@ -30,220 +32,118 @@ Band Management is a full-stack web application designed to streamline band oper
|
||||
```
|
||||
.cursor/
|
||||
├── instructions.md # This file - overview and quick start
|
||||
├── ARCHITECTURE.md # System architecture and data models
|
||||
├── ARCHITECTURE.md # System architecture, schema, API routes
|
||||
└── rules/
|
||||
├── 001_workspace.mdc # Project structure and conventions
|
||||
├── 100_laravel.mdc # Laravel API patterns
|
||||
├── 101_vue.mdc # Vue + Vuexy patterns
|
||||
└── 200_testing.mdc # Testing strategies
|
||||
├── 001_workspace.mdc # Project structure, conventions, multi-tenancy
|
||||
├── 100_laravel.mdc # Laravel API patterns and templates
|
||||
├── 101_vue.mdc # Vue + Vuexy patterns and templates
|
||||
└── 200_testing.mdc # Testing strategies and templates
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Core Features
|
||||
## Core Modules
|
||||
|
||||
### Authentication & Authorization
|
||||
### Phase 1 - Foundation
|
||||
- [ ] Multi-tenant architecture + Auth (Sanctum + Spatie)
|
||||
- [ ] Users, Roles & Permissions (three-level model)
|
||||
- [ ] Organisations CRUD + User Invitations
|
||||
- [ ] Events CRUD with lifecycle status
|
||||
- [ ] Crowd Types (org-level configuration)
|
||||
- [ ] Festival Sections + Time Slots + Shifts
|
||||
- [ ] Persons & Crowd Lists
|
||||
- [ ] Accreditation Engine (categories, items, access zones)
|
||||
|
||||
- [ ] User registration with email verification
|
||||
- [ ] User login/logout
|
||||
- [ ] Password reset functionality
|
||||
- [ ] Role-based access control (Admin, Booking Agent, Music Manager, Member)
|
||||
- [ ] Permission middleware for route protection
|
||||
- [ ] Session management
|
||||
### Phase 2 - Core Operations
|
||||
- [ ] Briefings & Communication (template builder, queue-based sending)
|
||||
- [ ] Staff & Crew Management (crowd pool, accreditation matrix)
|
||||
- [ ] Volunteer Management + Portal (registration, shift claiming, approval flow)
|
||||
- [ ] Form Builder (drag-drop, conditional logic, iframe embed)
|
||||
- [ ] Artist Advancing + Portal (token-based access)
|
||||
- [ ] Timetable & Stage management
|
||||
- [ ] Show Day Mode
|
||||
- [ ] Shift Swap & Waitlist
|
||||
- [ ] Volunteer Profile + Festival Passport
|
||||
- [ ] Communication Hub (email/SMS/WhatsApp via Zender, urgency levels)
|
||||
|
||||
### Member Management
|
||||
### Phase 3 - Advancing & Show Day
|
||||
- [ ] Guests & Hospitality
|
||||
- [ ] Suppliers & Production (production requests, supplier portal)
|
||||
- [ ] Mission Control (real-time check-in, artist handling, scanner management)
|
||||
- [ ] Communication Campaigns (email + SMS batch)
|
||||
- [ ] Allocation Sheet PDF (Browsershot)
|
||||
- [ ] Scan infrastructure (hardware pairing)
|
||||
- [ ] Reporting & Insights
|
||||
- [ ] No-show automation
|
||||
- [ ] Post-festival evaluation + retrospective
|
||||
|
||||
- [ ] List all members with search and filter
|
||||
- [ ] Create new member with role assignment
|
||||
- [ ] Edit member profile and roles
|
||||
- [ ] Deactivate/reactivate members
|
||||
- [ ] Member profile page (instruments, bio, contact info)
|
||||
- [ ] Avatar upload
|
||||
- [ ] Member invitation via email
|
||||
- [ ] Activity log per member
|
||||
### Phase 4 - Differentiators
|
||||
- [ ] Real-time WebSocket notifications (Echo + Pusher/Soketi)
|
||||
- [ ] Cross-event crew pool with reliability score
|
||||
- [ ] Global search (cmd+K)
|
||||
- [ ] Crew PWA
|
||||
- [ ] Public REST API + webhook system
|
||||
- [ ] CO2/sustainability reporting
|
||||
|
||||
### Events/Gigs Management
|
||||
---
|
||||
|
||||
- [ ] List events with calendar and list view
|
||||
- [ ] Create event with details (title, date, time, fee, notes)
|
||||
- [ ] Edit/delete events
|
||||
- [ ] Link event to location (from Location Manager)
|
||||
- [ ] Link event to customer (from Customer Manager)
|
||||
- [ ] Event status workflow (Draft → Pending → Confirmed → Completed → Cancelled)
|
||||
- [ ] Invite members to event
|
||||
- [ ] View RSVP responses per event
|
||||
- [ ] Attach setlist to event
|
||||
- [ ] Event detail page with all related info
|
||||
- [ ] Duplicate event functionality
|
||||
## Module Development Order (per module)
|
||||
|
||||
### RSVP System
|
||||
Always follow this sequence:
|
||||
|
||||
- [ ] Member receives event invitation notification
|
||||
- [ ] RSVP response options (Available, Unavailable, Tentative)
|
||||
- [ ] Add note/reason with RSVP
|
||||
- [ ] Change RSVP before deadline
|
||||
- [ ] RSVP deadline per event
|
||||
- [ ] Overview of member availability per event
|
||||
- [ ] Automatic reminders for pending RSVPs
|
||||
|
||||
### Music Management
|
||||
|
||||
- [ ] List all music numbers with search and filter
|
||||
- [ ] Add music number with metadata (title, artist, genre, duration)
|
||||
- [ ] Edit/delete music numbers
|
||||
- [ ] Additional fields: key, tempo (BPM), time signature
|
||||
- [ ] File attachments (lyrics, chord sheets, audio files)
|
||||
- [ ] Categorization with tags/genres
|
||||
- [ ] Notes field for arrangements/cues
|
||||
|
||||
### Setlist Manager
|
||||
|
||||
- [ ] List all setlists
|
||||
- [ ] Create setlist with name and description
|
||||
- [ ] Add music numbers to setlist from catalog
|
||||
- [ ] Drag-and-drop reordering of songs
|
||||
- [ ] Add set breaks/intermissions
|
||||
- [ ] Auto-calculate total duration
|
||||
- [ ] Clone existing setlist
|
||||
- [ ] Link setlist to event(s)
|
||||
- [ ] Delete/archive setlists
|
||||
|
||||
### Location Manager
|
||||
|
||||
- [ ] List all locations with search
|
||||
- [ ] Add location with details (name, address, capacity)
|
||||
- [ ] Edit/delete locations
|
||||
- [ ] Contact information (phone, email, contact person)
|
||||
- [ ] Technical specifications (stage size, PA, backline, parking)
|
||||
- [ ] Notes and special requirements
|
||||
|
||||
### Customer Manager
|
||||
|
||||
- [ ] List all customers with search
|
||||
- [ ] Add customer (company or individual)
|
||||
- [ ] Edit/delete customers
|
||||
- [ ] Contact details (name, email, phone, address)
|
||||
- [ ] Customer type classification
|
||||
- [ ] Notes and preferences
|
||||
- [ ] View booking history per customer
|
||||
|
||||
### Customer Portal
|
||||
|
||||
- [ ] Customer dashboard with booked events
|
||||
- [ ] Submit booking requests
|
||||
- [ ] Track request status
|
||||
- [ ] View assigned setlists (if permitted)
|
||||
- [ ] Profile settings
|
||||
|
||||
### Band Member Portal
|
||||
|
||||
- [ ] Member dashboard with upcoming events
|
||||
- [ ] Personal event calendar
|
||||
- [ ] RSVP management interface
|
||||
- [ ] View event details (location, time, setlist)
|
||||
- [ ] Browse music catalog (view-only)
|
||||
- [ ] View setlists assigned to events
|
||||
- [ ] Profile settings
|
||||
- [ ] Notification preferences
|
||||
|
||||
### Admin Dashboard
|
||||
|
||||
- [ ] Dashboard with statistics/overview
|
||||
- [ ] Quick actions panel
|
||||
- [ ] Recent activity feed
|
||||
- [ ] Upcoming events widget
|
||||
- [ ] Pending RSVPs overview
|
||||
- [ ] Booking requests management
|
||||
|
||||
### Notifications
|
||||
|
||||
- [ ] Email notifications for event invitations
|
||||
- [ ] RSVP reminder notifications
|
||||
- [ ] Event update notifications
|
||||
- [ ] In-app notification center
|
||||
- [ ] Notification preferences per user
|
||||
1. Migration(s) - ULID PKs, composite indexes, constrained FKs
|
||||
2. Eloquent Model - HasUlids, relations, scopes, OrganisationScope
|
||||
3. Factory - realistic Dutch test data
|
||||
4. Policy - authorization via Spatie roles
|
||||
5. Form Request(s) - Store + Update validation
|
||||
6. API Resource - computed fields, `whenLoaded()`, permission-dependent fields
|
||||
7. Resource Controller - index/show/store/update/destroy
|
||||
8. Routes in `api.php`
|
||||
9. PHPUnit Feature Test - happy path (200/201) + unauthenticated (401) + wrong organisation (403) + validation (422)
|
||||
10. Vue Composable (`useModuleName.ts`) - TanStack Query
|
||||
11. Pinia Store (if cross-component state needed)
|
||||
12. Vue Page Component
|
||||
13. Vue Router entry
|
||||
|
||||
---
|
||||
|
||||
## Getting Started Prompts
|
||||
|
||||
### 1. Create Laravel API
|
||||
### 1. Phase 1 Foundation (Backend)
|
||||
|
||||
```
|
||||
Create a Laravel 12 project in api/ with:
|
||||
- Sanctum for API authentication
|
||||
- MySQL configuration (host: 127.0.0.1, db: band_management, user: band_management, pass: secret)
|
||||
- CORS configured for localhost:5173, localhost:5174, localhost:5175
|
||||
- API response trait for consistent JSON responses
|
||||
- Base controller with response helpers
|
||||
Read CLAUDE.md. Then generate Phase 1 Foundation:
|
||||
|
||||
Follow the patterns in .cursor/rules/100_laravel.mdc
|
||||
1. Migrations: Update users (add timezone, locale, deleted_at). Create organisations (ULID, name, slug, billing_status, settings JSON, deleted_at), organisation_user pivot, user_invitations, events (ULID, organisation_id, name, slug, start_date, end_date, timezone, status enum, deleted_at), event_user_roles pivot.
|
||||
2. Models: User (update), Organisation, UserInvitation, Event. All with HasUlids, SoftDeletes where applicable, OrganisationScope on Event.
|
||||
3. Spatie Permission: RoleSeeder with roles: super_admin, org_admin, org_member, event_manager, staff_coordinator, volunteer_coordinator.
|
||||
4. Auth: LoginController, LogoutController, MeController (returns user + organisations + active event roles).
|
||||
5. Organisations: Controller, Policy, Request, Resource.
|
||||
6. Events: Controller nested under organisations, Policy, Request, Resource.
|
||||
7. Feature tests per step. Run php artisan test after each step.
|
||||
```
|
||||
|
||||
### 2. Create Database Migrations
|
||||
### 2. Phase 1 Foundation (Frontend)
|
||||
|
||||
```
|
||||
Create all migrations based on the schema in .cursor/ARCHITECTURE.md:
|
||||
- Users, Customers, Locations
|
||||
- Events, EventInvitations
|
||||
- MusicNumbers, MusicAttachments
|
||||
- Setlists, SetlistItems
|
||||
- BookingRequests, Notifications, ActivityLogs
|
||||
|
||||
Use ULIDs for primary keys. Follow Laravel conventions.
|
||||
Build auth flow in apps/app/:
|
||||
1. stores/useAuthStore.ts - token storage, isAuthenticated, me() loading
|
||||
2. pages/login.vue - use Vuexy login layout
|
||||
3. Router guard - redirect to login if not authenticated
|
||||
4. Replace Vuexy demo navigation with EventCrew structure
|
||||
5. CASL permissions: connect to Spatie roles from auth/me response
|
||||
```
|
||||
|
||||
### 3. Create Models with Relationships
|
||||
### 3. Module Generation (example: Shifts)
|
||||
|
||||
```
|
||||
Create Eloquent models for all tables with:
|
||||
- HasUlids trait for ULID primary keys
|
||||
- Proper relationships (belongsTo, hasMany, etc.)
|
||||
- Fillable arrays
|
||||
- Casts for enums, dates, and JSON fields
|
||||
- Scopes for common queries
|
||||
|
||||
Follow patterns in .cursor/rules/100_laravel.mdc
|
||||
```
|
||||
|
||||
### 4. Create Authentication System
|
||||
|
||||
```
|
||||
Create auth system with:
|
||||
- AuthController (login, logout, register, user, forgot-password, reset-password)
|
||||
- Form requests for validation
|
||||
- API resources for responses
|
||||
- Sanctum token generation
|
||||
|
||||
Follow patterns in .cursor/rules/100_laravel.mdc
|
||||
```
|
||||
|
||||
### 5. Integrate Vuexy with API
|
||||
|
||||
```
|
||||
I've copied Vuexy Vue (typescript-version/full-version) to apps/admin/.
|
||||
|
||||
Update it to:
|
||||
1. Create src/lib/api-client.ts for API calls with auth token handling
|
||||
2. Install and configure @tanstack/vue-query
|
||||
3. Replace Vuexy's fake auth with our Laravel API
|
||||
4. Update navigation menu for our modules
|
||||
|
||||
Follow patterns in .cursor/rules/101_vue.mdc
|
||||
```
|
||||
|
||||
### 6. Create Feature Modules
|
||||
|
||||
```
|
||||
Create the Events module with:
|
||||
- EventController with CRUD + invite/RSVP endpoints
|
||||
- StoreEventRequest, UpdateEventRequest for validation
|
||||
- EventResource, EventCollection for responses
|
||||
- CreateEventAction, UpdateEventAction for business logic
|
||||
- EventPolicy for authorization
|
||||
- Feature tests
|
||||
|
||||
Follow patterns in .cursor/rules/100_laravel.mdc and .cursor/rules/200_testing.mdc
|
||||
Build the Shifts module following CLAUDE.md module order:
|
||||
- Migration with ULID PK, festival_section_id, time_slot_id, location_id, slots_total, slots_open_for_claiming, status. Composite indexes.
|
||||
- Model with HasUlids, SoftDeletes, relations, computed accessors (slots_filled, fill_rate).
|
||||
- shift_assignments with denormalized time_slot_id, status machine (pending_approval > approved/rejected/cancelled/completed), UNIQUE(person_id, time_slot_id).
|
||||
- Configurable auto-approve per shift.
|
||||
- Queued notification jobs using ZenderService for WhatsApp.
|
||||
- Feature tests covering 200/401/403/422.
|
||||
```
|
||||
|
||||
---
|
||||
@@ -256,25 +156,25 @@ Follow patterns in .cursor/rules/100_laravel.mdc and .cursor/rules/200_testing.m
|
||||
2. Create Form Request in `app/Http/Requests/Api/V1/`
|
||||
3. Create/update API Resource in `app/Http/Resources/Api/V1/`
|
||||
4. Add route in `routes/api.php`
|
||||
5. Create Action class if complex logic needed
|
||||
6. Write feature test
|
||||
5. Create Service class if complex business logic needed
|
||||
6. Write PHPUnit Feature Test (200/401/403/422)
|
||||
|
||||
### Add a New Vue Page
|
||||
|
||||
1. Create page component in `src/pages/`
|
||||
2. Add route in `src/router/index.ts`
|
||||
2. Route added automatically by file-based routing (or add to router)
|
||||
3. Add navigation item in `src/navigation/`
|
||||
4. Create composable for API calls in `src/composables/`
|
||||
5. Use Vuexy components for UI
|
||||
5. Use Vuexy/Vuetify components for UI
|
||||
|
||||
### Add a New Database Table
|
||||
|
||||
1. Create migration: `php artisan make:migration create_tablename_table`
|
||||
2. Create model with relationships
|
||||
3. Create factory and seeder
|
||||
4. Create controller, requests, resources
|
||||
5. Add API routes
|
||||
6. Write tests
|
||||
1. Create migration with ULID PK, composite indexes
|
||||
2. Create model with HasUlids, relations, OrganisationScope (if applicable)
|
||||
3. Create factory with realistic Dutch test data
|
||||
4. Create Policy, Form Request, Resource, Controller
|
||||
5. Register routes in `api.php`
|
||||
6. Write PHPUnit Feature Test
|
||||
|
||||
---
|
||||
|
||||
@@ -282,15 +182,15 @@ Follow patterns in .cursor/rules/100_laravel.mdc and .cursor/rules/200_testing.m
|
||||
|
||||
When generating code, always:
|
||||
|
||||
- Use PHP 8.3 features (typed properties, enums, match, readonly)
|
||||
- Use strict types: `declare(strict_types=1);`
|
||||
- Use `final` classes for Actions, Form Requests, Resources
|
||||
- Use ULIDs for all primary keys
|
||||
- Follow PSR-12 coding standards
|
||||
- Use TypeScript strict mode in Vue
|
||||
- Use Vue 3 Composition API with `<script setup lang="ts">`
|
||||
- Use TanStack Query for API calls
|
||||
- Return consistent API response format
|
||||
- Use PHP 8.2+ features (typed properties, enums, match, readonly)
|
||||
- Use `declare(strict_types=1);`
|
||||
- Use ULID primary keys via HasUlids trait
|
||||
- Use Spatie laravel-permission for roles (never hardcode role strings)
|
||||
- Scope all queries on `organisation_id` via Global Scope
|
||||
- Use `<script setup lang="ts">` for Vue components
|
||||
- Use TanStack Query for all API calls
|
||||
- Use VeeValidate + Zod for form validation
|
||||
- Use Vuetify/Vuexy components for UI (never custom CSS if Vuetify class exists)
|
||||
|
||||
---
|
||||
|
||||
@@ -306,13 +206,19 @@ make services-stop # Stop services
|
||||
```bash
|
||||
make api # Laravel on :8000
|
||||
make admin # Admin SPA on :5173
|
||||
make band # Band Portal on :5174
|
||||
make customers # Customer Portal on :5175
|
||||
make app # Organizer SPA on :5174
|
||||
make portal # Portal SPA on :5175
|
||||
```
|
||||
|
||||
### Database
|
||||
```bash
|
||||
make migrate # Run migrations
|
||||
make fresh # Fresh migrate + seed
|
||||
make db-shell # MySQL CLI
|
||||
```
|
||||
|
||||
### Testing
|
||||
```bash
|
||||
cd api && php artisan test # All tests
|
||||
cd api && php artisan test --filter=ShiftTest # Specific test
|
||||
cd api && php artisan test --coverage # With coverage
|
||||
```
|
||||
|
||||
@@ -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