refactor: align codebase with EventCrew domain and trim legacy band stack
- Update API: events, users, policies, routes, resources, migrations - Remove deprecated models/resources (customers, setlists, invitations, etc.) - Refresh admin app and docs; remove apps/band Made-with: Cursor
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -1,28 +1,30 @@
|
||||
# Band Management - Cursor AI Instructions
|
||||
# EventCrew - Cursor AI Instructions
|
||||
|
||||
> This document provides AI assistants with comprehensive context about the project.
|
||||
> Update this file as the project evolves.
|
||||
> Multi-tenant SaaS platform for event- and festival management.
|
||||
> Design Document: `/resources/design/EventCrew_Design_Document_v1.3.docx`
|
||||
> Dev Guide: `/resources/design/EventCrew_Dev_Guide_v1.0.docx`
|
||||
> Start Guide: `/resources/design/EventCrew_Start_Guide_v1.0.docx`
|
||||
|
||||
## Project Overview
|
||||
|
||||
**Name**: Band Management Platform
|
||||
**Type**: Full-stack web application (API-first architecture)
|
||||
**Name**: EventCrew
|
||||
**Type**: Multi-tenant SaaS platform (API-first architecture)
|
||||
**Status**: Development
|
||||
|
||||
### Description
|
||||
|
||||
Band Management is a full-stack web application designed to streamline band operations by centralizing member coordination, gig management, music cataloging, and setlist planning. The platform serves as the single source of truth for all band-related activities.
|
||||
EventCrew is a multi-tenant SaaS platform for professional event and festival management. It supports the full operational cycle: artist booking and advancing, staff planning and volunteer management, accreditation, briefings, and real-time show-day operations (Mission Control). Built for a professional volunteer organisation, with SaaS expansion potential.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Component | Technology | Location | Port |
|
||||
|-----------|------------|----------|------|
|
||||
| API | Laravel 12 + Sanctum | `api/` | 8000 |
|
||||
| Admin Dashboard | Vue 3 + Vuexy (full) | `apps/admin/` | 5173 |
|
||||
| Band Portal | Vue 3 + Vuexy (starter) | `apps/band/` | 5174 |
|
||||
| Customer Portal | Vue 3 + Vuexy (starter) | `apps/customers/` | 5175 |
|
||||
| Database | MySQL 8.0 | Docker | 3306 |
|
||||
| Cache | Redis | Docker | 6379 |
|
||||
| API | Laravel 12 + Sanctum + Spatie Permission | `api/` | 8000 |
|
||||
| Admin (Super Admin) | Vue 3 + Vuexy (full) | `apps/admin/` | 5173 |
|
||||
| Organizer App (Main) | Vue 3 + Vuexy (full) | `apps/app/` | 5174 |
|
||||
| Portal (External) | Vue 3 + Vuexy (stripped) | `apps/portal/` | 5175 |
|
||||
| Database | MySQL 8 | Docker | 3306 |
|
||||
| Cache / Queues | Redis | Docker | 6379 |
|
||||
| Mail | Mailpit | Docker | 8025 |
|
||||
|
||||
## Documentation Structure
|
||||
@@ -30,220 +32,118 @@ Band Management is a full-stack web application designed to streamline band oper
|
||||
```
|
||||
.cursor/
|
||||
├── instructions.md # This file - overview and quick start
|
||||
├── ARCHITECTURE.md # System architecture and data models
|
||||
├── ARCHITECTURE.md # System architecture, schema, API routes
|
||||
└── rules/
|
||||
├── 001_workspace.mdc # Project structure and conventions
|
||||
├── 100_laravel.mdc # Laravel API patterns
|
||||
├── 101_vue.mdc # Vue + Vuexy patterns
|
||||
└── 200_testing.mdc # Testing strategies
|
||||
├── 001_workspace.mdc # Project structure, conventions, multi-tenancy
|
||||
├── 100_laravel.mdc # Laravel API patterns and templates
|
||||
├── 101_vue.mdc # Vue + Vuexy patterns and templates
|
||||
└── 200_testing.mdc # Testing strategies and templates
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Core Features
|
||||
## Core Modules
|
||||
|
||||
### Authentication & Authorization
|
||||
### Phase 1 - Foundation
|
||||
- [ ] Multi-tenant architecture + Auth (Sanctum + Spatie)
|
||||
- [ ] Users, Roles & Permissions (three-level model)
|
||||
- [ ] Organisations CRUD + User Invitations
|
||||
- [ ] Events CRUD with lifecycle status
|
||||
- [ ] Crowd Types (org-level configuration)
|
||||
- [ ] Festival Sections + Time Slots + Shifts
|
||||
- [ ] Persons & Crowd Lists
|
||||
- [ ] Accreditation Engine (categories, items, access zones)
|
||||
|
||||
- [ ] User registration with email verification
|
||||
- [ ] User login/logout
|
||||
- [ ] Password reset functionality
|
||||
- [ ] Role-based access control (Admin, Booking Agent, Music Manager, Member)
|
||||
- [ ] Permission middleware for route protection
|
||||
- [ ] Session management
|
||||
### Phase 2 - Core Operations
|
||||
- [ ] Briefings & Communication (template builder, queue-based sending)
|
||||
- [ ] Staff & Crew Management (crowd pool, accreditation matrix)
|
||||
- [ ] Volunteer Management + Portal (registration, shift claiming, approval flow)
|
||||
- [ ] Form Builder (drag-drop, conditional logic, iframe embed)
|
||||
- [ ] Artist Advancing + Portal (token-based access)
|
||||
- [ ] Timetable & Stage management
|
||||
- [ ] Show Day Mode
|
||||
- [ ] Shift Swap & Waitlist
|
||||
- [ ] Volunteer Profile + Festival Passport
|
||||
- [ ] Communication Hub (email/SMS/WhatsApp via Zender, urgency levels)
|
||||
|
||||
### Member Management
|
||||
### Phase 3 - Advancing & Show Day
|
||||
- [ ] Guests & Hospitality
|
||||
- [ ] Suppliers & Production (production requests, supplier portal)
|
||||
- [ ] Mission Control (real-time check-in, artist handling, scanner management)
|
||||
- [ ] Communication Campaigns (email + SMS batch)
|
||||
- [ ] Allocation Sheet PDF (Browsershot)
|
||||
- [ ] Scan infrastructure (hardware pairing)
|
||||
- [ ] Reporting & Insights
|
||||
- [ ] No-show automation
|
||||
- [ ] Post-festival evaluation + retrospective
|
||||
|
||||
- [ ] List all members with search and filter
|
||||
- [ ] Create new member with role assignment
|
||||
- [ ] Edit member profile and roles
|
||||
- [ ] Deactivate/reactivate members
|
||||
- [ ] Member profile page (instruments, bio, contact info)
|
||||
- [ ] Avatar upload
|
||||
- [ ] Member invitation via email
|
||||
- [ ] Activity log per member
|
||||
### Phase 4 - Differentiators
|
||||
- [ ] Real-time WebSocket notifications (Echo + Pusher/Soketi)
|
||||
- [ ] Cross-event crew pool with reliability score
|
||||
- [ ] Global search (cmd+K)
|
||||
- [ ] Crew PWA
|
||||
- [ ] Public REST API + webhook system
|
||||
- [ ] CO2/sustainability reporting
|
||||
|
||||
### Events/Gigs Management
|
||||
---
|
||||
|
||||
- [ ] List events with calendar and list view
|
||||
- [ ] Create event with details (title, date, time, fee, notes)
|
||||
- [ ] Edit/delete events
|
||||
- [ ] Link event to location (from Location Manager)
|
||||
- [ ] Link event to customer (from Customer Manager)
|
||||
- [ ] Event status workflow (Draft → Pending → Confirmed → Completed → Cancelled)
|
||||
- [ ] Invite members to event
|
||||
- [ ] View RSVP responses per event
|
||||
- [ ] Attach setlist to event
|
||||
- [ ] Event detail page with all related info
|
||||
- [ ] Duplicate event functionality
|
||||
## Module Development Order (per module)
|
||||
|
||||
### RSVP System
|
||||
Always follow this sequence:
|
||||
|
||||
- [ ] Member receives event invitation notification
|
||||
- [ ] RSVP response options (Available, Unavailable, Tentative)
|
||||
- [ ] Add note/reason with RSVP
|
||||
- [ ] Change RSVP before deadline
|
||||
- [ ] RSVP deadline per event
|
||||
- [ ] Overview of member availability per event
|
||||
- [ ] Automatic reminders for pending RSVPs
|
||||
|
||||
### Music Management
|
||||
|
||||
- [ ] List all music numbers with search and filter
|
||||
- [ ] Add music number with metadata (title, artist, genre, duration)
|
||||
- [ ] Edit/delete music numbers
|
||||
- [ ] Additional fields: key, tempo (BPM), time signature
|
||||
- [ ] File attachments (lyrics, chord sheets, audio files)
|
||||
- [ ] Categorization with tags/genres
|
||||
- [ ] Notes field for arrangements/cues
|
||||
|
||||
### Setlist Manager
|
||||
|
||||
- [ ] List all setlists
|
||||
- [ ] Create setlist with name and description
|
||||
- [ ] Add music numbers to setlist from catalog
|
||||
- [ ] Drag-and-drop reordering of songs
|
||||
- [ ] Add set breaks/intermissions
|
||||
- [ ] Auto-calculate total duration
|
||||
- [ ] Clone existing setlist
|
||||
- [ ] Link setlist to event(s)
|
||||
- [ ] Delete/archive setlists
|
||||
|
||||
### Location Manager
|
||||
|
||||
- [ ] List all locations with search
|
||||
- [ ] Add location with details (name, address, capacity)
|
||||
- [ ] Edit/delete locations
|
||||
- [ ] Contact information (phone, email, contact person)
|
||||
- [ ] Technical specifications (stage size, PA, backline, parking)
|
||||
- [ ] Notes and special requirements
|
||||
|
||||
### Customer Manager
|
||||
|
||||
- [ ] List all customers with search
|
||||
- [ ] Add customer (company or individual)
|
||||
- [ ] Edit/delete customers
|
||||
- [ ] Contact details (name, email, phone, address)
|
||||
- [ ] Customer type classification
|
||||
- [ ] Notes and preferences
|
||||
- [ ] View booking history per customer
|
||||
|
||||
### Customer Portal
|
||||
|
||||
- [ ] Customer dashboard with booked events
|
||||
- [ ] Submit booking requests
|
||||
- [ ] Track request status
|
||||
- [ ] View assigned setlists (if permitted)
|
||||
- [ ] Profile settings
|
||||
|
||||
### Band Member Portal
|
||||
|
||||
- [ ] Member dashboard with upcoming events
|
||||
- [ ] Personal event calendar
|
||||
- [ ] RSVP management interface
|
||||
- [ ] View event details (location, time, setlist)
|
||||
- [ ] Browse music catalog (view-only)
|
||||
- [ ] View setlists assigned to events
|
||||
- [ ] Profile settings
|
||||
- [ ] Notification preferences
|
||||
|
||||
### Admin Dashboard
|
||||
|
||||
- [ ] Dashboard with statistics/overview
|
||||
- [ ] Quick actions panel
|
||||
- [ ] Recent activity feed
|
||||
- [ ] Upcoming events widget
|
||||
- [ ] Pending RSVPs overview
|
||||
- [ ] Booking requests management
|
||||
|
||||
### Notifications
|
||||
|
||||
- [ ] Email notifications for event invitations
|
||||
- [ ] RSVP reminder notifications
|
||||
- [ ] Event update notifications
|
||||
- [ ] In-app notification center
|
||||
- [ ] Notification preferences per user
|
||||
1. Migration(s) - ULID PKs, composite indexes, constrained FKs
|
||||
2. Eloquent Model - HasUlids, relations, scopes, OrganisationScope
|
||||
3. Factory - realistic Dutch test data
|
||||
4. Policy - authorization via Spatie roles
|
||||
5. Form Request(s) - Store + Update validation
|
||||
6. API Resource - computed fields, `whenLoaded()`, permission-dependent fields
|
||||
7. Resource Controller - index/show/store/update/destroy
|
||||
8. Routes in `api.php`
|
||||
9. PHPUnit Feature Test - happy path (200/201) + unauthenticated (401) + wrong organisation (403) + validation (422)
|
||||
10. Vue Composable (`useModuleName.ts`) - TanStack Query
|
||||
11. Pinia Store (if cross-component state needed)
|
||||
12. Vue Page Component
|
||||
13. Vue Router entry
|
||||
|
||||
---
|
||||
|
||||
## Getting Started Prompts
|
||||
|
||||
### 1. Create Laravel API
|
||||
### 1. Phase 1 Foundation (Backend)
|
||||
|
||||
```
|
||||
Create a Laravel 12 project in api/ with:
|
||||
- Sanctum for API authentication
|
||||
- MySQL configuration (host: 127.0.0.1, db: band_management, user: band_management, pass: secret)
|
||||
- CORS configured for localhost:5173, localhost:5174, localhost:5175
|
||||
- API response trait for consistent JSON responses
|
||||
- Base controller with response helpers
|
||||
Read CLAUDE.md. Then generate Phase 1 Foundation:
|
||||
|
||||
Follow the patterns in .cursor/rules/100_laravel.mdc
|
||||
1. Migrations: Update users (add timezone, locale, deleted_at). Create organisations (ULID, name, slug, billing_status, settings JSON, deleted_at), organisation_user pivot, user_invitations, events (ULID, organisation_id, name, slug, start_date, end_date, timezone, status enum, deleted_at), event_user_roles pivot.
|
||||
2. Models: User (update), Organisation, UserInvitation, Event. All with HasUlids, SoftDeletes where applicable, OrganisationScope on Event.
|
||||
3. Spatie Permission: RoleSeeder with roles: super_admin, org_admin, org_member, event_manager, staff_coordinator, volunteer_coordinator.
|
||||
4. Auth: LoginController, LogoutController, MeController (returns user + organisations + active event roles).
|
||||
5. Organisations: Controller, Policy, Request, Resource.
|
||||
6. Events: Controller nested under organisations, Policy, Request, Resource.
|
||||
7. Feature tests per step. Run php artisan test after each step.
|
||||
```
|
||||
|
||||
### 2. Create Database Migrations
|
||||
### 2. Phase 1 Foundation (Frontend)
|
||||
|
||||
```
|
||||
Create all migrations based on the schema in .cursor/ARCHITECTURE.md:
|
||||
- Users, Customers, Locations
|
||||
- Events, EventInvitations
|
||||
- MusicNumbers, MusicAttachments
|
||||
- Setlists, SetlistItems
|
||||
- BookingRequests, Notifications, ActivityLogs
|
||||
|
||||
Use ULIDs for primary keys. Follow Laravel conventions.
|
||||
Build auth flow in apps/app/:
|
||||
1. stores/useAuthStore.ts - token storage, isAuthenticated, me() loading
|
||||
2. pages/login.vue - use Vuexy login layout
|
||||
3. Router guard - redirect to login if not authenticated
|
||||
4. Replace Vuexy demo navigation with EventCrew structure
|
||||
5. CASL permissions: connect to Spatie roles from auth/me response
|
||||
```
|
||||
|
||||
### 3. Create Models with Relationships
|
||||
### 3. Module Generation (example: Shifts)
|
||||
|
||||
```
|
||||
Create Eloquent models for all tables with:
|
||||
- HasUlids trait for ULID primary keys
|
||||
- Proper relationships (belongsTo, hasMany, etc.)
|
||||
- Fillable arrays
|
||||
- Casts for enums, dates, and JSON fields
|
||||
- Scopes for common queries
|
||||
|
||||
Follow patterns in .cursor/rules/100_laravel.mdc
|
||||
```
|
||||
|
||||
### 4. Create Authentication System
|
||||
|
||||
```
|
||||
Create auth system with:
|
||||
- AuthController (login, logout, register, user, forgot-password, reset-password)
|
||||
- Form requests for validation
|
||||
- API resources for responses
|
||||
- Sanctum token generation
|
||||
|
||||
Follow patterns in .cursor/rules/100_laravel.mdc
|
||||
```
|
||||
|
||||
### 5. Integrate Vuexy with API
|
||||
|
||||
```
|
||||
I've copied Vuexy Vue (typescript-version/full-version) to apps/admin/.
|
||||
|
||||
Update it to:
|
||||
1. Create src/lib/api-client.ts for API calls with auth token handling
|
||||
2. Install and configure @tanstack/vue-query
|
||||
3. Replace Vuexy's fake auth with our Laravel API
|
||||
4. Update navigation menu for our modules
|
||||
|
||||
Follow patterns in .cursor/rules/101_vue.mdc
|
||||
```
|
||||
|
||||
### 6. Create Feature Modules
|
||||
|
||||
```
|
||||
Create the Events module with:
|
||||
- EventController with CRUD + invite/RSVP endpoints
|
||||
- StoreEventRequest, UpdateEventRequest for validation
|
||||
- EventResource, EventCollection for responses
|
||||
- CreateEventAction, UpdateEventAction for business logic
|
||||
- EventPolicy for authorization
|
||||
- Feature tests
|
||||
|
||||
Follow patterns in .cursor/rules/100_laravel.mdc and .cursor/rules/200_testing.mdc
|
||||
Build the Shifts module following CLAUDE.md module order:
|
||||
- Migration with ULID PK, festival_section_id, time_slot_id, location_id, slots_total, slots_open_for_claiming, status. Composite indexes.
|
||||
- Model with HasUlids, SoftDeletes, relations, computed accessors (slots_filled, fill_rate).
|
||||
- shift_assignments with denormalized time_slot_id, status machine (pending_approval > approved/rejected/cancelled/completed), UNIQUE(person_id, time_slot_id).
|
||||
- Configurable auto-approve per shift.
|
||||
- Queued notification jobs using ZenderService for WhatsApp.
|
||||
- Feature tests covering 200/401/403/422.
|
||||
```
|
||||
|
||||
---
|
||||
@@ -256,25 +156,25 @@ Follow patterns in .cursor/rules/100_laravel.mdc and .cursor/rules/200_testing.m
|
||||
2. Create Form Request in `app/Http/Requests/Api/V1/`
|
||||
3. Create/update API Resource in `app/Http/Resources/Api/V1/`
|
||||
4. Add route in `routes/api.php`
|
||||
5. Create Action class if complex logic needed
|
||||
6. Write feature test
|
||||
5. Create Service class if complex business logic needed
|
||||
6. Write PHPUnit Feature Test (200/401/403/422)
|
||||
|
||||
### Add a New Vue Page
|
||||
|
||||
1. Create page component in `src/pages/`
|
||||
2. Add route in `src/router/index.ts`
|
||||
2. Route added automatically by file-based routing (or add to router)
|
||||
3. Add navigation item in `src/navigation/`
|
||||
4. Create composable for API calls in `src/composables/`
|
||||
5. Use Vuexy components for UI
|
||||
5. Use Vuexy/Vuetify components for UI
|
||||
|
||||
### Add a New Database Table
|
||||
|
||||
1. Create migration: `php artisan make:migration create_tablename_table`
|
||||
2. Create model with relationships
|
||||
3. Create factory and seeder
|
||||
4. Create controller, requests, resources
|
||||
5. Add API routes
|
||||
6. Write tests
|
||||
1. Create migration with ULID PK, composite indexes
|
||||
2. Create model with HasUlids, relations, OrganisationScope (if applicable)
|
||||
3. Create factory with realistic Dutch test data
|
||||
4. Create Policy, Form Request, Resource, Controller
|
||||
5. Register routes in `api.php`
|
||||
6. Write PHPUnit Feature Test
|
||||
|
||||
---
|
||||
|
||||
@@ -282,15 +182,15 @@ Follow patterns in .cursor/rules/100_laravel.mdc and .cursor/rules/200_testing.m
|
||||
|
||||
When generating code, always:
|
||||
|
||||
- Use PHP 8.3 features (typed properties, enums, match, readonly)
|
||||
- Use strict types: `declare(strict_types=1);`
|
||||
- Use `final` classes for Actions, Form Requests, Resources
|
||||
- Use ULIDs for all primary keys
|
||||
- Follow PSR-12 coding standards
|
||||
- Use TypeScript strict mode in Vue
|
||||
- Use Vue 3 Composition API with `<script setup lang="ts">`
|
||||
- Use TanStack Query for API calls
|
||||
- Return consistent API response format
|
||||
- Use PHP 8.2+ features (typed properties, enums, match, readonly)
|
||||
- Use `declare(strict_types=1);`
|
||||
- Use ULID primary keys via HasUlids trait
|
||||
- Use Spatie laravel-permission for roles (never hardcode role strings)
|
||||
- Scope all queries on `organisation_id` via Global Scope
|
||||
- Use `<script setup lang="ts">` for Vue components
|
||||
- Use TanStack Query for all API calls
|
||||
- Use VeeValidate + Zod for form validation
|
||||
- Use Vuetify/Vuexy components for UI (never custom CSS if Vuetify class exists)
|
||||
|
||||
---
|
||||
|
||||
@@ -306,13 +206,19 @@ make services-stop # Stop services
|
||||
```bash
|
||||
make api # Laravel on :8000
|
||||
make admin # Admin SPA on :5173
|
||||
make band # Band Portal on :5174
|
||||
make customers # Customer Portal on :5175
|
||||
make app # Organizer SPA on :5174
|
||||
make portal # Portal SPA on :5175
|
||||
```
|
||||
|
||||
### Database
|
||||
```bash
|
||||
make migrate # Run migrations
|
||||
make fresh # Fresh migrate + seed
|
||||
make db-shell # MySQL CLI
|
||||
```
|
||||
|
||||
### Testing
|
||||
```bash
|
||||
cd api && php artisan test # All tests
|
||||
cd api && php artisan test --filter=ShiftTest # Specific test
|
||||
cd api && php artisan test --coverage # With coverage
|
||||
```
|
||||
|
||||
@@ -1,223 +1,168 @@
|
||||
---
|
||||
description: Core workspace rules for Laravel + Vue/TypeScript full-stack application
|
||||
description: Core workspace rules for EventCrew multi-tenant SaaS platform
|
||||
globs: ["**/*"]
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Workspace Rules
|
||||
|
||||
You are an expert full-stack developer working on a Laravel API backend with a Vue 3/TypeScript frontend using Vuexy admin template. This is an API-first architecture where the backend and frontend are completely separated.
|
||||
You are an expert full-stack developer working on EventCrew, a multi-tenant SaaS platform for event and festival management. The backend is a Laravel 12 REST API (JSON only, no Blade), and three Vue 3 SPA frontends communicate via CORS + Sanctum tokens.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
### Backend (Laravel)
|
||||
- PHP 8.3+
|
||||
- Laravel 12+
|
||||
- Laravel Sanctum for SPA authentication (token-based)
|
||||
- MySQL 8.0 database
|
||||
- Redis for cache and queues
|
||||
- Pest for testing
|
||||
- PHP 8.2+
|
||||
- Laravel 12
|
||||
- Laravel Sanctum (SPA token auth)
|
||||
- Spatie laravel-permission (three-level roles)
|
||||
- Spatie laravel-activitylog (audit log)
|
||||
- Spatie laravel-medialibrary (file management)
|
||||
- MySQL 8 (primary), Redis (cache, queues, sessions)
|
||||
- Laravel Horizon (queue monitoring)
|
||||
- PHPUnit for testing
|
||||
|
||||
### Frontend (Vue)
|
||||
- Vue 3 with TypeScript (strict mode)
|
||||
- Vite as build tool
|
||||
- Vuexy Admin Template
|
||||
- TanStack Query (Vue Query) for server state
|
||||
- Pinia for client state
|
||||
- Vue Router for routing
|
||||
- Axios for HTTP client
|
||||
- TypeScript 5.9+
|
||||
- Vue 3.5+ (Composition API, `<script setup>` only)
|
||||
- Vite 7+
|
||||
- Vuexy 9.5 + Vuetify 3.10
|
||||
- Pinia 3 (client state)
|
||||
- TanStack Query / Vue Query (server state)
|
||||
- Axios (HTTP client)
|
||||
- VeeValidate + Zod (form validation)
|
||||
- VueDraggable (drag-and-drop)
|
||||
- Vue I18n (internationalization)
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
band-management/
|
||||
├── api/ # Laravel 12 API
|
||||
event-crew/
|
||||
├── api/ # Laravel 12 REST API (JSON only)
|
||||
│ ├── app/
|
||||
│ │ ├── Actions/ # Single-purpose business logic
|
||||
│ │ ├── Enums/ # PHP enums
|
||||
│ │ ├── Http/
|
||||
│ │ │ ├── Controllers/Api/V1/
|
||||
│ │ │ ├── Middleware/
|
||||
│ │ │ ├── Requests/ # Form Request validation
|
||||
│ │ │ └── Resources/ # API Resources
|
||||
│ │ ├── Models/ # Eloquent models
|
||||
│ │ ├── Policies/ # Authorization
|
||||
│ │ │ ├── Middleware/ # OrganisationRoleMiddleware, EventRoleMiddleware, PortalTokenMiddleware
|
||||
│ │ │ ├── Requests/Api/V1/ # Form Request validation
|
||||
│ │ │ └── Resources/Api/V1/ # API Resources
|
||||
│ │ ├── Models/ # Eloquent models with HasUlids
|
||||
│ │ ├── Policies/ # Authorization (never hardcode roles)
|
||||
│ │ ├── Services/ # Complex business logic
|
||||
│ │ └── Traits/ # Shared traits
|
||||
│ │ ├── Events/ + Listeners/
|
||||
│ │ └── Jobs/ # Queue jobs (briefings, PDF, notifications)
|
||||
│ ├── database/
|
||||
│ │ ├── factories/
|
||||
│ │ ├── migrations/
|
||||
│ │ ├── factories/
|
||||
│ │ └── seeders/
|
||||
│ ├── routes/
|
||||
│ │ └── api.php # API routes
|
||||
│ └── tests/
|
||||
│ ├── Feature/Api/
|
||||
│ └── Unit/
|
||||
│ └── tests/Feature/Api/V1/
|
||||
│
|
||||
├── apps/
|
||||
│ ├── admin/ # Admin Dashboard (Vuexy full)
|
||||
│ │ ├── src/
|
||||
│ │ │ ├── @core/ # Vuexy core (don't modify)
|
||||
│ │ │ ├── @layouts/ # Vuexy layouts (don't modify)
|
||||
│ │ │ ├── components/ # Custom components
|
||||
│ │ │ ├── composables/ # Vue composables
|
||||
│ │ │ ├── layouts/ # App layouts
|
||||
│ │ │ ├── lib/ # Utilities (api-client, etc.)
|
||||
│ │ │ ├── navigation/ # Menu configuration
|
||||
│ │ │ ├── pages/ # Page components
|
||||
│ │ │ ├── plugins/ # Vue plugins
|
||||
│ │ │ ├── router/ # Vue Router
|
||||
│ │ │ ├── stores/ # Pinia stores
|
||||
│ │ │ └── types/ # TypeScript types
|
||||
│ │ └── ...
|
||||
│ ├── admin/ # Super Admin SPA (Vuexy full)
|
||||
│ │ └── src/
|
||||
│ │ ├── @core/ # Vuexy core (NEVER modify)
|
||||
│ │ ├── @layouts/ # Vuexy layouts (NEVER modify)
|
||||
│ │ ├── components/
|
||||
│ │ ├── composables/ # useModule.ts composables
|
||||
│ │ ├── lib/ # axios.ts (single instance)
|
||||
│ │ ├── pages/
|
||||
│ │ ├── plugins/ # vue-query, casl, vuetify
|
||||
│ │ ├── stores/ # Pinia stores
|
||||
│ │ └── types/ # TypeScript interfaces
|
||||
│ │
|
||||
│ ├── band/ # Band Portal (Vuexy starter)
|
||||
│ └── customers/ # Customer Portal (Vuexy starter)
|
||||
│ ├── app/ # Organizer SPA (Vuexy full) - MAIN APP
|
||||
│ │ └── src/ # Same structure as admin/
|
||||
│ │
|
||||
│ └── portal/ # External Portal SPA (Vuexy stripped)
|
||||
│ └── src/ # No sidebar, no customizer, top-bar only
|
||||
│
|
||||
├── docker/ # Docker configurations
|
||||
├── docs/ # Documentation
|
||||
├── resources/design/ # Design documents (source of truth)
|
||||
└── .cursor/ # Cursor AI configuration
|
||||
```
|
||||
|
||||
## Multi-Tenancy Rules (CRITICAL)
|
||||
|
||||
1. **EVERY query on event-data MUST scope on `organisation_id`** via `OrganisationScope` Eloquent Global Scope.
|
||||
2. **Never use direct id-checks in controllers** - always use Policies.
|
||||
3. **Never use `Model::all()` without a where-clause** - always scope.
|
||||
4. **Never hardcode role strings** like `$user->role === 'admin'` - use `$user->hasRole()` and Policies.
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
### PHP (Laravel)
|
||||
|
||||
| Type | Convention | Example |
|
||||
|------|------------|---------|
|
||||
| Models | Singular PascalCase | `Event`, `MusicNumber` |
|
||||
| Controllers | PascalCase + Controller | `EventController` |
|
||||
| Form Requests | Action + Resource + Request | `StoreEventRequest` |
|
||||
| Resources | Resource + Resource | `EventResource` |
|
||||
| Actions | Verb + Resource + Action | `CreateEventAction` |
|
||||
| Models | Singular PascalCase | `Event`, `FestivalSection`, `ShiftAssignment` |
|
||||
| Controllers | PascalCase + Controller | `EventController`, `ShiftController` |
|
||||
| Form Requests | Action + Resource + Request | `StoreEventRequest`, `UpdateShiftRequest` |
|
||||
| Resources | Resource + Resource | `EventResource`, `PersonResource` |
|
||||
| Services | PascalCase + Service | `ZenderService`, `BriefingService` |
|
||||
| Migrations | snake_case with timestamp | `create_events_table` |
|
||||
| Tables | Plural snake_case | `events`, `music_numbers` |
|
||||
| Columns | snake_case | `event_date`, `created_at` |
|
||||
| Enums | Singular PascalCase | `EventStatus` |
|
||||
| Tables | Plural snake_case | `events`, `festival_sections`, `shift_assignments` |
|
||||
| Columns | snake_case | `organisation_id`, `slots_total`, `created_at` |
|
||||
| Enums | Singular PascalCase | `EventStatus`, `BookingStatus` |
|
||||
|
||||
### TypeScript (Vue)
|
||||
|
||||
| Type | Convention | Example |
|
||||
|------|------------|---------|
|
||||
| Components | PascalCase | `EventCard.vue` |
|
||||
| Pages | PascalCase + Page | `EventsPage.vue` |
|
||||
| Composables | camelCase with "use" | `useEvents.ts` |
|
||||
| Stores | camelCase | `authStore.ts` |
|
||||
| Types/Interfaces | PascalCase | `Event`, `ApiResponse` |
|
||||
| Files | kebab-case or camelCase | `api-client.ts` |
|
||||
| Components | PascalCase | `ShiftAssignPanel.vue`, `PersonCard.vue` |
|
||||
| Composables | use-prefix camelCase | `useShifts.ts`, `usePersons.ts` |
|
||||
| Pinia Stores | use-prefix + Store suffix | `useEventStore.ts`, `useAuthStore.ts` |
|
||||
| Types/Interfaces | PascalCase | `Event`, `Person`, `ShiftAssignment` |
|
||||
| Variables | camelCase | `slotsFilled`, `fillRate` |
|
||||
|
||||
## Code Style
|
||||
|
||||
### General Principles
|
||||
|
||||
1. **Explicit over implicit** - Be clear about types, returns, and intentions
|
||||
2. **Small, focused units** - Each file/function does one thing well
|
||||
3. **Consistent formatting** - Use automated formatters
|
||||
4. **Descriptive names** - Names should explain purpose
|
||||
5. **No magic** - Avoid hidden behavior
|
||||
|
||||
### PHP
|
||||
|
||||
- Use `declare(strict_types=1);` in all files
|
||||
- Use `final` for classes that shouldn't be extended
|
||||
- Use readonly properties where applicable
|
||||
- Prefer named arguments for clarity
|
||||
- Use enums instead of string constants
|
||||
|
||||
### TypeScript
|
||||
|
||||
- Enable strict mode in tsconfig
|
||||
- No `any` types - use `unknown` if truly unknown
|
||||
- Use interface for objects, type for unions/primitives
|
||||
- Prefer `const` over `let`
|
||||
- Use optional chaining and nullish coalescing
|
||||
### Database
|
||||
- Primary keys: ULID via `HasUlids` trait (NOT UUID v4, NOT auto-increment for business tables)
|
||||
- Pure pivot tables: auto-increment integer PK for join performance
|
||||
- DB columns: `snake_case`
|
||||
|
||||
## Environment Configuration
|
||||
|
||||
### Development URLs
|
||||
|
||||
| Service | URL |
|
||||
|---------|-----|
|
||||
| API | http://localhost:8000/api/v1 |
|
||||
| Admin SPA | http://localhost:5173 |
|
||||
| Band SPA | http://localhost:5174 |
|
||||
| Customer SPA | http://localhost:5175 |
|
||||
| MySQL | localhost:3306 |
|
||||
| Redis | localhost:6379 |
|
||||
| Mailpit | http://localhost:8025 |
|
||||
| Service | URL | Env Variable |
|
||||
|---------|-----|--------------|
|
||||
| API | `http://localhost:8000/api/v1` | - |
|
||||
| Admin SPA | `http://localhost:5173` | `FRONTEND_ADMIN_URL` |
|
||||
| Organizer SPA | `http://localhost:5174` | `FRONTEND_APP_URL` |
|
||||
| Portal SPA | `http://localhost:5175` | `FRONTEND_PORTAL_URL` |
|
||||
| MySQL | `localhost:3306` | - |
|
||||
| Redis | `localhost:6379` | - |
|
||||
| Mailpit | `http://localhost:8025` | - |
|
||||
|
||||
### Database Credentials (Development)
|
||||
|
||||
```
|
||||
Host: 127.0.0.1
|
||||
Port: 3306
|
||||
Database: band_management
|
||||
Username: band_management
|
||||
Password: secret
|
||||
```
|
||||
|
||||
### Production URLs
|
||||
|
||||
| Service | URL |
|
||||
|---------|-----|
|
||||
| API | https://api.bandmanagement.nl |
|
||||
| Admin | https://admin.bandmanagement.nl |
|
||||
| Band | https://band.bandmanagement.nl |
|
||||
| Customers | https://customers.bandmanagement.nl |
|
||||
### CORS
|
||||
Three frontend origins configured in `config/cors.php` via env variables. Each Vite dev server gets its own port for CORS isolation.
|
||||
|
||||
## Git Conventions
|
||||
|
||||
### Branch Names
|
||||
- `feature/event-management`
|
||||
- `fix/rsvp-validation`
|
||||
- `refactor/auth-system`
|
||||
- `feature/shift-planning`
|
||||
- `fix/organisation-scoping`
|
||||
- `refactor/accreditation-engine`
|
||||
|
||||
### Commit Messages
|
||||
```
|
||||
feat: add event RSVP functionality
|
||||
fix: correct date validation in events
|
||||
refactor: extract event creation to action class
|
||||
docs: update API documentation
|
||||
test: add event controller tests
|
||||
feat: add shift claiming with approval flow
|
||||
fix: enforce organisation scope on persons query
|
||||
refactor: extract briefing logic to BriefingService
|
||||
test: add accreditation assignment tests
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
## Forbidden Patterns
|
||||
|
||||
### PHP (api/composer.json)
|
||||
- PHP 8.3+
|
||||
- Laravel 12
|
||||
- Laravel Sanctum
|
||||
- Laravel Pint (formatting)
|
||||
- Pest PHP (testing)
|
||||
|
||||
### Node (apps/*/package.json)
|
||||
- Vue 3.4+
|
||||
- TypeScript 5.3+
|
||||
- Vite 5+
|
||||
- Pinia
|
||||
- @tanstack/vue-query
|
||||
- axios
|
||||
- NEVER: `$user->role === 'admin'` (use Policies + Spatie roles)
|
||||
- NEVER: `Model::all()` without where-clause (always scope)
|
||||
- NEVER: `dd()` or `var_dump()` left in code
|
||||
- NEVER: Hardcode `.env` values in code
|
||||
- NEVER: JSON columns for queryable/filterable data
|
||||
- NEVER: UUID v4 as primary key (use HasUlids for ULID)
|
||||
- NEVER: Blade views or Inertia (API-only backend)
|
||||
- NEVER: Business logic in controllers without Policy authorization
|
||||
|
||||
## Code Style Principles
|
||||
|
||||
1. **Readability over cleverness** - Write code that is easy to understand
|
||||
1. **Explicit over implicit** - Be clear about types, returns, and intentions
|
||||
2. **Single Responsibility** - Each class/function does one thing well
|
||||
3. **Type Safety** - Leverage TypeScript and PHP type hints everywhere
|
||||
4. **Testability** - Write code that is easy to test
|
||||
5. **API Consistency** - Follow RESTful conventions
|
||||
|
||||
## Response Format
|
||||
|
||||
When generating code:
|
||||
1. Always include proper type hints/annotations
|
||||
2. Add brief comments for complex logic only
|
||||
3. Follow the established patterns in the codebase
|
||||
4. Consider error handling and edge cases
|
||||
5. Suggest tests for new functionality
|
||||
|
||||
## Communication Style
|
||||
|
||||
- Be concise and direct
|
||||
- Provide working code examples
|
||||
- Explain architectural decisions briefly
|
||||
- Ask clarifying questions only when truly ambiguous
|
||||
3. **Type Safety** - PHP type hints and TypeScript strict mode everywhere
|
||||
4. **Multi-tenant first** - Every feature must respect organisation boundaries
|
||||
5. **Mobile-first** - Responsive design, minimum 375px width
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
description: Laravel API development guidelines
|
||||
description: Laravel API development guidelines for EventCrew multi-tenant platform
|
||||
globs: ["api/**/*.php"]
|
||||
alwaysApply: true
|
||||
---
|
||||
@@ -8,52 +8,25 @@ alwaysApply: true
|
||||
|
||||
## PHP Conventions
|
||||
|
||||
- Use PHP 8.3+ features: constructor property promotion, readonly properties, match expressions
|
||||
- Use `match` operator over `switch` wherever possible
|
||||
- Import all classes with `use` statements; avoid fully-qualified class names inline
|
||||
- Use named arguments for functions with 3+ parameters
|
||||
- Use PHP 8.2+ features: constructor property promotion, readonly properties, match expressions, enums
|
||||
- Use `declare(strict_types=1);` in all files
|
||||
- Use `match` over `switch` wherever possible
|
||||
- Import all classes with `use` statements
|
||||
- Prefer early returns over nested conditionals
|
||||
|
||||
```php
|
||||
// ✅ Good - constructor property promotion
|
||||
public function __construct(
|
||||
private readonly UserRepository $users,
|
||||
private readonly Mailer $mailer,
|
||||
) {}
|
||||
|
||||
// ✅ Good - early return
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
if (!$request->user()) {
|
||||
return response()->json(['error' => 'Unauthorized'], 401);
|
||||
}
|
||||
|
||||
// Main logic here
|
||||
}
|
||||
|
||||
// ❌ Avoid - nested conditionals
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
if ($request->user()) {
|
||||
// Nested logic
|
||||
} else {
|
||||
return response()->json(['error' => 'Unauthorized'], 401);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Core Principles
|
||||
|
||||
1. **API-only** - No Blade views, no web routes
|
||||
2. **Thin controllers** - Business logic in Actions
|
||||
3. **Consistent responses** - Use API Resources and response trait
|
||||
4. **Validate everything** - Use Form Requests
|
||||
5. **Authorize properly** - Use Policies
|
||||
6. **Test thoroughly** - Feature tests for all endpoints
|
||||
1. **API-only** - No Blade views, no web routes. Every response is JSON.
|
||||
2. **Multi-tenant** - Every query scoped on `organisation_id` via Global Scope.
|
||||
3. **Resource Controllers** - Use index/show/store/update/destroy.
|
||||
4. **Validate via Form Requests** - Never inline `validate()`.
|
||||
5. **Authorize via Policies** - Never hardcode role strings in controllers.
|
||||
6. **Respond via API Resources** - Never return model attributes directly.
|
||||
7. **ULID primary keys** - Via HasUlids trait on all business models.
|
||||
|
||||
## File Templates
|
||||
|
||||
### Model
|
||||
### Model (with OrganisationScope)
|
||||
|
||||
```php
|
||||
<?php
|
||||
@@ -68,83 +41,78 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
final class Event extends Model
|
||||
class Event extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use HasUlids;
|
||||
use SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'title',
|
||||
'description',
|
||||
'location_id',
|
||||
'customer_id',
|
||||
'setlist_id',
|
||||
'event_date',
|
||||
'start_time',
|
||||
'end_time',
|
||||
'fee',
|
||||
'currency',
|
||||
'organisation_id',
|
||||
'name',
|
||||
'slug',
|
||||
'start_date',
|
||||
'end_date',
|
||||
'timezone',
|
||||
'status',
|
||||
'visibility',
|
||||
'rsvp_deadline',
|
||||
'notes',
|
||||
'internal_notes',
|
||||
'created_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'event_date' => 'date',
|
||||
'start_time' => 'datetime:H:i',
|
||||
'end_time' => 'datetime:H:i',
|
||||
'fee' => 'decimal:2',
|
||||
'start_date' => 'date',
|
||||
'end_date' => 'date',
|
||||
'status' => EventStatus::class,
|
||||
'rsvp_deadline' => 'datetime',
|
||||
];
|
||||
|
||||
// Global Scope: always scope on organisation
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::addGlobalScope('organisation', function (Builder $builder) {
|
||||
if ($organisationId = auth()->user()?->current_organisation_id) {
|
||||
$builder->where('organisation_id', $organisationId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Relationships
|
||||
|
||||
public function location(): BelongsTo
|
||||
public function organisation(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Location::class);
|
||||
return $this->belongsTo(Organisation::class);
|
||||
}
|
||||
|
||||
public function customer(): BelongsTo
|
||||
public function festivalSections(): HasMany
|
||||
{
|
||||
return $this->belongsTo(Customer::class);
|
||||
return $this->hasMany(FestivalSection::class);
|
||||
}
|
||||
|
||||
public function setlist(): BelongsTo
|
||||
public function timeSlots(): HasMany
|
||||
{
|
||||
return $this->belongsTo(Setlist::class);
|
||||
return $this->hasMany(TimeSlot::class);
|
||||
}
|
||||
|
||||
public function creator(): BelongsTo
|
||||
public function persons(): HasMany
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
return $this->hasMany(Person::class);
|
||||
}
|
||||
|
||||
public function invitations(): HasMany
|
||||
public function artists(): HasMany
|
||||
{
|
||||
return $this->hasMany(EventInvitation::class);
|
||||
return $this->hasMany(Artist::class);
|
||||
}
|
||||
|
||||
// Scopes
|
||||
|
||||
public function scopeUpcoming($query)
|
||||
public function scopeWithStatus(Builder $query, EventStatus $status): Builder
|
||||
{
|
||||
return $query->where('event_date', '>=', now()->toDateString())
|
||||
->orderBy('event_date');
|
||||
}
|
||||
|
||||
public function scopeConfirmed($query)
|
||||
{
|
||||
return $query->where('status', EventStatus::Confirmed);
|
||||
return $query->where('status', $status);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Enum
|
||||
### Enum (EventStatus)
|
||||
|
||||
```php
|
||||
<?php
|
||||
@@ -156,30 +124,36 @@ namespace App\Enums;
|
||||
enum EventStatus: string
|
||||
{
|
||||
case Draft = 'draft';
|
||||
case Pending = 'pending';
|
||||
case Confirmed = 'confirmed';
|
||||
case Completed = 'completed';
|
||||
case Cancelled = 'cancelled';
|
||||
case Published = 'published';
|
||||
case RegistrationOpen = 'registration_open';
|
||||
case BuildUp = 'buildup';
|
||||
case ShowDay = 'showday';
|
||||
case TearDown = 'teardown';
|
||||
case Closed = 'closed';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Draft => 'Draft',
|
||||
self::Pending => 'Pending Confirmation',
|
||||
self::Confirmed => 'Confirmed',
|
||||
self::Completed => 'Completed',
|
||||
self::Cancelled => 'Cancelled',
|
||||
self::Published => 'Published',
|
||||
self::RegistrationOpen => 'Registration Open',
|
||||
self::BuildUp => 'Build-Up',
|
||||
self::ShowDay => 'Show Day',
|
||||
self::TearDown => 'Tear-Down',
|
||||
self::Closed => 'Closed',
|
||||
};
|
||||
}
|
||||
|
||||
public function color(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Draft => 'gray',
|
||||
self::Pending => 'yellow',
|
||||
self::Confirmed => 'green',
|
||||
self::Completed => 'blue',
|
||||
self::Cancelled => 'red',
|
||||
self::Draft => 'secondary',
|
||||
self::Published => 'info',
|
||||
self::RegistrationOpen => 'primary',
|
||||
self::BuildUp => 'warning',
|
||||
self::ShowDay => 'success',
|
||||
self::TearDown => 'warning',
|
||||
self::Closed => 'secondary',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -202,28 +176,17 @@ return new class extends Migration
|
||||
{
|
||||
Schema::create('events', function (Blueprint $table) {
|
||||
$table->ulid('id')->primary();
|
||||
$table->string('title');
|
||||
$table->text('description')->nullable();
|
||||
$table->foreignUlid('location_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->foreignUlid('customer_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->foreignUlid('setlist_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->date('event_date');
|
||||
$table->time('start_time');
|
||||
$table->time('end_time')->nullable();
|
||||
$table->time('load_in_time')->nullable();
|
||||
$table->time('soundcheck_time')->nullable();
|
||||
$table->decimal('fee', 10, 2)->nullable();
|
||||
$table->string('currency', 3)->default('EUR');
|
||||
$table->enum('status', ['draft', 'pending', 'confirmed', 'completed', 'cancelled'])->default('draft');
|
||||
$table->enum('visibility', ['private', 'members', 'public'])->default('members');
|
||||
$table->dateTime('rsvp_deadline')->nullable();
|
||||
$table->text('notes')->nullable();
|
||||
$table->text('internal_notes')->nullable();
|
||||
$table->boolean('is_public_setlist')->default(false);
|
||||
$table->foreignUlid('created_by')->constrained('users');
|
||||
$table->foreignUlid('organisation_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('name');
|
||||
$table->string('slug');
|
||||
$table->date('start_date');
|
||||
$table->date('end_date');
|
||||
$table->string('timezone')->default('Europe/Amsterdam');
|
||||
$table->string('status')->default('draft');
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
|
||||
$table->index(['event_date', 'status']);
|
||||
$table->index(['organisation_id', 'status']);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -234,7 +197,7 @@ return new class extends Migration
|
||||
};
|
||||
```
|
||||
|
||||
### Controller
|
||||
### Controller (Resource Controller with Policy)
|
||||
|
||||
```php
|
||||
<?php
|
||||
@@ -243,60 +206,59 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Actions\Events\CreateEventAction;
|
||||
use App\Actions\Events\UpdateEventAction;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Api\V1\StoreEventRequest;
|
||||
use App\Http\Requests\Api\V1\UpdateEventRequest;
|
||||
use App\Http\Resources\Api\V1\EventCollection;
|
||||
use App\Http\Resources\Api\V1\EventResource;
|
||||
use App\Models\Event;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
|
||||
final class EventController extends Controller
|
||||
class EventController extends Controller
|
||||
{
|
||||
public function index(): EventCollection
|
||||
public function __construct()
|
||||
{
|
||||
$events = Event::query()
|
||||
->with(['location', 'customer'])
|
||||
->latest('event_date')
|
||||
->paginate();
|
||||
|
||||
return new EventCollection($events);
|
||||
$this->authorizeResource(Event::class, 'event');
|
||||
}
|
||||
|
||||
public function store(StoreEventRequest $request, CreateEventAction $action): JsonResponse
|
||||
public function index(): AnonymousResourceCollection
|
||||
{
|
||||
$event = $action->execute($request->validated());
|
||||
$events = Event::query()
|
||||
->with(['organisation', 'festivalSections'])
|
||||
->latest('start_date')
|
||||
->paginate();
|
||||
|
||||
return $this->created(
|
||||
new EventResource($event->load(['location', 'customer'])),
|
||||
'Event created successfully'
|
||||
);
|
||||
return EventResource::collection($events);
|
||||
}
|
||||
|
||||
public function store(StoreEventRequest $request): JsonResponse
|
||||
{
|
||||
$event = Event::create($request->validated());
|
||||
|
||||
return (new EventResource($event))
|
||||
->response()
|
||||
->setStatusCode(201);
|
||||
}
|
||||
|
||||
public function show(Event $event): EventResource
|
||||
{
|
||||
return new EventResource(
|
||||
$event->load(['location', 'customer', 'setlist', 'invitations.user'])
|
||||
$event->load(['organisation', 'festivalSections', 'timeSlots', 'persons'])
|
||||
);
|
||||
}
|
||||
|
||||
public function update(UpdateEventRequest $request, Event $event, UpdateEventAction $action): JsonResponse
|
||||
public function update(UpdateEventRequest $request, Event $event): EventResource
|
||||
{
|
||||
$event = $action->execute($event, $request->validated());
|
||||
$event->update($request->validated());
|
||||
|
||||
return $this->success(
|
||||
new EventResource($event),
|
||||
'Event updated successfully'
|
||||
);
|
||||
return new EventResource($event);
|
||||
}
|
||||
|
||||
public function destroy(Event $event): JsonResponse
|
||||
{
|
||||
$event->delete();
|
||||
|
||||
return $this->success(null, 'Event deleted successfully');
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -311,45 +273,26 @@ declare(strict_types=1);
|
||||
namespace App\Http\Requests\Api\V1;
|
||||
|
||||
use App\Enums\EventStatus;
|
||||
use App\Enums\EventVisibility;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
final class StoreEventRequest extends FormRequest
|
||||
class StoreEventRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true; // Or use policy
|
||||
return true; // Handled by Policy via authorizeResource
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'title' => ['required', 'string', 'max:255'],
|
||||
'description' => ['nullable', 'string', 'max:5000'],
|
||||
'location_id' => ['nullable', 'ulid', 'exists:locations,id'],
|
||||
'customer_id' => ['nullable', 'ulid', 'exists:customers,id'],
|
||||
'setlist_id' => ['nullable', 'ulid', 'exists:setlists,id'],
|
||||
'event_date' => ['required', 'date', 'after_or_equal:today'],
|
||||
'start_time' => ['required', 'date_format:H:i'],
|
||||
'end_time' => ['nullable', 'date_format:H:i', 'after:start_time'],
|
||||
'load_in_time' => ['nullable', 'date_format:H:i'],
|
||||
'soundcheck_time' => ['nullable', 'date_format:H:i'],
|
||||
'fee' => ['nullable', 'numeric', 'min:0', 'max:999999.99'],
|
||||
'currency' => ['sometimes', 'string', 'size:3'],
|
||||
'organisation_id' => ['required', 'ulid', 'exists:organisations,id'],
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'slug' => ['required', 'string', 'max:255', Rule::unique('events')->where('organisation_id', $this->organisation_id)],
|
||||
'start_date' => ['required', 'date'],
|
||||
'end_date' => ['required', 'date', 'after_or_equal:start_date'],
|
||||
'timezone' => ['sometimes', 'string', 'timezone'],
|
||||
'status' => ['sometimes', Rule::enum(EventStatus::class)],
|
||||
'visibility' => ['sometimes', Rule::enum(EventVisibility::class)],
|
||||
'rsvp_deadline' => ['nullable', 'date', 'before:event_date'],
|
||||
'notes' => ['nullable', 'string', 'max:5000'],
|
||||
'internal_notes' => ['nullable', 'string', 'max:5000'],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'event_date.after_or_equal' => 'The event date must be today or a future date.',
|
||||
'end_time.after' => 'The end time must be after the start time.',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -367,35 +310,30 @@ namespace App\Http\Resources\Api\V1;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
final class EventResource extends JsonResource
|
||||
class EventResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'title' => $this->title,
|
||||
'description' => $this->description,
|
||||
'event_date' => $this->event_date->toDateString(),
|
||||
'start_time' => $this->start_time?->format('H:i'),
|
||||
'end_time' => $this->end_time?->format('H:i'),
|
||||
'load_in_time' => $this->load_in_time?->format('H:i'),
|
||||
'soundcheck_time' => $this->soundcheck_time?->format('H:i'),
|
||||
'fee' => $this->fee,
|
||||
'currency' => $this->currency,
|
||||
'organisation_id' => $this->organisation_id,
|
||||
'name' => $this->name,
|
||||
'slug' => $this->slug,
|
||||
'start_date' => $this->start_date->toDateString(),
|
||||
'end_date' => $this->end_date->toDateString(),
|
||||
'timezone' => $this->timezone,
|
||||
'status' => $this->status->value,
|
||||
'status_label' => $this->status->label(),
|
||||
'visibility' => $this->visibility,
|
||||
'rsvp_deadline' => $this->rsvp_deadline?->toIso8601String(),
|
||||
'notes' => $this->notes,
|
||||
'internal_notes' => $this->when(
|
||||
$request->user()?->isAdmin(),
|
||||
$this->internal_notes
|
||||
'status_color' => $this->status->color(),
|
||||
'festival_sections' => FestivalSectionResource::collection(
|
||||
$this->whenLoaded('festivalSections')
|
||||
),
|
||||
'location' => new LocationResource($this->whenLoaded('location')),
|
||||
'customer' => new CustomerResource($this->whenLoaded('customer')),
|
||||
'setlist' => new SetlistResource($this->whenLoaded('setlist')),
|
||||
'invitations' => EventInvitationResource::collection(
|
||||
$this->whenLoaded('invitations')
|
||||
'time_slots' => TimeSlotResource::collection(
|
||||
$this->whenLoaded('timeSlots')
|
||||
),
|
||||
'persons_count' => $this->when(
|
||||
$this->persons_count !== null,
|
||||
$this->persons_count
|
||||
),
|
||||
'created_at' => $this->created_at->toIso8601String(),
|
||||
'updated_at' => $this->updated_at->toIso8601String(),
|
||||
@@ -404,147 +342,7 @@ final class EventResource extends JsonResource
|
||||
}
|
||||
```
|
||||
|
||||
### Resource Collection
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\Api\V1;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\ResourceCollection;
|
||||
|
||||
final class EventCollection extends ResourceCollection
|
||||
{
|
||||
public $collects = EventResource::class;
|
||||
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'data' => $this->collection,
|
||||
];
|
||||
}
|
||||
|
||||
public function with(Request $request): array
|
||||
{
|
||||
return [
|
||||
'success' => true,
|
||||
'meta' => [
|
||||
'pagination' => [
|
||||
'current_page' => $this->currentPage(),
|
||||
'per_page' => $this->perPage(),
|
||||
'total' => $this->total(),
|
||||
'last_page' => $this->lastPage(),
|
||||
'from' => $this->firstItem(),
|
||||
'to' => $this->lastItem(),
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Action Class
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Events;
|
||||
|
||||
use App\Models\Event;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
final class CreateEventAction
|
||||
{
|
||||
public function execute(array $data): Event
|
||||
{
|
||||
$data['created_by'] = Auth::id();
|
||||
|
||||
return Event::create($data);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### API Response Trait
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Traits;
|
||||
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
trait ApiResponse
|
||||
{
|
||||
protected function success(mixed $data = null, string $message = 'Success', int $code = 200): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $data,
|
||||
'message' => $message,
|
||||
], $code);
|
||||
}
|
||||
|
||||
protected function created(mixed $data = null, string $message = 'Created'): JsonResponse
|
||||
{
|
||||
return $this->success($data, $message, 201);
|
||||
}
|
||||
|
||||
protected function error(string $message, int $code = 400, array $errors = []): JsonResponse
|
||||
{
|
||||
$response = [
|
||||
'success' => false,
|
||||
'message' => $message,
|
||||
];
|
||||
|
||||
if (!empty($errors)) {
|
||||
$response['errors'] = $errors;
|
||||
}
|
||||
|
||||
return response()->json($response, $code);
|
||||
}
|
||||
|
||||
protected function notFound(string $message = 'Resource not found'): JsonResponse
|
||||
{
|
||||
return $this->error($message, 404);
|
||||
}
|
||||
|
||||
protected function unauthorized(string $message = 'Unauthorized'): JsonResponse
|
||||
{
|
||||
return $this->error($message, 401);
|
||||
}
|
||||
|
||||
protected function forbidden(string $message = 'Forbidden'): JsonResponse
|
||||
{
|
||||
return $this->error($message, 403);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Base Controller
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Traits\ApiResponse;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
use ApiResponse;
|
||||
}
|
||||
```
|
||||
|
||||
### Policy
|
||||
### Policy (with Spatie Roles)
|
||||
|
||||
```php
|
||||
<?php
|
||||
@@ -556,31 +354,33 @@ namespace App\Policies;
|
||||
use App\Models\Event;
|
||||
use App\Models\User;
|
||||
|
||||
final class EventPolicy
|
||||
class EventPolicy
|
||||
{
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
return true;
|
||||
return $user->hasAnyRole(['super_admin', 'org_admin', 'org_member', 'org_readonly']);
|
||||
}
|
||||
|
||||
public function view(User $user, Event $event): bool
|
||||
{
|
||||
return true;
|
||||
return $user->belongsToOrganisation($event->organisation_id);
|
||||
}
|
||||
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return $user->isAdmin() || $user->isBookingAgent();
|
||||
return $user->hasAnyRole(['super_admin', 'org_admin']);
|
||||
}
|
||||
|
||||
public function update(User $user, Event $event): bool
|
||||
{
|
||||
return $user->isAdmin() || $user->isBookingAgent();
|
||||
return $user->hasAnyRole(['super_admin', 'org_admin'])
|
||||
&& $user->belongsToOrganisation($event->organisation_id);
|
||||
}
|
||||
|
||||
public function delete(User $user, Event $event): bool
|
||||
{
|
||||
return $user->isAdmin();
|
||||
return $user->hasAnyRole(['super_admin', 'org_admin'])
|
||||
&& $user->belongsToOrganisation($event->organisation_id);
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -592,195 +392,90 @@ final class EventPolicy
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Http\Controllers\Api\V1\AuthController;
|
||||
use App\Http\Controllers\Api\V1\EventController;
|
||||
use App\Http\Controllers\Api\V1\LocationController;
|
||||
use App\Http\Controllers\Api\V1\MemberController;
|
||||
use App\Http\Controllers\Api\V1\MusicController;
|
||||
use App\Http\Controllers\Api\V1\SetlistController;
|
||||
use App\Http\Controllers\Api\V1\CustomerController;
|
||||
use App\Http\Controllers\Api\V1;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::prefix('v1')->group(function () {
|
||||
// Public routes
|
||||
Route::post('auth/login', [AuthController::class, 'login']);
|
||||
Route::post('auth/register', [AuthController::class, 'register']);
|
||||
Route::post('auth/forgot-password', [AuthController::class, 'forgotPassword']);
|
||||
Route::post('auth/reset-password', [AuthController::class, 'resetPassword']);
|
||||
Route::post('auth/login', [V1\AuthController::class, 'login']);
|
||||
Route::post('portal/token-auth', [V1\PortalAuthController::class, 'tokenAuth']);
|
||||
|
||||
// Protected routes
|
||||
// Protected routes (login-based)
|
||||
Route::middleware('auth:sanctum')->group(function () {
|
||||
// Auth
|
||||
Route::get('auth/user', [AuthController::class, 'user']);
|
||||
Route::post('auth/logout', [AuthController::class, 'logout']);
|
||||
Route::post('auth/logout', [V1\AuthController::class, 'logout']);
|
||||
Route::get('auth/me', [V1\AuthController::class, 'me']);
|
||||
|
||||
// Resources
|
||||
Route::apiResource('events', EventController::class);
|
||||
Route::post('events/{event}/invite', [EventController::class, 'invite']);
|
||||
Route::post('events/{event}/rsvp', [EventController::class, 'rsvp']);
|
||||
// Organisations
|
||||
Route::apiResource('organisations', V1\OrganisationController::class);
|
||||
Route::post('organisations/{organisation}/invite', [V1\OrganisationController::class, 'invite']);
|
||||
|
||||
Route::apiResource('members', MemberController::class);
|
||||
Route::apiResource('music', MusicController::class);
|
||||
Route::apiResource('setlists', SetlistController::class);
|
||||
Route::apiResource('locations', LocationController::class);
|
||||
Route::apiResource('customers', CustomerController::class);
|
||||
// Events (nested under organisations)
|
||||
Route::apiResource('organisations.events', V1\EventController::class)->shallow();
|
||||
|
||||
// Festival Sections (nested under events)
|
||||
Route::apiResource('events.festival-sections', V1\FestivalSectionController::class)->shallow();
|
||||
|
||||
// Time Slots
|
||||
Route::apiResource('events.time-slots', V1\TimeSlotController::class)->shallow();
|
||||
|
||||
// Shifts (nested under sections)
|
||||
Route::apiResource('festival-sections.shifts', V1\ShiftController::class)->shallow();
|
||||
Route::post('shifts/{shift}/assign', [V1\ShiftController::class, 'assign']);
|
||||
Route::post('shifts/{shift}/claim', [V1\ShiftController::class, 'claim']);
|
||||
|
||||
// Persons
|
||||
Route::apiResource('events.persons', V1\PersonController::class)->shallow();
|
||||
Route::post('persons/{person}/approve', [V1\PersonController::class, 'approve']);
|
||||
Route::post('persons/{person}/checkin', [V1\PersonController::class, 'checkin']);
|
||||
|
||||
// Artists
|
||||
Route::apiResource('events.artists', V1\ArtistController::class)->shallow();
|
||||
|
||||
// Accreditation
|
||||
Route::apiResource('events.accreditation-items', V1\AccreditationItemController::class)->shallow();
|
||||
Route::apiResource('events.access-zones', V1\AccessZoneController::class)->shallow();
|
||||
|
||||
// Briefings
|
||||
Route::apiResource('events.briefings', V1\BriefingController::class)->shallow();
|
||||
Route::post('briefings/{briefing}/send', [V1\BriefingController::class, 'send']);
|
||||
});
|
||||
|
||||
// Token-based portal routes
|
||||
Route::middleware('portal.token')->prefix('portal')->group(function () {
|
||||
Route::get('artist', [V1\PortalArtistController::class, 'show']);
|
||||
Route::post('advancing', [V1\PortalArtistController::class, 'submitAdvance']);
|
||||
Route::get('supplier', [V1\PortalSupplierController::class, 'show']);
|
||||
Route::post('production-request', [V1\PortalSupplierController::class, 'submit']);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Soft Delete Strategy
|
||||
|
||||
**Soft delete ON**: Organisation, Event, FestivalSection, Shift, ShiftAssignment, Person, Artist, Company, ProductionRequest.
|
||||
|
||||
**Soft delete OFF** (immutable audit records): CheckIn, BriefingSend, MessageReply, ShiftWaitlist, VolunteerFestivalHistory.
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Always Use
|
||||
|
||||
- `declare(strict_types=1)` at the top of every file
|
||||
- `final` keyword for Action classes, Form Requests, Resources
|
||||
- `declare(strict_types=1)` at top of every file
|
||||
- HasUlids trait for ULID primary keys on business models
|
||||
- OrganisationScope for multi-tenant data isolation
|
||||
- Type hints for all parameters and return types
|
||||
- Named arguments for better readability
|
||||
- Enums for status fields and fixed options
|
||||
- ULIDs for all primary keys
|
||||
- Eager loading to prevent N+1 queries
|
||||
- API Resources for all responses
|
||||
- API Resources for all responses (never raw models)
|
||||
- Spatie roles and Policies for authorization
|
||||
- Composite indexes as documented in design document
|
||||
|
||||
### Avoid
|
||||
|
||||
- Business logic in controllers
|
||||
- String constants (use enums)
|
||||
- Auto-increment IDs
|
||||
- Direct model creation in controllers
|
||||
- Business logic in controllers (use Services for complex logic)
|
||||
- String constants for statuses (use enums)
|
||||
- Auto-increment IDs for business tables (use ULIDs)
|
||||
- Returning raw models (use Resources)
|
||||
- Hardcoded strings for error messages
|
||||
|
||||
## DTOs (Data Transfer Objects)
|
||||
|
||||
Use DTOs for complex data passing between layers:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\DTOs;
|
||||
|
||||
readonly class CreateEventDTO
|
||||
{
|
||||
public function __construct(
|
||||
public string $title,
|
||||
public string $eventDate,
|
||||
public string $startTime,
|
||||
public ?string $description = null,
|
||||
public ?string $locationId = null,
|
||||
public ?string $customerId = null,
|
||||
public ?string $endTime = null,
|
||||
public ?float $fee = null,
|
||||
) {}
|
||||
|
||||
public static function from(array $data): self
|
||||
{
|
||||
return new self(
|
||||
title: $data['title'],
|
||||
eventDate: $data['event_date'],
|
||||
startTime: $data['start_time'],
|
||||
description: $data['description'] ?? null,
|
||||
locationId: $data['location_id'] ?? null,
|
||||
customerId: $data['customer_id'] ?? null,
|
||||
endTime: $data['end_time'] ?? null,
|
||||
fee: $data['fee'] ?? null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
$dto = CreateEventDTO::from($request->validated());
|
||||
$event = $action->execute($dto);
|
||||
```
|
||||
|
||||
## Helpers
|
||||
|
||||
Use Laravel helpers instead of facades:
|
||||
|
||||
```php
|
||||
// ✅ Good
|
||||
auth()->id()
|
||||
auth()->user()
|
||||
now()
|
||||
str($string)->slug()
|
||||
collect($array)->filter()
|
||||
cache()->remember('key', 3600, fn() => $value)
|
||||
|
||||
// ❌ Avoid
|
||||
Auth::id()
|
||||
Carbon::now()
|
||||
Str::slug($string)
|
||||
Cache::remember(...)
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
Create domain-specific exceptions:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use Exception;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class EventNotFoundException extends Exception
|
||||
{
|
||||
public function render(): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Event not found',
|
||||
], 404);
|
||||
}
|
||||
}
|
||||
|
||||
class EventAlreadyConfirmedException extends Exception
|
||||
{
|
||||
public function render(): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Event has already been confirmed and cannot be modified',
|
||||
], 422);
|
||||
}
|
||||
}
|
||||
|
||||
// Usage in Action
|
||||
if ($event->isConfirmed()) {
|
||||
throw new EventAlreadyConfirmedException();
|
||||
}
|
||||
```
|
||||
|
||||
## Query Scopes
|
||||
|
||||
Add reusable query scopes to models:
|
||||
|
||||
```php
|
||||
// In Event model
|
||||
public function scopeUpcoming(Builder $query): Builder
|
||||
{
|
||||
return $query->where('event_date', '>=', now()->toDateString())
|
||||
->orderBy('event_date');
|
||||
}
|
||||
|
||||
public function scopeForUser(Builder $query, User $user): Builder
|
||||
{
|
||||
return $query->whereHas('invitations', fn ($q) =>
|
||||
$q->where('user_id', $user->id)
|
||||
);
|
||||
}
|
||||
|
||||
public function scopeConfirmed(Builder $query): Builder
|
||||
{
|
||||
return $query->where('status', EventStatus::Confirmed);
|
||||
}
|
||||
|
||||
// Usage
|
||||
Event::upcoming()->confirmed()->get();
|
||||
Event::forUser($user)->upcoming()->get();
|
||||
```
|
||||
- Hardcoded role checks in controllers (use Policies)
|
||||
- JSON columns for data that needs to be filtered/sorted
|
||||
- `Model::all()` without organisation scoping
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
221
.cursor/rules/102_multi_tenancy.mdc
Normal file
221
.cursor/rules/102_multi_tenancy.mdc
Normal file
@@ -0,0 +1,221 @@
|
||||
---
|
||||
description: Multi-tenancy and portal architecture rules for EventCrew
|
||||
globs: ["api/**/*.php", "apps/portal/**/*.{vue,ts}"]
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Multi-Tenancy & Portal Rules
|
||||
|
||||
## Organisation Scoping (CRITICAL)
|
||||
|
||||
Every query on event-related data MUST be scoped to the current organisation. This is enforced via Eloquent Global Scopes, NOT manual where-clauses in controllers.
|
||||
|
||||
### OrganisationScope Implementation
|
||||
|
||||
```php
|
||||
// Applied in model's booted() method
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::addGlobalScope('organisation', function (Builder $builder) {
|
||||
if ($organisationId = auth()->user()?->current_organisation_id) {
|
||||
$builder->where('organisation_id', $organisationId);
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Models That Need OrganisationScope
|
||||
- Event (direct `organisation_id`)
|
||||
- CrowdType (direct `organisation_id`)
|
||||
- AccreditationCategory (direct `organisation_id`)
|
||||
- Company (direct `organisation_id`)
|
||||
|
||||
### Models Scoped via Parent
|
||||
These don't have `organisation_id` directly but inherit scope through their parent:
|
||||
- FestivalSection → via Event
|
||||
- TimeSlot → via Event
|
||||
- Shift → via FestivalSection → Event
|
||||
- Person → via Event
|
||||
- Artist → via Event
|
||||
- All other event-child models
|
||||
|
||||
### Rules
|
||||
1. NEVER use `Model::all()` without a scope
|
||||
2. NEVER pass organisation_id in URL params for filtering — always derive from authenticated user
|
||||
3. ALWAYS use Policies to verify the user belongs to the organisation
|
||||
4. ALWAYS test that users from Organisation A cannot see Organisation B's data
|
||||
|
||||
## Three-Level Authorization
|
||||
|
||||
### Level 1: App Level (Spatie Roles)
|
||||
```php
|
||||
// super_admin, support_agent
|
||||
$user->hasRole('super_admin');
|
||||
```
|
||||
|
||||
### Level 2: Organisation Level (Spatie Team Permissions)
|
||||
```php
|
||||
// org_admin, org_member, org_readonly
|
||||
// Organisation acts as Spatie "team"
|
||||
$user->hasRole('org_admin'); // within current organisation context
|
||||
```
|
||||
|
||||
### Level 3: Event Level (Custom Pivot)
|
||||
```php
|
||||
// event_manager, artist_manager, staff_coordinator, volunteer_coordinator, accreditation_officer
|
||||
// Stored in event_user_roles pivot table
|
||||
$user->eventRoles()->where('event_id', $event->id)->pluck('role');
|
||||
```
|
||||
|
||||
### Middleware Stack
|
||||
```php
|
||||
// routes/api.php
|
||||
Route::middleware(['auth:sanctum', 'organisation.role:org_admin,org_member'])->group(...);
|
||||
Route::middleware(['auth:sanctum', 'event.role:event_manager'])->group(...);
|
||||
```
|
||||
|
||||
## User Invitation Flow
|
||||
|
||||
### Internal Staff (login-based)
|
||||
1. Organiser enters email + selects role
|
||||
2. System checks if account exists
|
||||
3. If no: invitation email with activation link (24h valid), account created on activation
|
||||
4. If yes: invitation email, existing user linked to new org/event role on acceptance
|
||||
5. User can switch between organisations via org-switcher
|
||||
|
||||
### Volunteer Registration
|
||||
1. Volunteer fills public registration form (multi-step)
|
||||
2. Person record created with `status = 'pending'`
|
||||
3. Organiser approves/rejects → `status = 'approved'`
|
||||
4. On approval: check if email has platform account → link or create
|
||||
5. Volunteer logs in to portal
|
||||
|
||||
## Portal Architecture
|
||||
|
||||
### Two Access Modes in One App (`apps/portal/`)
|
||||
|
||||
| Mode | Middleware | Users | Token Source |
|
||||
|------|-----------|-------|-------------|
|
||||
| Login | `auth:sanctum` | Volunteers, Crew | Bearer token from login |
|
||||
| Token | `portal.token` | Artists, Suppliers, Press | URL token param: `?token=ULID` |
|
||||
|
||||
### Token-Based Authentication Flow
|
||||
```
|
||||
1. Artist/supplier receives email with link: https://portal.eventcrew.app/advance?token=01HQ3K...
|
||||
2. Portal detects token in URL query parameter
|
||||
3. POST /api/v1/portal/token-auth { token: '01HQ3K...' }
|
||||
4. Backend validates token against artists.portal_token or production_requests.token
|
||||
5. Returns person context (name, event, crowd_type, permissions)
|
||||
6. Portal stores context in Pinia, shows relevant portal view
|
||||
```
|
||||
|
||||
### Login-Based Authentication Flow
|
||||
```
|
||||
1. Volunteer navigates to https://portal.eventcrew.app/login
|
||||
2. Enters email + password
|
||||
3. POST /api/v1/auth/login (same endpoint as apps/app/)
|
||||
4. Returns user + organisations + event roles
|
||||
5. Portal shows volunteer-specific views (My Shifts, Claim Shifts, Messages, Profile)
|
||||
```
|
||||
|
||||
### Backend Route Structure
|
||||
```php
|
||||
// Public
|
||||
Route::post('auth/login', ...);
|
||||
Route::post('portal/token-auth', ...);
|
||||
Route::post('portal/form-submit', ...); // Public form submission
|
||||
|
||||
// Login-based portal (auth:sanctum)
|
||||
Route::middleware('auth:sanctum')->prefix('portal')->group(function () {
|
||||
Route::get('my-shifts', ...);
|
||||
Route::post('shifts/{shift}/claim', ...);
|
||||
Route::get('messages', ...);
|
||||
Route::get('profile', ...);
|
||||
});
|
||||
|
||||
// Token-based portal (portal.token)
|
||||
Route::middleware('portal.token')->prefix('portal')->group(function () {
|
||||
Route::get('artist', ...);
|
||||
Route::post('advancing', ...);
|
||||
Route::get('supplier', ...);
|
||||
Route::post('production-request', ...);
|
||||
});
|
||||
```
|
||||
|
||||
### PortalTokenMiddleware
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\Artist;
|
||||
use App\Models\ProductionRequest;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class PortalTokenMiddleware
|
||||
{
|
||||
public function handle(Request $request, Closure $next)
|
||||
{
|
||||
$token = $request->bearerToken() ?? $request->query('token');
|
||||
|
||||
if (!$token) {
|
||||
return response()->json(['message' => 'Token required'], 401);
|
||||
}
|
||||
|
||||
// Try artist token
|
||||
$artist = Artist::where('portal_token', $token)->first();
|
||||
if ($artist) {
|
||||
$request->merge(['portal_context' => 'artist', 'portal_entity' => $artist]);
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
// Try production request token
|
||||
$productionRequest = ProductionRequest::where('token', $token)->first();
|
||||
if ($productionRequest) {
|
||||
$request->merge(['portal_context' => 'supplier', 'portal_entity' => $productionRequest]);
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
return response()->json(['message' => 'Invalid token'], 401);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## CORS Configuration
|
||||
|
||||
```php
|
||||
// config/cors.php
|
||||
'allowed_origins' => [
|
||||
env('FRONTEND_ADMIN_URL', 'http://localhost:5173'),
|
||||
env('FRONTEND_APP_URL', 'http://localhost:5174'),
|
||||
env('FRONTEND_PORTAL_URL', 'http://localhost:5175'),
|
||||
],
|
||||
'supports_credentials' => true,
|
||||
```
|
||||
|
||||
## Shift Claiming & Approval Flow
|
||||
|
||||
### Three Assignment Strategies per Shift
|
||||
1. **Fully controlled**: `slots_open_for_claiming = 0`. Organiser assigns manually.
|
||||
2. **Fully self-service**: `slots_open_for_claiming = slots_total`. Volunteers fill all spots.
|
||||
3. **Hybrid**: `slots_open_for_claiming < slots_total`. Some reserved for manual.
|
||||
|
||||
### Claim Flow
|
||||
```
|
||||
1. Volunteer claims shift → POST /shifts/{id}/claim
|
||||
2. Backend checks: slot availability, time_slot conflict (UNIQUE person_id + time_slot_id)
|
||||
3. Creates ShiftAssignment with status = 'pending_approval' (or 'approved' if auto_approve)
|
||||
4. Dispatches NotifyCoordinatorOfClaimJob (queued, WhatsApp via Zender)
|
||||
5. Coordinator approves/rejects via Organizer app
|
||||
6. Volunteer receives confirmation email
|
||||
```
|
||||
|
||||
### Status Machine
|
||||
```
|
||||
pending_approval → approved → completed
|
||||
→ rejected (final)
|
||||
→ cancelled (by volunteer or organiser)
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
21
.cursorrules
Normal file
21
.cursorrules
Normal file
@@ -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
|
||||
- <script setup lang='ts'> altijd
|
||||
- TanStack Query voor API state, Pinia voor UI state
|
||||
- Vuetify componenten eerst, custom CSS als laatste redmiddel
|
||||
|
||||
## Naamgeving
|
||||
- snake_case DB | camelCase JS | PascalCase Vue | use* composables | use*Store Pinia
|
||||
|
||||
## Tests
|
||||
- PHPUnit Feature Test per controller, minimaal: 200 + 401 + 403
|
||||
129
CLAUDE.md
Normal file
129
CLAUDE.md
Normal file
@@ -0,0 +1,129 @@
|
||||
# EventCrew — Claude Code Instructies
|
||||
|
||||
## Project Context
|
||||
|
||||
EventCrew is een multi-tenant SaaS platform voor event- en festivalbeheer.
|
||||
Gebouwd voor een professionele vrijwilligersorganisatie, met SaaS-uitbreidingspotentieel.
|
||||
Design Document: /docs/EventCrew_Design_Document_v1.3.docx
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- Backend: PHP 8.2+, Laravel 12, Sanctum, Spatie Permission, MySQL 8, Redis
|
||||
- Frontend: TypeScript, Vue 3 (Composition API), Vuexy/Vuetify, Pinia, TanStack Query
|
||||
- Testing: PHPUnit (backend), Vitest (frontend)
|
||||
|
||||
## Repository Structuur
|
||||
|
||||
- api/ Laravel backend
|
||||
- apps/admin/ Super Admin SPA
|
||||
- apps/app/ Organizer SPA (hoofdapp)
|
||||
- apps/portal/ Externe portals (vrijwilliger, artiest, leverancier)
|
||||
|
||||
## Apps & Portal Architectuur
|
||||
|
||||
- apps/admin/ = Super Admin — platformbeheer, organisaties aanmaken
|
||||
- apps/app/ = Organizer — event management per organisatie
|
||||
- apps/portal/ = Externe gebruikers — één app, twee toegangsmodi:
|
||||
- Login-based (auth:sanctum): vrijwilligers, crew — persons met user_id
|
||||
- Token-based (portal.token middleware): artiesten, leveranciers, pers — persons zonder user_id
|
||||
|
||||
### CORS
|
||||
|
||||
Drie frontend origins configureren in zowel Laravel (config/cors.php via env) als Vite dev server proxy:
|
||||
- admin: localhost:5173
|
||||
- app: localhost:5174
|
||||
- portal: localhost:5175
|
||||
|
||||
## Backend Regels (STRIKT VOLGEN)
|
||||
|
||||
### Multi-tenancy
|
||||
|
||||
- ELKE query op event-data MOET scoperen op organisation_id
|
||||
- Gebruik OrganisationScope als Eloquent Global Scope op alle event-gerelateerde modellen
|
||||
- Nooit directe id-checks in controllers — gebruik altijd Policies
|
||||
|
||||
### Controllers
|
||||
|
||||
- Gebruik Resource Controllers (index/show/store/update/destroy)
|
||||
- Namespace: App\Http\Controllers\Api\V1\
|
||||
- Alle responses via API Resources (nooit model-attributen direct teruggeven)
|
||||
- Validatie via Form Requests (nooit inline validate())
|
||||
|
||||
### Modellen
|
||||
|
||||
- Gebruik HasUlids trait op alle business-modellen (GEEN UUID v4)
|
||||
- Soft deletes op: Organisation, Event, FestivalSection, Shift, ShiftAssignment, Person, Artist
|
||||
- GEEN soft deletes op: CheckIn, BriefingSend, MessageReply, ShiftWaitlist (audit-records)
|
||||
- JSON kolommen ALLEEN voor opaque configuratie — nooit voor queryable data
|
||||
|
||||
### Database
|
||||
|
||||
- Primaire sleutels: ULID via HasUlids (niet UUID v4, niet auto-increment voor business tables)
|
||||
- Elke migratie in volgorde aanmaken: eerst foundation, dan afhankelijke tabellen
|
||||
- ALTIJD composite indexes toevoegen zoals gedocumenteerd in het design document sectie 3.5
|
||||
|
||||
### Rollen & Permissies
|
||||
|
||||
- Gebruik Spatie laravel-permission
|
||||
- Check rollen via $user->hasRole() en Policies — nooit hardcoded role strings in controllers
|
||||
- Drie niveaus: app (super_admin), organisatie (org_admin/org_member), event (event_manager etc.)
|
||||
|
||||
### Testing
|
||||
|
||||
- Schrijf PHPUnit Feature Tests per controller
|
||||
- Minimaal per endpoint: happy path + unauthenticated (401) + wrong organisation (403)
|
||||
- Gebruik factories voor alle test-data
|
||||
- Draai tests NA elke module: php artisan test --filter=ModuleNaam
|
||||
|
||||
## Frontend Regels (STRIKT VOLGEN)
|
||||
|
||||
### Vue Componenten
|
||||
|
||||
- Altijd <script setup lang='ts'> — nooit Options API
|
||||
- Props altijd getypeerd met defineProps<{...}>()
|
||||
- Emits altijd gedeclareerd met defineEmits<{...}>()
|
||||
|
||||
### API Calls
|
||||
|
||||
- Gebruik TanStack Query (useQuery / useMutation) voor ALLE API calls
|
||||
- Nooit direct axios in een component — altijd via een composable in composables/api/
|
||||
- Pinia stores voor cross-component state — nooit prop drilling
|
||||
|
||||
### Naamgeving
|
||||
|
||||
- DB kolommen: snake_case
|
||||
- TypeScript/JS variabelen: camelCase
|
||||
- Vue componenten: PascalCase (bijv. ShiftAssignPanel.vue)
|
||||
- Composables: use-prefix (bijv. useShifts.ts)
|
||||
- Pinia stores: use-suffix store (bijv. useEventStore.ts)
|
||||
|
||||
### UI
|
||||
|
||||
- Gebruik ALTIJD Vuexy/Vuetify componenten voor layout, forms, tabellen, dialogen
|
||||
- Nooit custom CSS schrijven als een Vuetify klasse bestaat
|
||||
- Responsief: mobile-first, minimaal werkend op 375px breedte
|
||||
|
||||
## Verboden Patronen
|
||||
|
||||
- NOOIT: $user->role === 'admin' (gebruik policies)
|
||||
- NOOIT: Model::all() zonder where-clausule (altijd scopen)
|
||||
- NOOIT: dd() of var_dump() achterlaten in code
|
||||
- NOOIT: .env waarden hardcoden in code
|
||||
- NOOIT: JSON kolommen gebruiken voor data waarop gefilterd wordt
|
||||
- NOOIT: UUID v4 als primaire sleutel (gebruik HasUlids)
|
||||
|
||||
## Volgorde bij elke nieuwe module
|
||||
|
||||
1. Migratie(s) aanmaken en draaien
|
||||
2. Eloquent Model met relaties, scopes en HasUlids
|
||||
3. Factory voor test-data
|
||||
4. Policy voor autorisatie
|
||||
5. Form Request(s) voor validatie
|
||||
6. API Resource voor response transformatie
|
||||
7. Resource Controller
|
||||
8. Routes registreren in api.php
|
||||
9. PHPUnit Feature Test schrijven en draaien
|
||||
10. Vue composable voor API calls (useModuleNaam.ts)
|
||||
11. Pinia store indien cross-component state nodig
|
||||
12. Vue pagina component
|
||||
13. Route toevoegen in Vue Router
|
||||
24
Makefile
24
Makefile
@@ -1,4 +1,4 @@
|
||||
.PHONY: help services services-stop api admin band customers
|
||||
.PHONY: help services services-stop api admin app portal
|
||||
|
||||
# Colors
|
||||
GREEN := \033[0;32m
|
||||
@@ -9,7 +9,7 @@ NC := \033[0m
|
||||
help:
|
||||
@echo ""
|
||||
@echo "$(GREEN)╔══════════════════════════════════════════════════════════════╗$(NC)"
|
||||
@echo "$(GREEN)║ BAND MANAGEMENT - Development Commands ║$(NC)"
|
||||
@echo "$(GREEN)║ EVENT CREW - Development Commands ║$(NC)"
|
||||
@echo "$(GREEN)╚══════════════════════════════════════════════════════════════╝$(NC)"
|
||||
@echo ""
|
||||
@echo " $(YELLOW)Services (Docker):$(NC)"
|
||||
@@ -19,8 +19,8 @@ help:
|
||||
@echo " $(YELLOW)Development Servers:$(NC)"
|
||||
@echo " make api Laravel API → http://localhost:8000"
|
||||
@echo " make admin Admin Dashboard → http://localhost:5173"
|
||||
@echo " make band Band Portal → http://localhost:5174"
|
||||
@echo " make customers Customer Portal → http://localhost:5175"
|
||||
@echo " make app Organizer SPA → http://localhost:5174"
|
||||
@echo " make portal Portal SPA → http://localhost:5175"
|
||||
@echo ""
|
||||
@echo " $(YELLOW)Database:$(NC)"
|
||||
@echo " make migrate Run migrations"
|
||||
@@ -33,7 +33,7 @@ services:
|
||||
@docker compose up -d
|
||||
@echo ""
|
||||
@echo "$(GREEN)Services:$(NC)"
|
||||
@echo " $(CYAN)MySQL:$(NC) localhost:3306 (band_management / secret)"
|
||||
@echo " $(CYAN)MySQL:$(NC) localhost:3306 (event_crew / secret)"
|
||||
@echo " $(CYAN)Redis:$(NC) localhost:6379"
|
||||
@echo " $(CYAN)Mailpit:$(NC) http://localhost:8025"
|
||||
@echo ""
|
||||
@@ -53,13 +53,13 @@ admin:
|
||||
@echo "$(GREEN)Starting Admin SPA → http://localhost:5173$(NC)"
|
||||
@cd apps/admin && pnpm dev
|
||||
|
||||
band:
|
||||
@echo "$(GREEN)Starting Band Portal → http://localhost:5174$(NC)"
|
||||
@cd apps/band && pnpm dev --port 5174
|
||||
app:
|
||||
@echo "$(GREEN)Starting Organizer SPA → http://localhost:5174$(NC)"
|
||||
@cd apps/app && pnpm dev
|
||||
|
||||
customers:
|
||||
@echo "$(GREEN)Starting Customer Portal → http://localhost:5175$(NC)"
|
||||
@cd apps/customers && pnpm dev --port 5175
|
||||
portal:
|
||||
@echo "$(GREEN)Starting Portal SPA → http://localhost:5175$(NC)"
|
||||
@cd apps/portal && pnpm dev
|
||||
|
||||
migrate:
|
||||
@cd api && php artisan migrate
|
||||
@@ -68,4 +68,4 @@ fresh:
|
||||
@cd api && php artisan migrate:fresh --seed
|
||||
|
||||
db-shell:
|
||||
@docker exec -it bm_mysql mysql -u band_management -psecret band_management
|
||||
@docker exec -it bm_mysql mysql -u event_crew -psecret event_crew
|
||||
|
||||
14
README.md
14
README.md
@@ -1,6 +1,6 @@
|
||||
# Band Management
|
||||
# Event Crew
|
||||
|
||||
Full-stack band/artist operations management platform.
|
||||
Full-stack event crew operations management platform.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
@@ -25,7 +25,7 @@ See [docs/SETUP.md](docs/SETUP.md) for detailed instructions.
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
band-management/
|
||||
event-crew/
|
||||
├── api/ # Laravel 12 API
|
||||
├── apps/
|
||||
│ ├── admin/ # Admin Dashboard
|
||||
@@ -56,10 +56,10 @@ All frontend apps are built with **Vue 3 + TypeScript** using the [Vuexy Admin T
|
||||
|
||||
| App | Development | Production |
|
||||
|-----|-------------|------------|
|
||||
| API | http://localhost:8000/api/v1 | https://api.bandmanagement.nl |
|
||||
| Admin | http://localhost:5173 | https://admin.bandmanagement.nl |
|
||||
| Band Portal | http://localhost:5174 | https://band.bandmanagement.nl |
|
||||
| Customer Portal | http://localhost:5175 | https://customers.bandmanagement.nl |
|
||||
| API | http://localhost:8000/api/v1 | https://api.eventcrew.nl |
|
||||
| Admin | http://localhost:5173 | https://admin.eventcrew.nl |
|
||||
| Band Portal | http://localhost:5174 | https://band.eventcrew.nl |
|
||||
| Customer Portal | http://localhost:5175 | https://customers.eventcrew.nl |
|
||||
|
||||
## Development Commands
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
APP_NAME="Band Management"
|
||||
APP_NAME="Event Crew"
|
||||
APP_ENV=local
|
||||
APP_KEY=
|
||||
APP_DEBUG=true
|
||||
@@ -20,8 +20,8 @@ LOG_LEVEL=debug
|
||||
DB_CONNECTION=mysql
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=3306
|
||||
DB_DATABASE=band_management
|
||||
DB_USERNAME=band_management
|
||||
DB_DATABASE=event_crew
|
||||
DB_USERNAME=event_crew
|
||||
DB_PASSWORD=secret
|
||||
|
||||
SESSION_DRIVER=database
|
||||
@@ -47,7 +47,7 @@ MAIL_PORT=1025
|
||||
MAIL_USERNAME=null
|
||||
MAIL_PASSWORD=null
|
||||
MAIL_ENCRYPTION=null
|
||||
MAIL_FROM_ADDRESS="noreply@bandmanagement.nl"
|
||||
MAIL_FROM_ADDRESS="noreply@eventcrew.nl"
|
||||
MAIL_FROM_NAME="${APP_NAME}"
|
||||
|
||||
# CORS - Frontend SPAs
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum EventStatus: string
|
||||
{
|
||||
case Draft = 'draft';
|
||||
case Pending = 'pending';
|
||||
case Confirmed = 'confirmed';
|
||||
case Completed = 'completed';
|
||||
case Cancelled = 'cancelled';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Draft => 'Draft',
|
||||
self::Pending => 'Pending Confirmation',
|
||||
self::Confirmed => 'Confirmed',
|
||||
self::Completed => 'Completed',
|
||||
self::Cancelled => 'Cancelled',
|
||||
};
|
||||
}
|
||||
|
||||
public function color(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Draft => 'secondary',
|
||||
self::Pending => 'warning',
|
||||
self::Confirmed => 'success',
|
||||
self::Completed => 'info',
|
||||
self::Cancelled => 'error',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum EventVisibility: string
|
||||
{
|
||||
case Private = 'private';
|
||||
case Members = 'members';
|
||||
case Public = 'public';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Private => 'Private',
|
||||
self::Members => 'Members Only',
|
||||
self::Public => 'Public',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum RsvpStatus: string
|
||||
{
|
||||
case Pending = 'pending';
|
||||
case Available = 'available';
|
||||
case Unavailable = 'unavailable';
|
||||
case Tentative = 'tentative';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Pending => 'Pending',
|
||||
self::Available => 'Available',
|
||||
self::Unavailable => 'Unavailable',
|
||||
self::Tentative => 'Tentative',
|
||||
};
|
||||
}
|
||||
|
||||
public function color(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Pending => 'secondary',
|
||||
self::Available => 'success',
|
||||
self::Unavailable => 'error',
|
||||
self::Tentative => 'warning',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Api\V1\LoginRequest;
|
||||
use App\Http\Requests\Api\V1\RegisterRequest;
|
||||
use App\Http\Resources\Api\V1\UserResource;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
final class AuthController extends Controller
|
||||
{
|
||||
public function login(LoginRequest $request): JsonResponse
|
||||
{
|
||||
$credentials = $request->only('email', 'password');
|
||||
|
||||
if (!Auth::attempt($credentials)) {
|
||||
return $this->unauthorized('Invalid credentials');
|
||||
}
|
||||
|
||||
$user = Auth::user();
|
||||
|
||||
if (!$user->isActive()) {
|
||||
Auth::logout();
|
||||
return $this->forbidden('Your account is inactive');
|
||||
}
|
||||
|
||||
$token = $user->createToken('auth-token')->plainTextToken;
|
||||
|
||||
return $this->success([
|
||||
'user' => new UserResource($user),
|
||||
'token' => $token,
|
||||
], 'Login successful');
|
||||
}
|
||||
|
||||
public function register(RegisterRequest $request): JsonResponse
|
||||
{
|
||||
$user = User::create([
|
||||
'name' => $request->name,
|
||||
'email' => $request->email,
|
||||
'password' => Hash::make($request->password),
|
||||
'type' => 'member',
|
||||
'role' => 'member',
|
||||
'status' => 'active',
|
||||
]);
|
||||
|
||||
$token = $user->createToken('auth-token')->plainTextToken;
|
||||
|
||||
return $this->created([
|
||||
'user' => new UserResource($user),
|
||||
'token' => $token,
|
||||
], 'Registration successful');
|
||||
}
|
||||
|
||||
public function user(Request $request): JsonResponse
|
||||
{
|
||||
return $this->success(
|
||||
new UserResource($request->user())
|
||||
);
|
||||
}
|
||||
|
||||
public function logout(Request $request): JsonResponse
|
||||
{
|
||||
$request->user()->currentAccessToken()->delete();
|
||||
|
||||
return $this->success(null, 'Logged out successfully');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,151 +4,55 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Enums\RsvpStatus;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Api\V1\InviteToEventRequest;
|
||||
use App\Http\Requests\Api\V1\RsvpEventRequest;
|
||||
use App\Http\Requests\Api\V1\StoreEventRequest;
|
||||
use App\Http\Requests\Api\V1\UpdateEventRequest;
|
||||
use App\Http\Resources\Api\V1\EventCollection;
|
||||
use App\Http\Resources\Api\V1\EventInvitationResource;
|
||||
use App\Http\Resources\Api\V1\EventResource;
|
||||
use App\Models\Event;
|
||||
use App\Models\EventInvitation;
|
||||
use App\Models\Organisation;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
final class EventController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display a listing of events.
|
||||
*/
|
||||
public function index(): EventCollection
|
||||
public function index(Organisation $organisation): AnonymousResourceCollection
|
||||
{
|
||||
Gate::authorize('viewAny', Event::class);
|
||||
Gate::authorize('viewAny', [Event::class, $organisation]);
|
||||
|
||||
$events = Event::query()
|
||||
->with(['location', 'customer'])
|
||||
->latest('event_date')
|
||||
$events = $organisation->events()
|
||||
->latest('start_date')
|
||||
->paginate();
|
||||
|
||||
return new EventCollection($events);
|
||||
return EventResource::collection($events);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a newly created event.
|
||||
*/
|
||||
public function store(StoreEventRequest $request): JsonResponse
|
||||
{
|
||||
Gate::authorize('create', Event::class);
|
||||
|
||||
$data = $request->validated();
|
||||
$data['created_by'] = auth()->id();
|
||||
$data['currency'] = $data['currency'] ?? 'EUR';
|
||||
|
||||
$event = Event::create($data);
|
||||
|
||||
return $this->created(
|
||||
new EventResource($event->load(['location', 'customer'])),
|
||||
'Event created successfully'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the specified event.
|
||||
*/
|
||||
public function show(Event $event): EventResource
|
||||
public function show(Organisation $organisation, Event $event): JsonResponse
|
||||
{
|
||||
Gate::authorize('view', $event);
|
||||
|
||||
return new EventResource(
|
||||
$event->load(['location', 'customer', 'setlist.items.musicNumber', 'invitations.user', 'creator'])
|
||||
);
|
||||
abort_unless($event->organisation_id === $organisation->id, 404);
|
||||
|
||||
return $this->success(new EventResource($event->load('organisation')));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the specified event.
|
||||
*/
|
||||
public function update(UpdateEventRequest $request, Event $event): JsonResponse
|
||||
public function store(StoreEventRequest $request, Organisation $organisation): JsonResponse
|
||||
{
|
||||
Gate::authorize('create', [Event::class, $organisation]);
|
||||
|
||||
$event = $organisation->events()->create($request->validated());
|
||||
|
||||
return $this->created(new EventResource($event));
|
||||
}
|
||||
|
||||
public function update(UpdateEventRequest $request, Organisation $organisation, Event $event): JsonResponse
|
||||
{
|
||||
Gate::authorize('update', $event);
|
||||
|
||||
abort_unless($event->organisation_id === $organisation->id, 404);
|
||||
|
||||
$event->update($request->validated());
|
||||
|
||||
return $this->success(
|
||||
new EventResource($event->load(['location', 'customer'])),
|
||||
'Event updated successfully'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the specified event.
|
||||
*/
|
||||
public function destroy(Event $event): JsonResponse
|
||||
{
|
||||
Gate::authorize('delete', $event);
|
||||
|
||||
$event->delete();
|
||||
|
||||
return $this->success(null, 'Event deleted successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* Invite members to an event.
|
||||
*/
|
||||
public function invite(InviteToEventRequest $request, Event $event): JsonResponse
|
||||
{
|
||||
Gate::authorize('invite', $event);
|
||||
|
||||
$userIds = $request->validated()['user_ids'];
|
||||
$invitedCount = 0;
|
||||
|
||||
foreach ($userIds as $userId) {
|
||||
// Skip if already invited
|
||||
if ($event->invitations()->where('user_id', $userId)->exists()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$event->invitations()->create([
|
||||
'user_id' => $userId,
|
||||
'rsvp_status' => RsvpStatus::Pending,
|
||||
'invited_at' => now(),
|
||||
]);
|
||||
|
||||
$invitedCount++;
|
||||
}
|
||||
|
||||
return $this->success(
|
||||
EventInvitationResource::collection(
|
||||
$event->invitations()->with('user')->get()
|
||||
),
|
||||
"{$invitedCount} member(s) invited successfully"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Respond to an event invitation (RSVP).
|
||||
*/
|
||||
public function rsvp(RsvpEventRequest $request, Event $event): JsonResponse
|
||||
{
|
||||
Gate::authorize('rsvp', $event);
|
||||
|
||||
$invitation = EventInvitation::where('event_id', $event->id)
|
||||
->where('user_id', auth()->id())
|
||||
->firstOrFail();
|
||||
|
||||
$data = $request->validated();
|
||||
|
||||
$invitation->update([
|
||||
'rsvp_status' => $data['status'],
|
||||
'rsvp_note' => $data['note'] ?? null,
|
||||
'rsvp_responded_at' => now(),
|
||||
]);
|
||||
|
||||
return $this->success(
|
||||
new EventInvitationResource($invitation->load('user')),
|
||||
'RSVP updated successfully'
|
||||
);
|
||||
return $this->success(new EventResource($event->fresh()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
29
api/app/Http/Controllers/Api/V1/LoginController.php
Normal file
29
api/app/Http/Controllers/Api/V1/LoginController.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Api\V1\LoginRequest;
|
||||
use App\Http\Resources\Api\V1\UserResource;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
final class LoginController extends Controller
|
||||
{
|
||||
public function __invoke(LoginRequest $request): JsonResponse
|
||||
{
|
||||
if (!Auth::attempt($request->only('email', 'password'))) {
|
||||
return $this->unauthorized('Invalid credentials');
|
||||
}
|
||||
|
||||
$user = Auth::user()->load(['organisations', 'roles']);
|
||||
$token = $user->createToken('auth-token')->plainTextToken;
|
||||
|
||||
return $this->success([
|
||||
'user' => new UserResource($user),
|
||||
'token' => $token,
|
||||
], 'Login successful');
|
||||
}
|
||||
}
|
||||
19
api/app/Http/Controllers/Api/V1/LogoutController.php
Normal file
19
api/app/Http/Controllers/Api/V1/LogoutController.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
final class LogoutController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request): JsonResponse
|
||||
{
|
||||
$request->user()->currentAccessToken()->delete();
|
||||
|
||||
return $this->success(null, 'Logged out successfully');
|
||||
}
|
||||
}
|
||||
20
api/app/Http/Controllers/Api/V1/MeController.php
Normal file
20
api/app/Http/Controllers/Api/V1/MeController.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Resources\Api\V1\UserResource;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
final class MeController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request): JsonResponse
|
||||
{
|
||||
$user = $request->user()->load(['organisations', 'events']);
|
||||
|
||||
return $this->success(new UserResource($user));
|
||||
}
|
||||
}
|
||||
55
api/app/Http/Controllers/Api/V1/OrganisationController.php
Normal file
55
api/app/Http/Controllers/Api/V1/OrganisationController.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Api\V1\StoreOrganisationRequest;
|
||||
use App\Http\Requests\Api\V1\UpdateOrganisationRequest;
|
||||
use App\Http\Resources\Api\V1\OrganisationResource;
|
||||
use App\Models\Organisation;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
final class OrganisationController extends Controller
|
||||
{
|
||||
public function index(): AnonymousResourceCollection
|
||||
{
|
||||
Gate::authorize('viewAny', Organisation::class);
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
$organisations = $user->hasRole('super_admin')
|
||||
? Organisation::query()->paginate()
|
||||
: $user->organisations()->paginate();
|
||||
|
||||
return OrganisationResource::collection($organisations);
|
||||
}
|
||||
|
||||
public function show(Organisation $organisation): JsonResponse
|
||||
{
|
||||
Gate::authorize('view', $organisation);
|
||||
|
||||
return $this->success(new OrganisationResource($organisation));
|
||||
}
|
||||
|
||||
public function store(StoreOrganisationRequest $request): JsonResponse
|
||||
{
|
||||
Gate::authorize('create', Organisation::class);
|
||||
|
||||
$organisation = Organisation::create($request->validated());
|
||||
|
||||
return $this->created(new OrganisationResource($organisation));
|
||||
}
|
||||
|
||||
public function update(UpdateOrganisationRequest $request, Organisation $organisation): JsonResponse
|
||||
{
|
||||
Gate::authorize('update', $organisation);
|
||||
|
||||
$organisation->update($request->validated());
|
||||
|
||||
return $this->success(new OrganisationResource($organisation->fresh()));
|
||||
}
|
||||
}
|
||||
20
api/app/Http/Middleware/PortalTokenMiddleware.php
Normal file
20
api/app/Http/Middleware/PortalTokenMiddleware.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class PortalTokenMiddleware
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Api\V1;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
final class InviteToEventRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'user_ids' => ['required', 'array', 'min:1'],
|
||||
'user_ids.*' => ['required', 'ulid', 'exists:users,id'],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'user_ids.required' => 'Please select at least one member to invite.',
|
||||
'user_ids.*.exists' => 'One or more selected members do not exist.',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Api\V1;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
final class RegisterRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'string', 'email', 'max:255', 'unique:users,email'],
|
||||
'password' => ['required', 'string', 'confirmed', Password::defaults()],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'name.required' => 'Name is required',
|
||||
'email.required' => 'Email is required',
|
||||
'email.email' => 'Please enter a valid email address',
|
||||
'email.unique' => 'This email is already registered',
|
||||
'password.required' => 'Password is required',
|
||||
'password.confirmed' => 'Password confirmation does not match',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Api\V1;
|
||||
|
||||
use App\Enums\RsvpStatus;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
final class RsvpEventRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'status' => ['required', Rule::enum(RsvpStatus::class)],
|
||||
'note' => ['nullable', 'string', 'max:1000'],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'status.required' => 'Please select your availability status.',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Api\V1;
|
||||
|
||||
use App\Enums\EventStatus;
|
||||
use App\Enums\EventVisibility;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
final class StoreEventRequest extends FormRequest
|
||||
{
|
||||
@@ -16,37 +13,16 @@ final class StoreEventRequest extends FormRequest
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'title' => ['required', 'string', 'max:255'],
|
||||
'description' => ['nullable', 'string', 'max:5000'],
|
||||
'location_id' => ['nullable', 'ulid', 'exists:locations,id'],
|
||||
'customer_id' => ['nullable', 'ulid', 'exists:customers,id'],
|
||||
'setlist_id' => ['nullable', 'ulid', 'exists:setlists,id'],
|
||||
'event_date' => ['required', 'date', 'after_or_equal:today'],
|
||||
'start_time' => ['required', 'date_format:H:i'],
|
||||
'end_time' => ['nullable', 'date_format:H:i', 'after:start_time'],
|
||||
'load_in_time' => ['nullable', 'date_format:H:i'],
|
||||
'soundcheck_time' => ['nullable', 'date_format:H:i'],
|
||||
'fee' => ['nullable', 'numeric', 'min:0', 'max:999999.99'],
|
||||
'currency' => ['sometimes', 'string', 'size:3'],
|
||||
'status' => ['sometimes', Rule::enum(EventStatus::class)],
|
||||
'visibility' => ['sometimes', Rule::enum(EventVisibility::class)],
|
||||
'rsvp_deadline' => ['nullable', 'date', 'before:event_date'],
|
||||
'notes' => ['nullable', 'string', 'max:5000'],
|
||||
'internal_notes' => ['nullable', 'string', 'max:5000'],
|
||||
'is_public_setlist' => ['sometimes', 'boolean'],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'event_date.after_or_equal' => 'The event date must be today or a future date.',
|
||||
'end_time.after' => 'The end time must be after the start time.',
|
||||
'rsvp_deadline.before' => 'The RSVP deadline must be before the event date.',
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'slug' => ['required', 'string', 'max:255', 'regex:/^[a-z0-9-]+$/'],
|
||||
'start_date' => ['required', 'date'],
|
||||
'end_date' => ['required', 'date', 'after_or_equal:start_date'],
|
||||
'timezone' => ['sometimes', 'string', 'max:50'],
|
||||
'status' => ['sometimes', 'string', 'in:draft,published,registration_open,buildup,showday,teardown,closed'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
26
api/app/Http/Requests/Api/V1/StoreOrganisationRequest.php
Normal file
26
api/app/Http/Requests/Api/V1/StoreOrganisationRequest.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Api\V1;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
final class StoreOrganisationRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'slug' => ['required', 'string', 'max:255', 'unique:organisations,slug', 'regex:/^[a-z0-9-]+$/'],
|
||||
'billing_status' => ['sometimes', 'string', 'in:active,trial,suspended'],
|
||||
'settings' => ['sometimes', 'array'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Api\V1;
|
||||
|
||||
use App\Enums\EventStatus;
|
||||
use App\Enums\EventVisibility;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
final class UpdateEventRequest extends FormRequest
|
||||
{
|
||||
@@ -16,35 +13,16 @@ final class UpdateEventRequest extends FormRequest
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'title' => ['sometimes', 'string', 'max:255'],
|
||||
'description' => ['nullable', 'string', 'max:5000'],
|
||||
'location_id' => ['nullable', 'ulid', 'exists:locations,id'],
|
||||
'customer_id' => ['nullable', 'ulid', 'exists:customers,id'],
|
||||
'setlist_id' => ['nullable', 'ulid', 'exists:setlists,id'],
|
||||
'event_date' => ['sometimes', 'date'],
|
||||
'start_time' => ['sometimes', 'date_format:H:i'],
|
||||
'end_time' => ['nullable', 'date_format:H:i'],
|
||||
'load_in_time' => ['nullable', 'date_format:H:i'],
|
||||
'soundcheck_time' => ['nullable', 'date_format:H:i'],
|
||||
'fee' => ['nullable', 'numeric', 'min:0', 'max:999999.99'],
|
||||
'currency' => ['sometimes', 'string', 'size:3'],
|
||||
'status' => ['sometimes', Rule::enum(EventStatus::class)],
|
||||
'visibility' => ['sometimes', Rule::enum(EventVisibility::class)],
|
||||
'rsvp_deadline' => ['nullable', 'date'],
|
||||
'notes' => ['nullable', 'string', 'max:5000'],
|
||||
'internal_notes' => ['nullable', 'string', 'max:5000'],
|
||||
'is_public_setlist' => ['sometimes', 'boolean'],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'end_time.after' => 'The end time must be after the start time.',
|
||||
'name' => ['sometimes', 'string', 'max:255'],
|
||||
'slug' => ['sometimes', 'string', 'max:255', 'regex:/^[a-z0-9-]+$/'],
|
||||
'start_date' => ['sometimes', 'date'],
|
||||
'end_date' => ['sometimes', 'date', 'after_or_equal:start_date'],
|
||||
'timezone' => ['sometimes', 'string', 'max:50'],
|
||||
'status' => ['sometimes', 'string', 'in:draft,published,registration_open,buildup,showday,teardown,closed'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
30
api/app/Http/Requests/Api/V1/UpdateOrganisationRequest.php
Normal file
30
api/app/Http/Requests/Api/V1/UpdateOrganisationRequest.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Api\V1;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
final class UpdateOrganisationRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['sometimes', 'string', 'max:255'],
|
||||
'slug' => [
|
||||
'sometimes', 'string', 'max:255', 'regex:/^[a-z0-9-]+$/',
|
||||
Rule::unique('organisations', 'slug')->ignore($this->route('organisation')),
|
||||
],
|
||||
'billing_status' => ['sometimes', 'string', 'in:active,trial,suspended'],
|
||||
'settings' => ['sometimes', 'array'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\Api\V1;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
final class CustomerResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
'company_name' => $this->company_name,
|
||||
'type' => $this->type,
|
||||
'email' => $this->email,
|
||||
'phone' => $this->phone,
|
||||
'address' => $this->address,
|
||||
'city' => $this->city,
|
||||
'postal_code' => $this->postal_code,
|
||||
'country' => $this->country,
|
||||
'notes' => $this->notes,
|
||||
'is_portal_enabled' => $this->is_portal_enabled,
|
||||
'display_name' => $this->displayName(),
|
||||
'created_at' => $this->created_at->toIso8601String(),
|
||||
'updated_at' => $this->updated_at->toIso8601String(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\Api\V1;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\ResourceCollection;
|
||||
|
||||
final class EventCollection extends ResourceCollection
|
||||
{
|
||||
public $collects = EventResource::class;
|
||||
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'data' => $this->collection,
|
||||
];
|
||||
}
|
||||
|
||||
public function with(Request $request): array
|
||||
{
|
||||
return [
|
||||
'success' => true,
|
||||
'meta' => [
|
||||
'pagination' => [
|
||||
'current_page' => $this->currentPage(),
|
||||
'per_page' => $this->perPage(),
|
||||
'total' => $this->total(),
|
||||
'last_page' => $this->lastPage(),
|
||||
'from' => $this->firstItem(),
|
||||
'to' => $this->lastItem(),
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\Api\V1;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
final class EventInvitationResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'event_id' => $this->event_id,
|
||||
'user_id' => $this->user_id,
|
||||
'rsvp_status' => $this->rsvp_status->value,
|
||||
'rsvp_status_label' => $this->rsvp_status->label(),
|
||||
'rsvp_status_color' => $this->rsvp_status->color(),
|
||||
'rsvp_note' => $this->rsvp_note,
|
||||
'rsvp_responded_at' => $this->rsvp_responded_at?->toIso8601String(),
|
||||
'invited_at' => $this->invited_at?->toIso8601String(),
|
||||
'user' => new UserResource($this->whenLoaded('user')),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,35 +13,16 @@ final class EventResource extends JsonResource
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'title' => $this->title,
|
||||
'description' => $this->description,
|
||||
'event_date' => $this->event_date->toDateString(),
|
||||
'start_time' => $this->start_time?->format('H:i'),
|
||||
'end_time' => $this->end_time?->format('H:i'),
|
||||
'load_in_time' => $this->load_in_time?->format('H:i'),
|
||||
'soundcheck_time' => $this->soundcheck_time?->format('H:i'),
|
||||
'fee' => $this->fee,
|
||||
'currency' => $this->currency,
|
||||
'status' => $this->status->value,
|
||||
'status_label' => $this->status->label(),
|
||||
'status_color' => $this->status->color(),
|
||||
'visibility' => $this->visibility->value,
|
||||
'visibility_label' => $this->visibility->label(),
|
||||
'rsvp_deadline' => $this->rsvp_deadline?->toIso8601String(),
|
||||
'notes' => $this->notes,
|
||||
'internal_notes' => $this->when(
|
||||
$request->user()?->role === 'admin' || $request->user()?->role === 'booking_agent',
|
||||
$this->internal_notes
|
||||
),
|
||||
'is_public_setlist' => $this->is_public_setlist,
|
||||
'location' => new LocationResource($this->whenLoaded('location')),
|
||||
'customer' => new CustomerResource($this->whenLoaded('customer')),
|
||||
'setlist' => new SetlistResource($this->whenLoaded('setlist')),
|
||||
'invitations' => EventInvitationResource::collection($this->whenLoaded('invitations')),
|
||||
'creator' => new UserResource($this->whenLoaded('creator')),
|
||||
'organisation_id' => $this->organisation_id,
|
||||
'name' => $this->name,
|
||||
'slug' => $this->slug,
|
||||
'start_date' => $this->start_date->toDateString(),
|
||||
'end_date' => $this->end_date->toDateString(),
|
||||
'timezone' => $this->timezone,
|
||||
'status' => $this->status,
|
||||
'created_at' => $this->created_at->toIso8601String(),
|
||||
'updated_at' => $this->updated_at->toIso8601String(),
|
||||
'organisation' => new OrganisationResource($this->whenLoaded('organisation')),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\Api\V1;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
final class LocationResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
'address' => $this->address,
|
||||
'city' => $this->city,
|
||||
'postal_code' => $this->postal_code,
|
||||
'country' => $this->country,
|
||||
'latitude' => $this->latitude,
|
||||
'longitude' => $this->longitude,
|
||||
'capacity' => $this->capacity,
|
||||
'contact_name' => $this->contact_name,
|
||||
'contact_email' => $this->contact_email,
|
||||
'contact_phone' => $this->contact_phone,
|
||||
'notes' => $this->notes,
|
||||
'created_at' => $this->created_at->toIso8601String(),
|
||||
'updated_at' => $this->updated_at->toIso8601String(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\Api\V1;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
final class MusicAttachmentResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'music_number_id' => $this->music_number_id,
|
||||
'file_name' => $this->file_name,
|
||||
'original_name' => $this->original_name,
|
||||
'file_type' => $this->file_type,
|
||||
'file_size' => $this->file_size,
|
||||
'mime_type' => $this->mime_type,
|
||||
'created_at' => $this->created_at->toIso8601String(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\Api\V1;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
final class MusicNumberResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'title' => $this->title,
|
||||
'artist' => $this->artist,
|
||||
'genre' => $this->genre,
|
||||
'duration_seconds' => $this->duration_seconds,
|
||||
'key' => $this->key,
|
||||
'tempo_bpm' => $this->tempo_bpm,
|
||||
'time_signature' => $this->time_signature,
|
||||
'lyrics' => $this->lyrics,
|
||||
'notes' => $this->notes,
|
||||
'tags' => $this->tags,
|
||||
'play_count' => $this->play_count,
|
||||
'is_active' => $this->is_active,
|
||||
'attachments' => MusicAttachmentResource::collection($this->whenLoaded('attachments')),
|
||||
'created_at' => $this->created_at->toIso8601String(),
|
||||
'updated_at' => $this->updated_at->toIso8601String(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
24
api/app/Http/Resources/Api/V1/OrganisationResource.php
Normal file
24
api/app/Http/Resources/Api/V1/OrganisationResource.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\Api\V1;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
final class OrganisationResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
'slug' => $this->slug,
|
||||
'billing_status' => $this->billing_status,
|
||||
'settings' => $this->settings,
|
||||
'created_at' => $this->created_at->toIso8601String(),
|
||||
'updated_at' => $this->updated_at->toIso8601String(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\Api\V1;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
final class SetlistItemResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'setlist_id' => $this->setlist_id,
|
||||
'music_number_id' => $this->music_number_id,
|
||||
'position' => $this->position,
|
||||
'set_number' => $this->set_number,
|
||||
'is_break' => $this->is_break,
|
||||
'break_duration_seconds' => $this->break_duration_seconds,
|
||||
'notes' => $this->notes,
|
||||
'music_number' => new MusicNumberResource($this->whenLoaded('musicNumber')),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\Api\V1;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
final class SetlistResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
'description' => $this->description,
|
||||
'total_duration_seconds' => $this->total_duration_seconds,
|
||||
'formatted_duration' => $this->formattedDuration(),
|
||||
'is_template' => $this->is_template,
|
||||
'is_archived' => $this->is_archived,
|
||||
'items' => SetlistItemResource::collection($this->whenLoaded('items')),
|
||||
'creator' => new UserResource($this->whenLoaded('creator')),
|
||||
'created_at' => $this->created_at->toIso8601String(),
|
||||
'updated_at' => $this->updated_at->toIso8601String(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,17 +15,28 @@ final class UserResource extends JsonResource
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
'email' => $this->email,
|
||||
'phone' => $this->phone,
|
||||
'bio' => $this->bio,
|
||||
'instruments' => $this->instruments,
|
||||
'avatar' => $this->avatar_path ? asset('storage/' . $this->avatar_path) : null,
|
||||
'type' => $this->type,
|
||||
'role' => $this->role,
|
||||
'status' => $this->status,
|
||||
'roles' => $this->getRoleNames()->values()->all(),
|
||||
'timezone' => $this->timezone,
|
||||
'locale' => $this->locale,
|
||||
'avatar' => $this->avatar,
|
||||
'email_verified_at' => $this->email_verified_at?->toIso8601String(),
|
||||
'created_at' => $this->created_at->toIso8601String(),
|
||||
'updated_at' => $this->updated_at->toIso8601String(),
|
||||
'organisations' => $this->whenLoaded('organisations', fn () =>
|
||||
$this->organisations->map(fn ($org) => [
|
||||
'id' => $org->id,
|
||||
'name' => $org->name,
|
||||
'slug' => $org->slug,
|
||||
'role' => $org->pivot->role,
|
||||
])
|
||||
),
|
||||
'event_roles' => $this->whenLoaded('events', fn () =>
|
||||
$this->events->map(fn ($event) => [
|
||||
'event_id' => $event->id,
|
||||
'event_name' => $event->name,
|
||||
'role' => $event->pivot->role,
|
||||
])
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUlids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
final class Customer extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use HasUlids;
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'company_name',
|
||||
'type',
|
||||
'email',
|
||||
'phone',
|
||||
'address',
|
||||
'city',
|
||||
'postal_code',
|
||||
'country',
|
||||
'notes',
|
||||
'is_portal_enabled',
|
||||
'user_id',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'is_portal_enabled' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
// Relationships
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function events(): HasMany
|
||||
{
|
||||
return $this->hasMany(Event::class);
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
|
||||
public function isCompany(): bool
|
||||
{
|
||||
return $this->type === 'company';
|
||||
}
|
||||
|
||||
public function displayName(): string
|
||||
{
|
||||
return $this->company_name ?? $this->name;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,129 +4,52 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\EventStatus;
|
||||
use App\Enums\EventVisibility;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUlids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
final class Event extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use HasUlids;
|
||||
use SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'title',
|
||||
'description',
|
||||
'location_id',
|
||||
'customer_id',
|
||||
'setlist_id',
|
||||
'event_date',
|
||||
'start_time',
|
||||
'end_time',
|
||||
'load_in_time',
|
||||
'soundcheck_time',
|
||||
'fee',
|
||||
'currency',
|
||||
'organisation_id',
|
||||
'name',
|
||||
'slug',
|
||||
'start_date',
|
||||
'end_date',
|
||||
'timezone',
|
||||
'status',
|
||||
'visibility',
|
||||
'rsvp_deadline',
|
||||
'notes',
|
||||
'internal_notes',
|
||||
'is_public_setlist',
|
||||
'created_by',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'event_date' => 'date',
|
||||
'start_time' => 'datetime:H:i',
|
||||
'end_time' => 'datetime:H:i',
|
||||
'load_in_time' => 'datetime:H:i',
|
||||
'soundcheck_time' => 'datetime:H:i',
|
||||
'fee' => 'decimal:2',
|
||||
'status' => EventStatus::class,
|
||||
'visibility' => EventVisibility::class,
|
||||
'rsvp_deadline' => 'datetime',
|
||||
'is_public_setlist' => 'boolean',
|
||||
'start_date' => 'date',
|
||||
'end_date' => 'date',
|
||||
];
|
||||
}
|
||||
|
||||
// Relationships
|
||||
|
||||
public function location(): BelongsTo
|
||||
public function organisation(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Location::class);
|
||||
return $this->belongsTo(Organisation::class);
|
||||
}
|
||||
|
||||
public function customer(): BelongsTo
|
||||
public function users(): BelongsToMany
|
||||
{
|
||||
return $this->belongsTo(Customer::class);
|
||||
}
|
||||
|
||||
public function setlist(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Setlist::class);
|
||||
}
|
||||
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
return $this->belongsToMany(User::class, 'event_user_roles')
|
||||
->withPivot('role')
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
public function invitations(): HasMany
|
||||
{
|
||||
return $this->hasMany(EventInvitation::class);
|
||||
}
|
||||
|
||||
// Scopes
|
||||
|
||||
public function scopeUpcoming(Builder $query): Builder
|
||||
{
|
||||
return $query->where('event_date', '>=', now()->toDateString())
|
||||
->orderBy('event_date');
|
||||
}
|
||||
|
||||
public function scopePast(Builder $query): Builder
|
||||
{
|
||||
return $query->where('event_date', '<', now()->toDateString())
|
||||
->orderByDesc('event_date');
|
||||
}
|
||||
|
||||
public function scopeConfirmed(Builder $query): Builder
|
||||
{
|
||||
return $query->where('status', EventStatus::Confirmed);
|
||||
}
|
||||
|
||||
public function scopeForUser(Builder $query, User $user): Builder
|
||||
{
|
||||
return $query->whereHas('invitations', fn (Builder $q) => $q->where('user_id', $user->id));
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
|
||||
public function isUpcoming(): bool
|
||||
{
|
||||
return $this->event_date->isFuture() || $this->event_date->isToday();
|
||||
}
|
||||
|
||||
public function isPast(): bool
|
||||
{
|
||||
return $this->event_date->isPast() && !$this->event_date->isToday();
|
||||
}
|
||||
|
||||
public function isConfirmed(): bool
|
||||
{
|
||||
return $this->status === EventStatus::Confirmed;
|
||||
}
|
||||
|
||||
public function isCancelled(): bool
|
||||
{
|
||||
return $this->status === EventStatus::Cancelled;
|
||||
return $this->hasMany(UserInvitation::class);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\RsvpStatus;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUlids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
final class EventInvitation extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use HasUlids;
|
||||
|
||||
protected $fillable = [
|
||||
'event_id',
|
||||
'user_id',
|
||||
'rsvp_status',
|
||||
'rsvp_note',
|
||||
'rsvp_responded_at',
|
||||
'invited_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'rsvp_status' => RsvpStatus::class,
|
||||
'rsvp_responded_at' => 'datetime',
|
||||
'invited_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
// Relationships
|
||||
|
||||
public function event(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Event::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
|
||||
public function isPending(): bool
|
||||
{
|
||||
return $this->rsvp_status === RsvpStatus::Pending;
|
||||
}
|
||||
|
||||
public function isAvailable(): bool
|
||||
{
|
||||
return $this->rsvp_status === RsvpStatus::Available;
|
||||
}
|
||||
|
||||
public function hasResponded(): bool
|
||||
{
|
||||
return $this->rsvp_responded_at !== null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUlids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
final class Location extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use HasUlids;
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'address',
|
||||
'city',
|
||||
'postal_code',
|
||||
'country',
|
||||
'latitude',
|
||||
'longitude',
|
||||
'capacity',
|
||||
'contact_name',
|
||||
'contact_email',
|
||||
'contact_phone',
|
||||
'notes',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'latitude' => 'decimal:7',
|
||||
'longitude' => 'decimal:7',
|
||||
'capacity' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
// Relationships
|
||||
|
||||
public function events(): HasMany
|
||||
{
|
||||
return $this->hasMany(Event::class);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUlids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
final class MusicAttachment extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use HasUlids;
|
||||
|
||||
protected $fillable = [
|
||||
'music_number_id',
|
||||
'file_name',
|
||||
'original_name',
|
||||
'file_path',
|
||||
'file_type',
|
||||
'file_size',
|
||||
'mime_type',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'file_size' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
// Relationships
|
||||
|
||||
public function musicNumber(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(MusicNumber::class);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUlids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
final class MusicNumber extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use HasUlids;
|
||||
|
||||
protected $fillable = [
|
||||
'title',
|
||||
'artist',
|
||||
'genre',
|
||||
'duration_seconds',
|
||||
'key',
|
||||
'tempo_bpm',
|
||||
'time_signature',
|
||||
'lyrics',
|
||||
'notes',
|
||||
'tags',
|
||||
'play_count',
|
||||
'is_active',
|
||||
'created_by',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'duration_seconds' => 'integer',
|
||||
'tempo_bpm' => 'integer',
|
||||
'tags' => 'array',
|
||||
'play_count' => 'integer',
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
// Relationships
|
||||
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
public function attachments(): HasMany
|
||||
{
|
||||
return $this->hasMany(MusicAttachment::class);
|
||||
}
|
||||
|
||||
public function setlistItems(): HasMany
|
||||
{
|
||||
return $this->hasMany(SetlistItem::class);
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
|
||||
public function formattedDuration(): string
|
||||
{
|
||||
if (!$this->duration_seconds) {
|
||||
return '0:00';
|
||||
}
|
||||
|
||||
$minutes = floor($this->duration_seconds / 60);
|
||||
$seconds = $this->duration_seconds % 60;
|
||||
|
||||
return sprintf('%d:%02d', $minutes, $seconds);
|
||||
}
|
||||
|
||||
public function isActive(): bool
|
||||
{
|
||||
return $this->is_active;
|
||||
}
|
||||
}
|
||||
|
||||
50
api/app/Models/Organisation.php
Normal file
50
api/app/Models/Organisation.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUlids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
final class Organisation extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use HasUlids;
|
||||
use SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'slug',
|
||||
'billing_status',
|
||||
'settings',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'settings' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
public function users(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(User::class, 'organisation_user')
|
||||
->withPivot('role')
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
public function events(): HasMany
|
||||
{
|
||||
return $this->hasMany(Event::class);
|
||||
}
|
||||
|
||||
public function invitations(): HasMany
|
||||
{
|
||||
return $this->hasMany(UserInvitation::class);
|
||||
}
|
||||
}
|
||||
21
api/app/Models/Scopes/OrganisationScope.php
Normal file
21
api/app/Models/Scopes/OrganisationScope.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models\Scopes;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Scope;
|
||||
|
||||
final class OrganisationScope implements Scope
|
||||
{
|
||||
public function __construct(
|
||||
private readonly string $organisationId,
|
||||
) {}
|
||||
|
||||
public function apply(Builder $builder, Model $model): void
|
||||
{
|
||||
$builder->where($model->getTable() . '.organisation_id', $this->organisationId);
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUlids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
final class Setlist extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use HasUlids;
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'description',
|
||||
'total_duration_seconds',
|
||||
'is_template',
|
||||
'is_archived',
|
||||
'created_by',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'total_duration_seconds' => 'integer',
|
||||
'is_template' => 'boolean',
|
||||
'is_archived' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
// Relationships
|
||||
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
public function items(): HasMany
|
||||
{
|
||||
return $this->hasMany(SetlistItem::class)->orderBy('position');
|
||||
}
|
||||
|
||||
public function events(): HasMany
|
||||
{
|
||||
return $this->hasMany(Event::class);
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
|
||||
public function isTemplate(): bool
|
||||
{
|
||||
return $this->is_template;
|
||||
}
|
||||
|
||||
public function isArchived(): bool
|
||||
{
|
||||
return $this->is_archived;
|
||||
}
|
||||
|
||||
public function formattedDuration(): string
|
||||
{
|
||||
if (!$this->total_duration_seconds) {
|
||||
return '0:00';
|
||||
}
|
||||
|
||||
$minutes = floor($this->total_duration_seconds / 60);
|
||||
$seconds = $this->total_duration_seconds % 60;
|
||||
|
||||
return sprintf('%d:%02d', $minutes, $seconds);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUlids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
final class SetlistItem extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use HasUlids;
|
||||
|
||||
protected $fillable = [
|
||||
'setlist_id',
|
||||
'music_number_id',
|
||||
'position',
|
||||
'set_number',
|
||||
'is_break',
|
||||
'break_duration_seconds',
|
||||
'notes',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'position' => 'integer',
|
||||
'set_number' => 'integer',
|
||||
'is_break' => 'boolean',
|
||||
'break_duration_seconds' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
// Relationships
|
||||
|
||||
public function setlist(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Setlist::class);
|
||||
}
|
||||
|
||||
public function musicNumber(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(MusicNumber::class);
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
|
||||
public function isBreak(): bool
|
||||
{
|
||||
return $this->is_break;
|
||||
}
|
||||
|
||||
public function formattedBreakDuration(): string
|
||||
{
|
||||
if (!$this->is_break || !$this->break_duration_seconds) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$minutes = floor($this->break_duration_seconds / 60);
|
||||
$seconds = $this->break_duration_seconds % 60;
|
||||
|
||||
return sprintf('%d:%02d', $minutes, $seconds);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,28 +6,29 @@ namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUlids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Laravel\Sanctum\HasApiTokens;
|
||||
use Spatie\Permission\Traits\HasRoles;
|
||||
|
||||
final class User extends Authenticatable
|
||||
{
|
||||
use HasApiTokens;
|
||||
use HasFactory;
|
||||
use HasRoles;
|
||||
use HasUlids;
|
||||
use Notifiable;
|
||||
use SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'email',
|
||||
'phone',
|
||||
'bio',
|
||||
'instruments',
|
||||
'avatar_path',
|
||||
'type',
|
||||
'role',
|
||||
'status',
|
||||
'password',
|
||||
'timezone',
|
||||
'locale',
|
||||
'avatar',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
@@ -40,39 +41,20 @@ final class User extends Authenticatable
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
'instruments' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
|
||||
public function isAdmin(): bool
|
||||
public function organisations(): BelongsToMany
|
||||
{
|
||||
return $this->role === 'admin';
|
||||
return $this->belongsToMany(Organisation::class, 'organisation_user')
|
||||
->withPivot('role')
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
public function isBookingAgent(): bool
|
||||
public function events(): BelongsToMany
|
||||
{
|
||||
return $this->role === 'booking_agent';
|
||||
}
|
||||
|
||||
public function isMusicManager(): bool
|
||||
{
|
||||
return $this->role === 'music_manager';
|
||||
}
|
||||
|
||||
public function isMember(): bool
|
||||
{
|
||||
return $this->type === 'member';
|
||||
}
|
||||
|
||||
public function isCustomer(): bool
|
||||
{
|
||||
return $this->type === 'customer';
|
||||
}
|
||||
|
||||
public function isActive(): bool
|
||||
{
|
||||
return $this->status === 'active';
|
||||
return $this->belongsToMany(Event::class, 'event_user_roles')
|
||||
->withPivot('role')
|
||||
->withTimestamps();
|
||||
}
|
||||
}
|
||||
|
||||
49
api/app/Models/UserInvitation.php
Normal file
49
api/app/Models/UserInvitation.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUlids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
final class UserInvitation extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use HasUlids;
|
||||
|
||||
protected $fillable = [
|
||||
'email',
|
||||
'invited_by_user_id',
|
||||
'organisation_id',
|
||||
'event_id',
|
||||
'role',
|
||||
'token',
|
||||
'status',
|
||||
'expires_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'expires_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function invitedBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'invited_by_user_id');
|
||||
}
|
||||
|
||||
public function organisation(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Organisation::class);
|
||||
}
|
||||
|
||||
public function event(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Event::class);
|
||||
}
|
||||
}
|
||||
@@ -5,79 +5,44 @@ declare(strict_types=1);
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\Event;
|
||||
use App\Models\Organisation;
|
||||
use App\Models\User;
|
||||
|
||||
final class EventPolicy
|
||||
{
|
||||
/**
|
||||
* Determine whether the user can view any events.
|
||||
*/
|
||||
public function viewAny(User $user): bool
|
||||
public function viewAny(User $user, Organisation $organisation): bool
|
||||
{
|
||||
return true;
|
||||
return $user->hasRole('super_admin')
|
||||
|| $organisation->users()->where('user_id', $user->id)->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can view the event.
|
||||
*/
|
||||
public function view(User $user, Event $event): bool
|
||||
{
|
||||
// Admins and booking agents can view all events
|
||||
if ($this->isAdminOrBookingAgent($user)) {
|
||||
return $user->hasRole('super_admin')
|
||||
|| $event->organisation->users()->where('user_id', $user->id)->exists();
|
||||
}
|
||||
|
||||
public function create(User $user, Organisation $organisation): bool
|
||||
{
|
||||
if ($user->hasRole('super_admin')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Members can view events they're invited to
|
||||
return $event->invitations()->where('user_id', $user->id)->exists();
|
||||
return $organisation->users()
|
||||
->where('user_id', $user->id)
|
||||
->wherePivotIn('role', ['org_admin', 'org_member'])
|
||||
->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can create events.
|
||||
*/
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return $this->isAdminOrBookingAgent($user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can update the event.
|
||||
*/
|
||||
public function update(User $user, Event $event): bool
|
||||
{
|
||||
return $this->isAdminOrBookingAgent($user);
|
||||
if ($user->hasRole('super_admin')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can delete the event.
|
||||
*/
|
||||
public function delete(User $user, Event $event): bool
|
||||
{
|
||||
return $user->role === 'admin';
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can invite members to the event.
|
||||
*/
|
||||
public function invite(User $user, Event $event): bool
|
||||
{
|
||||
return $this->isAdminOrBookingAgent($user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can RSVP to the event.
|
||||
*/
|
||||
public function rsvp(User $user, Event $event): bool
|
||||
{
|
||||
// User must be invited to RSVP
|
||||
return $event->invitations()->where('user_id', $user->id)->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the user is an admin or booking agent.
|
||||
*/
|
||||
private function isAdminOrBookingAgent(User $user): bool
|
||||
{
|
||||
return in_array($user->role, ['admin', 'booking_agent'], true);
|
||||
return $event->organisation->users()
|
||||
->where('user_id', $user->id)
|
||||
->wherePivotIn('role', ['org_admin', 'org_member'])
|
||||
->exists();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
40
api/app/Policies/OrganisationPolicy.php
Normal file
40
api/app/Policies/OrganisationPolicy.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\Organisation;
|
||||
use App\Models\User;
|
||||
|
||||
final class OrganisationPolicy
|
||||
{
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
// All authenticated users can list their organisations
|
||||
return true;
|
||||
}
|
||||
|
||||
public function view(User $user, Organisation $organisation): bool
|
||||
{
|
||||
return $user->hasRole('super_admin')
|
||||
|| $organisation->users()->where('user_id', $user->id)->exists();
|
||||
}
|
||||
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return $user->hasRole('super_admin');
|
||||
}
|
||||
|
||||
public function update(User $user, Organisation $organisation): bool
|
||||
{
|
||||
if ($user->hasRole('super_admin')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $organisation->users()
|
||||
->where('user_id', $user->id)
|
||||
->wherePivot('role', 'org_admin')
|
||||
->exists();
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,10 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware): void {
|
||||
// API uses token-based auth, no CSRF needed
|
||||
|
||||
$middleware->alias([
|
||||
'portal.token' => \App\Http\Middleware\PortalTokenMiddleware::class,
|
||||
]);
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
// Return JSON for all API exceptions
|
||||
|
||||
@@ -7,9 +7,14 @@
|
||||
"license": "MIT",
|
||||
"require": {
|
||||
"php": "^8.2",
|
||||
"barryvdh/laravel-dompdf": "^3.1",
|
||||
"endroid/qr-code": "^6.1",
|
||||
"laravel/framework": "^12.0",
|
||||
"laravel/sanctum": "^4.0",
|
||||
"laravel/tinker": "^2.10.1"
|
||||
"laravel/tinker": "^2.10.1",
|
||||
"spatie/laravel-activitylog": "^5.0",
|
||||
"spatie/laravel-medialibrary": "^11.21",
|
||||
"spatie/laravel-permission": "^7.2"
|
||||
},
|
||||
"require-dev": {
|
||||
"fakerphp/faker": "^1.23",
|
||||
|
||||
1396
api/composer.lock
generated
1396
api/composer.lock
generated
File diff suppressed because it is too large
Load Diff
73
api/config/activitylog.php
Normal file
73
api/config/activitylog.php
Normal file
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
use Spatie\Activitylog\Actions\CleanActivityLogAction;
|
||||
use Spatie\Activitylog\Actions\LogActivityAction;
|
||||
use Spatie\Activitylog\Models\Activity;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
* If set to false, no activities will be saved to the database.
|
||||
*/
|
||||
'enabled' => env('ACTIVITYLOG_ENABLED', true),
|
||||
|
||||
/*
|
||||
* When the clean command is executed, all recording activities older than
|
||||
* the number of days specified here will be deleted.
|
||||
*/
|
||||
'clean_after_days' => 365,
|
||||
|
||||
/*
|
||||
* If no log name is passed to the activity() helper
|
||||
* we use this default log name.
|
||||
*/
|
||||
'default_log_name' => 'default',
|
||||
|
||||
/*
|
||||
* You can specify an auth driver here that gets user models.
|
||||
* If this is null we'll use the current Laravel auth driver.
|
||||
*/
|
||||
'default_auth_driver' => null,
|
||||
|
||||
/*
|
||||
* If set to true, the subject relationship on activities
|
||||
* will include soft deleted models.
|
||||
*/
|
||||
'include_soft_deleted_subjects' => false,
|
||||
|
||||
/*
|
||||
* This model will be used to log activity.
|
||||
* It should implement the Spatie\Activitylog\Contracts\Activity interface
|
||||
* and extend Illuminate\Database\Eloquent\Model.
|
||||
*/
|
||||
'activity_model' => Activity::class,
|
||||
|
||||
/*
|
||||
* These attributes will be excluded from logging for all models.
|
||||
* Model-specific exclusions via logExcept() are merged with these.
|
||||
*/
|
||||
'default_except_attributes' => [],
|
||||
|
||||
/*
|
||||
* When enabled, activities are buffered in memory and inserted in a
|
||||
* single bulk query after the response has been sent to the client.
|
||||
* This can significantly reduce the number of database queries when
|
||||
* many activities are logged during a single request.
|
||||
*
|
||||
* Only enable this if your application logs a high volume of activities
|
||||
* per request. Buffered activities will not have an ID until the
|
||||
* buffer is flushed.
|
||||
*/
|
||||
'buffer' => [
|
||||
'enabled' => env('ACTIVITYLOG_BUFFER_ENABLED', false),
|
||||
],
|
||||
|
||||
/*
|
||||
* These action classes can be overridden to customize how activities
|
||||
* are logged and cleaned. Your custom classes must extend the originals.
|
||||
*/
|
||||
'actions' => [
|
||||
'log_activity' => LogActivityAction::class,
|
||||
'clean_log' => CleanActivityLogAction::class,
|
||||
],
|
||||
];
|
||||
@@ -22,9 +22,9 @@ return [
|
||||
'allowed_methods' => ['*'],
|
||||
|
||||
'allowed_origins' => [
|
||||
'http://localhost:5173', // Admin SPA
|
||||
'http://localhost:5174', // Band SPA
|
||||
'http://localhost:5175', // Customer SPA
|
||||
env('FRONTEND_ADMIN_URL', 'http://localhost:5173'),
|
||||
env('FRONTEND_APP_URL', 'http://localhost:5174'),
|
||||
env('FRONTEND_PORTAL_URL', 'http://localhost:5175'),
|
||||
],
|
||||
|
||||
'allowed_origins_patterns' => [],
|
||||
|
||||
202
api/config/permission.php
Normal file
202
api/config/permission.php
Normal file
@@ -0,0 +1,202 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
'models' => [
|
||||
|
||||
/*
|
||||
* When using the "HasPermissions" trait from this package, we need to know which
|
||||
* Eloquent model should be used to retrieve your permissions. Of course, it
|
||||
* is often just the "Permission" model but you may use whatever you like.
|
||||
*
|
||||
* The model you want to use as a Permission model needs to implement the
|
||||
* `Spatie\Permission\Contracts\Permission` contract.
|
||||
*/
|
||||
|
||||
'permission' => Spatie\Permission\Models\Permission::class,
|
||||
|
||||
/*
|
||||
* When using the "HasRoles" trait from this package, we need to know which
|
||||
* Eloquent model should be used to retrieve your roles. Of course, it
|
||||
* is often just the "Role" model but you may use whatever you like.
|
||||
*
|
||||
* The model you want to use as a Role model needs to implement the
|
||||
* `Spatie\Permission\Contracts\Role` contract.
|
||||
*/
|
||||
|
||||
'role' => Spatie\Permission\Models\Role::class,
|
||||
|
||||
],
|
||||
|
||||
'table_names' => [
|
||||
|
||||
/*
|
||||
* When using the "HasRoles" trait from this package, we need to know which
|
||||
* table should be used to retrieve your roles. We have chosen a basic
|
||||
* default value but you may easily change it to any table you like.
|
||||
*/
|
||||
|
||||
'roles' => 'roles',
|
||||
|
||||
/*
|
||||
* When using the "HasPermissions" trait from this package, we need to know which
|
||||
* table should be used to retrieve your permissions. We have chosen a basic
|
||||
* default value but you may easily change it to any table you like.
|
||||
*/
|
||||
|
||||
'permissions' => 'permissions',
|
||||
|
||||
/*
|
||||
* When using the "HasPermissions" trait from this package, we need to know which
|
||||
* table should be used to retrieve your models permissions. We have chosen a
|
||||
* basic default value but you may easily change it to any table you like.
|
||||
*/
|
||||
|
||||
'model_has_permissions' => 'model_has_permissions',
|
||||
|
||||
/*
|
||||
* When using the "HasRoles" trait from this package, we need to know which
|
||||
* table should be used to retrieve your models roles. We have chosen a
|
||||
* basic default value but you may easily change it to any table you like.
|
||||
*/
|
||||
|
||||
'model_has_roles' => 'model_has_roles',
|
||||
|
||||
/*
|
||||
* When using the "HasRoles" trait from this package, we need to know which
|
||||
* table should be used to retrieve your roles permissions. We have chosen a
|
||||
* basic default value but you may easily change it to any table you like.
|
||||
*/
|
||||
|
||||
'role_has_permissions' => 'role_has_permissions',
|
||||
],
|
||||
|
||||
'column_names' => [
|
||||
/*
|
||||
* Change this if you want to name the related pivots other than defaults
|
||||
*/
|
||||
'role_pivot_key' => null, // default 'role_id',
|
||||
'permission_pivot_key' => null, // default 'permission_id',
|
||||
|
||||
/*
|
||||
* Change this if you want to name the related model primary key other than
|
||||
* `model_id`.
|
||||
*
|
||||
* For example, this would be nice if your primary keys are all UUIDs. In
|
||||
* that case, name this `model_uuid`.
|
||||
*/
|
||||
|
||||
'model_morph_key' => 'model_id',
|
||||
|
||||
/*
|
||||
* Change this if you want to use the teams feature and your related model's
|
||||
* foreign key is other than `team_id`.
|
||||
*/
|
||||
|
||||
'team_foreign_key' => 'team_id',
|
||||
],
|
||||
|
||||
/*
|
||||
* When set to true, the method for checking permissions will be registered on the gate.
|
||||
* Set this to false if you want to implement custom logic for checking permissions.
|
||||
*/
|
||||
|
||||
'register_permission_check_method' => true,
|
||||
|
||||
/*
|
||||
* When set to true, Laravel\Octane\Events\OperationTerminated event listener will be registered
|
||||
* this will refresh permissions on every TickTerminated, TaskTerminated and RequestTerminated
|
||||
* NOTE: This should not be needed in most cases, but an Octane/Vapor combination benefited from it.
|
||||
*/
|
||||
'register_octane_reset_listener' => false,
|
||||
|
||||
/*
|
||||
* Events will fire when a role or permission is assigned/unassigned:
|
||||
* \Spatie\Permission\Events\RoleAttachedEvent
|
||||
* \Spatie\Permission\Events\RoleDetachedEvent
|
||||
* \Spatie\Permission\Events\PermissionAttachedEvent
|
||||
* \Spatie\Permission\Events\PermissionDetachedEvent
|
||||
*
|
||||
* To enable, set to true, and then create listeners to watch these events.
|
||||
*/
|
||||
'events_enabled' => false,
|
||||
|
||||
/*
|
||||
* Teams Feature.
|
||||
* When set to true the package implements teams using the 'team_foreign_key'.
|
||||
* If you want the migrations to register the 'team_foreign_key', you must
|
||||
* set this to true before doing the migration.
|
||||
* If you already did the migration then you must make a new migration to also
|
||||
* add 'team_foreign_key' to 'roles', 'model_has_roles', and 'model_has_permissions'
|
||||
* (view the latest version of this package's migration file)
|
||||
*/
|
||||
|
||||
'teams' => false,
|
||||
|
||||
/*
|
||||
* The class to use to resolve the permissions team id
|
||||
*/
|
||||
'team_resolver' => \Spatie\Permission\DefaultTeamResolver::class,
|
||||
|
||||
/*
|
||||
* Passport Client Credentials Grant
|
||||
* When set to true the package will use Passports Client to check permissions
|
||||
*/
|
||||
|
||||
'use_passport_client_credentials' => false,
|
||||
|
||||
/*
|
||||
* When set to true, the required permission names are added to exception messages.
|
||||
* This could be considered an information leak in some contexts, so the default
|
||||
* setting is false here for optimum safety.
|
||||
*/
|
||||
|
||||
'display_permission_in_exception' => false,
|
||||
|
||||
/*
|
||||
* When set to true, the required role names are added to exception messages.
|
||||
* This could be considered an information leak in some contexts, so the default
|
||||
* setting is false here for optimum safety.
|
||||
*/
|
||||
|
||||
'display_role_in_exception' => false,
|
||||
|
||||
/*
|
||||
* By default wildcard permission lookups are disabled.
|
||||
* See documentation to understand supported syntax.
|
||||
*/
|
||||
|
||||
'enable_wildcard_permission' => false,
|
||||
|
||||
/*
|
||||
* The class to use for interpreting wildcard permissions.
|
||||
* If you need to modify delimiters, override the class and specify its name here.
|
||||
*/
|
||||
// 'wildcard_permission' => Spatie\Permission\WildcardPermission::class,
|
||||
|
||||
/* Cache-specific settings */
|
||||
|
||||
'cache' => [
|
||||
|
||||
/*
|
||||
* By default all permissions are cached for 24 hours to speed up performance.
|
||||
* When permissions or roles are updated the cache is flushed automatically.
|
||||
*/
|
||||
|
||||
'expiration_time' => \DateInterval::createFromDateString('24 hours'),
|
||||
|
||||
/*
|
||||
* The cache key used to store all permissions.
|
||||
*/
|
||||
|
||||
'key' => 'spatie.permission.cache',
|
||||
|
||||
/*
|
||||
* You may optionally indicate a specific cache driver to use for permission and
|
||||
* role caching using any of the `store` drivers listed in the cache.php config
|
||||
* file. Using 'default' here means to use the `default` set in cache.php.
|
||||
*/
|
||||
|
||||
'store' => 'default',
|
||||
],
|
||||
];
|
||||
@@ -1,57 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Customer;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<Customer>
|
||||
*/
|
||||
final class CustomerFactory extends Factory
|
||||
{
|
||||
protected $model = Customer::class;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
$type = fake()->randomElement(['individual', 'company']);
|
||||
|
||||
return [
|
||||
'name' => fake()->name(),
|
||||
'company_name' => $type === 'company' ? fake()->company() : null,
|
||||
'type' => $type,
|
||||
'email' => fake()->optional()->safeEmail(),
|
||||
'phone' => fake()->optional()->phoneNumber(),
|
||||
'address' => fake()->optional()->streetAddress(),
|
||||
'city' => fake()->optional()->city(),
|
||||
'postal_code' => fake()->optional()->postcode(),
|
||||
'country' => 'NL',
|
||||
'notes' => fake()->optional()->paragraph(),
|
||||
'is_portal_enabled' => fake()->boolean(20),
|
||||
];
|
||||
}
|
||||
|
||||
public function individual(): static
|
||||
{
|
||||
return $this->state(fn () => [
|
||||
'type' => 'individual',
|
||||
'company_name' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function company(): static
|
||||
{
|
||||
return $this->state(fn () => [
|
||||
'type' => 'company',
|
||||
'company_name' => fake()->company(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function withPortal(): static
|
||||
{
|
||||
return $this->state(fn () => ['is_portal_enabled' => true]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,100 +4,32 @@ declare(strict_types=1);
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Enums\EventStatus;
|
||||
use App\Enums\EventVisibility;
|
||||
use App\Models\Customer;
|
||||
use App\Models\Event;
|
||||
use App\Models\Location;
|
||||
use App\Models\User;
|
||||
use App\Models\Organisation;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<Event>
|
||||
*/
|
||||
/** @extends Factory<Event> */
|
||||
final class EventFactory extends Factory
|
||||
{
|
||||
protected $model = Event::class;
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function definition(): array
|
||||
{
|
||||
$startTime = fake()->time('H:i');
|
||||
$endTime = fake()->optional()->time('H:i');
|
||||
$name = fake()->unique()->words(3, true);
|
||||
$startDate = fake()->dateTimeBetween('+1 week', '+3 months');
|
||||
|
||||
return [
|
||||
'title' => fake()->sentence(3),
|
||||
'description' => fake()->optional()->paragraph(),
|
||||
'event_date' => fake()->dateTimeBetween('+1 week', '+6 months'),
|
||||
'start_time' => $startTime,
|
||||
'end_time' => $endTime,
|
||||
'fee' => fake()->optional()->randomFloat(2, 100, 5000),
|
||||
'currency' => 'EUR',
|
||||
'status' => fake()->randomElement(EventStatus::cases()),
|
||||
'visibility' => EventVisibility::Members,
|
||||
'notes' => fake()->optional()->paragraph(),
|
||||
'created_by' => User::factory(),
|
||||
'organisation_id' => Organisation::factory(),
|
||||
'name' => ucfirst($name),
|
||||
'slug' => str($name)->slug()->toString(),
|
||||
'start_date' => $startDate,
|
||||
'end_date' => fake()->dateTimeBetween($startDate, (clone $startDate)->modify('+3 days')),
|
||||
'timezone' => 'Europe/Amsterdam',
|
||||
'status' => 'draft',
|
||||
];
|
||||
}
|
||||
|
||||
public function draft(): static
|
||||
public function published(): static
|
||||
{
|
||||
return $this->state(fn () => ['status' => EventStatus::Draft]);
|
||||
}
|
||||
|
||||
public function pending(): static
|
||||
{
|
||||
return $this->state(fn () => ['status' => EventStatus::Pending]);
|
||||
}
|
||||
|
||||
public function confirmed(): static
|
||||
{
|
||||
return $this->state(fn () => ['status' => EventStatus::Confirmed]);
|
||||
}
|
||||
|
||||
public function completed(): static
|
||||
{
|
||||
return $this->state(fn () => ['status' => EventStatus::Completed]);
|
||||
}
|
||||
|
||||
public function cancelled(): static
|
||||
{
|
||||
return $this->state(fn () => ['status' => EventStatus::Cancelled]);
|
||||
}
|
||||
|
||||
public function withLocation(): static
|
||||
{
|
||||
return $this->state(fn () => ['location_id' => Location::factory()]);
|
||||
}
|
||||
|
||||
public function withCustomer(): static
|
||||
{
|
||||
return $this->state(fn () => ['customer_id' => Customer::factory()]);
|
||||
}
|
||||
|
||||
public function upcoming(): 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,
|
||||
]);
|
||||
}
|
||||
|
||||
public function privateEvent(): static
|
||||
{
|
||||
return $this->state(fn () => ['visibility' => EventVisibility::Private]);
|
||||
}
|
||||
|
||||
public function publicEvent(): static
|
||||
{
|
||||
return $this->state(fn () => ['visibility' => EventVisibility::Public]);
|
||||
return $this->state(fn () => ['status' => 'published']);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Enums\RsvpStatus;
|
||||
use App\Models\Event;
|
||||
use App\Models\EventInvitation;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<EventInvitation>
|
||||
*/
|
||||
final class EventInvitationFactory extends Factory
|
||||
{
|
||||
protected $model = EventInvitation::class;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'event_id' => Event::factory(),
|
||||
'user_id' => User::factory(),
|
||||
'rsvp_status' => RsvpStatus::Pending,
|
||||
'rsvp_note' => null,
|
||||
'rsvp_responded_at' => null,
|
||||
'invited_at' => now(),
|
||||
];
|
||||
}
|
||||
|
||||
public function pending(): static
|
||||
{
|
||||
return $this->state(fn () => [
|
||||
'rsvp_status' => RsvpStatus::Pending,
|
||||
'rsvp_responded_at' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function available(): static
|
||||
{
|
||||
return $this->state(fn () => [
|
||||
'rsvp_status' => RsvpStatus::Available,
|
||||
'rsvp_responded_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function unavailable(): static
|
||||
{
|
||||
return $this->state(fn () => [
|
||||
'rsvp_status' => RsvpStatus::Unavailable,
|
||||
'rsvp_responded_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function tentative(): static
|
||||
{
|
||||
return $this->state(fn () => [
|
||||
'rsvp_status' => RsvpStatus::Tentative,
|
||||
'rsvp_responded_at' => now(),
|
||||
'rsvp_note' => fake()->sentence(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Location;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<Location>
|
||||
*/
|
||||
final class LocationFactory extends Factory
|
||||
{
|
||||
protected $model = Location::class;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'name' => fake()->company() . ' ' . fake()->randomElement(['Theater', 'Hall', 'Arena', 'Club', 'Venue']),
|
||||
'address' => fake()->streetAddress(),
|
||||
'city' => fake()->city(),
|
||||
'postal_code' => fake()->postcode(),
|
||||
'country' => 'NL',
|
||||
'latitude' => fake()->optional()->latitude(),
|
||||
'longitude' => fake()->optional()->longitude(),
|
||||
'capacity' => fake()->optional()->numberBetween(50, 2000),
|
||||
'contact_name' => fake()->optional()->name(),
|
||||
'contact_email' => fake()->optional()->safeEmail(),
|
||||
'contact_phone' => fake()->optional()->phoneNumber(),
|
||||
'notes' => fake()->optional()->paragraph(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
25
api/database/factories/OrganisationFactory.php
Normal file
25
api/database/factories/OrganisationFactory.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Organisation;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/** @extends Factory<Organisation> */
|
||||
final class OrganisationFactory extends Factory
|
||||
{
|
||||
/** @return array<string, mixed> */
|
||||
public function definition(): array
|
||||
{
|
||||
$name = fake()->unique()->company();
|
||||
|
||||
return [
|
||||
'name' => $name,
|
||||
'slug' => str($name)->slug()->toString(),
|
||||
'billing_status' => 'active',
|
||||
'settings' => [],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,26 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\User>
|
||||
*/
|
||||
class UserFactory extends Factory
|
||||
/** @extends Factory<User> */
|
||||
final class UserFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* The current password being used by the factory.
|
||||
*/
|
||||
protected static ?string $password;
|
||||
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
/** @return array<string, mixed> */
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
@@ -28,17 +22,14 @@ class UserFactory extends Factory
|
||||
'email' => fake()->unique()->safeEmail(),
|
||||
'email_verified_at' => now(),
|
||||
'password' => static::$password ??= Hash::make('password'),
|
||||
'timezone' => 'Europe/Amsterdam',
|
||||
'locale' => 'nl',
|
||||
'remember_token' => Str::random(10),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicate that the model's email address should be unverified.
|
||||
*/
|
||||
public function unverified(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'email_verified_at' => null,
|
||||
]);
|
||||
return $this->state(fn () => ['email_verified_at' => null]);
|
||||
}
|
||||
}
|
||||
|
||||
30
api/database/factories/UserInvitationFactory.php
Normal file
30
api/database/factories/UserInvitationFactory.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Organisation;
|
||||
use App\Models\User;
|
||||
use App\Models\UserInvitation;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/** @extends Factory<UserInvitation> */
|
||||
final class UserInvitationFactory extends Factory
|
||||
{
|
||||
/** @return array<string, mixed> */
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'email' => fake()->unique()->safeEmail(),
|
||||
'invited_by_user_id' => User::factory(),
|
||||
'organisation_id' => Organisation::factory(),
|
||||
'event_id' => null,
|
||||
'role' => 'org_member',
|
||||
'token' => strtolower((string) Str::ulid()),
|
||||
'status' => 'pending',
|
||||
'expires_at' => now()->addDays(7),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('customers', function (Blueprint $table) {
|
||||
$table->ulid('id')->primary();
|
||||
$table->string('name');
|
||||
$table->string('company_name')->nullable();
|
||||
$table->enum('type', ['individual', 'company'])->default('individual');
|
||||
$table->string('email')->nullable();
|
||||
$table->string('phone', 20)->nullable();
|
||||
$table->text('address')->nullable();
|
||||
$table->string('city')->nullable();
|
||||
$table->string('postal_code', 20)->nullable();
|
||||
$table->string('country', 2)->default('NL');
|
||||
$table->text('notes')->nullable();
|
||||
$table->boolean('is_portal_enabled')->default(false);
|
||||
$table->foreignUlid('user_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['type', 'city']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('customers');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('locations', function (Blueprint $table) {
|
||||
$table->ulid('id')->primary();
|
||||
$table->string('name');
|
||||
$table->text('address')->nullable();
|
||||
$table->string('city');
|
||||
$table->string('postal_code', 20)->nullable();
|
||||
$table->string('country', 2)->default('NL');
|
||||
$table->decimal('latitude', 10, 7)->nullable();
|
||||
$table->decimal('longitude', 10, 7)->nullable();
|
||||
$table->unsignedInteger('capacity')->nullable();
|
||||
$table->string('contact_name')->nullable();
|
||||
$table->string('contact_email')->nullable();
|
||||
$table->string('contact_phone', 20)->nullable();
|
||||
$table->text('notes')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index('city');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('locations');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('setlists', function (Blueprint $table) {
|
||||
$table->ulid('id')->primary();
|
||||
$table->string('name');
|
||||
$table->text('description')->nullable();
|
||||
$table->unsignedInteger('total_duration_seconds')->nullable();
|
||||
$table->boolean('is_template')->default(false);
|
||||
$table->boolean('is_archived')->default(false);
|
||||
$table->foreignUlid('created_by')->constrained('users');
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['is_template', 'is_archived']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('setlists');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('events', function (Blueprint $table) {
|
||||
$table->ulid('id')->primary();
|
||||
$table->string('title');
|
||||
$table->text('description')->nullable();
|
||||
$table->foreignUlid('location_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->foreignUlid('customer_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->foreignUlid('setlist_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->date('event_date');
|
||||
$table->time('start_time');
|
||||
$table->time('end_time')->nullable();
|
||||
$table->time('load_in_time')->nullable();
|
||||
$table->time('soundcheck_time')->nullable();
|
||||
$table->decimal('fee', 10, 2)->nullable();
|
||||
$table->string('currency', 3)->default('EUR');
|
||||
$table->enum('status', ['draft', 'pending', 'confirmed', 'completed', 'cancelled'])->default('draft');
|
||||
$table->enum('visibility', ['private', 'members', 'public'])->default('members');
|
||||
$table->dateTime('rsvp_deadline')->nullable();
|
||||
$table->text('notes')->nullable();
|
||||
$table->text('internal_notes')->nullable();
|
||||
$table->boolean('is_public_setlist')->default(false);
|
||||
$table->foreignUlid('created_by')->constrained('users');
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['event_date', 'status']);
|
||||
$table->index('status');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('events');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('event_invitations', function (Blueprint $table) {
|
||||
$table->ulid('id')->primary();
|
||||
$table->foreignUlid('event_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignUlid('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->enum('rsvp_status', ['pending', 'available', 'unavailable', 'tentative'])->default('pending');
|
||||
$table->text('rsvp_note')->nullable();
|
||||
$table->timestamp('rsvp_responded_at')->nullable();
|
||||
$table->timestamp('invited_at');
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['event_id', 'user_id']);
|
||||
$table->index(['user_id', 'rsvp_status']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('event_invitations');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('music_numbers', function (Blueprint $table) {
|
||||
$table->ulid('id')->primary();
|
||||
$table->string('title');
|
||||
$table->string('artist')->nullable();
|
||||
$table->string('genre')->nullable();
|
||||
$table->unsignedInteger('duration_seconds')->nullable();
|
||||
$table->string('key', 10)->nullable();
|
||||
$table->unsignedSmallInteger('tempo_bpm')->nullable();
|
||||
$table->string('time_signature', 10)->nullable();
|
||||
$table->text('lyrics')->nullable();
|
||||
$table->text('notes')->nullable();
|
||||
$table->json('tags')->nullable();
|
||||
$table->unsignedInteger('play_count')->default(0);
|
||||
$table->boolean('is_active')->default(true);
|
||||
$table->foreignUlid('created_by')->constrained('users');
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['is_active', 'title']);
|
||||
$table->index('genre');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('music_numbers');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('music_attachments', function (Blueprint $table) {
|
||||
$table->ulid('id')->primary();
|
||||
$table->foreignUlid('music_number_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('file_name');
|
||||
$table->string('original_name');
|
||||
$table->string('file_path');
|
||||
$table->enum('file_type', ['lyrics', 'chords', 'sheet_music', 'audio', 'other'])->default('other');
|
||||
$table->unsignedInteger('file_size');
|
||||
$table->string('mime_type');
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['music_number_id', 'file_type']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('music_attachments');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('setlist_items', function (Blueprint $table) {
|
||||
$table->ulid('id')->primary();
|
||||
$table->foreignUlid('setlist_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignUlid('music_number_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->unsignedSmallInteger('position');
|
||||
$table->unsignedTinyInteger('set_number')->default(1);
|
||||
$table->boolean('is_break')->default(false);
|
||||
$table->unsignedSmallInteger('break_duration_seconds')->nullable();
|
||||
$table->text('notes')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['setlist_id', 'position']);
|
||||
$table->index(['setlist_id', 'set_number']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('setlist_items');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('booking_requests', function (Blueprint $table) {
|
||||
$table->ulid('id')->primary();
|
||||
$table->foreignUlid('customer_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->string('contact_name');
|
||||
$table->string('contact_email');
|
||||
$table->string('contact_phone', 20)->nullable();
|
||||
$table->string('event_type')->nullable();
|
||||
$table->date('preferred_date');
|
||||
$table->date('alternative_date')->nullable();
|
||||
$table->time('preferred_time')->nullable();
|
||||
$table->string('location')->nullable();
|
||||
$table->unsignedInteger('expected_guests')->nullable();
|
||||
$table->decimal('budget', 10, 2)->nullable();
|
||||
$table->text('message')->nullable();
|
||||
$table->enum('status', ['new', 'contacted', 'quoted', 'accepted', 'declined', 'cancelled'])->default('new');
|
||||
$table->text('internal_notes')->nullable();
|
||||
$table->foreignUlid('assigned_to')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->foreignUlid('converted_event_id')->nullable()->constrained('events')->nullOnDelete();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['status', 'preferred_date']);
|
||||
$table->index('assigned_to');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('booking_requests');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('activity_logs', function (Blueprint $table) {
|
||||
$table->ulid('id')->primary();
|
||||
$table->string('log_name')->nullable();
|
||||
$table->text('description');
|
||||
$table->nullableMorphs('subject');
|
||||
$table->nullableMorphs('causer');
|
||||
$table->json('properties')->nullable();
|
||||
$table->string('event')->nullable();
|
||||
$table->uuid('batch_uuid')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index('log_name');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('activity_logs');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('activity_log', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('log_name')->nullable()->index();
|
||||
$table->text('description');
|
||||
$table->nullableMorphs('subject', 'subject');
|
||||
$table->string('event')->nullable();
|
||||
$table->nullableMorphs('causer', 'causer');
|
||||
$table->json('attribute_changes')->nullable();
|
||||
$table->json('properties')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
$teams = config('permission.teams');
|
||||
$tableNames = config('permission.table_names');
|
||||
$columnNames = config('permission.column_names');
|
||||
$pivotRole = $columnNames['role_pivot_key'] ?? 'role_id';
|
||||
$pivotPermission = $columnNames['permission_pivot_key'] ?? 'permission_id';
|
||||
|
||||
throw_if(empty($tableNames), 'Error: config/permission.php not loaded. Run [php artisan config:clear] and try again.');
|
||||
throw_if($teams && empty($columnNames['team_foreign_key'] ?? null), 'Error: team_foreign_key on config/permission.php not loaded. Run [php artisan config:clear] and try again.');
|
||||
|
||||
/**
|
||||
* See `docs/prerequisites.md` for suggested lengths on 'name' and 'guard_name' if "1071 Specified key was too long" errors are encountered.
|
||||
*/
|
||||
Schema::create($tableNames['permissions'], static function (Blueprint $table) {
|
||||
$table->id(); // permission id
|
||||
$table->string('name');
|
||||
$table->string('guard_name');
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['name', 'guard_name']);
|
||||
});
|
||||
|
||||
/**
|
||||
* See `docs/prerequisites.md` for suggested lengths on 'name' and 'guard_name' if "1071 Specified key was too long" errors are encountered.
|
||||
*/
|
||||
Schema::create($tableNames['roles'], static function (Blueprint $table) use ($teams, $columnNames) {
|
||||
$table->id(); // role id
|
||||
if ($teams || config('permission.testing')) { // permission.testing is a fix for sqlite testing
|
||||
$table->unsignedBigInteger($columnNames['team_foreign_key'])->nullable();
|
||||
$table->index($columnNames['team_foreign_key'], 'roles_team_foreign_key_index');
|
||||
}
|
||||
$table->string('name');
|
||||
$table->string('guard_name');
|
||||
$table->timestamps();
|
||||
if ($teams || config('permission.testing')) {
|
||||
$table->unique([$columnNames['team_foreign_key'], 'name', 'guard_name']);
|
||||
} else {
|
||||
$table->unique(['name', 'guard_name']);
|
||||
}
|
||||
});
|
||||
|
||||
Schema::create($tableNames['model_has_permissions'], static function (Blueprint $table) use ($tableNames, $columnNames, $pivotPermission, $teams) {
|
||||
$table->unsignedBigInteger($pivotPermission);
|
||||
|
||||
$table->string('model_type');
|
||||
$table->string($columnNames['model_morph_key'], 26);
|
||||
$table->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_permissions_model_id_model_type_index');
|
||||
|
||||
$table->foreign($pivotPermission)
|
||||
->references('id') // permission id
|
||||
->on($tableNames['permissions'])
|
||||
->cascadeOnDelete();
|
||||
if ($teams) {
|
||||
$table->unsignedBigInteger($columnNames['team_foreign_key']);
|
||||
$table->index($columnNames['team_foreign_key'], 'model_has_permissions_team_foreign_key_index');
|
||||
|
||||
$table->primary([$columnNames['team_foreign_key'], $pivotPermission, $columnNames['model_morph_key'], 'model_type'],
|
||||
'model_has_permissions_permission_model_type_primary');
|
||||
} else {
|
||||
$table->primary([$pivotPermission, $columnNames['model_morph_key'], 'model_type'],
|
||||
'model_has_permissions_permission_model_type_primary');
|
||||
}
|
||||
});
|
||||
|
||||
Schema::create($tableNames['model_has_roles'], static function (Blueprint $table) use ($tableNames, $columnNames, $pivotRole, $teams) {
|
||||
$table->unsignedBigInteger($pivotRole);
|
||||
|
||||
$table->string('model_type');
|
||||
$table->string($columnNames['model_morph_key'], 26);
|
||||
$table->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_roles_model_id_model_type_index');
|
||||
|
||||
$table->foreign($pivotRole)
|
||||
->references('id') // role id
|
||||
->on($tableNames['roles'])
|
||||
->cascadeOnDelete();
|
||||
if ($teams) {
|
||||
$table->unsignedBigInteger($columnNames['team_foreign_key']);
|
||||
$table->index($columnNames['team_foreign_key'], 'model_has_roles_team_foreign_key_index');
|
||||
|
||||
$table->primary([$columnNames['team_foreign_key'], $pivotRole, $columnNames['model_morph_key'], 'model_type'],
|
||||
'model_has_roles_role_model_type_primary');
|
||||
} else {
|
||||
$table->primary([$pivotRole, $columnNames['model_morph_key'], 'model_type'],
|
||||
'model_has_roles_role_model_type_primary');
|
||||
}
|
||||
});
|
||||
|
||||
Schema::create($tableNames['role_has_permissions'], static function (Blueprint $table) use ($tableNames, $pivotRole, $pivotPermission) {
|
||||
$table->unsignedBigInteger($pivotPermission);
|
||||
$table->unsignedBigInteger($pivotRole);
|
||||
|
||||
$table->foreign($pivotPermission)
|
||||
->references('id') // permission id
|
||||
->on($tableNames['permissions'])
|
||||
->cascadeOnDelete();
|
||||
|
||||
$table->foreign($pivotRole)
|
||||
->references('id') // role id
|
||||
->on($tableNames['roles'])
|
||||
->cascadeOnDelete();
|
||||
|
||||
$table->primary([$pivotPermission, $pivotRole], 'role_has_permissions_permission_id_role_id_primary');
|
||||
});
|
||||
|
||||
app('cache')
|
||||
->store(config('permission.cache.store') != 'default' ? config('permission.cache.store') : null)
|
||||
->forget(config('permission.cache.key'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
$tableNames = config('permission.table_names');
|
||||
|
||||
throw_if(empty($tableNames), 'Error: config/permission.php not found and defaults could not be merged. Please publish the package configuration before proceeding, or drop the tables manually.');
|
||||
|
||||
Schema::dropIfExists($tableNames['role_has_permissions']);
|
||||
Schema::dropIfExists($tableNames['model_has_roles']);
|
||||
Schema::dropIfExists($tableNames['model_has_permissions']);
|
||||
Schema::dropIfExists($tableNames['roles']);
|
||||
Schema::dropIfExists($tableNames['permissions']);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
// Remove old band-management columns
|
||||
$table->dropIndex(['type', 'status']);
|
||||
$table->dropColumn(['phone', 'bio', 'instruments', 'avatar_path', 'type', 'role', 'status']);
|
||||
});
|
||||
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
// Add EventCrew columns per SCHEMA.md
|
||||
$table->string('timezone')->default('Europe/Amsterdam')->after('password');
|
||||
$table->string('locale')->default('nl')->after('timezone');
|
||||
$table->string('avatar')->nullable()->after('locale');
|
||||
$table->softDeletes();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropSoftDeletes();
|
||||
$table->dropColumn(['timezone', 'locale', 'avatar']);
|
||||
});
|
||||
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->string('phone', 20)->nullable();
|
||||
$table->text('bio')->nullable();
|
||||
$table->json('instruments')->nullable();
|
||||
$table->string('avatar_path')->nullable();
|
||||
$table->enum('type', ['member', 'customer'])->default('member');
|
||||
$table->enum('role', ['admin', 'booking_agent', 'music_manager', 'member'])->nullable();
|
||||
$table->enum('status', ['active', 'inactive'])->default('active');
|
||||
$table->index(['type', 'status']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -10,21 +10,19 @@ return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('notifications', function (Blueprint $table) {
|
||||
Schema::create('organisations', function (Blueprint $table) {
|
||||
$table->ulid('id')->primary();
|
||||
$table->string('type');
|
||||
$table->morphs('notifiable');
|
||||
$table->json('data');
|
||||
$table->timestamp('read_at')->nullable();
|
||||
$table->string('name');
|
||||
$table->string('slug')->unique();
|
||||
$table->string('billing_status')->default('active');
|
||||
$table->json('settings')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['notifiable_type', 'notifiable_id', 'read_at']);
|
||||
$table->softDeletes();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('notifications');
|
||||
Schema::dropIfExists('organisations');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('organisation_user', function (Blueprint $table) {
|
||||
$table->id(); // int AI for join performance
|
||||
$table->foreignUlid('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignUlid('organisation_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('role');
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['user_id', 'organisation_id']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('organisation_user');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('events', function (Blueprint $table) {
|
||||
$table->ulid('id')->primary();
|
||||
$table->foreignUlid('organisation_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('name');
|
||||
$table->string('slug');
|
||||
$table->date('start_date');
|
||||
$table->date('end_date');
|
||||
$table->string('timezone')->default('Europe/Amsterdam');
|
||||
$table->enum('status', [
|
||||
'draft',
|
||||
'published',
|
||||
'registration_open',
|
||||
'buildup',
|
||||
'showday',
|
||||
'teardown',
|
||||
'closed',
|
||||
])->default('draft');
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
|
||||
$table->index(['organisation_id', 'status']);
|
||||
$table->unique(['organisation_id', 'slug']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('events');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('user_invitations', function (Blueprint $table) {
|
||||
$table->ulid('id')->primary();
|
||||
$table->string('email');
|
||||
$table->foreignUlid('invited_by_user_id')->constrained('users')->cascadeOnDelete();
|
||||
$table->foreignUlid('organisation_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignUlid('event_id')->nullable()->constrained()->cascadeOnDelete();
|
||||
$table->string('role');
|
||||
$table->ulid('token')->unique();
|
||||
$table->enum('status', ['pending', 'accepted', 'expired'])->default('pending');
|
||||
$table->timestamp('expires_at');
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['email', 'status']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('user_invitations');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('event_user_roles', function (Blueprint $table) {
|
||||
$table->id(); // int AI for join performance
|
||||
$table->foreignUlid('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignUlid('event_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('role');
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['user_id', 'event_id', 'role']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('event_user_roles');
|
||||
}
|
||||
};
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\Organisation;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
@@ -12,44 +13,23 @@ class DatabaseSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
// Create admin user
|
||||
User::create([
|
||||
'name' => 'Admin User',
|
||||
'email' => 'admin@bandmanagement.nl',
|
||||
$this->call(RoleSeeder::class);
|
||||
|
||||
$admin = User::create([
|
||||
'name' => 'Super Admin',
|
||||
'email' => 'admin@eventcrew.nl',
|
||||
'password' => Hash::make('password'),
|
||||
'type' => 'member',
|
||||
'role' => 'admin',
|
||||
'status' => 'active',
|
||||
]);
|
||||
|
||||
// Create booking agent
|
||||
User::create([
|
||||
'name' => 'Booking Agent',
|
||||
'email' => 'booking@bandmanagement.nl',
|
||||
'password' => Hash::make('password'),
|
||||
'type' => 'member',
|
||||
'role' => 'booking_agent',
|
||||
'status' => 'active',
|
||||
$admin->assignRole('super_admin');
|
||||
|
||||
$organisation = Organisation::query()->create([
|
||||
'name' => 'Demo Organisation',
|
||||
'slug' => 'demo',
|
||||
'billing_status' => 'active',
|
||||
'settings' => [],
|
||||
]);
|
||||
|
||||
// Create music manager
|
||||
User::create([
|
||||
'name' => 'Music Manager',
|
||||
'email' => 'music@bandmanagement.nl',
|
||||
'password' => Hash::make('password'),
|
||||
'type' => 'member',
|
||||
'role' => 'music_manager',
|
||||
'status' => 'active',
|
||||
]);
|
||||
|
||||
// Create regular member
|
||||
User::create([
|
||||
'name' => 'Band Member',
|
||||
'email' => 'member@bandmanagement.nl',
|
||||
'password' => Hash::make('password'),
|
||||
'type' => 'member',
|
||||
'role' => 'member',
|
||||
'status' => 'active',
|
||||
]);
|
||||
$admin->organisations()->attach($organisation->id, ['role' => 'org_admin']);
|
||||
}
|
||||
}
|
||||
|
||||
26
api/database/seeders/RoleSeeder.php
Normal file
26
api/database/seeders/RoleSeeder.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use Illuminate\Database\Seeder;
|
||||
use Spatie\Permission\Models\Role;
|
||||
|
||||
class RoleSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
// App-level
|
||||
Role::findOrCreate('super_admin', 'web');
|
||||
|
||||
// Organisation-level
|
||||
Role::findOrCreate('org_admin', 'web');
|
||||
Role::findOrCreate('org_member', 'web');
|
||||
|
||||
// Event-level
|
||||
Role::findOrCreate('event_manager', 'web');
|
||||
Role::findOrCreate('staff_coordinator', 'web');
|
||||
Role::findOrCreate('volunteer_coordinator', 'web');
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,11 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Http\Controllers\Api\V1\AuthController;
|
||||
use App\Http\Controllers\Api\V1\EventController;
|
||||
use App\Http\Controllers\Api\V1\LoginController;
|
||||
use App\Http\Controllers\Api\V1\LogoutController;
|
||||
use App\Http\Controllers\Api\V1\MeController;
|
||||
use App\Http\Controllers\Api\V1\OrganisationController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
/*
|
||||
@@ -18,26 +21,24 @@ use Illuminate\Support\Facades\Route;
|
||||
// Health check
|
||||
Route::get('/', fn () => response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Band Management API v1',
|
||||
'message' => 'EventCrew API v1',
|
||||
'timestamp' => now()->toIso8601String(),
|
||||
]));
|
||||
|
||||
// Public auth routes
|
||||
Route::prefix('auth')->group(function () {
|
||||
Route::post('/login', [AuthController::class, 'login']);
|
||||
Route::post('/register', [AuthController::class, 'register']);
|
||||
});
|
||||
Route::post('auth/login', LoginController::class);
|
||||
|
||||
// Protected routes
|
||||
Route::middleware('auth:sanctum')->group(function () {
|
||||
// Auth
|
||||
Route::prefix('auth')->group(function () {
|
||||
Route::get('/user', [AuthController::class, 'user']);
|
||||
Route::post('/logout', [AuthController::class, 'logout']);
|
||||
});
|
||||
Route::get('auth/me', MeController::class);
|
||||
Route::post('auth/logout', LogoutController::class);
|
||||
|
||||
// Events
|
||||
Route::apiResource('events', EventController::class);
|
||||
Route::post('events/{event}/invite', [EventController::class, 'invite'])->name('events.invite');
|
||||
Route::post('events/{event}/rsvp', [EventController::class, 'rsvp'])->name('events.rsvp');
|
||||
// Organisations
|
||||
Route::apiResource('organisations', OrganisationController::class)
|
||||
->only(['index', 'show', 'store', 'update']);
|
||||
|
||||
// Events (nested under organisations)
|
||||
Route::apiResource('organisations.events', EventController::class)
|
||||
->only(['index', 'show', 'store', 'update']);
|
||||
});
|
||||
|
||||
52
api/tests/Feature/Auth/LoginTest.php
Normal file
52
api/tests/Feature/Auth/LoginTest.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Auth;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class LoginTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_user_can_login_with_valid_credentials(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this->postJson('/api/v1/auth/login', [
|
||||
'email' => $user->email,
|
||||
'password' => 'password',
|
||||
]);
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonStructure([
|
||||
'success',
|
||||
'data' => ['user' => ['id', 'name', 'email'], 'token'],
|
||||
'message',
|
||||
])
|
||||
->assertJson(['success' => true]);
|
||||
}
|
||||
|
||||
public function test_login_fails_with_invalid_credentials(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this->postJson('/api/v1/auth/login', [
|
||||
'email' => $user->email,
|
||||
'password' => 'wrong-password',
|
||||
]);
|
||||
|
||||
$response->assertUnauthorized();
|
||||
}
|
||||
|
||||
public function test_login_requires_email_and_password(): void
|
||||
{
|
||||
$response = $this->postJson('/api/v1/auth/login', []);
|
||||
|
||||
$response->assertUnprocessable()
|
||||
->assertJsonValidationErrors(['email', 'password']);
|
||||
}
|
||||
}
|
||||
33
api/tests/Feature/Auth/LogoutTest.php
Normal file
33
api/tests/Feature/Auth/LogoutTest.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Auth;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
use Tests\TestCase;
|
||||
|
||||
class LogoutTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_authenticated_user_can_logout(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
Sanctum::actingAs($user);
|
||||
|
||||
$response = $this->postJson('/api/v1/auth/logout');
|
||||
|
||||
$response->assertOk()
|
||||
->assertJson(['success' => true]);
|
||||
}
|
||||
|
||||
public function test_unauthenticated_user_cannot_logout(): void
|
||||
{
|
||||
$response = $this->postJson('/api/v1/auth/logout');
|
||||
|
||||
$response->assertUnauthorized();
|
||||
}
|
||||
}
|
||||
45
api/tests/Feature/Auth/MeTest.php
Normal file
45
api/tests/Feature/Auth/MeTest.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Auth;
|
||||
|
||||
use App\Models\Organisation;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
use Tests\TestCase;
|
||||
|
||||
class MeTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_authenticated_user_can_get_profile(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$organisation = Organisation::factory()->create();
|
||||
$organisation->users()->attach($user, ['role' => 'org_admin']);
|
||||
|
||||
Sanctum::actingAs($user);
|
||||
|
||||
$response = $this->getJson('/api/v1/auth/me');
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonStructure([
|
||||
'success',
|
||||
'data' => [
|
||||
'id', 'name', 'email', 'timezone', 'locale',
|
||||
'organisations',
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertCount(1, $response->json('data.organisations'));
|
||||
}
|
||||
|
||||
public function test_unauthenticated_user_cannot_get_profile(): void
|
||||
{
|
||||
$response = $this->getJson('/api/v1/auth/me');
|
||||
|
||||
$response->assertUnauthorized();
|
||||
}
|
||||
}
|
||||
185
api/tests/Feature/Event/EventTest.php
Normal file
185
api/tests/Feature/Event/EventTest.php
Normal file
@@ -0,0 +1,185 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Event;
|
||||
|
||||
use App\Models\Event;
|
||||
use App\Models\Organisation;
|
||||
use App\Models\User;
|
||||
use Database\Seeders\RoleSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
use Tests\TestCase;
|
||||
|
||||
class EventTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private User $admin;
|
||||
private User $orgAdmin;
|
||||
private User $outsider;
|
||||
private Organisation $organisation;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->seed(RoleSeeder::class);
|
||||
|
||||
$this->admin = User::factory()->create();
|
||||
$this->admin->assignRole('super_admin');
|
||||
|
||||
$this->organisation = Organisation::factory()->create();
|
||||
|
||||
$this->orgAdmin = User::factory()->create();
|
||||
$this->organisation->users()->attach($this->orgAdmin, ['role' => 'org_admin']);
|
||||
|
||||
$this->outsider = User::factory()->create();
|
||||
}
|
||||
|
||||
// --- INDEX ---
|
||||
|
||||
public function test_org_admin_can_list_events(): void
|
||||
{
|
||||
Event::factory()->count(3)->create(['organisation_id' => $this->organisation->id]);
|
||||
|
||||
Sanctum::actingAs($this->orgAdmin);
|
||||
|
||||
$response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/events");
|
||||
|
||||
$response->assertOk();
|
||||
$this->assertCount(3, $response->json('data'));
|
||||
}
|
||||
|
||||
public function test_outsider_cannot_list_events(): void
|
||||
{
|
||||
Sanctum::actingAs($this->outsider);
|
||||
|
||||
$response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/events");
|
||||
|
||||
$response->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_unauthenticated_user_cannot_list_events(): void
|
||||
{
|
||||
$response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/events");
|
||||
|
||||
$response->assertUnauthorized();
|
||||
}
|
||||
|
||||
// --- SHOW ---
|
||||
|
||||
public function test_org_admin_can_view_event(): void
|
||||
{
|
||||
$event = Event::factory()->create(['organisation_id' => $this->organisation->id]);
|
||||
|
||||
Sanctum::actingAs($this->orgAdmin);
|
||||
|
||||
$response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/events/{$event->id}");
|
||||
|
||||
$response->assertOk()
|
||||
->assertJson(['data' => ['id' => $event->id]]);
|
||||
}
|
||||
|
||||
public function test_outsider_cannot_view_event(): void
|
||||
{
|
||||
$event = Event::factory()->create(['organisation_id' => $this->organisation->id]);
|
||||
|
||||
Sanctum::actingAs($this->outsider);
|
||||
|
||||
$response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/events/{$event->id}");
|
||||
|
||||
$response->assertForbidden();
|
||||
}
|
||||
|
||||
// --- STORE ---
|
||||
|
||||
public function test_org_admin_can_create_event(): void
|
||||
{
|
||||
Sanctum::actingAs($this->orgAdmin);
|
||||
|
||||
$response = $this->postJson("/api/v1/organisations/{$this->organisation->id}/events", [
|
||||
'name' => 'Summer Festival 2026',
|
||||
'slug' => 'summer-festival-2026',
|
||||
'start_date' => '2026-07-01',
|
||||
'end_date' => '2026-07-03',
|
||||
]);
|
||||
|
||||
$response->assertCreated()
|
||||
->assertJson(['data' => ['name' => 'Summer Festival 2026']]);
|
||||
|
||||
$this->assertDatabaseHas('events', [
|
||||
'organisation_id' => $this->organisation->id,
|
||||
'slug' => 'summer-festival-2026',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_outsider_cannot_create_event(): void
|
||||
{
|
||||
Sanctum::actingAs($this->outsider);
|
||||
|
||||
$response = $this->postJson("/api/v1/organisations/{$this->organisation->id}/events", [
|
||||
'name' => 'Hacked Event',
|
||||
'slug' => 'hacked-event',
|
||||
'start_date' => '2026-07-01',
|
||||
'end_date' => '2026-07-03',
|
||||
]);
|
||||
|
||||
$response->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_unauthenticated_user_cannot_create_event(): void
|
||||
{
|
||||
$response = $this->postJson("/api/v1/organisations/{$this->organisation->id}/events", [
|
||||
'name' => 'Anon Event',
|
||||
'slug' => 'anon-event',
|
||||
'start_date' => '2026-07-01',
|
||||
'end_date' => '2026-07-03',
|
||||
]);
|
||||
|
||||
$response->assertUnauthorized();
|
||||
}
|
||||
|
||||
// --- UPDATE ---
|
||||
|
||||
public function test_org_admin_can_update_event(): void
|
||||
{
|
||||
$event = Event::factory()->create(['organisation_id' => $this->organisation->id]);
|
||||
|
||||
Sanctum::actingAs($this->orgAdmin);
|
||||
|
||||
$response = $this->putJson("/api/v1/organisations/{$this->organisation->id}/events/{$event->id}", [
|
||||
'name' => 'Updated Festival',
|
||||
]);
|
||||
|
||||
$response->assertOk()
|
||||
->assertJson(['data' => ['name' => 'Updated Festival']]);
|
||||
}
|
||||
|
||||
public function test_outsider_cannot_update_event(): void
|
||||
{
|
||||
$event = Event::factory()->create(['organisation_id' => $this->organisation->id]);
|
||||
|
||||
Sanctum::actingAs($this->outsider);
|
||||
|
||||
$response = $this->putJson("/api/v1/organisations/{$this->organisation->id}/events/{$event->id}", [
|
||||
'name' => 'Hacked',
|
||||
]);
|
||||
|
||||
$response->assertForbidden();
|
||||
}
|
||||
|
||||
// --- CROSS-ORG ---
|
||||
|
||||
public function test_event_from_other_org_returns_404(): void
|
||||
{
|
||||
$otherOrg = Organisation::factory()->create();
|
||||
$event = Event::factory()->create(['organisation_id' => $otherOrg->id]);
|
||||
|
||||
Sanctum::actingAs($this->admin);
|
||||
|
||||
$response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/events/{$event->id}");
|
||||
|
||||
$response->assertNotFound();
|
||||
}
|
||||
}
|
||||
@@ -2,18 +2,18 @@
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
// use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ExampleTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* A basic test example.
|
||||
*/
|
||||
public function test_the_application_returns_a_successful_response(): void
|
||||
{
|
||||
$response = $this->get('/');
|
||||
use RefreshDatabase;
|
||||
|
||||
$response->assertStatus(200);
|
||||
public function test_the_api_returns_a_successful_response(): void
|
||||
{
|
||||
$response = $this->getJson('/api/v1');
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJson(['success' => true]);
|
||||
}
|
||||
}
|
||||
|
||||
158
api/tests/Feature/Organisation/OrganisationTest.php
Normal file
158
api/tests/Feature/Organisation/OrganisationTest.php
Normal file
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Organisation;
|
||||
|
||||
use App\Models\Organisation;
|
||||
use App\Models\User;
|
||||
use Database\Seeders\RoleSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
use Tests\TestCase;
|
||||
|
||||
class OrganisationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->seed(RoleSeeder::class);
|
||||
}
|
||||
|
||||
// --- INDEX ---
|
||||
|
||||
public function test_super_admin_can_list_all_organisations(): void
|
||||
{
|
||||
$admin = User::factory()->create();
|
||||
$admin->assignRole('super_admin');
|
||||
Organisation::factory()->count(3)->create();
|
||||
|
||||
Sanctum::actingAs($admin);
|
||||
|
||||
$response = $this->getJson('/api/v1/organisations');
|
||||
|
||||
$response->assertOk();
|
||||
$this->assertCount(3, $response->json('data'));
|
||||
}
|
||||
|
||||
public function test_org_member_sees_only_own_organisations(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$ownOrg = Organisation::factory()->create();
|
||||
$otherOrg = Organisation::factory()->create();
|
||||
|
||||
$ownOrg->users()->attach($user, ['role' => 'org_member']);
|
||||
|
||||
Sanctum::actingAs($user);
|
||||
|
||||
$response = $this->getJson('/api/v1/organisations');
|
||||
|
||||
$response->assertOk();
|
||||
$this->assertCount(1, $response->json('data'));
|
||||
$this->assertEquals($ownOrg->id, $response->json('data.0.id'));
|
||||
}
|
||||
|
||||
public function test_unauthenticated_user_cannot_list_organisations(): void
|
||||
{
|
||||
$response = $this->getJson('/api/v1/organisations');
|
||||
|
||||
$response->assertUnauthorized();
|
||||
}
|
||||
|
||||
// --- SHOW ---
|
||||
|
||||
public function test_org_member_can_view_own_organisation(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$org = Organisation::factory()->create();
|
||||
$org->users()->attach($user, ['role' => 'org_member']);
|
||||
|
||||
Sanctum::actingAs($user);
|
||||
|
||||
$response = $this->getJson("/api/v1/organisations/{$org->id}");
|
||||
|
||||
$response->assertOk()
|
||||
->assertJson(['data' => ['id' => $org->id]]);
|
||||
}
|
||||
|
||||
public function test_user_cannot_view_other_organisation(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$org = Organisation::factory()->create();
|
||||
|
||||
Sanctum::actingAs($user);
|
||||
|
||||
$response = $this->getJson("/api/v1/organisations/{$org->id}");
|
||||
|
||||
$response->assertForbidden();
|
||||
}
|
||||
|
||||
// --- STORE ---
|
||||
|
||||
public function test_super_admin_can_create_organisation(): void
|
||||
{
|
||||
$admin = User::factory()->create();
|
||||
$admin->assignRole('super_admin');
|
||||
|
||||
Sanctum::actingAs($admin);
|
||||
|
||||
$response = $this->postJson('/api/v1/organisations', [
|
||||
'name' => 'Test Org',
|
||||
'slug' => 'test-org',
|
||||
]);
|
||||
|
||||
$response->assertCreated()
|
||||
->assertJson(['data' => ['name' => 'Test Org', 'slug' => 'test-org']]);
|
||||
|
||||
$this->assertDatabaseHas('organisations', ['slug' => 'test-org']);
|
||||
}
|
||||
|
||||
public function test_non_admin_cannot_create_organisation(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
Sanctum::actingAs($user);
|
||||
|
||||
$response = $this->postJson('/api/v1/organisations', [
|
||||
'name' => 'Test Org',
|
||||
'slug' => 'test-org',
|
||||
]);
|
||||
|
||||
$response->assertForbidden();
|
||||
}
|
||||
|
||||
// --- UPDATE ---
|
||||
|
||||
public function test_org_admin_can_update_organisation(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$org = Organisation::factory()->create();
|
||||
$org->users()->attach($user, ['role' => 'org_admin']);
|
||||
|
||||
Sanctum::actingAs($user);
|
||||
|
||||
$response = $this->putJson("/api/v1/organisations/{$org->id}", [
|
||||
'name' => 'Updated Name',
|
||||
]);
|
||||
|
||||
$response->assertOk()
|
||||
->assertJson(['data' => ['name' => 'Updated Name']]);
|
||||
}
|
||||
|
||||
public function test_org_member_cannot_update_organisation(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$org = Organisation::factory()->create();
|
||||
$org->users()->attach($user, ['role' => 'org_member']);
|
||||
|
||||
Sanctum::actingAs($user);
|
||||
|
||||
$response = $this->putJson("/api/v1/organisations/{$org->id}", [
|
||||
'name' => 'Hacked Name',
|
||||
]);
|
||||
|
||||
$response->assertForbidden();
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ pnpm install
|
||||
|
||||
```env
|
||||
VITE_API_URL=http://localhost:8000/api/v1
|
||||
VITE_APP_NAME="Band Management Admin"
|
||||
VITE_APP_NAME="Event Crew Admin"
|
||||
```
|
||||
|
||||
4. Start development:
|
||||
|
||||
1
apps/admin/components.d.ts
vendored
1
apps/admin/components.d.ts
vendored
@@ -370,7 +370,6 @@ declare module 'vue' {
|
||||
EnableOneTimePasswordDialog: typeof import('./src/components/dialogs/EnableOneTimePasswordDialog.vue')['default']
|
||||
ErrorHeader: typeof import('./src/components/ErrorHeader.vue')['default']
|
||||
I18n: typeof import('./src/@core/components/I18n.vue')['default']
|
||||
InviteMembersDialog: typeof import('./src/components/events/InviteMembersDialog.vue')['default']
|
||||
MoreBtn: typeof import('./src/@core/components/MoreBtn.vue')['default']
|
||||
Notifications: typeof import('./src/@core/components/Notifications.vue')['default']
|
||||
PaymentProvidersDialog: typeof import('./src/components/dialogs/PaymentProvidersDialog.vue')['default']
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Band Management Admin</title>
|
||||
<title>Event Crew Admin</title>
|
||||
<link rel="stylesheet" type="text/css" href="/loader.css" />
|
||||
</head>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "vuexy-vuejs-admin-template",
|
||||
"name": "eventcrew-admin",
|
||||
"version": "9.5.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -19,6 +19,7 @@
|
||||
"@floating-ui/dom": "1.6.8",
|
||||
"@formkit/drag-and-drop": "0.1.6",
|
||||
"@sindresorhus/is": "7.1.0",
|
||||
"@tanstack/vue-query": "^5.95.2",
|
||||
"@tiptap/extension-highlight": "^2.27.1",
|
||||
"@tiptap/extension-image": "^2.27.1",
|
||||
"@tiptap/extension-link": "^2.27.1",
|
||||
@@ -28,6 +29,7 @@
|
||||
"@tiptap/vue-3": "^2.27.1",
|
||||
"@types/jquery": "^3.5.33",
|
||||
"@types/moment": "^2.13.0",
|
||||
"@vee-validate/zod": "^4.15.1",
|
||||
"@vueuse/core": "10.11.1",
|
||||
"@vueuse/math": "10.11.1",
|
||||
"apexcharts": "3.54.1",
|
||||
@@ -50,6 +52,7 @@
|
||||
"swiper": "11.2.10",
|
||||
"ufo": "1.6.1",
|
||||
"unplugin-vue-define-options": "1.5.5",
|
||||
"vee-validate": "^4.15.1",
|
||||
"vue": "3.5.22",
|
||||
"vue-chartjs": "5.3.2",
|
||||
"vue-flatpickr-component": "11.0.5",
|
||||
@@ -59,7 +62,8 @@
|
||||
"vue3-apexcharts": "1.5.3",
|
||||
"vue3-perfect-scrollbar": "2.0.0",
|
||||
"vuetify": "3.10.8",
|
||||
"webfontloader": "1.6.28"
|
||||
"webfontloader": "1.6.28",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@antfu/eslint-config-vue": "0.43.1",
|
||||
|
||||
95
apps/admin/pnpm-lock.yaml
generated
95
apps/admin/pnpm-lock.yaml
generated
@@ -27,6 +27,9 @@ importers:
|
||||
'@sindresorhus/is':
|
||||
specifier: 7.1.0
|
||||
version: 7.1.0
|
||||
'@tanstack/vue-query':
|
||||
specifier: ^5.95.2
|
||||
version: 5.95.2(vue@3.5.22(typescript@5.9.3))
|
||||
'@tiptap/extension-highlight':
|
||||
specifier: ^2.27.1
|
||||
version: 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))
|
||||
@@ -54,6 +57,9 @@ importers:
|
||||
'@types/moment':
|
||||
specifier: ^2.13.0
|
||||
version: 2.13.0
|
||||
'@vee-validate/zod':
|
||||
specifier: ^4.15.1
|
||||
version: 4.15.1(vue@3.5.22(typescript@5.9.3))(zod@3.25.76)
|
||||
'@vueuse/core':
|
||||
specifier: 10.11.1
|
||||
version: 10.11.1(vue@3.5.22(typescript@5.9.3))
|
||||
@@ -120,6 +126,9 @@ importers:
|
||||
unplugin-vue-define-options:
|
||||
specifier: 1.5.5
|
||||
version: 1.5.5(vue@3.5.22(typescript@5.9.3))
|
||||
vee-validate:
|
||||
specifier: ^4.15.1
|
||||
version: 4.15.1(vue@3.5.22(typescript@5.9.3))
|
||||
vue:
|
||||
specifier: 3.5.22
|
||||
version: 3.5.22(typescript@5.9.3)
|
||||
@@ -150,6 +159,9 @@ importers:
|
||||
webfontloader:
|
||||
specifier: 1.6.28
|
||||
version: 1.6.28
|
||||
zod:
|
||||
specifier: ^3.25.76
|
||||
version: 3.25.76
|
||||
devDependencies:
|
||||
'@antfu/eslint-config-vue':
|
||||
specifier: 0.43.1
|
||||
@@ -1043,56 +1055,67 @@ packages:
|
||||
resolution: {integrity: sha512-PsNAbcyv9CcecAUagQefwX8fQn9LQ4nZkpDboBOttmyffnInRy8R8dSg6hxxl2Re5QhHBf6FYIDhIj5v982ATQ==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.52.5':
|
||||
resolution: {integrity: sha512-Fw4tysRutyQc/wwkmcyoqFtJhh0u31K+Q6jYjeicsGJJ7bbEq8LwPWV/w0cnzOqR2m694/Af6hpFayLJZkG2VQ==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-arm64-gnu@4.52.5':
|
||||
resolution: {integrity: sha512-a+3wVnAYdQClOTlyapKmyI6BLPAFYs0JM8HRpgYZQO02rMR09ZcV9LbQB+NL6sljzG38869YqThrRnfPMCDtZg==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm64-musl@4.52.5':
|
||||
resolution: {integrity: sha512-AvttBOMwO9Pcuuf7m9PkC1PUIKsfaAJ4AYhy944qeTJgQOqJYJ9oVl2nYgY7Rk0mkbsuOpCAYSs6wLYB2Xiw0Q==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-loong64-gnu@4.52.5':
|
||||
resolution: {integrity: sha512-DkDk8pmXQV2wVrF6oq5tONK6UHLz/XcEVow4JTTerdeV1uqPeHxwcg7aFsfnSm9L+OO8WJsWotKM2JJPMWrQtA==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-ppc64-gnu@4.52.5':
|
||||
resolution: {integrity: sha512-W/b9ZN/U9+hPQVvlGwjzi+Wy4xdoH2I8EjaCkMvzpI7wJUs8sWJ03Rq96jRnHkSrcHTpQe8h5Tg3ZzUPGauvAw==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.52.5':
|
||||
resolution: {integrity: sha512-sjQLr9BW7R/ZiXnQiWPkErNfLMkkWIoCz7YMn27HldKsADEKa5WYdobaa1hmN6slu9oWQbB6/jFpJ+P2IkVrmw==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-musl@4.52.5':
|
||||
resolution: {integrity: sha512-hq3jU/kGyjXWTvAh2awn8oHroCbrPm8JqM7RUpKjalIRWWXE01CQOf/tUNWNHjmbMHg/hmNCwc/Pz3k1T/j/Lg==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-s390x-gnu@4.52.5':
|
||||
resolution: {integrity: sha512-gn8kHOrku8D4NGHMK1Y7NA7INQTRdVOntt1OCYypZPRt6skGbddska44K8iocdpxHTMMNui5oH4elPH4QOLrFQ==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-gnu@4.52.5':
|
||||
resolution: {integrity: sha512-hXGLYpdhiNElzN770+H2nlx+jRog8TyynpTVzdlc6bndktjKWyZyiCsuDAlpd+j+W+WNqfcyAWz9HxxIGfZm1Q==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-musl@4.52.5':
|
||||
resolution: {integrity: sha512-arCGIcuNKjBoKAXD+y7XomR9gY6Mw7HnFBv5Rw7wQRvwYLR7gBAgV7Mb2QTyjXfTveBNFAtPt46/36vV9STLNg==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-openharmony-arm64@4.52.5':
|
||||
resolution: {integrity: sha512-QoFqB6+/9Rly/RiPjaomPLmR/13cgkIGfA40LHly9zcH1S0bN2HVFYk3a1eAyHQyjs3ZJYlXvIGtcCs5tko9Cw==}
|
||||
@@ -1178,6 +1201,22 @@ packages:
|
||||
peerDependencies:
|
||||
stylelint: ^16.0.2
|
||||
|
||||
'@tanstack/match-sorter-utils@8.19.4':
|
||||
resolution: {integrity: sha512-Wo1iKt2b9OT7d+YGhvEPD3DXvPv2etTusIMhMUoG7fbhmxcXCtIjJDEygy91Y2JFlwGyjqiBPRozme7UD8hoqg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
'@tanstack/query-core@5.95.2':
|
||||
resolution: {integrity: sha512-o4T8vZHZET4Bib3jZ/tCW9/7080urD4c+0/AUaYVpIqOsr7y0reBc1oX3ttNaSW5mYyvZHctiQ/UOP2PfdmFEQ==}
|
||||
|
||||
'@tanstack/vue-query@5.95.2':
|
||||
resolution: {integrity: sha512-GleO0GrUPdvObtff/D3iQ5kUERQM3dM6vT5pWl4zC3ap2JO84x4SQbUa1G7czKx96lETRiHnw7ZuatSRaaZqQQ==}
|
||||
peerDependencies:
|
||||
'@vue/composition-api': ^1.1.2
|
||||
vue: ^2.6.0 || ^3.3.0
|
||||
peerDependenciesMeta:
|
||||
'@vue/composition-api':
|
||||
optional: true
|
||||
|
||||
'@tiptap/core@2.27.1':
|
||||
resolution: {integrity: sha512-nkerkl8syHj44ZzAB7oA2GPmmZINKBKCa79FuNvmGJrJ4qyZwlkDzszud23YteFZEytbc87kVd/fP76ROS6sLg==}
|
||||
peerDependencies:
|
||||
@@ -1678,41 +1717,49 @@ packages:
|
||||
resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@unrs/resolver-binding-linux-arm64-musl@1.11.1':
|
||||
resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@unrs/resolver-binding-linux-ppc64-gnu@1.11.1':
|
||||
resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@unrs/resolver-binding-linux-riscv64-gnu@1.11.1':
|
||||
resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@unrs/resolver-binding-linux-riscv64-musl@1.11.1':
|
||||
resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@unrs/resolver-binding-linux-s390x-gnu@1.11.1':
|
||||
resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@unrs/resolver-binding-linux-x64-gnu@1.11.1':
|
||||
resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@unrs/resolver-binding-linux-x64-musl@1.11.1':
|
||||
resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@unrs/resolver-binding-wasm32-wasi@1.11.1':
|
||||
resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==}
|
||||
@@ -1734,6 +1781,11 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@vee-validate/zod@4.15.1':
|
||||
resolution: {integrity: sha512-329Z4TDBE5Vx0FdbA8S4eR9iGCFFUNGbxjpQ20ff5b5wGueScjocUIx9JHPa79LTG06RnlUR4XogQsjN4tecKA==}
|
||||
peerDependencies:
|
||||
zod: ^3.24.0
|
||||
|
||||
'@vitejs/plugin-vue-jsx@5.1.1':
|
||||
resolution: {integrity: sha512-uQkfxzlF8SGHJJVH966lFTdjM/lGcwJGzwAHpVqAPDD/QcsqoUGa+q31ox1BrUfi+FLP2ChVp7uLXE3DkHyDdQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
@@ -3896,6 +3948,9 @@ packages:
|
||||
resolution: {integrity: sha512-qx+xQGZVsy55CH0a1hiVwHmqjLryfh7wQyF5HO07XJ9f7dQMY/gPQHhlyDkIzJKC+x2fUCpCcUODUUUFrm7SHA==}
|
||||
hasBin: true
|
||||
|
||||
remove-accents@0.5.0:
|
||||
resolution: {integrity: sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A==}
|
||||
|
||||
require-directory@2.1.1:
|
||||
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -4536,6 +4591,11 @@ packages:
|
||||
validate-npm-package-license@3.0.4:
|
||||
resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==}
|
||||
|
||||
vee-validate@4.15.1:
|
||||
resolution: {integrity: sha512-DkFsiTwEKau8VIxyZBGdO6tOudD+QoUBPuHj3e6QFqmbfCRj1ArmYWue9lEp6jLSWBIw4XPlDLjFIZNLdRAMSg==}
|
||||
peerDependencies:
|
||||
vue: ^3.4.26
|
||||
|
||||
vfile-message@4.0.3:
|
||||
resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==}
|
||||
|
||||
@@ -4838,6 +4898,9 @@ packages:
|
||||
resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
zod@3.25.76:
|
||||
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
|
||||
|
||||
zwitch@2.0.4:
|
||||
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
|
||||
|
||||
@@ -5727,6 +5790,20 @@ snapshots:
|
||||
style-search: 0.1.0
|
||||
stylelint: 16.8.0(typescript@5.9.3)
|
||||
|
||||
'@tanstack/match-sorter-utils@8.19.4':
|
||||
dependencies:
|
||||
remove-accents: 0.5.0
|
||||
|
||||
'@tanstack/query-core@5.95.2': {}
|
||||
|
||||
'@tanstack/vue-query@5.95.2(vue@3.5.22(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@tanstack/match-sorter-utils': 8.19.4
|
||||
'@tanstack/query-core': 5.95.2
|
||||
'@vue/devtools-api': 6.6.4
|
||||
vue: 3.5.22(typescript@5.9.3)
|
||||
vue-demi: 0.14.10(vue@3.5.22(typescript@5.9.3))
|
||||
|
||||
'@tiptap/core@2.27.1(@tiptap/pm@2.27.1)':
|
||||
dependencies:
|
||||
'@tiptap/pm': 2.27.1
|
||||
@@ -6335,6 +6412,14 @@ snapshots:
|
||||
'@unrs/resolver-binding-win32-x64-msvc@1.11.1':
|
||||
optional: true
|
||||
|
||||
'@vee-validate/zod@4.15.1(vue@3.5.22(typescript@5.9.3))(zod@3.25.76)':
|
||||
dependencies:
|
||||
type-fest: 4.41.0
|
||||
vee-validate: 4.15.1(vue@3.5.22(typescript@5.9.3))
|
||||
zod: 3.25.76
|
||||
transitivePeerDependencies:
|
||||
- vue
|
||||
|
||||
'@vitejs/plugin-vue-jsx@5.1.1(vite@7.1.12(@types/node@24.9.2)(sass@1.76.0)(tsx@4.20.6)(yaml@2.8.1))(vue@3.5.22(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@babel/core': 7.28.5
|
||||
@@ -8904,6 +8989,8 @@ snapshots:
|
||||
dependencies:
|
||||
jsesc: 0.5.0
|
||||
|
||||
remove-accents@0.5.0: {}
|
||||
|
||||
require-directory@2.1.1: {}
|
||||
|
||||
require-from-string@2.0.2: {}
|
||||
@@ -9707,6 +9794,12 @@ snapshots:
|
||||
spdx-correct: 3.2.0
|
||||
spdx-expression-parse: 3.0.1
|
||||
|
||||
vee-validate@4.15.1(vue@3.5.22(typescript@5.9.3)):
|
||||
dependencies:
|
||||
'@vue/devtools-api': 7.7.7
|
||||
type-fest: 4.41.0
|
||||
vue: 3.5.22(typescript@5.9.3)
|
||||
|
||||
vfile-message@4.0.3:
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
@@ -10020,4 +10113,6 @@ snapshots:
|
||||
|
||||
yoctocolors@2.1.2: {}
|
||||
|
||||
zod@3.25.76: {}
|
||||
|
||||
zwitch@2.0.4: {}
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { $api } from '@/utils/api'
|
||||
import type { User, ApiResponse } from '@/types/events'
|
||||
|
||||
const props = defineProps<{
|
||||
isDialogOpen: boolean
|
||||
eventId: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:isDialogOpen', val: boolean): void
|
||||
(e: 'invited'): void
|
||||
}>()
|
||||
|
||||
const selectedUserIds = ref<string[]>([])
|
||||
const availableUsers = ref<User[]>([])
|
||||
const isLoading = ref(false)
|
||||
const isInviting = ref(false)
|
||||
const searchQuery = ref('')
|
||||
|
||||
const isDialogOpenModel = computed({
|
||||
get: () => props.isDialogOpen,
|
||||
set: (val) => emit('update:isDialogOpen', val),
|
||||
})
|
||||
|
||||
// Fetch available users (members)
|
||||
async function fetchUsers() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
// TODO: Replace with actual users/members endpoint when available
|
||||
// For now, this will fail gracefully and show an empty list
|
||||
// Expected endpoint: GET /users or GET /members with filters for type=member
|
||||
const response = await $api<ApiResponse<User[]>>('/users', {
|
||||
method: 'GET',
|
||||
query: {
|
||||
type: 'member',
|
||||
status: 'active',
|
||||
},
|
||||
})
|
||||
availableUsers.value = response.data
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch users. Make sure a /users endpoint exists:', err)
|
||||
// Set empty array on error so UI doesn't break
|
||||
availableUsers.value = []
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Watch dialog open to fetch users
|
||||
watch(() => props.isDialogOpen, (isOpen) => {
|
||||
if (isOpen) {
|
||||
fetchUsers()
|
||||
selectedUserIds.value = []
|
||||
}
|
||||
})
|
||||
|
||||
async function handleInvite() {
|
||||
if (selectedUserIds.value.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
isInviting.value = true
|
||||
try {
|
||||
await $api(`/events/${props.eventId}/invite`, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
user_ids: selectedUserIds.value,
|
||||
},
|
||||
})
|
||||
emit('invited')
|
||||
isDialogOpenModel.value = false
|
||||
} catch (err) {
|
||||
console.error('Failed to invite members:', err)
|
||||
} finally {
|
||||
isInviting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const filteredUsers = computed(() => {
|
||||
if (!searchQuery.value) {
|
||||
return availableUsers.value
|
||||
}
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
return availableUsers.value.filter(
|
||||
user =>
|
||||
user.name.toLowerCase().includes(query) ||
|
||||
user.email.toLowerCase().includes(query)
|
||||
)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog
|
||||
v-model="isDialogOpenModel"
|
||||
max-width="600"
|
||||
>
|
||||
<VCard>
|
||||
<VCardTitle>Invite Members to Event</VCardTitle>
|
||||
|
||||
<VDivider />
|
||||
|
||||
<VCardText>
|
||||
<AppTextField
|
||||
v-model="searchQuery"
|
||||
placeholder="Search members..."
|
||||
prepend-inner-icon="tabler-search"
|
||||
class="mb-4"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="text-center py-8"
|
||||
>
|
||||
<VProgressCircular
|
||||
indeterminate
|
||||
color="primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="member-list"
|
||||
style="max-height: 400px; overflow-y: auto;"
|
||||
>
|
||||
<VCheckbox
|
||||
v-for="user in filteredUsers"
|
||||
:key="user.id"
|
||||
v-model="selectedUserIds"
|
||||
:value="user.id"
|
||||
class="mb-2"
|
||||
>
|
||||
<template #label>
|
||||
<div>
|
||||
<div class="text-body-1">
|
||||
{{ user.name }}
|
||||
</div>
|
||||
<div class="text-body-2 text-medium-emphasis">
|
||||
{{ user.email }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</VCheckbox>
|
||||
|
||||
<VAlert
|
||||
v-if="filteredUsers.length === 0"
|
||||
type="info"
|
||||
>
|
||||
No members found
|
||||
</VAlert>
|
||||
</div>
|
||||
</VCardText>
|
||||
|
||||
<VDivider />
|
||||
|
||||
<VCardActions>
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
variant="text"
|
||||
@click="isDialogOpenModel = false"
|
||||
>
|
||||
Cancel
|
||||
</VBtn>
|
||||
<VBtn
|
||||
color="primary"
|
||||
:disabled="selectedUserIds.length === 0"
|
||||
:loading="isInviting"
|
||||
@click="handleInvite"
|
||||
>
|
||||
Invite {{ selectedUserIds.length }} Member(s)
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user