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,276 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1\Portal;
use App\Enums\Artist\AdvanceSectionSubmissionStatus;
use App\Enums\FormBuilder\FormPurpose;
use App\Exceptions\Artist\ArtistDeletedException;
use App\Exceptions\Artist\InvalidPortalTokenException;
use App\FormBuilder\Resolvers\ArtistResolver;
use App\FormBuilder\Resolvers\ArtistResolverResult;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\V1\Portal\SubmitEngagementSectionRequest;
use App\Http\Resources\Api\V1\Portal\EngagementPortalResource;
use App\Models\AdvanceSection;
use App\Models\ArtistEngagement;
use App\Models\FormBuilder\FormField;
use App\Models\FormBuilder\FormSchema;
use App\Models\FormBuilder\FormSchemaSection;
use App\Models\FormBuilder\FormSubmission;
use App\Models\FormBuilder\FormSubmissionSectionStatus;
use App\Models\FormBuilder\FormValue;
use App\Models\Scopes\OrganisationScope;
use App\Services\FormBuilder\FormSubmissionService;
use App\Services\FormBuilder\FormValueService;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
/**
* Public artist-advance portal endpoints. Mounted under /p/artist/{token}/...
*
* GET /p/artist/{token} engagement summary + sections
* GET /p/artist/{token}/sections/{section} schema + draft values for one section
* POST /p/artist/{token}/sections/{section} submit one section
*
* The route token is the plaintext portal token; resolution happens
* via ArtistResolver::fromPortalToken (SHA-256 digest match against
* artist_engagements.portal_token, Session 1 commit eb6d396). Master
* Artist is the FormSubmission subject; engagement.event_id populates
* form_submissions.event_id per WS-4 denormalisation.
*
* The standard FormBindingApplicator pipeline (RFC-WS-6 v1.3.1) runs
* via the FormSubmissionSectionSubmitted listener this controller
* does not duplicate any binding-apply logic.
*
* AdvanceSection (engagement-scoped) FormSchemaSection bridge:
* matched by name (case-sensitive) on the organisation's artist_advance
* schema. The default seeder names them in lockstep.
*/
final class EngagementPortalController extends Controller
{
public function __construct(
private readonly ArtistResolver $artistResolver,
private readonly FormSubmissionService $submissionService,
private readonly FormValueService $valueService,
) {}
public function show(string $token): JsonResponse
{
try {
$resolved = $this->artistResolver->fromPortalToken($token);
} catch (InvalidPortalTokenException) {
return $this->error('Engagement not found.', 404);
} catch (ArtistDeletedException) {
return $this->error('Engagement no longer available.', 410);
}
$engagement = $resolved->engagement->load([
'artist' => fn ($q) => $q->withoutGlobalScope(OrganisationScope::class),
'event' => fn ($q) => $q->withoutGlobalScope(OrganisationScope::class),
'advanceSections' => fn ($q) => $q->withoutGlobalScope(OrganisationScope::class)->orderBy('sort_order'),
]);
return $this->success(new EngagementPortalResource($engagement));
}
public function showSection(string $token, string $section): JsonResponse
{
try {
$resolved = $this->artistResolver->fromPortalToken($token);
} catch (InvalidPortalTokenException) {
return $this->error('Engagement not found.', 404);
} catch (ArtistDeletedException) {
return $this->error('Engagement no longer available.', 410);
}
$advanceSection = $this->findAdvanceSection($resolved->engagement, $section);
if ($advanceSection === null) {
return $this->error('Section not found on this engagement.', 404);
}
$schema = $this->resolveAdvanceSchema($resolved);
if ($schema === null) {
return $this->error('Artist advance schema not configured for this organisation.', 404);
}
$schemaSection = $this->findSchemaSectionFor($schema, $advanceSection);
if ($schemaSection === null) {
return $this->error('Section is not mapped to a form schema section.', 404);
}
$fields = FormField::query()
->withoutGlobalScope(OrganisationScope::class)
->where('form_schema_section_id', $schemaSection->id)
->orderBy('sort_order')
->get();
$submission = $this->findExistingDraft($schema, $resolved->engagement);
$existingValues = $submission instanceof FormSubmission
? FormValue::query()
->withoutGlobalScope(OrganisationScope::class)
->where('form_submission_id', $submission->id)
->whereIn('form_field_id', $fields->pluck('id')->all())
->get()
->keyBy(fn (FormValue $v) => (string) $v->form_field_id)
: collect();
return $this->success([
'section' => [
'id' => (string) $advanceSection->id,
'name' => (string) $advanceSection->name,
'type' => $advanceSection->getRawOriginal('type'),
'submission_status' => $advanceSection->getRawOriginal('submission_status'),
'is_open' => (bool) $advanceSection->is_open,
],
'fields' => $fields->map(static fn (FormField $field): array => [
'id' => (string) $field->id,
'slug' => (string) $field->slug,
'label' => (string) $field->label,
'help_text' => $field->help_text,
'field_type' => (string) $field->field_type,
'is_required' => (bool) $field->is_required,
'display_width' => $field->getRawOriginal('display_width'),
'sort_order' => (int) $field->sort_order,
])->all(),
'values' => $fields->mapWithKeys(static function (FormField $field) use ($existingValues): array {
$value = $existingValues->get((string) $field->id);
return [(string) $field->slug => $value?->value];
})->all(),
]);
}
public function submitSection(SubmitEngagementSectionRequest $request, string $token, string $section): JsonResponse
{
try {
$resolved = $this->artistResolver->fromPortalToken($token);
} catch (InvalidPortalTokenException) {
return $this->error('Engagement not found.', 404);
} catch (ArtistDeletedException) {
return $this->error('Engagement no longer available.', 410);
}
$advanceSection = $this->findAdvanceSection($resolved->engagement, $section);
if ($advanceSection === null) {
return $this->error('Section not found on this engagement.', 404);
}
$schema = $this->resolveAdvanceSchema($resolved);
if ($schema === null) {
return $this->error('Artist advance schema not configured for this organisation.', 404);
}
$schemaSection = $this->findSchemaSectionFor($schema, $advanceSection);
if ($schemaSection === null) {
return $this->error('Section is not mapped to a form schema section.', 404);
}
/** @var array<string, mixed> $values */
$values = (array) $request->validated('values', []);
$result = DB::transaction(function () use ($resolved, $schema, $schemaSection, $advanceSection, $values): array {
$submission = $this->findOrCreateDraft($schema, $resolved);
$this->valueService->upsertMany($submission, $values, null);
$sectionStatus = FormSubmissionSectionStatus::query()
->withoutGlobalScope(OrganisationScope::class)
->where('form_submission_id', $submission->id)
->where('form_schema_section_id', $schemaSection->id)
->first();
if ($sectionStatus === null) {
$sectionStatus = new FormSubmissionSectionStatus;
$sectionStatus->form_submission_id = $submission->id;
$sectionStatus->form_schema_section_id = $schemaSection->id;
}
$sectionStatus->status = 'submitted';
$sectionStatus->submitted_at = now();
$sectionStatus->save();
$advanceSection->submission_status = AdvanceSectionSubmissionStatus::Submitted->value;
$advanceSection->last_submitted_at = now()->toDateTimeString();
$advanceSection->save();
return [$submission->refresh(), $sectionStatus->refresh(), $advanceSection->refresh()];
});
[$submission, $sectionStatus] = $result;
\App\Events\FormBuilder\FormSubmissionSectionSubmitted::dispatch($submission, $sectionStatus);
return $this->success([
'submission_id' => (string) $submission->id,
'section_status' => (string) $sectionStatus->status,
'advance_section_status' => AdvanceSectionSubmissionStatus::Submitted->value,
]);
}
private function findAdvanceSection(ArtistEngagement $engagement, string $sectionId): ?AdvanceSection
{
return AdvanceSection::query()
->withoutGlobalScope(OrganisationScope::class)
->where('engagement_id', $engagement->id)
->whereKey($sectionId)
->first();
}
private function resolveAdvanceSchema(ArtistResolverResult $resolved): ?FormSchema
{
return FormSchema::query()
->withoutGlobalScope(OrganisationScope::class)
->where('organisation_id', $resolved->engagement->organisation_id)
->where('purpose', FormPurpose::ARTIST_ADVANCE->value)
->first();
}
private function findSchemaSectionFor(FormSchema $schema, AdvanceSection $advanceSection): ?FormSchemaSection
{
return FormSchemaSection::query()
->withoutGlobalScope(OrganisationScope::class)
->where('form_schema_id', $schema->id)
->where('name', $advanceSection->name)
->first();
}
private function findExistingDraft(FormSchema $schema, ArtistEngagement $engagement): ?FormSubmission
{
return FormSubmission::query()
->withoutGlobalScope(OrganisationScope::class)
->where('form_schema_id', $schema->id)
->where('subject_type', 'artist')
->where('subject_id', $engagement->artist_id)
->where('event_id', $engagement->event_id)
->orderBy('created_at')
->first();
}
private function findOrCreateDraft(FormSchema $schema, ArtistResolverResult $resolved): FormSubmission
{
$existing = $this->findExistingDraft($schema, $resolved->engagement);
if ($existing instanceof FormSubmission) {
return $existing;
}
// Pre-set event_id so the FormSubmissionObserver doesn't fall back
// to the route('event') lookup (this portal route has no {event}
// parameter — the engagement is the source of truth per WS-4).
$submission = $this->submissionService->createDraft(
schema: $schema,
subject: $resolved->subject,
submitter: null,
context: [
'idempotency_key' => 'artist_advance:'.$resolved->engagement->id,
],
);
if ($submission->event_id === null) {
$submission->event_id = $resolved->eventId;
$submission->save();
}
return $submission->refresh();
}
}