- 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
222 lines
7.0 KiB
Plaintext
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)
|
|
```
|