feat(timetable): ArtistResolver::fromPortalToken — engagement-scoped subject resolution

Resolves the artist subject + event_id + engagement for the
artist_advance portal flow. Per RFC v0.2 D15 + ARCH-FORM-BUILDER
§17.3 footnote: master Artist is the subject (preserves
form_submissions.subject_type='artist'), engagement provides
event_id (per WS-4 denormalisation), and engagement itself rides
along so callers can resolve advance_section context without a
second query.

Token comparison uses SHA-256 hex digest matching Session 1's
storage shape (commit eb6d396). Two domain exceptions distinguish
404 (no matching token → InvalidPortalTokenException) from 410
(master artist soft-deleted post-engagement → ArtistDeletedException
with engagementId attached).

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

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Artist;
use DomainException;
/**
* Raised by ArtistResolver::fromPortalToken when the engagement's
* portal_token matches but the master Artist has been soft-deleted.
* Per RFC v0.2 D27 the engagement itself remains usable; the portal
* flow surfaces a clear 410 Gone rather than crashing on a null
* subject downstream.
*/
final class ArtistDeletedException extends DomainException
{
public function __construct(public readonly string $engagementId)
{
parent::__construct(sprintf(
'Master Artist for engagement %s has been deleted; portal flow is not available.',
$engagementId,
));
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Artist;
use DomainException;
/**
* Raised by ArtistResolver::fromPortalToken when the supplied portal
* token does not match any active artist_engagements row. Maps to a
* 404 at the HTTP boundary distinguishes from ArtistDeletedException
* (engagement exists but master Artist is soft-deleted, 410 Gone).
*/
final class InvalidPortalTokenException extends DomainException
{
public static function create(): self
{
return new self('Portal token does not resolve to an active engagement.');
}
}

View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\FormBuilder\Resolvers;
use App\Exceptions\Artist\ArtistDeletedException;
use App\Exceptions\Artist\InvalidPortalTokenException;
use App\Models\Artist;
use App\Models\ArtistEngagement;
use App\Models\Scopes\OrganisationScope;
/**
* Engagement-scoped subject resolution for the artist_advance portal
* flow. Per ARCH-FORM-BUILDER §17.3 footnote and RFC-TIMETABLE v0.2
* D15: the master Artist is the FormSubmission subject (subject_type
* = 'artist'), but the engagement provides the event_id (denormalised
* onto form_submissions per WS-4) and any advance_section context.
*
* The portal token itself is stored on artist_engagements.portal_token
* as a SHA-256 hex digest (Session 1 commit eb6d396). Callers pass
* the plaintext token; we hash and look up.
*
* This resolver is the single shared helper for portal-token
* engagement resolution. PortalTokenMiddleware delegates to it; the
* EngagementPortalController calls it directly to produce the value
* object the FormSubmissionService needs.
*/
final class ArtistResolver
{
public function fromPortalToken(string $portalToken): ArtistResolverResult
{
$digest = hash('sha256', $portalToken);
$engagement = ArtistEngagement::query()
->withoutGlobalScope(OrganisationScope::class)
->where('portal_token', $digest)
->first();
if ($engagement === null) {
throw InvalidPortalTokenException::create();
}
$artist = Artist::query()
->withoutGlobalScope(OrganisationScope::class)
->whereKey($engagement->artist_id)
->first();
if (! $artist instanceof Artist) {
throw new ArtistDeletedException((string) $engagement->id);
}
return new ArtistResolverResult(
subject: $artist,
eventId: (string) $engagement->event_id,
engagement: $engagement,
);
}
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\FormBuilder\Resolvers;
use App\Models\Artist;
use App\Models\ArtistEngagement;
/**
* Value object returned by ArtistResolver::fromPortalToken.
*
* Per ARCH-FORM-BUILDER §17.3 footnote: artist_advance submissions use
* the master Artist as `subject` (preserves form_submissions.subject_type
* = 'artist'); `eventId` populates form_submissions.event_id per WS-4
* denormalisation; the engagement itself is returned so callers
* (controllers, listeners) can resolve advance_section context without
* a second query.
*/
final readonly class ArtistResolverResult
{
public function __construct(
public Artist $subject,
public string $eventId,
public ArtistEngagement $engagement,
) {}
}