diff --git a/.cursor/ARCHITECTURE.md b/.cursor/ARCHITECTURE.md index 4dc52be..3def2bb 100644 --- a/.cursor/ARCHITECTURE.md +++ b/.cursor/ARCHITECTURE.md @@ -1,97 +1,152 @@ -# Band Management - Architecture +# EventCrew - Architecture -> This document describes the system architecture, design decisions, and patterns used in this application. +> Multi-tenant SaaS platform for event- and festival management. +> Source of truth: `/resources/design/EventCrew_Design_Document_v1.3.docx` ## System Overview ``` ┌─────────────────────────────────────────────────────────────────────────┐ -│ INTERNET │ +│ INTERNET │ └─────────────────────────────────────────────────────────────────────────┘ │ ┌───────────────────────────┼───────────────────────────┐ │ │ │ ▼ ▼ ▼ ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ -│ Admin SPA │ │ Band SPA │ │ Customer SPA │ -│ (Vuexy Full) │ │ (Vuexy Lite) │ │ (Vuexy Lite) │ +│ Admin SPA │ │ Organizer │ │ Portal SPA │ +│ (Super Admin)│ │ SPA (Main) │ │ (External) │ │ :5173 │ │ :5174 │ │ :5175 │ └───────┬───────┘ └───────┬───────┘ └───────┬───────┘ │ │ │ └───────────────────────────┼───────────────────────────┘ - │ + │ CORS + Sanctum tokens ▼ ┌───────────────────────┐ - │ Laravel API │ - │ (Sanctum) │ - │ :8000 │ + │ Laravel 12 REST API │ + │ (JSON only, no │ + │ Blade views) │ + │ :8000 │ └───────────┬───────────┘ │ ┌───────────────┼───────────────┐ │ │ │ ▼ ▼ ▼ ┌───────────┐ ┌───────────┐ ┌───────────┐ - │ MySQL │ │ Redis │ │ Mailpit │ + │ MySQL 8 │ │ Redis │ │ Mailpit │ │ :3306 │ │ :6379 │ │ :8025 │ └───────────┘ └───────────┘ └───────────┘ ``` +**Golden Rule:** Laravel is exclusively a JSON REST API. No Blade views, no Mix, no Inertia. Every response is `application/json`. Vue handles ALL UI via three SPAs. + --- ## Applications ### Admin Dashboard (`apps/admin/`) -**Purpose**: Full management interface for band administrators. +**Purpose**: Super Admin platform management. -**Users**: Band leaders, managers, booking agents +**Users**: Platform owner only (super_admin role). **Features**: -- Member management (CRUD, roles, invitations) -- Event/gig management (calendar, list, RSVP tracking) -- Music catalog (songs, attachments, metadata) -- Setlist builder (drag-drop, templates) -- Location management (venues, contacts) -- Customer CRM (companies, individuals, history) -- Booking request management -- Reports and analytics +- Organisation management (CRUD, billing status) +- Platform user management +- Global settings **Vuexy Version**: `typescript-version/full-version` --- -### Band Portal (`apps/band/`) +### Organizer App (`apps/app/`) -**Purpose**: Member-facing interface for band members. +**Purpose**: Main application for event management per organisation. -**Users**: Musicians, performers, crew +**Users**: Organisation Admins, Event Managers, Staff Coordinators, Artist Managers, Volunteer Coordinators. **Features**: -- Personal dashboard (upcoming gigs) -- Event calendar with RSVP -- View setlists and music -- Download attachments (lyrics, charts) -- Profile settings -- Notifications +- Event lifecycle management (Draft through Closed) +- Festival Sections, Time Slots, Shift planning +- Person & Crowd management (Crew, Volunteers, Artists, Guests, Press, Partners, Suppliers) +- Accreditation engine (categories, items, access zones) +- Artist booking & advancing +- Timetable & stage management +- Briefing builder & communication hub +- Mission Control (show day operations) +- Form builder with conditional logic +- Supplier & production management +- Reporting & insights -**Vuexy Version**: `typescript-version/starter-kit` +**Vuexy Version**: `typescript-version/full-version` (customized navigation) --- -### Customer Portal (`apps/customers/`) +### Portal (`apps/portal/`) -**Purpose**: Client-facing interface for customers. +**Purpose**: External-facing portal with two access modes. -**Users**: Event organizers, venue managers, clients +**Users**: Volunteers, Crew (login-based), Artists, Suppliers, Press (token-based). -**Features**: -- View booked events -- Submit booking requests -- Track request status -- View assigned setlists (if permitted) -- Profile settings +**Access Modes**: -**Vuexy Version**: `typescript-version/starter-kit` +| User | Access Mode | Rationale | +|------|-------------|-----------| +| Volunteer / Crew | Login (`auth:sanctum`) | Long-term relationship, festival passport, shift history | +| Artist / Tour Manager | Token (`portal.token` middleware) | Per-event, advance portal via signed URL | +| Supplier / Partner | Token (`portal.token` middleware) | Per-event, production request via token | +| Press / Media | Token (`portal.token` middleware) | Per-event accreditation, no recurring relationship | + +**Router guard logic**: If `route.query.token` -> token mode. If `authStore.isAuthenticated` -> login mode. Otherwise -> redirect to `/login`. + +**Vuexy Version**: `typescript-version/starter-kit` (stripped: no sidebar, no customizer, no dark mode toggle; uses Vuetify components + Vuexy SCSS) + +--- + +## Multi-Tenant Data Model + +Shared database schema with organisation scoping on all tables. No row-level security at DB level; scoping enforced via Laravel Policies and Eloquent Global Scopes. + +**Scoping Rule**: EVERY query on event data MUST have an `organisation_id` scope via `OrganisationScope` Global Scope. + +**Tenancy Hierarchy**: + +``` +Platform (Super Admin) + └─ Organisation (client A) + └─ Event (event 1) + └─ Festival Section (Bar, Hospitality, Technical, ...) + ├─ Time Slots (DAY1-EARLY-CREW, DAY1-EARLY-VOLUNTEER, ...) + └─ Shifts (Bar x DAY1-EARLY-VOLUNTEER, 5 slots) +``` + +--- + +## Three-Level Role & Permission Model + +Managed via Spatie `laravel-permission` with team-based permissions. + +| Level | Scope | Roles | Implementation | +|-------|-------|-------|----------------| +| App Level | Whole platform | `super_admin`, `support_agent` | Spatie role | +| Organisation Level | Within one org | `org_admin`, `org_member`, `org_readonly` | Spatie team = organisation | +| Event Level | Within one event | `event_manager`, `artist_manager`, `staff_coordinator`, `volunteer_coordinator`, `accreditation_officer` | `event_user_roles` pivot table | + +**Middleware**: `OrganisationRoleMiddleware` and `EventRoleMiddleware` check per route. + +--- + +## Event Lifecycle + +| Phase | Description | +|-------|-------------| +| `draft` | Created but not published. Only admin sees it. | +| `published` | Active in planning. Internal modules available. External portals closed. | +| `registration_open` | Volunteer registration and artist advance portals open. | +| `buildup` | Setup days. Crew shifts begin. Accreditation distribution starts. | +| `showday` | Active event days. Mission Control active. Real-time check-in. | +| `teardown` | Breakdown days. Inventory return. Shift closure. | +| `closed` | Event completed. Read-only. Reports available. | --- @@ -99,410 +154,102 @@ ### Base URL - Development: `http://localhost:8000/api/v1` -- Production: `https://api.bandmanagement.nl/api/v1` -### Authentication Endpoints -``` -POST /auth/register Register new user -POST /auth/login Login, returns token -POST /auth/logout Logout, revokes token -GET /auth/user Get authenticated user -POST /auth/forgot-password Request password reset -POST /auth/reset-password Reset password with token -``` - -### Resource Endpoints -``` -# Events -GET /events List events (paginated, filterable) -POST /events Create event -GET /events/{id} Get event details -PUT /events/{id} Update event -DELETE /events/{id} Delete event -POST /events/{id}/invite Invite members to event -GET /events/{id}/invitations Get event invitations -POST /events/{id}/rsvp Submit RSVP response -POST /events/{id}/duplicate Duplicate event - -# Members -GET /members List members -POST /members Create member -GET /members/{id} Get member details -PUT /members/{id} Update member -DELETE /members/{id} Delete/deactivate member -POST /members/invite Send invitation email - -# Music -GET /music List music numbers -POST /music Create music number -GET /music/{id} Get music number -PUT /music/{id} Update music number -DELETE /music/{id} Delete music number -POST /music/{id}/attachments Upload attachment -DELETE /music/{id}/attachments/{aid} Delete attachment - -# Setlists -GET /setlists List setlists -POST /setlists Create setlist -GET /setlists/{id} Get setlist with items -PUT /setlists/{id} Update setlist -DELETE /setlists/{id} Delete setlist -POST /setlists/{id}/clone Clone setlist -PUT /setlists/{id}/items Reorder/update items - -# Locations -GET /locations List locations -POST /locations Create location -GET /locations/{id} Get location -PUT /locations/{id} Update location -DELETE /locations/{id} Delete location - -# Customers -GET /customers List customers -POST /customers Create customer -GET /customers/{id} Get customer -PUT /customers/{id} Update customer -DELETE /customers/{id} Delete customer - -# Booking Requests (Customer Portal) -GET /booking-requests List user's requests -POST /booking-requests Submit booking request -GET /booking-requests/{id} Get request details - -# Notifications -GET /notifications List notifications -PUT /notifications/{id}/read Mark as read -POST /notifications/read-all Mark all as read -``` - ---- - -## Database Schema - -### Entity Relationship Diagram +### Route Groups ``` -┌──────────────┐ ┌──────────────┐ ┌──────────────┐ -│ users │ │ customers │ │ locations │ -├──────────────┤ ├──────────────┤ ├──────────────┤ -│ id (ULID) │──┐ │ id (ULID) │ │ id (ULID) │ -│ name │ │ │ user_id (FK) │───────│ name │ -│ email │ │ │ name │ │ address │ -│ type │ │ │ company_name │ │ city │ -│ role │ │ │ type │ │ capacity │ -│ status │ │ │ email │ │ contact_* │ -└──────────────┘ │ └──────────────┘ └──────────────┘ - │ │ │ │ - │ │ │ │ - ▼ │ ▼ ▼ -┌──────────────┐ │ ┌──────────────┐ ┌──────────────┐ -│event_invites │ │ │ events │───────│ setlists │ -├──────────────┤ │ ├──────────────┤ ├──────────────┤ -│ id (ULID) │ │ │ id (ULID) │ │ id (ULID) │ -│ event_id(FK) │──┼───▶│ title │ │ name │ -│ user_id (FK) │──┘ │ location_id │ │ description │ -│ rsvp_status │ │ customer_id │ │ is_template │ -│ rsvp_note │ │ setlist_id │◀──────│ is_archived │ -└──────────────┘ │ event_date │ └──────────────┘ - │ status │ │ - │ created_by │ │ - └──────────────┘ ▼ - ┌──────────────┐ -┌──────────────┐ ┌──────────────┐ │setlist_items │ -│music_numbers │───────│music_attach │ ├──────────────┤ -├──────────────┤ ├──────────────┤ │ id (ULID) │ -│ id (ULID) │ │ id (ULID) │ │ setlist_id │ -│ title │ │ music_num_id │ │ music_num_id │ -│ artist │ │ file_name │ │ position │ -│ duration │ │ file_type │ │ set_number │ -│ key, tempo │ │ file_path │ │ is_break │ -│ tags (JSON) │ └──────────────┘ └──────────────┘ -└──────────────┘ +# Public (no auth) +POST /auth/login +POST /portal/token-auth Token-based portal access +POST /portal/form-submit Public form submission + +# Protected (auth:sanctum) +POST /auth/logout +GET /auth/me Returns user + organisations + event roles + +# Organisations +GET/POST /organisations +GET/PUT /organisations/{id} +POST /organisations/{id}/invite +GET /organisations/{id}/members + +# Events (nested under organisations) +GET/POST /organisations/{org}/events +GET/PUT/DELETE /events/{id} +PUT /events/{id}/status + +# Festival Sections +GET/POST /events/{event}/sections +GET/PUT/DELETE /sections/{id} +GET /sections/{id}/dashboard + +# Time Slots +GET/POST /events/{event}/time-slots +PUT/DELETE /time-slots/{id} + +# Shifts +GET/POST /sections/{section}/shifts +PUT/DELETE /shifts/{id} +POST /shifts/{id}/assign +POST /shifts/{id}/claim Volunteer self-service + +# Persons +GET/POST /events/{event}/persons +GET/PUT /persons/{id} +POST /persons/{id}/approve +POST /persons/{id}/checkin + +# Crowd Types & Lists +GET/POST /organisations/{org}/crowd-types +GET/POST /events/{event}/crowd-lists + +# Artists & Advancing +GET/POST /events/{event}/artists +GET/PUT /artists/{id} +GET/POST /artists/{id}/sections Advance sections +POST /sections/{id}/submit Advance submission + +# Accreditation +GET/POST /events/{event}/accreditation-items +POST /persons/{id}/accreditations +GET/POST /events/{event}/access-zones + +# Briefings & Communication +GET/POST /events/{event}/briefings +POST /briefings/{id}/send +GET/POST /events/{event}/campaigns +POST /campaigns/{id}/send + +# Mission Control +GET /events/{event}/mission-control +POST /persons/{id}/checkin-item + +# Scanners & Inventory +GET/POST /events/{event}/scanners +POST /scan +GET/POST /events/{event}/inventory + +# Reports +GET /events/{event}/reports/{type} + +# Portal (token-based, portal.token middleware) +GET /portal/artist +POST /portal/advancing +GET /portal/supplier +POST /portal/production-request ``` -### Table Definitions +### API Response Format -#### users -| Column | Type | Description | -|--------|------|-------------| -| id | ULID | Primary key | -| name | string | Full name | -| email | string | Unique email | -| email_verified_at | timestamp | Email verification | -| password | string | Hashed password | -| phone | string? | Phone number | -| bio | text? | Biography | -| instruments | json? | Array of instruments | -| avatar_path | string? | Avatar file path | -| type | enum | `member`, `customer` | -| role | enum? | `admin`, `booking_agent`, `music_manager`, `member` | -| status | enum | `active`, `inactive` | -| invited_at | timestamp? | When invited | -| last_login_at | timestamp? | Last login | -| remember_token | string? | Remember me token | -| created_at | timestamp | Created | -| updated_at | timestamp | Updated | - -#### customers -| Column | Type | Description | -|--------|------|-------------| -| id | ULID | Primary key | -| user_id | ULID? | FK to users (for portal access) | -| name | string | Contact name | -| company_name | string? | Company name | -| type | enum | `individual`, `company` | -| email | string? | Email | -| phone | string? | Phone | -| address | string? | Street address | -| city | string? | City | -| postal_code | string? | Postal code | -| country | string | Country (default: NL) | -| notes | text? | Internal notes | -| is_portal_enabled | boolean | Can access portal | -| created_at | timestamp | Created | -| updated_at | timestamp | Updated | - -#### locations -| Column | Type | Description | -|--------|------|-------------| -| id | ULID | Primary key | -| name | string | Venue name | -| address | string | Street address | -| city | string | City | -| postal_code | string? | Postal code | -| country | string | Country (default: NL) | -| latitude | decimal? | GPS latitude | -| longitude | decimal? | GPS longitude | -| capacity | integer? | Max capacity | -| contact_name | string? | Contact person | -| contact_email | string? | Contact email | -| contact_phone | string? | Contact phone | -| stage_specs | text? | Stage specifications | -| technical_notes | text? | Technical requirements | -| parking_info | text? | Parking information | -| notes | text? | General notes | -| created_at | timestamp | Created | -| updated_at | timestamp | Updated | - -#### events -| Column | Type | Description | -|--------|------|-------------| -| id | ULID | Primary key | -| title | string | Event title | -| description | text? | Description | -| location_id | ULID? | FK to locations | -| customer_id | ULID? | FK to customers | -| setlist_id | ULID? | FK to setlists | -| event_date | date | Date of event | -| start_time | time | Start time | -| end_time | time? | End time | -| load_in_time | time? | Load-in time | -| soundcheck_time | time? | Soundcheck time | -| fee | decimal(10,2)? | Payment amount | -| currency | string | Currency (default: EUR) | -| status | enum | `draft`, `pending`, `confirmed`, `completed`, `cancelled` | -| visibility | enum | `private`, `members`, `public` | -| rsvp_deadline | datetime? | RSVP deadline | -| notes | text? | Public notes | -| internal_notes | text? | Admin-only notes | -| is_public_setlist | boolean | Show setlist to customer | -| created_by | ULID | FK to users | -| created_at | timestamp | Created | -| updated_at | timestamp | Updated | - -#### event_invitations -| Column | Type | Description | -|--------|------|-------------| -| id | ULID | Primary key | -| event_id | ULID | FK to events | -| user_id | ULID | FK to users | -| rsvp_status | enum | `pending`, `available`, `unavailable`, `tentative` | -| rsvp_note | text? | Response note | -| rsvp_responded_at | timestamp? | When responded | -| invited_at | timestamp | When invited | -| reminder_sent_at | timestamp? | Last reminder | -| created_at | timestamp | Created | -| updated_at | timestamp | Updated | - -#### music_numbers -| Column | Type | Description | -|--------|------|-------------| -| id | ULID | Primary key | -| title | string | Song title | -| artist | string? | Original artist | -| genre | string? | Genre/style | -| duration_seconds | integer? | Duration in seconds | -| key | string? | Musical key (e.g., "Am", "G") | -| tempo_bpm | integer? | Tempo in BPM | -| time_signature | string? | Time signature (e.g., "4/4") | -| lyrics | text? | Full lyrics | -| notes | text? | Performance notes | -| tags | json? | Array of tags | -| play_count | integer | Times played (default: 0) | -| last_played_at | timestamp? | Last performed | -| is_active | boolean | Active in catalog | -| created_by | ULID? | FK to users | -| created_at | timestamp | Created | -| updated_at | timestamp | Updated | - -#### music_attachments -| Column | Type | Description | -|--------|------|-------------| -| id | ULID | Primary key | -| music_number_id | ULID | FK to music_numbers | -| file_name | string | Stored filename | -| original_name | string | Original filename | -| file_path | string | Storage path | -| file_type | enum | `lyrics`, `chords`, `sheet_music`, `audio`, `other` | -| file_size | integer | Size in bytes | -| mime_type | string | MIME type | -| created_at | timestamp | Created | -| updated_at | timestamp | Updated | - -#### setlists -| Column | Type | Description | -|--------|------|-------------| -| id | ULID | Primary key | -| name | string | Setlist name | -| description | text? | Description | -| total_duration_seconds | integer? | Calculated total | -| is_template | boolean | Is a template | -| is_archived | boolean | Archived | -| created_by | ULID? | FK to users | -| created_at | timestamp | Created | -| updated_at | timestamp | Updated | - -#### setlist_items -| Column | Type | Description | -|--------|------|-------------| -| id | ULID | Primary key | -| setlist_id | ULID | FK to setlists | -| music_number_id | ULID? | FK to music_numbers | -| position | integer | Order position | -| set_number | integer | Set number (1, 2, 3) | -| is_break | boolean | Is a break | -| break_duration_seconds | integer? | Break length | -| notes | string? | Item notes | -| created_at | timestamp | Created | -| updated_at | timestamp | Updated | - -#### booking_requests -| Column | Type | Description | -|--------|------|-------------| -| id | ULID | Primary key | -| customer_id | ULID | FK to customers | -| event_date | date | Requested date | -| start_time | time? | Requested start | -| end_time | time? | Requested end | -| location_name | string? | Venue name | -| location_address | string? | Venue address | -| event_type | string? | Type of event | -| expected_guests | integer? | Guest count | -| message | text? | Request message | -| status | enum | `pending`, `reviewed`, `accepted`, `declined` | -| admin_notes | text? | Admin notes | -| event_id | ULID? | FK to created event | -| reviewed_by | ULID? | FK to users | -| reviewed_at | timestamp? | When reviewed | -| created_at | timestamp | Created | -| updated_at | timestamp | Updated | - -#### notifications -| Column | Type | Description | -|--------|------|-------------| -| id | ULID | Primary key | -| user_id | ULID | FK to users | -| type | string | Notification type | -| title | string | Title | -| message | text | Message body | -| data | json? | Additional data | -| action_url | string? | Link URL | -| read_at | timestamp? | When read | -| created_at | timestamp | Created | -| updated_at | timestamp | Updated | - -#### activity_logs -| Column | Type | Description | -|--------|------|-------------| -| id | ULID | Primary key | -| user_id | ULID? | FK to users | -| loggable_type | string | Model class | -| loggable_id | ULID | Model ID | -| action | string | Action performed | -| description | text? | Description | -| changes | json? | Before/after data | -| ip_address | string? | Client IP | -| user_agent | string? | Browser info | -| created_at | timestamp | Created | - ---- - -## API Response Format - -### Success Response ```json { - "success": true, "data": { ... }, - "message": "Event created successfully", "meta": { "pagination": { "current_page": 1, "per_page": 15, "total": 100, - "last_page": 7, - "from": 1, - "to": 15 - } - } -} -``` - -### Error Response -```json -{ - "success": false, - "message": "Validation failed", - "errors": { - "title": ["The title field is required."], - "event_date": ["The event date must be a future date."] - } -} -``` - -### Single Resource -```json -{ - "success": true, - "data": { - "id": "01HQ3K5P7X...", - "title": "Summer Concert", - "event_date": "2025-07-15", - "status": "confirmed", - "location": { - "id": "01HQ3K5P7X...", - "name": "City Park Amphitheater" - }, - "created_at": "2025-01-15T10:30:00Z", - "updated_at": "2025-01-15T10:30:00Z" - } -} -``` - -### Collection (Paginated) -```json -{ - "success": true, - "data": [ - { "id": "...", "title": "Event 1" }, - { "id": "...", "title": "Event 2" } - ], - "meta": { - "pagination": { - "current_page": 1, - "per_page": 15, - "total": 45, - "last_page": 3 + "last_page": 7 } } } @@ -510,357 +257,193 @@ POST /notifications/read-all Mark all as read --- -## User Roles & Permissions - -### Roles - -| Role | Description | -|------|-------------| -| `admin` | Full access to everything | -| `booking_agent` | Manage events, locations, customers | -| `music_manager` | Manage music catalog and setlists | -| `member` | View events, RSVP, view music | - -### Permissions Matrix - -| Resource | Admin | Booking Agent | Music Manager | Member | -|----------|-------|---------------|---------------|--------| -| Members | CRUD | Read | Read | Read | -| Events | CRUD | CRUD | Read | Read | -| Locations | CRUD | CRUD | Read | Read | -| Customers | CRUD | CRUD | Read | - | -| Music | CRUD | Read | CRUD | Read | -| Setlists | CRUD | Read | CRUD | Read | -| Booking Requests | CRUD | CRUD | - | - | -| RSVP | All | All | Own | Own | - ---- - -## File Storage - -### Structure -``` -storage/app/ -├── public/ -│ ├── avatars/ # User avatars -│ └── music/ # Music attachments -│ ├── lyrics/ -│ ├── chords/ -│ ├── sheet_music/ -│ └── audio/ -└── private/ - └── exports/ # Generated reports -``` - -### File Types Allowed - -| Type | Extensions | Max Size | -|------|------------|----------| -| Avatar | jpg, png, webp | 2 MB | -| Lyrics | txt, pdf, docx | 5 MB | -| Chords | pdf, png, jpg | 10 MB | -| Sheet Music | pdf, png, jpg | 10 MB | -| Audio | mp3, wav, m4a | 50 MB | - ---- - -## Architectural Decisions - -### ADR-001: API-First Architecture - -**Status**: Accepted -**Date**: 2025-01-01 - -**Context**: We need to build a web application with three separate SPAs (Admin, Band, Customers) that may have mobile clients in the future. - -**Decision**: Implement a completely separated frontend and backend communicating via RESTful JSON API. - -**Consequences**: -- ✅ Frontend and backend can be developed/deployed independently -- ✅ Easy to add mobile or other clients later -- ✅ Clear API contracts -- ✅ Better scalability options -- ⚠️ More complex initial setup -- ⚠️ Requires CORS configuration - ---- - -### ADR-002: Laravel Sanctum for Authentication - -**Status**: Accepted -**Date**: 2025-01-01 - -**Context**: Need authentication for SPAs that's secure and simple to implement. - -**Decision**: Use Laravel Sanctum with token-based authentication for the SPAs. - -**Alternatives Considered**: -- **Passport**: Too complex for our needs (OAuth2 overkill for first-party SPA) -- **JWT**: Requires token storage in localStorage (XSS vulnerable) - -**Consequences**: -- ✅ Simple token-based auth for multiple SPAs -- ✅ Built into Laravel, minimal setup -- ✅ Works well with separate domains -- ⚠️ Need to handle token storage securely - ---- - -### ADR-003: TanStack Query for Server State - -**Status**: Accepted -**Date**: 2025-01-01 - -**Context**: Need efficient data fetching with caching, background updates, and optimistic updates. - -**Decision**: Use TanStack Query (Vue Query) for all server state management. - -**Consequences**: -- ✅ Automatic caching and deduplication -- ✅ Built-in loading/error states -- ✅ Background refetching -- ✅ Optimistic updates for better UX -- ✅ DevTools for debugging -- ⚠️ Learning curve for query key management - ---- - -### ADR-004: Action Pattern for Business Logic - -**Status**: Accepted -**Date**: 2025-01-01 - -**Context**: Controllers should be thin, and business logic needs to be reusable and testable. - -**Decision**: Use single-responsibility Action classes for all business logic. - -**Pattern**: -```php -class CreateEventAction -{ - public function execute(array $data): Event - { - // Business logic here - } -} - -// Usage in controller -public function store(Request $request, CreateEventAction $action) -{ - return $action->execute($request->validated()); -} -``` - -**Consequences**: -- ✅ Single Responsibility Principle -- ✅ Easy to test in isolation -- ✅ Reusable across controllers, commands, jobs -- ⚠️ More files to manage - ---- - -### ADR-005: Vuexy for All SPAs - -**Status**: Accepted -**Date**: 2025-01-01 - -**Context**: Need consistent UI across three SPAs with professional admin components. - -**Decision**: Use Vuexy Vue template for all SPAs (full version for Admin, starter-kit for Band/Customers). - -**Consequences**: -- ✅ Consistent UI/UX across all portals -- ✅ Pre-built admin components -- ✅ Single learning curve for the team -- ✅ Professional look out of the box -- ⚠️ License cost -- ⚠️ Dependency on third-party template - ---- - -## Security Architecture - -### Defense in Depth - -``` -┌─────────────────────────────────────────────────────────────┐ -│ 1. Network Layer │ -│ - HTTPS only │ -│ - Rate limiting │ -└─────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────┐ -│ 2. Application Layer │ -│ - CORS validation │ -│ - Input validation (Form Requests) │ -│ - SQL injection prevention (Eloquent) │ -│ - XSS prevention (Vue escaping) │ -└─────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────┐ -│ 3. Authentication & Authorization │ -│ - Sanctum token authentication │ -│ - Role-based access control │ -│ - Resource authorization (Policies) │ -└─────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────┐ -│ 4. Data Layer │ -│ - Encrypted connections (TLS) │ -│ - Sensitive data hashing (bcrypt) │ -│ - Database credentials via environment │ -└─────────────────────────────────────────────────────────────┘ -``` - -### CORS Configuration - -```php -// config/cors.php -'allowed_origins' => [ - 'http://localhost:5173', // Admin - 'http://localhost:5174', // Band - 'http://localhost:5175', // Customers -], -'supports_credentials' => true, -``` - ---- - -## Performance Considerations - -### Backend Optimizations - -1. **Database** - - Eager loading relationships (`with()`) - - Database indexes on filtered/sorted columns - - Query caching for expensive operations - -2. **Caching Strategy** - ```php - // Cache expensive queries - Cache::remember('stats:dashboard', 3600, fn() => - $this->calculateStats() - ); - ``` - -3. **Queue Heavy Operations** - - Email sending - - File processing - - Report generation - -### Frontend Optimizations - -1. **Code Splitting** - - Lazy load routes - - Dynamic imports for heavy components - -2. **Query Optimization** - ```typescript - // Deduplicate requests - useQuery({ queryKey: ['events'], staleTime: 5 * 60 * 1000 }) - - // Prefetch on hover - queryClient.prefetchQuery({ queryKey: ['event', id] }) - ``` - -3. **Bundle Size** - - Tree shaking enabled - - Dynamic imports for routes - ---- - -## Monitoring & Observability - -### Logging Strategy - -| Level | Use Case | Example | -|-------|----------|---------| -| DEBUG | Development only | Query details, variable dumps | -| INFO | Normal operations | User login, API calls | -| WARNING | Unexpected but handled | Rate limit approached | -| ERROR | Errors requiring attention | Failed payment | -| CRITICAL | System failures | Database down | - -### Health Checks - -``` -GET /api/v1/health -{ - "status": "healthy", - "checks": { - "database": "ok", - "redis": "ok", - "queue": "ok" - } -} -``` +## Core Database Schema + +**Primary Keys**: ULID on all business tables via `HasUlids` trait. Pure pivot tables use auto-increment integer PK. + +**Soft Deletes ON**: organisations, events, festival_sections, shifts, shift_assignments, persons, artists, companies, production_requests. + +**Soft Deletes OFF** (audit records): check_ins, briefing_sends, message_replies, shift_waitlist, volunteer_festival_history. + +**JSON Columns**: ONLY for opaque config (blocks, fields, settings, items). NEVER for dates, status values, foreign keys, booleans, or anything filtered/sorted/aggregated. + +### 1. Foundation + +| Table | Key Columns | Notes | +|-------|-------------|-------| +| `users` | id (ULID), name, email, password, timezone, locale, avatar, deleted_at | Platform-wide, unique per email. | +| `organisations` | id (ULID), name, slug, billing_status, settings (JSON: display prefs only), deleted_at | hasMany events, crowd_types. | +| `organisation_user` | id (int AI), user_id, organisation_id, role | Pivot. Integer PK. | +| `user_invitations` | id (ULID), email, invited_by_user_id, organisation_id, event_id (nullable), role, token (ULID unique), status, expires_at | INDEX: (token), (email, status). | +| `events` | id (ULID), organisation_id, name, slug, start_date, end_date, timezone, status (enum), deleted_at | INDEX: (organisation_id, status). | +| `event_user_roles` | id (int AI), user_id, event_id, role | Pivot. Integer PK. | + +### 2. Locations + +| Table | Key Columns | Notes | +|-------|-------------|-------| +| `locations` | id (ULID), event_id, name, address, lat, lng, description, access_instructions | INDEX: (event_id). | + +### 3. Festival Sections, Time Slots & Shifts + +| Table | Key Columns | Notes | +|-------|-------------|-------| +| `festival_sections` | id (ULID), event_id, name, sort_order, deleted_at | INDEX: (event_id, sort_order). | +| `time_slots` | id (ULID), event_id, name, person_type (CREW/VOLUNTEER/PRESS/...), date, start_time, end_time | INDEX: (event_id, person_type, date). | +| `shifts` | id (ULID), festival_section_id, time_slot_id, location_id, slots_total, slots_open_for_claiming, status, deleted_at | INDEX: (festival_section_id, time_slot_id). | +| `shift_assignments` | id (ULID), shift_id, person_id, time_slot_id (denormalized), status (pending_approval/approved/rejected/cancelled/completed), auto_approved, deleted_at | UNIQUE(person_id, time_slot_id). | +| `volunteer_availabilities` | id (ULID), person_id, time_slot_id, submitted_at | UNIQUE(person_id, time_slot_id). | +| `shift_waitlist` | id (ULID), shift_id, person_id, position, added_at | UNIQUE(shift_id, person_id). | +| `shift_swap_requests` | id (ULID), from_assignment_id, to_person_id, status, auto_approved | | +| `shift_absences` | id (ULID), shift_assignment_id, person_id, reason, status | | + +### 4. Volunteer Profile & History + +| Table | Key Columns | Notes | +|-------|-------------|-------| +| `volunteer_profiles` | id (ULID), user_id (unique), bio, tshirt_size, first_aid, driving_licence, reliability_score (0.00-5.00) | Platform-wide, 1:1 with users. | +| `volunteer_festival_history` | id (ULID), user_id, event_id, hours_planned, hours_completed, no_show_count, coordinator_rating, would_reinvite | UNIQUE(user_id, event_id). Never visible to volunteer. | +| `post_festival_evaluations` | id (ULID), event_id, person_id, overall_rating, would_return, feedback_text | | +| `festival_retrospectives` | id (ULID), event_id (unique), KPI columns, top_feedback (JSON) | | + +### 5. Crowd Types, Persons & Crowd Lists + +| Table | Key Columns | Notes | +|-------|-------------|-------| +| `crowd_types` | id (ULID), organisation_id, name, system_type (CREW/GUEST/ARTIST/VOLUNTEER/PRESS/PARTNER/SUPPLIER), color, icon | Org-level config. | +| `persons` | id (ULID), user_id (nullable), event_id, crowd_type_id, company_id (nullable), name, email, phone, status, is_blacklisted, custom_fields (JSON), deleted_at | user_id nullable for externals. UNIQUE(event_id, user_id) WHERE user_id IS NOT NULL. | +| `companies` | id (ULID), organisation_id, name, type, contact_*, deleted_at | Shared across events within org. | +| `crowd_lists` | id (ULID), event_id, crowd_type_id, name, type (internal/external), auto_approve, max_persons | | +| `crowd_list_persons` | id (int AI), crowd_list_id, person_id | Pivot. | + +### 6. Accreditation Engine + +| Table | Key Columns | Notes | +|-------|-------------|-------| +| `accreditation_categories` | id (ULID), organisation_id, name, sort_order, icon | Org-level. | +| `accreditation_items` | id (ULID), accreditation_category_id, name, is_date_dependent, barcode_type, cost_price | Org-level items. | +| `event_accreditation_items` | id (ULID), event_id, accreditation_item_id, max_quantity_per_person, is_active | Activates item per event. UNIQUE(event_id, accreditation_item_id). | +| `accreditation_assignments` | id (ULID), person_id, accreditation_item_id, event_id, date, quantity, is_handed_out | | +| `access_zones` | id (ULID), event_id, name, zone_code (unique per event) | | +| `access_zone_days` | id (int AI), access_zone_id, day_date | UNIQUE(access_zone_id, day_date). | +| `person_access_zones` | id (int AI), person_id, access_zone_id, valid_from, valid_to | | + +### 7. Artists & Advancing + +| Table | Key Columns | Notes | +|-------|-------------|-------| +| `artists` | id (ULID), event_id, name, booking_status (concept/requested/option/confirmed/contracted/cancelled), portal_token (ULID unique), deleted_at | | +| `performances` | id (ULID), artist_id, stage_id, date, start_time, end_time, check_in_status | INDEX: (stage_id, date, start_time). | +| `stages` | id (ULID), event_id, name, color, capacity | | +| `stage_days` | id (int AI), stage_id, day_date | UNIQUE(stage_id, day_date). | +| `advance_sections` | id (ULID), artist_id, name, type, is_open, sort_order | | +| `advance_submissions` | id (ULID), advance_section_id, data (JSON), status | | +| `artist_contacts` | id (ULID), artist_id, name, email, role | | +| `artist_riders` | id (ULID), artist_id, category (technical/hospitality), items (JSON) | | + +### 8. Communication & Briefings + +| Table | Key Columns | Notes | +|-------|-------------|-------| +| `briefing_templates` | id (ULID), event_id, name, type, blocks (JSON) | | +| `briefings` | id (ULID), event_id, briefing_template_id, name, target_crowd_types (JSON), status | | +| `briefing_sends` | id (ULID), briefing_id, person_id, status (queued/sent/opened/downloaded) | NO soft delete. | +| `communication_campaigns` | id (ULID), event_id, type (email/sms/whatsapp), status | | +| `messages` | id (ULID), event_id, sender_user_id, recipient_person_id, urgency (normal/urgent/emergency) | | +| `broadcast_messages` | id (ULID), event_id, sender_user_id, body, urgency | | + +### 9. Forms, Check-In & Operational + +| Table | Key Columns | Notes | +|-------|-------------|-------| +| `public_forms` | id (ULID), event_id, crowd_type_id, fields (JSON), conditional_logic (JSON), iframe_token | | +| `form_submissions` | id (ULID), public_form_id, person_id, data (JSON) | | +| `check_ins` | id (ULID), event_id, person_id, scanned_by_user_id, scanned_at | NO soft delete. Immutable audit record. | +| `scanners` | id (ULID), event_id, name, type, pairing_code | | +| `inventory_items` | id (ULID), event_id, name, item_code, assigned_to_person_id | | +| `production_requests` | id (ULID), event_id, company_id, title, status, token (ULID unique) | | +| `material_requests` | id (ULID), production_request_id, category, name, quantity, status | | --- ## Model Relationships **User** -- User has many EventInvitations -- User has many Notifications -- User has many ActivityLogs -- User has many created Events (as creator) -- User has many created MusicNumbers (as creator) -- User has many created Setlists (as creator) +- belongsToMany Organisations (via `organisation_user`) +- belongsToMany Events (via `event_user_roles`) + +**Organisation** +- hasMany Events +- hasMany CrowdTypes +- hasMany AccreditationCategories +- hasMany Companies +- belongsToMany Users (via `organisation_user`) **Event** -- Event belongs to Location (nullable) -- Event belongs to Customer (nullable) -- Event belongs to Setlist (nullable) -- Event belongs to User (created_by) -- Event has many EventInvitations -- Event has many Users through EventInvitations (invited members) +- belongsTo Organisation +- hasMany FestivalSections +- hasMany TimeSlots +- hasMany Persons +- hasMany Artists +- hasMany Briefings +- hasMany Locations +- hasMany AccessZones +- hasMany PublicForms -**EventInvitation** -- EventInvitation belongs to Event -- EventInvitation belongs to User +**FestivalSection** +- belongsTo Event +- hasMany Shifts -**Location** -- Location has many Events +**TimeSlot** +- belongsTo Event +- hasMany Shifts +- hasMany ShiftAssignments (denormalized) -**Customer** -- Customer belongs to User (nullable, for portal access) -- Customer has many Events -- Customer has many BookingRequests +**Shift** +- belongsTo FestivalSection +- belongsTo TimeSlot +- belongsTo Location (nullable) +- hasMany ShiftAssignments -**MusicNumber** -- MusicNumber belongs to User (created_by, nullable) -- MusicNumber has many MusicAttachments -- MusicNumber has many SetlistItems -- MusicNumber has many Setlists through SetlistItems +**Person** +- belongsTo Event +- belongsTo CrowdType +- belongsTo User (nullable) +- belongsTo Company (nullable) +- hasMany ShiftAssignments +- hasMany AccreditationAssignments +- hasMany CheckIns -**MusicAttachment** -- MusicAttachment belongs to MusicNumber - -**Setlist** -- Setlist belongs to User (created_by, nullable) -- Setlist has many SetlistItems -- Setlist has many MusicNumbers through SetlistItems -- Setlist has many Events - -**SetlistItem** -- SetlistItem belongs to Setlist -- SetlistItem belongs to MusicNumber (nullable, null when is_break = true) - -**BookingRequest** -- BookingRequest belongs to Customer -- BookingRequest belongs to Event (nullable, when accepted) -- BookingRequest belongs to User (reviewed_by, nullable) - -**Notification** -- Notification belongs to User - -**ActivityLog** -- ActivityLog belongs to User (nullable) -- ActivityLog is polymorphic (loggable_type, loggable_id) +**Artist** +- belongsTo Event +- hasMany Performances +- hasMany AdvanceSections +- hasMany ArtistContacts --- -*Last updated: 2025-01-01* +## Security & CORS + +Three frontend origins in `config/cors.php` (via env): + +| App | Dev URL | Env Variable | +|-----|---------|--------------| +| Admin | `http://localhost:5173` | `FRONTEND_ADMIN_URL` | +| App | `http://localhost:5174` | `FRONTEND_APP_URL` | +| Portal | `http://localhost:5175` | `FRONTEND_PORTAL_URL` | + +--- + +## Real-time Events (WebSocket) + +Via Laravel Echo + Pusher/Soketi: +- `PersonCheckedIn` +- `ShiftFillRateChanged` +- `ArtistCheckInStatusChanged` +- `AdvanceSectionSubmitted` +- `AccreditationItemHandedOut` +- `BriefingSendQueued` + +--- + +*Source: EventCrew Design Document v1.3, March 2026* diff --git a/.cursor/instructions.md b/.cursor/instructions.md index 7e105ac..bb24a67 100644 --- a/.cursor/instructions.md +++ b/.cursor/instructions.md @@ -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 ` @@ -559,7 +454,7 @@ function handleDelete() {

Events

- Manage your gigs and performances + Manage events for your organisation

-

Loading events...

+ + + {{ error?.message ?? 'Failed to load events' }} + + - - - - Title - Date - Location - Status - Actions - - - - - - - {{ event.title }} - - - {{ event.event_date }} - {{ event.location?.name ?? '-' }} - - - {{ event.status_label }} - - - - - - - - - - - - - - + + - - - - Delete Event? - - This action cannot be undone. - - - - Cancel + + + + + + + - - ``` -### Router Configuration - -```typescript -// src/router/index.ts -import { createRouter, createWebHistory } from 'vue-router' -import { useAuthStore } from '@/stores/auth' - -const router = createRouter({ - history: createWebHistory(import.meta.env.BASE_URL), - routes: [ - { - path: '/', - component: () => import('@/layouts/default.vue'), - children: [ - { - path: '', - name: 'dashboard', - component: () => import('@/pages/dashboard.vue'), - meta: { requiresAuth: true }, - }, - { - path: 'events', - name: 'events', - component: () => import('@/pages/events/index.vue'), - meta: { requiresAuth: true }, - }, - { - path: 'events/create', - name: 'events-create', - component: () => import('@/pages/events/create.vue'), - meta: { requiresAuth: true }, - }, - { - path: 'events/:id', - name: 'events-show', - component: () => import('@/pages/events/[id].vue'), - meta: { requiresAuth: true }, - }, - { - path: 'events/:id/edit', - name: 'events-edit', - component: () => import('@/pages/events/[id]/edit.vue'), - meta: { requiresAuth: true }, - }, - // Add more routes... - ], - }, - { - path: '/login', - name: 'login', - component: () => import('@/pages/login.vue'), - meta: { layout: 'blank', requiresAuth: false }, - }, - { - path: '/:pathMatch(.*)*', - name: 'not-found', - component: () => import('@/pages/[...error].vue'), - }, - ], -}) - -// Navigation guard -router.beforeEach(async (to, from, next) => { - const authStore = useAuthStore() - - // Check if route requires auth - if (to.meta.requiresAuth && !authStore.isAuthenticated) { - // Try to fetch user if we have a token - if (authStore.token) { - const success = await authStore.fetchUser() - if (success) { - return next() - } - } - return next({ name: 'login', query: { redirect: to.fullPath } }) - } - - // Redirect to dashboard if logged in and going to login - if (to.name === 'login' && authStore.isAuthenticated) { - return next({ name: 'dashboard' }) - } - - next() -}) - -export default router -``` - -### Navigation Menu +### Navigation Menu (Organizer App) ```typescript // src/navigation/vertical/index.ts @@ -766,291 +533,153 @@ export default [ to: { name: 'dashboard' }, icon: { icon: 'tabler-smart-home' }, }, - { - heading: 'Management', - }, + { heading: 'Event Management' }, { title: 'Events', to: { name: 'events' }, icon: { icon: 'tabler-calendar-event' }, }, { - title: 'Members', - to: { name: 'members' }, + title: 'Festival Sections', + to: { name: 'festival-sections' }, + icon: { icon: 'tabler-layout-grid' }, + }, + { + title: 'Time Slots & Shifts', + to: { name: 'shifts' }, + icon: { icon: 'tabler-clock' }, + }, + { heading: 'People' }, + { + title: 'Persons', + to: { name: 'persons' }, icon: { icon: 'tabler-users' }, }, { - title: 'Music', - to: { name: 'music' }, + title: 'Artists', + to: { name: 'artists' }, icon: { icon: 'tabler-music' }, }, { - title: 'Setlists', - to: { name: 'setlists' }, - icon: { icon: 'tabler-playlist' }, + title: 'Volunteers', + to: { name: 'volunteers' }, + icon: { icon: 'tabler-heart-handshake' }, + }, + { heading: 'Operations' }, + { + title: 'Accreditation', + to: { name: 'accreditation' }, + icon: { icon: 'tabler-id-badge-2' }, }, { - heading: 'CRM', + title: 'Briefings', + to: { name: 'briefings' }, + icon: { icon: 'tabler-mail' }, }, { - title: 'Locations', - to: { name: 'locations' }, - icon: { icon: 'tabler-map-pin' }, + title: 'Mission Control', + to: { name: 'mission-control' }, + icon: { icon: 'tabler-broadcast' }, }, + { heading: 'Insights' }, { - title: 'Customers', - to: { name: 'customers' }, - icon: { icon: 'tabler-building' }, + title: 'Reports', + to: { name: 'reports' }, + icon: { icon: 'tabler-chart-bar' }, }, ] as VerticalNavItems ``` -## Best Practices - -### Always Use - -- ` ``` -## Event Handlers +## Best Practices -Name handlers with `handle` prefix: +### Always Use +- ` -``` - -## File Organization - -``` -src/ -├── @core/ # Vuexy core (DON'T MODIFY) -├── @layouts/ # Vuexy layouts (DON'T MODIFY) -├── assets/ # Static assets -│ ├── images/ -│ └── styles/ -├── components/ # Shared components -│ ├── ui/ # Base UI components -│ └── features/ # Feature-specific components -├── composables/ # Custom composables -│ ├── useEvents.ts -│ ├── useMembers.ts -│ └── useAuth.ts -├── layouts/ # App layouts -├── lib/ # Utilities -│ ├── api-client.ts -│ ├── utils.ts -│ └── query-client.ts -├── navigation/ # Menu configuration -├── pages/ # Route pages -│ ├── dashboard.vue -│ ├── events/ -│ │ ├── index.vue -│ │ ├── create.vue -│ │ └── [id]/ -│ │ ├── index.vue -│ │ └── edit.vue -│ └── login.vue -├── plugins/ # Vue plugins -├── router/ # Vue Router -├── stores/ # Pinia stores -├── types/ # TypeScript types -│ └── index.ts -└── App.vue -``` - -## Performance - -### Lazy Load Routes +## Portal Router Guards ```typescript -// ✅ Good - lazy load all route components -const routes = [ - { - path: '/events', - component: () => import('@/pages/events/index.vue'), - }, -] -``` - -### Use Computed for Derived State - -```typescript -// ✅ Good - computed is cached -const upcomingEvents = computed(() => - events.value.filter(e => new Date(e.event_date) > new Date()) -) - -// ❌ Avoid - recalculates on every render -const upcomingEvents = events.value.filter(e => new Date(e.event_date) > new Date()) -``` - -### Prefetch on Hover - -```typescript -// Prefetch event details when user hovers -function handleEventHover(id: string) { - queryClient.prefetchQuery({ - queryKey: ['events', id], - queryFn: () => fetchEvent(id), - }) -} -``` - -### Use v-once for Static Content - -```vue - -
-

Welcome to Band Management

-

Manage your band operations efficiently.

-
-``` - -## Error Handling - -```vue - - - -``` - -## API Integration Pattern - -Create typed API functions: - -```typescript -// lib/api/events.ts -import { apiClient } from '@/lib/api-client' -import type { Event, CreateEventData, ApiResponse } from '@/types' - -export const eventsApi = { - list: async (params?: { page?: number; status?: string }) => { - const { data } = await apiClient.get>('/events', { params }) - return data - }, - - get: async (id: string) => { - const { data } = await apiClient.get>(`/events/${id}`) - return data.data - }, - - create: async (eventData: CreateEventData) => { - const { data } = await apiClient.post>('/events', eventData) - return data.data - }, - - update: async (id: string, eventData: Partial) => { - const { data } = await apiClient.put>(`/events/${id}`, eventData) - return data.data - }, - - delete: async (id: string) => { - await apiClient.delete(`/events/${id}`) - }, +// apps/portal/src/router/guards.ts +export function determineAccessMode(route: RouteLocationNormalized): 'token' | 'login' | 'unauthenticated' { + if (route.query.token) return 'token' + if (authStore.isAuthenticated) return 'login' + return 'unauthenticated' } + +// Token-based: POST /api/v1/portal/token-auth { token: '...' } -> returns person context +// Login-based: Same /api/v1/auth/login as app/ ``` diff --git a/.cursor/rules/102_multi_tenancy.mdc b/.cursor/rules/102_multi_tenancy.mdc new file mode 100644 index 0000000..2cc4f49 --- /dev/null +++ b/.cursor/rules/102_multi_tenancy.mdc @@ -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 +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) +``` diff --git a/.cursor/rules/200_testing.mdc b/.cursor/rules/200_testing.mdc index 8c9234c..14cad25 100644 --- a/.cursor/rules/200_testing.mdc +++ b/.cursor/rules/200_testing.mdc @@ -1,5 +1,5 @@ --- -description: Testing standards for Laravel API and Vue frontend +description: Testing standards for EventCrew multi-tenant platform globs: ["**/tests/**", "**/*.test.ts", "**/*.test.tsx", "**/*.spec.ts", "**/*.spec.tsx", "**/Test.php"] alwaysApply: true --- @@ -10,9 +10,9 @@ alwaysApply: true 1. **Test behavior, not implementation** - Focus on what, not how 2. **Feature tests for APIs** - Test full request/response cycles -3. **Unit tests for logic** - Test Actions and complex functions -4. **Integration tests for Vue** - Test component interactions -5. **Fast and isolated** - Each test should be independent +3. **Multi-tenant isolation** - Every test must verify organisation scoping +4. **Minimum per endpoint**: happy path (200/201) + unauthenticated (401) + wrong organisation (403) + validation (422) +5. **PHPUnit** for backend, **Vitest** for frontend ## Laravel Testing @@ -22,326 +22,270 @@ alwaysApply: true api/tests/ ├── Feature/ │ └── Api/ -│ ├── AuthTest.php -│ ├── EventTest.php -│ ├── MemberTest.php -│ ├── MusicTest.php -│ ├── SetlistTest.php -│ ├── LocationTest.php -│ └── CustomerTest.php +│ └── V1/ +│ ├── AuthTest.php +│ ├── OrganisationTest.php +│ ├── EventTest.php +│ ├── FestivalSectionTest.php +│ ├── TimeSlotTest.php +│ ├── ShiftTest.php +│ ├── PersonTest.php +│ ├── ArtistTest.php +│ ├── AccreditationTest.php +│ └── BriefingTest.php ├── Unit/ -│ ├── Actions/ -│ │ ├── CreateEventActionTest.php -│ │ └── ... +│ ├── Services/ │ └── Models/ -│ ├── EventTest.php -│ └── ... └── TestCase.php ``` -### Feature Test Template +### Feature Test Template (Multi-Tenant) ```php admin = User::factory()->admin()->create(); - $this->member = User::factory()->member()->create(); + // Seed Spatie roles + Role::create(['name' => 'org_admin']); + Role::create(['name' => 'org_member']); + + // Create organisations + $this->organisation = Organisation::factory()->create(); + $this->otherOrganisation = Organisation::factory()->create(); + + // Create users with Spatie roles + $this->orgAdmin = User::factory()->create(); + $this->orgAdmin->assignRole('org_admin'); + $this->organisation->users()->attach($this->orgAdmin); + + $this->orgMember = User::factory()->create(); + $this->orgMember->assignRole('org_member'); + $this->organisation->users()->attach($this->orgMember); + + $this->otherOrgUser = User::factory()->create(); + $this->otherOrgUser->assignRole('org_admin'); + $this->otherOrganisation->users()->attach($this->otherOrgUser); } // ========================================== - // INDEX + // AUTHENTICATION (401) // ========================================== - public function test_guests_cannot_list_events(): void + public function test_unauthenticated_user_cannot_list_events(): void { - $response = $this->getJson('/api/v1/events'); - - $response->assertStatus(401); + $this->getJson("/api/v1/organisations/{$this->organisation->id}/events") + ->assertStatus(401); } - public function test_authenticated_users_can_list_events(): void + public function test_unauthenticated_user_cannot_create_event(): void { - Event::factory()->count(3)->create(); + $this->postJson("/api/v1/organisations/{$this->organisation->id}/events", []) + ->assertStatus(401); + } - $response = $this->actingAs($this->member) - ->getJson('/api/v1/events'); + // ========================================== + // ORGANISATION ISOLATION (403) + // ========================================== + + public function test_user_cannot_list_events_from_other_organisation(): void + { + Event::factory()->for($this->otherOrganisation)->count(3)->create(); + + $this->actingAs($this->orgAdmin) + ->getJson("/api/v1/organisations/{$this->otherOrganisation->id}/events") + ->assertStatus(403); + } + + public function test_user_cannot_view_event_from_other_organisation(): void + { + $event = Event::factory()->for($this->otherOrganisation)->create(); + + $this->actingAs($this->orgAdmin) + ->getJson("/api/v1/events/{$event->id}") + ->assertStatus(403); + } + + public function test_user_cannot_update_event_from_other_organisation(): void + { + $event = Event::factory()->for($this->otherOrganisation)->create(); + + $this->actingAs($this->orgAdmin) + ->putJson("/api/v1/events/{$event->id}", ['name' => 'Hacked']) + ->assertStatus(403); + + $this->assertDatabaseMissing('events', ['name' => 'Hacked']); + } + + // ========================================== + // INDEX (200) + // ========================================== + + public function test_org_admin_can_list_events(): void + { + Event::factory()->for($this->organisation)->count(3)->create(); + Event::factory()->for($this->otherOrganisation)->count(2)->create(); // should not appear + + $response = $this->actingAs($this->orgAdmin) + ->getJson("/api/v1/organisations/{$this->organisation->id}/events"); $response->assertStatus(200) + ->assertJsonCount(3, 'data') ->assertJsonStructure([ - 'success', - 'data' => [ - '*' => ['id', 'title', 'event_date', 'status'], - ], - 'meta' => ['pagination'], - ]) - ->assertJsonCount(3, 'data'); - } - - public function test_events_are_paginated(): void - { - Event::factory()->count(20)->create(); - - $response = $this->actingAs($this->member) - ->getJson('/api/v1/events?per_page=10'); - - $response->assertStatus(200) - ->assertJsonCount(10, 'data') - ->assertJsonPath('meta.pagination.total', 20) - ->assertJsonPath('meta.pagination.per_page', 10); + 'data' => ['*' => ['id', 'name', 'slug', 'start_date', 'end_date', 'status']], + 'meta', + ]); } // ========================================== - // SHOW + // SHOW (200) // ========================================== - public function test_can_view_single_event(): void + public function test_can_view_own_organisation_event(): void { - $event = Event::factory()->create(['title' => 'Summer Concert']); + $event = Event::factory()->for($this->organisation)->create([ + 'name' => 'Festivalpark 2026', + ]); - $response = $this->actingAs($this->member) + $response = $this->actingAs($this->orgMember) ->getJson("/api/v1/events/{$event->id}"); $response->assertStatus(200) - ->assertJsonPath('success', true) - ->assertJsonPath('data.title', 'Summer Concert'); - } - - public function test_returns_404_for_nonexistent_event(): void - { - $response = $this->actingAs($this->member) - ->getJson('/api/v1/events/nonexistent-id'); - - $response->assertStatus(404); + ->assertJsonPath('data.name', 'Festivalpark 2026') + ->assertJsonPath('data.organisation_id', $this->organisation->id); } // ========================================== - // STORE + // STORE (201) // ========================================== - public function test_admin_can_create_event(): void + public function test_org_admin_can_create_event(): void { - $location = Location::factory()->create(); - $eventData = [ - 'title' => 'New Year Concert', - 'event_date' => now()->addMonth()->toDateString(), - 'start_time' => '20:00', - 'location_id' => $location->id, - 'status' => 'draft', + 'name' => 'Zomerfestival 2026', + 'slug' => 'zomerfestival-2026', + 'start_date' => '2026-07-15', + 'end_date' => '2026-07-17', + 'timezone' => 'Europe/Amsterdam', ]; - $response = $this->actingAs($this->admin) - ->postJson('/api/v1/events', $eventData); + $response = $this->actingAs($this->orgAdmin) + ->postJson("/api/v1/organisations/{$this->organisation->id}/events", $eventData); $response->assertStatus(201) - ->assertJsonPath('success', true) - ->assertJsonPath('data.title', 'New Year Concert'); + ->assertJsonPath('data.name', 'Zomerfestival 2026'); $this->assertDatabaseHas('events', [ - 'title' => 'New Year Concert', - 'location_id' => $location->id, + 'name' => 'Zomerfestival 2026', + 'organisation_id' => $this->organisation->id, ]); } - public function test_member_cannot_create_event(): void + public function test_org_member_cannot_create_event(): void { - $eventData = [ - 'title' => 'Unauthorized Event', - 'event_date' => now()->addMonth()->toDateString(), - 'start_time' => '20:00', - ]; - - $response = $this->actingAs($this->member) - ->postJson('/api/v1/events', $eventData); - - $response->assertStatus(403); - } - - public function test_validation_errors_are_returned(): void - { - $response = $this->actingAs($this->admin) - ->postJson('/api/v1/events', []); - - $response->assertStatus(422) - ->assertJsonPath('success', false) - ->assertJsonValidationErrors(['title', 'event_date', 'start_time']); - } - - public function test_event_date_must_be_future(): void - { - $response = $this->actingAs($this->admin) - ->postJson('/api/v1/events', [ - 'title' => 'Past Event', - 'event_date' => now()->subDay()->toDateString(), - 'start_time' => '20:00', - ]); - - $response->assertStatus(422) - ->assertJsonValidationErrors(['event_date']); + $this->actingAs($this->orgMember) + ->postJson("/api/v1/organisations/{$this->organisation->id}/events", [ + 'name' => 'Unauthorized Event', + 'slug' => 'unauthorized', + 'start_date' => '2026-07-15', + 'end_date' => '2026-07-17', + ]) + ->assertStatus(403); } // ========================================== - // UPDATE + // VALIDATION (422) // ========================================== - public function test_admin_can_update_event(): void + public function test_validation_errors_for_missing_required_fields(): void { - $event = Event::factory()->create(['title' => 'Old Title']); + $this->actingAs($this->orgAdmin) + ->postJson("/api/v1/organisations/{$this->organisation->id}/events", []) + ->assertStatus(422) + ->assertJsonValidationErrors(['name', 'start_date', 'end_date']); + } - $response = $this->actingAs($this->admin) - ->putJson("/api/v1/events/{$event->id}", [ - 'title' => 'Updated Title', - ]); + public function test_end_date_must_be_after_start_date(): void + { + $this->actingAs($this->orgAdmin) + ->postJson("/api/v1/organisations/{$this->organisation->id}/events", [ + 'name' => 'Invalid Event', + 'slug' => 'invalid-event', + 'start_date' => '2026-07-20', + 'end_date' => '2026-07-15', // before start + ]) + ->assertStatus(422) + ->assertJsonValidationErrors(['end_date']); + } - $response->assertStatus(200) - ->assertJsonPath('data.title', 'Updated Title'); + // ========================================== + // UPDATE (200) + // ========================================== + + public function test_org_admin_can_update_event(): void + { + $event = Event::factory()->for($this->organisation)->create(); + + $this->actingAs($this->orgAdmin) + ->putJson("/api/v1/events/{$event->id}", ['name' => 'Updated Name']) + ->assertStatus(200) + ->assertJsonPath('data.name', 'Updated Name'); $this->assertDatabaseHas('events', [ 'id' => $event->id, - 'title' => 'Updated Title', + 'name' => 'Updated Name', ]); } - public function test_can_update_event_status(): void - { - $event = Event::factory()->draft()->create(); - - $response = $this->actingAs($this->admin) - ->putJson("/api/v1/events/{$event->id}", [ - 'status' => 'confirmed', - ]); - - $response->assertStatus(200) - ->assertJsonPath('data.status', 'confirmed'); - } - // ========================================== - // DELETE + // DELETE (204) // ========================================== - public function test_admin_can_delete_event(): void + public function test_org_admin_can_delete_event(): void { - $event = Event::factory()->create(); + $event = Event::factory()->for($this->organisation)->create(); - $response = $this->actingAs($this->admin) - ->deleteJson("/api/v1/events/{$event->id}"); + $this->actingAs($this->orgAdmin) + ->deleteJson("/api/v1/events/{$event->id}") + ->assertStatus(204); - $response->assertStatus(200) - ->assertJsonPath('success', true); - - $this->assertDatabaseMissing('events', ['id' => $event->id]); + $this->assertSoftDeleted('events', ['id' => $event->id]); } - public function test_member_cannot_delete_event(): void + public function test_org_member_cannot_delete_event(): void { - $event = Event::factory()->create(); + $event = Event::factory()->for($this->organisation)->create(); - $response = $this->actingAs($this->member) - ->deleteJson("/api/v1/events/{$event->id}"); + $this->actingAs($this->orgMember) + ->deleteJson("/api/v1/events/{$event->id}") + ->assertStatus(403); - $response->assertStatus(403); $this->assertDatabaseHas('events', ['id' => $event->id]); } - - // ========================================== - // RELATIONSHIPS - // ========================================== - - public function test_event_includes_location_when_loaded(): void - { - $location = Location::factory()->create(['name' => 'City Hall']); - $event = Event::factory()->create(['location_id' => $location->id]); - - $response = $this->actingAs($this->member) - ->getJson("/api/v1/events/{$event->id}"); - - $response->assertStatus(200) - ->assertJsonPath('data.location.name', 'City Hall'); - } -} -``` - -### Unit Test Template (Action) - -```php -action = new CreateEventAction(); - } - - public function test_creates_event_with_valid_data(): void - { - $user = User::factory()->create(); - $this->actingAs($user); - - $data = [ - 'title' => 'Test Event', - 'event_date' => now()->addMonth()->toDateString(), - 'start_time' => '20:00', - ]; - - $event = $this->action->execute($data); - - $this->assertInstanceOf(Event::class, $event); - $this->assertEquals('Test Event', $event->title); - $this->assertEquals($user->id, $event->created_by); - } - - public function test_sets_default_values(): void - { - $user = User::factory()->create(); - $this->actingAs($user); - - $event = $this->action->execute([ - 'title' => 'Test', - 'event_date' => now()->addMonth()->toDateString(), - 'start_time' => '20:00', - ]); - - $this->assertEquals('EUR', $event->currency); - $this->assertEquals('draft', $event->status->value); - } } ``` @@ -355,32 +299,30 @@ declare(strict_types=1); namespace Database\Factories; use App\Enums\EventStatus; -use App\Enums\EventVisibility; use App\Models\Event; -use App\Models\Location; -use App\Models\User; +use App\Models\Organisation; use Illuminate\Database\Eloquent\Factories\Factory; /** * @extends Factory */ -final class EventFactory extends Factory +class EventFactory extends Factory { protected $model = Event::class; public function definition(): array { + $startDate = fake('nl_NL')->dateTimeBetween('+1 week', '+6 months'); + $endDate = (clone $startDate)->modify('+' . fake()->numberBetween(1, 5) . ' days'); + return [ - 'title' => fake()->sentence(3), - 'description' => fake()->optional()->paragraph(), - 'event_date' => fake()->dateTimeBetween('+1 week', '+6 months'), - 'start_time' => fake()->time('H:i'), - 'end_time' => fake()->optional()->time('H:i'), - 'fee' => fake()->optional()->randomFloat(2, 100, 5000), - 'currency' => 'EUR', + 'organisation_id' => Organisation::factory(), + 'name' => fake('nl_NL')->words(3, true) . ' Festival', + 'slug' => fake()->slug(), + 'start_date' => $startDate, + 'end_date' => $endDate, + 'timezone' => 'Europe/Amsterdam', 'status' => fake()->randomElement(EventStatus::cases()), - 'visibility' => EventVisibility::Members, - 'created_by' => User::factory(), ]; } @@ -389,79 +331,54 @@ final class EventFactory extends Factory return $this->state(fn () => ['status' => EventStatus::Draft]); } - public function confirmed(): static + public function published(): static { - return $this->state(fn () => ['status' => EventStatus::Confirmed]); + return $this->state(fn () => ['status' => EventStatus::Published]); } - public function withLocation(): static - { - return $this->state(fn () => ['location_id' => Location::factory()]); - } - - public function upcoming(): static + public function showday(): static { return $this->state(fn () => [ - 'event_date' => fake()->dateTimeBetween('+1 day', '+1 month'), - 'status' => EventStatus::Confirmed, - ]); - } - - public function past(): static - { - return $this->state(fn () => [ - 'event_date' => fake()->dateTimeBetween('-6 months', '-1 day'), - 'status' => EventStatus::Completed, + 'status' => EventStatus::ShowDay, + 'start_date' => now(), + 'end_date' => now()->addDays(2), ]); } } ``` -### User Factory States +### Organisation Factory ```php state(fn () => [ - 'type' => 'member', - 'role' => 'admin', - 'status' => 'active', - ]); -} +namespace Database\Factories; -public function member(): static -{ - return $this->state(fn () => [ - 'type' => 'member', - 'role' => 'member', - 'status' => 'active', - ]); -} +use App\Models\Organisation; +use Illuminate\Database\Eloquent\Factories\Factory; -public function bookingAgent(): static +/** + * @extends Factory + */ +class OrganisationFactory extends Factory { - return $this->state(fn () => [ - 'type' => 'member', - 'role' => 'booking_agent', - 'status' => 'active', - ]); -} + protected $model = Organisation::class; -public function customer(): static -{ - return $this->state(fn () => [ - 'type' => 'customer', - 'role' => null, - 'status' => 'active', - ]); + public function definition(): array + { + return [ + 'name' => fake('nl_NL')->company(), + 'slug' => fake()->unique()->slug(), + 'billing_status' => 'active', + 'settings' => [], + ]; + } } ``` -### Test Helpers (TestCase.php) +### TestCase Base Class ```php admin()->create(); - $this->actingAs($admin); - return $admin; + Role::firstOrCreate(['name' => 'org_admin']); + + $organisation ??= Organisation::factory()->create(); + $user = User::factory()->create(); + $user->assignRole('org_admin'); + $organisation->users()->attach($user); + + return $user; } /** - * Create and authenticate as regular member. + * Create user with org_member role attached to an organisation. */ - protected function actingAsMember(): User + protected function createOrgMember(?Organisation $organisation = null): User { - $member = User::factory()->member()->create(); - $this->actingAs($member); - return $member; - } + Role::firstOrCreate(['name' => 'org_member']); - /** - * Assert API success response structure. - */ - protected function assertApiSuccess($response, int $status = 200): void - { - $response->assertStatus($status) - ->assertJsonStructure(['success', 'data']) - ->assertJsonPath('success', true); - } + $organisation ??= Organisation::factory()->create(); + $user = User::factory()->create(); + $user->assignRole('org_member'); + $organisation->users()->attach($user); - /** - * Assert API error response structure. - */ - protected function assertApiError($response, int $status = 400): void - { - $response->assertStatus($status) - ->assertJsonPath('success', false) - ->assertJsonStructure(['success', 'message']); + return $user; } } ``` ## Running Tests -### Laravel - ```bash -# Run all tests +# All tests cd api && php artisan test -# Run with coverage +# Specific test class +php artisan test --filter=EventTest + +# Specific test method +php artisan test --filter=test_org_admin_can_create_event + +# With coverage php artisan test --coverage -# Run specific test file -php artisan test tests/Feature/Api/EventTest.php - -# Run specific test method -php artisan test --filter test_admin_can_create_event - -# Run in parallel -php artisan test --parallel +# After each module +php artisan test --filter=ModuleName ``` -### Pest PHP Syntax +## Vue Testing (Vitest) -```php -admin = User::factory()->admin()->create(); -}); - -it('allows admin to create events', function () { - $response = $this->actingAs($this->admin) - ->postJson('/api/v1/events', [ - 'title' => 'Concert', - 'event_date' => now()->addMonth()->toDateString(), - 'start_time' => '20:00', - ]); - - $response->assertStatus(201); - expect(Event::count())->toBe(1); -}); - -it('validates required fields', function () { - $response = $this->actingAs($this->admin) - ->postJson('/api/v1/events', []); - - $response->assertStatus(422) - ->assertJsonValidationErrors(['title', 'event_date']); -}); - -it('returns paginated results', function () { - Event::factory()->count(25)->create(); - - $response = $this->actingAs($this->admin) - ->getJson('/api/v1/events?per_page=10'); - - expect($response->json('data'))->toHaveCount(10); - expect($response->json('meta.pagination.total'))->toBe(25); -}); -``` - -## Vue Testing (Vitest + Vue Test Utils) - -### File Organization - -``` -src/ -├── components/ -│ └── EventCard/ -│ ├── EventCard.vue -│ └── EventCard.test.ts -├── composables/ -│ └── useEvents.test.ts -└── test/ - ├── setup.ts - ├── mocks/ - │ ├── handlers.ts # MSW handlers - │ └── server.ts - └── utils.ts # Custom render with providers -``` - -### Vitest Configuration +### Configuration ```typescript // vitest.config.ts @@ -624,247 +470,80 @@ export default defineConfig({ }) ``` -### Test Setup - -```typescript -// src/test/setup.ts -import { vi, beforeAll, afterAll, afterEach } from 'vitest' -import { config } from '@vue/test-utils' -import { server } from './mocks/server' - -// Start MSW server -beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) -afterEach(() => server.resetHandlers()) -afterAll(() => server.close()) - -// Global stubs for Vuexy components -config.global.stubs = { - VBtn: true, - VCard: true, - VCardText: true, - VCardTitle: true, - VTable: true, - VDialog: true, - VProgressCircular: true, - RouterLink: true, -} -``` - -### MSW Setup for API Mocking - -```typescript -// src/test/mocks/handlers.ts -import { http, HttpResponse } from 'msw' - -export const handlers = [ - http.get('/api/v1/events', () => { - return HttpResponse.json({ - success: true, - data: [ - { id: '1', title: 'Event 1', status: 'confirmed' }, - { id: '2', title: 'Event 2', status: 'draft' }, - { id: '3', title: 'Event 3', status: 'pending' }, - ], - meta: { pagination: { current_page: 1, total: 3, per_page: 15 } } - }) - }), - - http.post('/api/v1/events', async ({ request }) => { - const body = await request.json() - return HttpResponse.json({ - success: true, - data: { id: '4', ...body }, - message: 'Event created successfully' - }, { status: 201 }) - }), - - http.get('/api/v1/auth/user', () => { - return HttpResponse.json({ - success: true, - data: { id: '1', name: 'Test User', email: 'test@example.com', role: 'admin' } - }) - }), -] - -// src/test/mocks/server.ts -import { setupServer } from 'msw/node' -import { handlers } from './handlers' - -export const server = setupServer(...handlers) -``` - -### Test Utilities - -```typescript -// src/test/utils.ts -import { mount, VueWrapper } from '@vue/test-utils' -import { createPinia, setActivePinia } from 'pinia' -import { QueryClient, VueQueryPlugin } from '@tanstack/vue-query' -import { createRouter, createWebHistory } from 'vue-router' -import type { Component } from 'vue' - -export function createTestingPinia() { - const pinia = createPinia() - setActivePinia(pinia) - return pinia -} - -export function createTestQueryClient() { - return new QueryClient({ - defaultOptions: { - queries: { retry: false, gcTime: 0 }, - mutations: { retry: false }, - }, - }) -} - -export function mountWithProviders(component: Component, options = {}) { - const pinia = createTestingPinia() - const queryClient = createTestQueryClient() - const router = createRouter({ - history: createWebHistory(), - routes: [{ path: '/', component: { template: '
' } }], - }) - - return mount(component, { - global: { - plugins: [pinia, router, [VueQueryPlugin, { queryClient }]], - stubs: { - VBtn: true, - VCard: true, - VTable: true, - }, - }, - ...options, - }) -} -``` - -### Component Test Pattern - -```typescript -// src/components/EventCard.test.ts -import { describe, it, expect, vi } from 'vitest' -import { mount } from '@vue/test-utils' -import EventCard from './EventCard.vue' - -describe('EventCard', () => { - const mockEvent = { - id: '1', - title: 'Summer Concert', - event_date: '2025-07-15', - status: 'confirmed', - status_label: 'Confirmed', - } - - it('displays event title', () => { - const wrapper = mount(EventCard, { - props: { event: mockEvent }, - }) - - expect(wrapper.text()).toContain('Summer Concert') - }) - - it('emits edit event when edit button clicked', async () => { - const wrapper = mount(EventCard, { - props: { event: mockEvent }, - }) - - await wrapper.find('[data-test="edit-btn"]').trigger('click') - - expect(wrapper.emitted('edit')).toBeTruthy() - expect(wrapper.emitted('edit')?.[0]).toEqual([mockEvent]) - }) - - it('shows correct status color', () => { - const wrapper = mount(EventCard, { - props: { event: mockEvent }, - }) - - const chip = wrapper.find('[data-test="status-chip"]') - expect(chip.classes()).toContain('bg-success') - }) -}) -``` - ### Composable Test Pattern ```typescript // src/composables/__tests__/useEvents.test.ts -import { describe, it, expect, vi, beforeEach } from 'vitest' +import { describe, it, expect, beforeEach } from 'vitest' import { flushPromises } from '@vue/test-utils' -import { useEvents } from '../useEvents' -import { createTestQueryClient, createTestingPinia } from '@/test/utils' -import { VueQueryPlugin } from '@tanstack/vue-query' +import { createPinia, setActivePinia } from 'pinia' +import { QueryClient, VueQueryPlugin } from '@tanstack/vue-query' import { createApp } from 'vue' describe('useEvents', () => { beforeEach(() => { - createTestingPinia() + setActivePinia(createPinia()) }) - it('fetches events successfully', async () => { + it('fetches events for organisation', async () => { const app = createApp({ template: '
' }) - const queryClient = createTestQueryClient() + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) app.use(VueQueryPlugin, { queryClient }) - - // Mount and test... + await flushPromises() - - // Assertions + // Assert API was called with correct organisation_id }) }) ``` -### Pest Expectations (Laravel) - -```php -// Use fluent expectations -expect($user)->toBeInstanceOf(User::class); -expect($collection)->toHaveCount(5); -expect($response->json('data'))->toMatchArray([...]); - -// Chain expectations -expect($user) - ->name->toBe('John') - ->email->toContain('@') - ->created_at->toBeInstanceOf(Carbon::class); -``` - ## Test Coverage Goals | Area | Target | Priority | |------|--------|----------| | API Endpoints | 90%+ | High | -| Actions | 100% | High | +| Organisation Isolation | 100% | Critical | +| Services | 100% | High | | Models | 80%+ | Medium | | Vue Composables | 80%+ | Medium | | Vue Components | 60%+ | Low | -**Minimum Coverage**: 80% line coverage +## Testing Checklist (per module) -## Coverage Requirements +### Backend +- [ ] Index: returns only own organisation's data (200) +- [ ] Show: can view own org resource (200) +- [ ] Show: cannot view other org resource (403) +- [ ] Store: org_admin can create (201) +- [ ] Store: org_member cannot create (403) +- [ ] Store: validation errors returned (422) +- [ ] Store: unauthenticated rejected (401) +- [ ] Update: org_admin can update (200) +- [ ] Update: cannot update other org resource (403) +- [ ] Delete: org_admin can delete (204) +- [ ] Delete: org_member cannot delete (403) +- [ ] Soft delete: record still exists with deleted_at -- **Feature tests**: All API endpoints must have tests -- **Unit tests**: All Actions and Services with business logic -- **Component tests**: All interactive components -- **Composable tests**: All custom composables that fetch data +### Frontend +- [ ] Loading state shown during fetch +- [ ] Error state with retry button on failure +- [ ] Empty state when no data +- [ ] Mobile responsive (375px) ## Best Practices ### Do - -- Test happy path AND edge cases -- Use descriptive test names +- Test organisation isolation for every endpoint +- Use Spatie roles in test setup (assignRole, hasRole) +- Use factories with realistic Dutch test data (`fake('nl_NL')`) +- Test soft deletes with `assertSoftDeleted` - One assertion focus per test -- Use factories for test data -- Clean up after tests (RefreshDatabase) -- Test authorization separately +- Use RefreshDatabase trait ### Don't - -- Test framework code (Laravel, Vue) -- Test private methods directly +- Skip organisation isolation tests - Share state between tests -- Use real external APIs -- Over-mock (test real behavior) +- Use real external APIs (Zender, SendGrid) +- Test Laravel/Vue framework internals +- Over-mock (test real behavior where possible) diff --git a/.cursorrules b/.cursorrules new file mode 100644 index 0000000..49121b6 --- /dev/null +++ b/.cursorrules @@ -0,0 +1,21 @@ +# EventCrew Cursor Rules + +## Stack +PHP 8.2 + Laravel 12 | TypeScript + Vue 3 + Vuexy/Vuetify | Pinia + TanStack Query + +## Laravel +- Resource Controllers, Form Requests, API Resources — altijd +- HasUlids op business modellen, HasFactory, SoftDeletes waar gedocumenteerd +- Global Scope OrganisationScope op event-gerelateerde modellen +- Policies voor autorisatie, nooit inline role checks + +## Vue 3 +- - - - diff --git a/apps/admin/src/composables/useApi.ts b/apps/admin/src/composables/useApi.ts deleted file mode 100644 index e4b7811..0000000 --- a/apps/admin/src/composables/useApi.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { createFetch } from '@vueuse/core' -import { destr } from 'destr' - -export const useApi = createFetch({ - baseUrl: import.meta.env.VITE_API_BASE_URL || '/api', - fetchOptions: { - headers: { - Accept: 'application/json', - }, - }, - options: { - refetch: true, - async beforeFetch({ options }) { - const accessToken = useCookie('accessToken').value - - if (accessToken) { - options.headers = { - ...options.headers, - Authorization: `Bearer ${accessToken}`, - } - } - - return { options } - }, - afterFetch(ctx) { - const { data, response } = ctx - - // Parse data if it's JSON - - let parsedData = null - try { - parsedData = destr(data) - } - catch (error) { - console.error(error) - } - - return { data: parsedData, response } - }, - }, -}) diff --git a/apps/admin/src/composables/useEvents.ts b/apps/admin/src/composables/useEvents.ts index 7f67c95..71f34a0 100644 --- a/apps/admin/src/composables/useEvents.ts +++ b/apps/admin/src/composables/useEvents.ts @@ -1,177 +1,137 @@ -import { ref, computed } from "vue"; -import { $api } from "@/utils/api"; -import type { - Event, - CreateEventData, - UpdateEventData, - InviteToEventData, - ApiResponse, - Pagination, -} from "@/types/events"; +import { computed, ref } from 'vue' +import { apiClient } from '@/lib/api-client' +import { useCurrentOrganisationId } from '@/composables/useOrganisationContext' +import type { ApiResponse, CreateEventData, Event, Pagination, UpdateEventData } from '@/types/events' + +/** Laravel paginated JSON resource response (no `success` wrapper). */ +interface LaravelPaginatedEventsBody { + data: Event[] + meta: { + current_page: number + per_page: number + total: number + last_page: number + from: number | null + to: number | null + } +} + +function requireOrganisationId(organisationId: string | null): string { + if (!organisationId) { + throw new Error('No organisation in session. Log in again or select an organisation.') + } + return organisationId +} export function useEvents() { - const events = ref([]); - const currentEvent = ref(null); - const pagination = ref(null); - const isLoading = ref(false); - const error = ref(null); + const { organisationId } = useCurrentOrganisationId() + const events = ref([]) + const currentEvent = ref(null) + const pagination = ref(null) + const isLoading = ref(false) + const error = ref(null) + + function eventsPath(): string { + const id = requireOrganisationId(organisationId.value) + return `/organisations/${id}/events` + } - // Fetch all events async function fetchEvents(params?: { - page?: number; - per_page?: number; - status?: string; + page?: number + per_page?: number }) { - isLoading.value = true; - error.value = null; + isLoading.value = true + error.value = null try { - const response = await $api>("/events", { - method: "GET", - query: params, - }); - events.value = response.data; - pagination.value = response.meta?.pagination || null; - } catch (err) { - error.value = - err instanceof Error ? err : new Error("Failed to fetch events"); - throw error.value; - } finally { - isLoading.value = false; + const { data } = await apiClient.get(eventsPath(), { params }) + events.value = data.data + pagination.value = { + current_page: data.meta.current_page, + per_page: data.meta.per_page, + total: data.meta.total, + last_page: data.meta.last_page, + from: data.meta.from, + to: data.meta.to, + } + } + catch (err) { + error.value = err instanceof Error ? err : new Error('Failed to fetch events') + throw error.value + } + finally { + isLoading.value = false } } - // Fetch single event async function fetchEvent(id: string) { - isLoading.value = true; - error.value = null; + isLoading.value = true + error.value = null try { - const response = await $api>(`/events/${id}`, { - method: "GET", - }); - currentEvent.value = response.data; - return response.data; - } catch (err) { - error.value = - err instanceof Error ? err : new Error("Failed to fetch event"); - throw error.value; - } finally { - isLoading.value = false; + const { data } = await apiClient.get>(`${eventsPath()}/${id}`) + currentEvent.value = data.data + return data.data + } + catch (err) { + error.value = err instanceof Error ? err : new Error('Failed to fetch event') + throw error.value + } + finally { + isLoading.value = false } } - // Create event async function createEvent(eventData: CreateEventData) { - isLoading.value = true; - error.value = null; + isLoading.value = true + error.value = null try { - const response = await $api>("/events", { - method: "POST", - body: eventData, - }); - events.value.unshift(response.data); - return response.data; - } catch (err) { - error.value = - err instanceof Error ? err : new Error("Failed to create event"); - throw error.value; - } finally { - isLoading.value = false; + const { data } = await apiClient.post>(eventsPath(), eventData) + events.value.unshift(data.data) + return data.data + } + catch (err) { + error.value = err instanceof Error ? err : new Error('Failed to create event') + throw error.value + } + finally { + isLoading.value = false } } - // Update event async function updateEvent(id: string, eventData: UpdateEventData) { - isLoading.value = true; - error.value = null; + isLoading.value = true + error.value = null try { - const response = await $api>(`/events/${id}`, { - method: "PUT", - body: eventData, - }); - const index = events.value.findIndex((e) => e.id === id); - if (index !== -1) { - events.value[index] = response.data; - } - if (currentEvent.value?.id === id) { - currentEvent.value = response.data; - } - return response.data; - } catch (err) { - error.value = - err instanceof Error ? err : new Error("Failed to update event"); - throw error.value; - } finally { - isLoading.value = false; + const { data } = await apiClient.put>(`${eventsPath()}/${id}`, eventData) + const index = events.value.findIndex(e => e.id === id) + if (index !== -1) + events.value[index] = data.data + if (currentEvent.value?.id === id) + currentEvent.value = data.data + return data.data } - } - - // Delete event - async function deleteEvent(id: string) { - isLoading.value = true; - error.value = null; - - try { - await $api(`/events/${id}`, { - method: "DELETE", - }); - events.value = events.value.filter((e) => e.id !== id); - if (currentEvent.value?.id === id) { - currentEvent.value = null; - } - } catch (err) { - error.value = - err instanceof Error ? err : new Error("Failed to delete event"); - throw error.value; - } finally { - isLoading.value = false; + catch (err) { + error.value = err instanceof Error ? err : new Error('Failed to update event') + throw error.value } - } - - // Invite members to event - async function inviteToEvent(eventId: string, inviteData: InviteToEventData) { - isLoading.value = true; - error.value = null; - - try { - const response = await $api>( - `/events/${eventId}/invite`, - { - method: "POST", - body: inviteData, - } - ); - // Refresh event to get updated invitations - if (currentEvent.value?.id === eventId) { - await fetchEvent(eventId); - } - return response.data; - } catch (err) { - error.value = - err instanceof Error ? err : new Error("Failed to invite members"); - throw error.value; - } finally { - isLoading.value = false; + finally { + isLoading.value = false } } return { - // State + organisationId: computed(() => organisationId.value), events: computed(() => events.value), currentEvent: computed(() => currentEvent.value), pagination: computed(() => pagination.value), isLoading: computed(() => isLoading.value), error: computed(() => error.value), - - // Actions fetchEvents, fetchEvent, createEvent, updateEvent, - deleteEvent, - inviteToEvent, - }; + } } diff --git a/apps/admin/src/composables/useOrganisationContext.ts b/apps/admin/src/composables/useOrganisationContext.ts new file mode 100644 index 0000000..bfe5db6 --- /dev/null +++ b/apps/admin/src/composables/useOrganisationContext.ts @@ -0,0 +1,28 @@ +import { useCookie } from '@core/composable/useCookie' +import { computed } from 'vue' + +export interface AuthOrganisationSummary { + id: string + name: string + slug: string + role: string +} + +export interface AuthUserCookie { + id: string + name: string + email: string + roles?: string[] + organisations?: AuthOrganisationSummary[] +} + +/** + * First organisation from the session cookie (set at login). Super-admins still need an organisation context for nested event routes. + */ +export function useCurrentOrganisationId() { + const userData = useCookie('userData') + + const organisationId = computed(() => userData.value?.organisations?.[0]?.id ?? null) + + return { organisationId } +} diff --git a/apps/admin/src/lib/api-client.ts b/apps/admin/src/lib/api-client.ts index 6714c8a..90ab992 100644 --- a/apps/admin/src/lib/api-client.ts +++ b/apps/admin/src/lib/api-client.ts @@ -1,24 +1,36 @@ import axios from 'axios' +import { parse } from 'cookie-es' import type { AxiosInstance, InternalAxiosRequestConfig } from 'axios' +/** + * Single axios instance for the real Laravel API (VITE_API_URL). + * Auth: Bearer token from cookie 'accessToken' (set by login). + * Use this for all event-crew API calls; useApi (composables/useApi) stays for Vuexy demo/mock endpoints. + */ const apiClient: AxiosInstance = axios.create({ baseURL: import.meta.env.VITE_API_URL, headers: { 'Content-Type': 'application/json', - 'Accept': 'application/json', + Accept: 'application/json', }, timeout: 30000, }) -// Request interceptor - add auth token +function getAccessToken(): string | null { + if (typeof document === 'undefined') return null + const cookies = parse(document.cookie) + const token = cookies.accessToken + + return token ?? null +} + apiClient.interceptors.request.use( (config: InternalAxiosRequestConfig) => { - const token = localStorage.getItem('auth_token') + const token = getAccessToken() if (token) { config.headers.Authorization = `Bearer ${token}` } - // Log in development if (import.meta.env.DEV) { console.log(`🚀 ${config.method?.toUpperCase()} ${config.url}`, config.data) } @@ -28,7 +40,6 @@ apiClient.interceptors.request.use( error => Promise.reject(error), ) -// Response interceptor - handle errors apiClient.interceptors.response.use( response => { if (import.meta.env.DEV) { @@ -39,13 +50,20 @@ apiClient.interceptors.response.use( }, error => { if (import.meta.env.DEV) { - console.error(`❌ ${error.response?.status} ${error.config?.url}`, error.response?.data) + console.error( + `❌ ${error.response?.status} ${error.config?.url}`, + error.response?.data, + ) } - // Handle 401 - redirect to login if (error.response?.status === 401) { - localStorage.removeItem('auth_token') - window.location.href = '/login' + // Clear auth cookies (align with utils/api.ts / login flow) + document.cookie = 'accessToken=; path=/; max-age=0' + document.cookie = 'userData=; path=/; max-age=0' + document.cookie = 'userAbilityRules=; path=/; max-age=0' + if (window.location.pathname !== '/login') { + window.location.href = '/login' + } } return Promise.reject(error) @@ -53,4 +71,3 @@ apiClient.interceptors.response.use( ) export { apiClient } - diff --git a/apps/admin/src/main.ts b/apps/admin/src/main.ts index ba6c598..159c494 100644 --- a/apps/admin/src/main.ts +++ b/apps/admin/src/main.ts @@ -1,4 +1,5 @@ import { createApp } from 'vue' +import { VueQueryPlugin } from '@tanstack/vue-query' import App from '@/App.vue' import { registerPlugins } from '@core/utils/plugins' @@ -17,6 +18,14 @@ app.config.errorHandler = (err, instance, info) => { } // Register plugins +app.use(VueQueryPlugin, { + queryClientConfig: { + defaultOptions: { + queries: { staleTime: 1000 * 60 * 5, retry: 1 }, + }, + }, +}) + try { registerPlugins(app) } catch (error) { diff --git a/apps/admin/src/pages/events/[id]/edit.vue b/apps/admin/src/pages/events/[id]/edit.vue index 9c8da20..a18bf41 100644 --- a/apps/admin/src/pages/events/[id]/edit.vue +++ b/apps/admin/src/pages/events/[id]/edit.vue @@ -1,7 +1,7 @@ diff --git a/apps/admin/src/pages/events/create.vue b/apps/admin/src/pages/events/create.vue index f1df63e..11455d5 100644 --- a/apps/admin/src/pages/events/create.vue +++ b/apps/admin/src/pages/events/create.vue @@ -1,7 +1,7 @@