--- description: Multi-tenancy and portal architecture rules for EventCrew globs: ["api/**/*.php", "apps/portal/**/*.{vue,ts}"] alwaysApply: true --- # Multi-Tenancy & Portal Rules ## Organisation Scoping (CRITICAL) Every query on event-related data MUST be scoped to the current organisation. This is enforced via Eloquent Global Scopes, NOT manual where-clauses in controllers. ### OrganisationScope Implementation ```php // Applied in model's booted() method protected static function booted(): void { static::addGlobalScope('organisation', function (Builder $builder) { if ($organisationId = auth()->user()?->current_organisation_id) { $builder->where('organisation_id', $organisationId); } }); } ``` ### Models That Need OrganisationScope - Event (direct `organisation_id`) - CrowdType (direct `organisation_id`) - AccreditationCategory (direct `organisation_id`) - Company (direct `organisation_id`) ### Models Scoped via Parent These don't have `organisation_id` directly but inherit scope through their parent: - FestivalSection → via Event - TimeSlot → via Event - Shift → via FestivalSection → Event - Person → via Event - Artist → via Event - All other event-child models ### Rules 1. NEVER use `Model::all()` without a scope 2. NEVER pass organisation_id in URL params for filtering — always derive from authenticated user 3. ALWAYS use Policies to verify the user belongs to the organisation 4. ALWAYS test that users from Organisation A cannot see Organisation B's data ## Three-Level Authorization ### Level 1: App Level (Spatie Roles) ```php // super_admin, support_agent $user->hasRole('super_admin'); ``` ### Level 2: Organisation Level (Spatie Team Permissions) ```php // org_admin, org_member, org_readonly // Organisation acts as Spatie "team" $user->hasRole('org_admin'); // within current organisation context ``` ### Level 3: Event Level (Custom Pivot) ```php // event_manager, artist_manager, staff_coordinator, volunteer_coordinator, accreditation_officer // Stored in event_user_roles pivot table $user->eventRoles()->where('event_id', $event->id)->pluck('role'); ``` ### Middleware Stack ```php // routes/api.php Route::middleware(['auth:sanctum', 'organisation.role:org_admin,org_member'])->group(...); Route::middleware(['auth:sanctum', 'event.role:event_manager'])->group(...); ``` ## User Invitation Flow ### Internal Staff (login-based) 1. Organiser enters email + selects role 2. System checks if account exists 3. If no: invitation email with activation link (24h valid), account created on activation 4. If yes: invitation email, existing user linked to new org/event role on acceptance 5. User can switch between organisations via org-switcher ### Volunteer Registration 1. Volunteer fills public registration form (multi-step) 2. Person record created with `status = 'pending'` 3. Organiser approves/rejects → `status = 'approved'` 4. On approval: check if email has platform account → link or create 5. Volunteer logs in to portal ## Portal Architecture ### Two Access Modes in One App (`apps/portal/`) | Mode | Middleware | Users | Token Source | |------|-----------|-------|-------------| | Login | `auth:sanctum` | Volunteers, Crew | Bearer token from login | | Token | `portal.token` | Artists, Suppliers, Press | URL token param: `?token=ULID` | ### Token-Based Authentication Flow ``` 1. Artist/supplier receives email with link: https://portal.eventcrew.app/advance?token=01HQ3K... 2. Portal detects token in URL query parameter 3. POST /api/v1/portal/token-auth { token: '01HQ3K...' } 4. Backend validates token against artists.portal_token or production_requests.token 5. Returns person context (name, event, crowd_type, permissions) 6. Portal stores context in Pinia, shows relevant portal view ``` ### Login-Based Authentication Flow ``` 1. Volunteer navigates to https://portal.eventcrew.app/login 2. Enters email + password 3. POST /api/v1/auth/login (same endpoint as apps/app/) 4. Returns user + organisations + event roles 5. Portal shows volunteer-specific views (My Shifts, Claim Shifts, Messages, Profile) ``` ### Backend Route Structure ```php // Public Route::post('auth/login', ...); Route::post('portal/token-auth', ...); Route::post('portal/form-submit', ...); // Public form submission // Login-based portal (auth:sanctum) Route::middleware('auth:sanctum')->prefix('portal')->group(function () { Route::get('my-shifts', ...); Route::post('shifts/{shift}/claim', ...); Route::get('messages', ...); Route::get('profile', ...); }); // Token-based portal (portal.token) Route::middleware('portal.token')->prefix('portal')->group(function () { Route::get('artist', ...); Route::post('advancing', ...); Route::get('supplier', ...); Route::post('production-request', ...); }); ``` ### PortalTokenMiddleware ```php bearerToken() ?? $request->query('token'); if (!$token) { return response()->json(['message' => 'Token required'], 401); } // Try artist token $artist = Artist::where('portal_token', $token)->first(); if ($artist) { $request->merge(['portal_context' => 'artist', 'portal_entity' => $artist]); return $next($request); } // Try production request token $productionRequest = ProductionRequest::where('token', $token)->first(); if ($productionRequest) { $request->merge(['portal_context' => 'supplier', 'portal_entity' => $productionRequest]); return $next($request); } return response()->json(['message' => 'Invalid token'], 401); } } ``` ## CORS Configuration ```php // config/cors.php 'allowed_origins' => [ env('FRONTEND_ADMIN_URL', 'http://localhost:5173'), env('FRONTEND_APP_URL', 'http://localhost:5174'), env('FRONTEND_PORTAL_URL', 'http://localhost:5175'), ], 'supports_credentials' => true, ``` ## Shift Claiming & Approval Flow ### Three Assignment Strategies per Shift 1. **Fully controlled**: `slots_open_for_claiming = 0`. Organiser assigns manually. 2. **Fully self-service**: `slots_open_for_claiming = slots_total`. Volunteers fill all spots. 3. **Hybrid**: `slots_open_for_claiming < slots_total`. Some reserved for manual. ### Claim Flow ``` 1. Volunteer claims shift → POST /shifts/{id}/claim 2. Backend checks: slot availability, time_slot conflict (UNIQUE person_id + time_slot_id) 3. Creates ShiftAssignment with status = 'pending_approval' (or 'approved' if auto_approve) 4. Dispatches NotifyCoordinatorOfClaimJob (queued, WhatsApp via Zender) 5. Coordinator approves/rejects via Organizer app 6. Volunteer receives confirmation email ``` ### Status Machine ``` pending_approval → approved → completed → rejected (final) → cancelled (by volunteer or organiser) ```