RFC-TIMETABLE v0.2 Session 3 — Form Builder integration #17
25
api/app/Exceptions/Artist/ArtistDeletedException.php
Normal file
25
api/app/Exceptions/Artist/ArtistDeletedException.php
Normal 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,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
21
api/app/Exceptions/Artist/InvalidPortalTokenException.php
Normal file
21
api/app/Exceptions/Artist/InvalidPortalTokenException.php
Normal 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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
59
api/app/FormBuilder/Resolvers/ArtistResolver.php
Normal file
59
api/app/FormBuilder/Resolvers/ArtistResolver.php
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
27
api/app/FormBuilder/Resolvers/ArtistResolverResult.php
Normal file
27
api/app/FormBuilder/Resolvers/ArtistResolverResult.php
Normal 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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user