feat(timetable): EngagementPortalController + /p/artist/{token}/* routes

Three backend endpoints under public throttle:30,1:
  GET  /p/artist/{token}                       — engagement summary + sections
  GET  /p/artist/{token}/sections/{section}    — form schema + draft values
  POST /p/artist/{token}/sections/{section}    — section submit

Token resolution via ArtistResolver::fromPortalToken (Step 2). The
master Artist becomes the FormSubmission subject; engagement.event_id
populates form_submissions.event_id per WS-4 denormalisation. Token
mismatches map to 404 (InvalidPortalTokenException), soft-deleted
master artists to 410 Gone (ArtistDeletedException).

Section submit reuses the existing FormBindingApplicator pipeline
(RFC-WS-6 v1.3.1) by dispatching FormSubmissionSectionSubmitted —
no parallel apply path. Drafts are idempotent on
'artist_advance:{engagement_id}', so repeated POSTs find the same
submission. AdvanceSection (engagement-scoped) ↔ FormSchemaSection
bridge: case-sensitive name match against the org's artist_advance
schema; the default seeder names them in lockstep.

Frontend in Session 5 — backend complete here.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-08 22:22:02 +02:00
parent 895a1690e7
commit eba162f255
4 changed files with 385 additions and 0 deletions

View File

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources\Api\V1\Portal;
use App\Models\AdvanceSection;
use App\Models\Artist;
use App\Models\ArtistEngagement;
use App\Models\Event;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* Public payload returned by GET /p/artist/{token}.
*
* Carries just enough for the portal to render the section
* navigation: artist + event identification, plus the engagement's
* AdvanceSection rows with their submission_status and ordering.
*
* @property ArtistEngagement $resource
*/
final class EngagementPortalResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
$engagement = $this->resource;
$artist = $engagement->getRelation('artist');
$event = $engagement->getRelation('event');
/** @var \Illuminate\Database\Eloquent\Collection<int, AdvanceSection> $sections */
$sections = $engagement->getRelation('advanceSections');
return [
'engagement_id' => (string) $engagement->id,
'artist' => $artist instanceof Artist ? [
'id' => (string) $artist->id,
'name' => (string) $artist->name,
] : null,
'event' => $event instanceof Event ? [
'id' => (string) $event->id,
'name' => (string) $event->name,
] : null,
'advancing_completed_count' => (int) $engagement->advancing_completed_count,
'advancing_total_count' => (int) $engagement->advancing_total_count,
'sections' => $sections
->sortBy('sort_order')
->values()
->map(static fn (AdvanceSection $section): array => [
'id' => (string) $section->id,
'name' => (string) $section->name,
'type' => $section->getRawOriginal('type'),
'sort_order' => (int) $section->sort_order,
'is_open' => (bool) $section->is_open,
'submission_status' => $section->getRawOriginal('submission_status'),
'last_submitted_at' => $section->getRawOriginal('last_submitted_at'),
])
->all(),
];
}
}