Files
band-management/.cursor/rules/102_multi_tenancy.mdc
bert.hausmans 1cb7674d52 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
2026-03-29 23:19:06 +02:00

222 lines
7.0 KiB
Plaintext

---
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)
```