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:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Api\V1\Portal;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
/**
|
||||
* Validates the body of POST /p/artist/{token}/sections/{section}.
|
||||
*
|
||||
* Body shape: { values: { "<field-slug>": <value>, ... } }
|
||||
*
|
||||
* 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<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'values' => ['required', 'array'],
|
||||
'values.*' => ['nullable'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user