From eba162f255b98a9c5ea03c4494e17a9050de5a71 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Fri, 8 May 2026 22:22:02 +0200 Subject: [PATCH] feat(timetable): EngagementPortalController + /p/artist/{token}/* routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../V1/Portal/EngagementPortalController.php | 276 ++++++++++++++++++ .../Portal/SubmitEngagementSectionRequest.php | 36 +++ .../V1/Portal/EngagementPortalResource.php | 64 ++++ api/routes/api.php | 9 + 4 files changed, 385 insertions(+) create mode 100644 api/app/Http/Controllers/Api/V1/Portal/EngagementPortalController.php create mode 100644 api/app/Http/Requests/Api/V1/Portal/SubmitEngagementSectionRequest.php create mode 100644 api/app/Http/Resources/Api/V1/Portal/EngagementPortalResource.php diff --git a/api/app/Http/Controllers/Api/V1/Portal/EngagementPortalController.php b/api/app/Http/Controllers/Api/V1/Portal/EngagementPortalController.php new file mode 100644 index 00000000..3cd576d3 --- /dev/null +++ b/api/app/Http/Controllers/Api/V1/Portal/EngagementPortalController.php @@ -0,0 +1,276 @@ +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 $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(); + } +} diff --git a/api/app/Http/Requests/Api/V1/Portal/SubmitEngagementSectionRequest.php b/api/app/Http/Requests/Api/V1/Portal/SubmitEngagementSectionRequest.php new file mode 100644 index 00000000..7d82952d --- /dev/null +++ b/api/app/Http/Requests/Api/V1/Portal/SubmitEngagementSectionRequest.php @@ -0,0 +1,36 @@ +": , ... } } + * + * Per-field type validation runs inside FormValueService against the + * form_field_validation_rules rows; this request only enforces the + * envelope shape so we can reject malformed requests early. + */ +final class SubmitEngagementSectionRequest extends FormRequest +{ + public function authorize(): bool + { + // Auth lives in the controller (via ArtistResolver token check). + return true; + } + + /** + * @return array + */ + public function rules(): array + { + return [ + 'values' => ['required', 'array'], + 'values.*' => ['nullable'], + ]; + } +} diff --git a/api/app/Http/Resources/Api/V1/Portal/EngagementPortalResource.php b/api/app/Http/Resources/Api/V1/Portal/EngagementPortalResource.php new file mode 100644 index 00000000..177e3802 --- /dev/null +++ b/api/app/Http/Resources/Api/V1/Portal/EngagementPortalResource.php @@ -0,0 +1,64 @@ + + */ + public function toArray(Request $request): array + { + $engagement = $this->resource; + $artist = $engagement->getRelation('artist'); + $event = $engagement->getRelation('event'); + + /** @var \Illuminate\Database\Eloquent\Collection $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(), + ]; + } +} diff --git a/api/routes/api.php b/api/routes/api.php index f532ec5f..e30880ef 100644 --- a/api/routes/api.php +++ b/api/routes/api.php @@ -97,6 +97,15 @@ Route::post('verify-email-change', [EmailChangeController::class, 'verify']); Route::post('public/check-email', CheckEmailController::class)->middleware('throttle:10,1'); Route::post('portal/token-auth', [PortalTokenController::class, 'auth'])->middleware('throttle:10,1'); +// RFC-TIMETABLE v0.2 D15 — artist advance portal (public, token-scoped). +// Token is the plaintext portal token; resolution via ArtistResolver +// inside the controller. Throttled like the other public portal routes. +Route::middleware('throttle:30,1')->prefix('p/artist/{token}')->group(function (): void { + Route::get('/', [\App\Http\Controllers\Api\V1\Portal\EngagementPortalController::class, 'show']); + Route::get('sections/{section}', [\App\Http\Controllers\Api\V1\Portal\EngagementPortalController::class, 'showSection']); + Route::post('sections/{section}', [\App\Http\Controllers\Api\V1\Portal\EngagementPortalController::class, 'submitSection']); +}); + // Public Form Builder routes (no auth — token-based, rate-limited per ARCH §10). // S2c D4: draft/save/submit split into three separate endpoints. Route::middleware('throttle:30,1')->group(function (): void {