Files
crewli/api/app/Http/Middleware/PortalTokenMiddleware.php
bert.hausmans 64878f2734 fix(timetable): wire portal-token auth through artist_engagements
RFC-TIMETABLE v0.2 §5.3 moved portal_token from artists to
artist_engagements (one master artist may have multiple per-event
portal links). PortalTokenController and PortalTokenMiddleware
queried the now-removed artists.portal_token column.

Update both lookups to query artist_engagements.portal_token, joining
to artists for the master name. Response shape unchanged: data.id =
engagement id, data.name = artist name, data.booking_status = engagement
status. Middleware sets portal_context='artist' (unchanged); the
attached portal_person object now carries the engagement row.

PortalTokenSecurityTest seeds artist_engagement rows via a private
helper that writes both an Artist (master) and an artist_engagements
row with the hashed token; test assertions adjusted to check the new
shape (no more milestone fields exposed since they don't exist on
the engagement).

Out of scope refactor disclaimer: this is a forced schema-migration
follow-up, not a Session 2-style controller refactor — the controller
queries the new table with minimal change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 19:15:13 +02:00

85 lines
2.8 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use App\Models\Event;
use App\Models\Scopes\OrganisationScope;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpFoundation\Response;
final class PortalTokenMiddleware
{
public function handle(Request $request, Closure $next): Response
{
$plainToken = $this->extractToken($request);
if ($plainToken === null) {
return response()->json(['message' => 'Portal token required.'], 401);
}
$hashedToken = hash('sha256', $plainToken);
// Artist portal token lives on artist_engagements (per RFC-TIMETABLE
// v0.2 §5.3); resolve to the engagement's event.
$engagement = DB::table('artist_engagements')->where('portal_token', $hashedToken)->first();
if ($engagement) {
$event = Event::withoutGlobalScope(OrganisationScope::class)->find($engagement->event_id);
if (! $event || in_array($event->status, ['draft', 'closed'], true)) {
return response()->json(['message' => 'Portal token required.'], 401);
}
$request->attributes->set('portal_context', 'artist');
$request->attributes->set('portal_person', $engagement);
$request->attributes->set('portal_event', $event);
return $next($request);
}
// Try production_requests table (may not exist yet)
try {
$productionRequest = DB::table('production_requests')->where('token', $hashedToken)->first();
if ($productionRequest) {
$event = Event::withoutGlobalScope(OrganisationScope::class)->find($productionRequest->event_id);
if (! $event || in_array($event->status, ['draft', 'closed'], true)) {
return response()->json(['message' => 'Portal token required.'], 401);
}
$request->attributes->set('portal_context', 'supplier');
$request->attributes->set('portal_person', $productionRequest);
$request->attributes->set('portal_event', $event);
return $next($request);
}
} catch (\Illuminate\Database\QueryException) {
// Table doesn't exist yet — skip
}
return response()->json(['message' => 'Portal token required.'], 401);
}
private function extractToken(Request $request): ?string
{
// Check Authorization: Bearer header
$bearer = $request->bearerToken();
if ($bearer !== null && $bearer !== '') {
return $bearer;
}
// Check query parameter
$queryToken = $request->query('token');
if (is_string($queryToken) && $queryToken !== '') {
return $queryToken;
}
return null;
}
}