feat(form-builder): add PurposeSubjectResolver per purpose (WS-6)

Parallel interface to PurposeGuardProvider for runtime subject
resolution. Seven concrete resolvers, one per v1.0 purpose. Wired
through purposes.php via subject_resolver_class key.

EventRegistration uses PersonProvisioner (may create). Other purposes
resolve from existing context (portal token, production request, auth).
IncidentReport is the only purpose allowed to return null (anonymous-
allowed configurations); the others return concrete model types
(narrowed via PHP covariance) for caller convenience.

Refs: RFC-WS-6.md §3 (Q9)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-26 12:57:21 +02:00
parent 47265e9d4f
commit 16a9265430
13 changed files with 606 additions and 0 deletions

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\FormBuilder;
use RuntimeException;
/**
* RFC-WS-6 §3 (Q9) failure during PurposeSubjectResolver operation.
*
* Reason codes (informal contract):
* - 'no_portal_token' artist_advance has no portal context
* - 'no_production_request' supplier_intake has no production_request link
* - 'no_auth' auth-based purpose has no authenticated user
* - 'no_person_for_user' user has no linked Person
* - 'subject_not_found' subject_type/subject_id set but record gone
*/
final class PurposeSubjectResolutionException extends RuntimeException
{
public function __construct(
public readonly string $purposeSlug,
public readonly string $reasonCode,
public readonly string $submissionId,
?string $message = null,
) {
parent::__construct($message ?? "Subject resolution failed for {$purposeSlug}: {$reasonCode} (submission {$submissionId})");
}
}

View File

@@ -19,6 +19,12 @@ final class PurposeRegistry
/** @var array<string, PurposeGuardProvider> */
private array $guardProviderCache = [];
/** @var array<string, class-string<PurposeSubjectResolver>>|null */
private ?array $resolverClassCache = null;
/** @var array<string, PurposeSubjectResolver> */
private array $resolverInstanceCache = [];
public function __construct(private readonly ConfigRepository $config) {}
/** @return array<string, PurposeDefinition> keyed by slug */
@@ -33,6 +39,7 @@ final class PurposeRegistry
$definitions = [];
$guardClasses = [];
$resolverClasses = [];
foreach ($raw as $slug => $attrs) {
$mode = $attrs['default_submission_mode'] ?? null;
if (! $mode instanceof FormSubmissionMode) {
@@ -48,6 +55,13 @@ final class PurposeRegistry
);
}
$resolverClass = $attrs['subject_resolver_class'] ?? null;
if (! is_string($resolverClass) || ! is_subclass_of($resolverClass, PurposeSubjectResolver::class)) {
throw new \InvalidArgumentException(
"Purpose '{$slug}' has invalid subject_resolver_class; expected class-string implementing PurposeSubjectResolver."
);
}
$definitions[(string) $slug] = new PurposeDefinition(
slug: (string) $slug,
label: (string) ($attrs['label'] ?? ''),
@@ -57,9 +71,11 @@ final class PurposeRegistry
requiredBindings: array_values((array) ($attrs['required_bindings'] ?? [])),
);
$guardClasses[(string) $slug] = $guardsClass;
$resolverClasses[(string) $slug] = $resolverClass;
}
$this->guardClassCache = $guardClasses;
$this->resolverClassCache = $resolverClasses;
return $this->cache = $definitions;
}
@@ -84,6 +100,26 @@ final class PurposeRegistry
return $this->guardProviderCache[$slug] = $instance;
}
public function subjectResolverFor(string $slug): PurposeSubjectResolver
{
if (! $this->has($slug)) {
throw Exceptions\PurposeNotFoundException::forSlug($slug);
}
if (isset($this->resolverInstanceCache[$slug])) {
return $this->resolverInstanceCache[$slug];
}
/** @var array<string, class-string<PurposeSubjectResolver>> $classes */
$classes = $this->resolverClassCache ?? [];
$class = $classes[$slug];
/** @var PurposeSubjectResolver $instance */
$instance = resolve($class);
return $this->resolverInstanceCache[$slug] = $instance;
}
public function get(string $slug): PurposeDefinition
{
$all = $this->all();

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\FormBuilder\Purposes;
use App\Models\FormBuilder\FormSubmission;
use Illuminate\Database\Eloquent\Model;
/**
* RFC-WS-6 §3 (Q9) parallel interface to PurposeGuardProvider for
* runtime subject resolution. PurposeDefinition stays a frozen value
* object; this contract lives separately.
*
* Returns null only for purposes that allow anonymous submissions
* (currently incident_report). All other purposes throw on failure.
*
* Called from inside FormBindingApplicator within a DB::transaction so
* any lockForUpdate semantics persist.
*/
interface PurposeSubjectResolver
{
/**
* @throws \App\Exceptions\FormBuilder\PurposeSubjectResolutionException
*/
public function resolveOrProvision(FormSubmission $submission): ?Model;
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\FormBuilder\Purposes\Resolvers;
use App\Exceptions\FormBuilder\PurposeSubjectResolutionException;
use App\FormBuilder\Purposes\PurposeSubjectResolver;
use App\Models\FormBuilder\FormSubmission;
use Illuminate\Database\Eloquent\Model;
/**
* RFC §3 Q9 artist_advance: subject is the Artist resolved via the
* portal token that opened the submission. Throws if no token.
*
* The Artist model is not yet implemented (BACKLOG: ARCH-09); this
* resolver short-circuits when the submission already carries
* subject_type='artist' (set by the portal route) the typical path.
* If subject is absent, throw the route should never have allowed
* the submission through without a portal token.
*/
final readonly class ArtistAdvanceSubjectResolver implements PurposeSubjectResolver
{
public function resolveOrProvision(FormSubmission $submission): Model
{
if ($submission->subject_type === 'artist' && $submission->subject_id !== null) {
$subject = $submission->subject;
if ($subject !== null) {
return $subject;
}
throw new PurposeSubjectResolutionException(
'artist_advance',
'subject_not_found',
(string) $submission->id,
"submission claims artist subject {$submission->subject_id} but record is gone",
);
}
throw new PurposeSubjectResolutionException(
'artist_advance',
'no_portal_token',
(string) $submission->id,
'artist_advance submission has no resolved Artist; portal token missing',
);
}
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\FormBuilder\Purposes\Resolvers;
use App\FormBuilder\Bindings\PersonProvisioner;
use App\FormBuilder\Purposes\PurposeSubjectResolver;
use App\Models\FormBuilder\FormSubmission;
use App\Models\Person;
/**
* RFC §3 Q9 event_registration: provisions a Person via
* PersonProvisioner (firstOrCreate semantics). Subject may be created.
*/
final readonly class EventRegistrationSubjectResolver implements PurposeSubjectResolver
{
public function __construct(private PersonProvisioner $provisioner) {}
public function resolveOrProvision(FormSubmission $submission): Person
{
return $this->provisioner->provisionFromSubmission($submission);
}
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\FormBuilder\Purposes\Resolvers;
use App\FormBuilder\Purposes\PurposeSubjectResolver;
use App\Models\FormBuilder\FormSubmission;
use App\Models\Person;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
/**
* RFC §3 Q9 incident_report: subject is Person via auth when present;
* null when anonymous (the only purpose that may legitimately resolve
* to no subject).
*/
final readonly class IncidentReportSubjectResolver implements PurposeSubjectResolver
{
public function resolveOrProvision(FormSubmission $submission): ?Model
{
if ($submission->subject_type === 'person' && $submission->subject_id !== null) {
$subject = $submission->subject;
if ($subject instanceof Person) {
return $subject;
}
}
if ($submission->submitted_by_user_id === null) {
// Anonymous-allowed: caller (FormBindingApplicator) handles
// the null subject path explicitly.
return null;
}
$user = User::query()->find($submission->submitted_by_user_id);
if (! $user instanceof User) {
return null;
}
return Person::query()
->withoutGlobalScopes()
->where('user_id', $user->id)
->where('event_id', $submission->event_id)
->first();
}
}

View File

@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace App\FormBuilder\Purposes\Resolvers;
use App\Exceptions\FormBuilder\PurposeSubjectResolutionException;
use App\FormBuilder\Purposes\PurposeSubjectResolver;
use App\Models\FormBuilder\FormSubmission;
use App\Models\Person;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
/**
* RFC §3 Q9 post_event_evaluation: subject is a Person resolved via
* the authenticated User linked Person. Throws if no auth or no
* Person link.
*/
final readonly class PostEventEvaluationSubjectResolver implements PurposeSubjectResolver
{
public function resolveOrProvision(FormSubmission $submission): Model
{
if ($submission->subject_type === 'person' && $submission->subject_id !== null) {
$subject = $submission->subject;
if ($subject instanceof Person) {
return $subject;
}
}
$user = $this->resolveUser($submission);
$person = Person::query()->withoutGlobalScopes()->where('user_id', $user->id)
->where('event_id', $submission->event_id)
->first();
if ($person === null) {
throw new PurposeSubjectResolutionException(
'post_event_evaluation',
'no_person_for_user',
(string) $submission->id,
"user {$user->id} has no Person linked for event {$submission->event_id}",
);
}
return $person;
}
private function resolveUser(FormSubmission $submission): User
{
if ($submission->submitted_by_user_id !== null) {
$user = User::query()->find($submission->submitted_by_user_id);
if ($user instanceof User) {
return $user;
}
}
throw new PurposeSubjectResolutionException(
'post_event_evaluation',
'no_auth',
(string) $submission->id,
'post_event_evaluation submission has no authenticated User',
);
}
}

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\FormBuilder\Purposes\Resolvers;
use App\Exceptions\FormBuilder\PurposeSubjectResolutionException;
use App\FormBuilder\Purposes\PurposeSubjectResolver;
use App\Models\FormBuilder\FormSubmission;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
/**
* RFC §3 Q9 signature_contract: subject is the authenticated User.
*/
final readonly class SignatureContractSubjectResolver implements PurposeSubjectResolver
{
public function resolveOrProvision(FormSubmission $submission): User
{
if ($submission->subject_type === 'user' && $submission->subject_id !== null) {
$subject = $submission->subject;
if ($subject instanceof User) {
return $subject;
}
}
if ($submission->submitted_by_user_id === null) {
throw new PurposeSubjectResolutionException(
'signature_contract',
'no_auth',
(string) $submission->id,
'signature_contract submission has no authenticated User',
);
}
$user = User::query()->find($submission->submitted_by_user_id);
if (! $user instanceof User) {
throw new PurposeSubjectResolutionException(
'signature_contract',
'no_auth',
(string) $submission->id,
"submitted_by_user_id {$submission->submitted_by_user_id} not found",
);
}
return $user;
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\FormBuilder\Purposes\Resolvers;
use App\Exceptions\FormBuilder\PurposeSubjectResolutionException;
use App\FormBuilder\Purposes\PurposeSubjectResolver;
use App\Models\Company;
use App\Models\FormBuilder\FormSubmission;
use Illuminate\Database\Eloquent\Model;
/**
* RFC §3 Q9 supplier_intake: subject is the Company associated with
* the production_request that created this submission. Throws if no
* Company subject is set (route should validate upstream).
*/
final readonly class SupplierIntakeSubjectResolver implements PurposeSubjectResolver
{
public function resolveOrProvision(FormSubmission $submission): Company
{
if ($submission->subject_type === 'company' && $submission->subject_id !== null) {
$subject = $submission->subject;
if ($subject instanceof Company) {
return $subject;
}
throw new PurposeSubjectResolutionException(
'supplier_intake',
'subject_not_found',
(string) $submission->id,
"submission claims company subject {$submission->subject_id} but record is gone",
);
}
throw new PurposeSubjectResolutionException(
'supplier_intake',
'no_production_request',
(string) $submission->id,
'supplier_intake submission has no Company subject — production_request link missing',
);
}
}

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\FormBuilder\Purposes\Resolvers;
use App\Exceptions\FormBuilder\PurposeSubjectResolutionException;
use App\FormBuilder\Purposes\PurposeSubjectResolver;
use App\Models\FormBuilder\FormSubmission;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
/**
* RFC §3 Q9 user_profile: subject is the authenticated User.
*/
final readonly class UserProfileSubjectResolver implements PurposeSubjectResolver
{
public function resolveOrProvision(FormSubmission $submission): User
{
if ($submission->subject_type === 'user' && $submission->subject_id !== null) {
$subject = $submission->subject;
if ($subject instanceof User) {
return $subject;
}
}
if ($submission->submitted_by_user_id === null) {
throw new PurposeSubjectResolutionException(
'user_profile',
'no_auth',
(string) $submission->id,
'user_profile submission has no authenticated User',
);
}
$user = User::query()->find($submission->submitted_by_user_id);
if (! $user instanceof User) {
throw new PurposeSubjectResolutionException(
'user_profile',
'no_auth',
(string) $submission->id,
"submitted_by_user_id {$submission->submitted_by_user_id} not found",
);
}
return $user;
}
}

View File

@@ -33,6 +33,7 @@ return [
'allows_public_access' => true,
'required_bindings' => ['person.email', 'person.first_name', 'person.last_name'],
'guards_class' => \App\FormBuilder\Purposes\Guards\EventRegistrationGuards::class,
'subject_resolver_class' => \App\FormBuilder\Purposes\Resolvers\EventRegistrationSubjectResolver::class,
],
'artist_advance' => [
@@ -42,6 +43,7 @@ return [
'allows_public_access' => false,
'required_bindings' => [],
'guards_class' => \App\FormBuilder\Purposes\Guards\ArtistAdvanceGuards::class,
'subject_resolver_class' => \App\FormBuilder\Purposes\Resolvers\ArtistAdvanceSubjectResolver::class,
],
'supplier_intake' => [
@@ -51,6 +53,7 @@ return [
'allows_public_access' => false,
'required_bindings' => ['company.name'],
'guards_class' => \App\FormBuilder\Purposes\Guards\SupplierIntakeGuards::class,
'subject_resolver_class' => \App\FormBuilder\Purposes\Resolvers\SupplierIntakeSubjectResolver::class,
],
'post_event_evaluation' => [
@@ -60,6 +63,7 @@ return [
'allows_public_access' => false,
'required_bindings' => [],
'guards_class' => \App\FormBuilder\Purposes\Guards\PostEventEvaluationGuards::class,
'subject_resolver_class' => \App\FormBuilder\Purposes\Resolvers\PostEventEvaluationSubjectResolver::class,
],
'incident_report' => [
@@ -69,6 +73,7 @@ return [
'allows_public_access' => false,
'required_bindings' => [],
'guards_class' => \App\FormBuilder\Purposes\Guards\IncidentReportGuards::class,
'subject_resolver_class' => \App\FormBuilder\Purposes\Resolvers\IncidentReportSubjectResolver::class,
],
'signature_contract' => [
@@ -78,6 +83,7 @@ return [
'allows_public_access' => false,
'required_bindings' => [],
'guards_class' => \App\FormBuilder\Purposes\Guards\SignatureContractGuards::class,
'subject_resolver_class' => \App\FormBuilder\Purposes\Resolvers\SignatureContractSubjectResolver::class,
],
'user_profile' => [
@@ -87,6 +93,7 @@ return [
'allows_public_access' => false,
'required_bindings' => [],
'guards_class' => \App\FormBuilder\Purposes\Guards\UserProfileGuards::class,
'subject_resolver_class' => \App\FormBuilder\Purposes\Resolvers\UserProfileSubjectResolver::class,
],
];

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\FormBuilder\Purposes;
use App\FormBuilder\Purposes\PurposeRegistry;
use App\FormBuilder\Purposes\PurposeSubjectResolver;
use Tests\TestCase;
final class AllPurposesSubjectResolverWiringTest extends TestCase
{
public function test_every_purpose_has_a_subject_resolver_class(): void
{
/** @var array<string, mixed> $config */
$config = config('form_builder.purposes');
$this->assertNotEmpty($config);
foreach ($config as $slug => $attrs) {
$this->assertArrayHasKey(
'subject_resolver_class',
$attrs,
"Purpose '{$slug}' is missing the subject_resolver_class config key.",
);
}
}
public function test_registry_resolves_a_subject_resolver_for_every_purpose(): void
{
$registry = $this->app->make(PurposeRegistry::class);
foreach (array_keys($registry->all()) as $slug) {
$resolver = $registry->subjectResolverFor($slug);
$this->assertInstanceOf(PurposeSubjectResolver::class, $resolver);
}
}
}

View File

@@ -0,0 +1,153 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\FormBuilder\Purposes\Resolvers;
use App\Exceptions\FormBuilder\PurposeSubjectResolutionException;
use App\FormBuilder\Purposes\Resolvers\ArtistAdvanceSubjectResolver;
use App\FormBuilder\Purposes\Resolvers\IncidentReportSubjectResolver;
use App\FormBuilder\Purposes\Resolvers\PostEventEvaluationSubjectResolver;
use App\FormBuilder\Purposes\Resolvers\SignatureContractSubjectResolver;
use App\FormBuilder\Purposes\Resolvers\SupplierIntakeSubjectResolver;
use App\FormBuilder\Purposes\Resolvers\UserProfileSubjectResolver;
use App\Models\Company;
use App\Models\CrowdType;
use App\Models\Event;
use App\Models\FormBuilder\FormSchema;
use App\Models\FormBuilder\FormSubmission;
use App\Models\Organisation;
use App\Models\Person;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
final class PurposeSubjectResolversTest extends TestCase
{
use RefreshDatabase;
public function test_artist_advance_throws_without_subject(): void
{
$submission = FormSubmission::factory()->create();
try {
$this->app->make(ArtistAdvanceSubjectResolver::class)->resolveOrProvision($submission);
$this->fail('Expected PurposeSubjectResolutionException');
} catch (PurposeSubjectResolutionException $e) {
$this->assertSame('artist_advance', $e->purposeSlug);
$this->assertSame('no_portal_token', $e->reasonCode);
}
}
public function test_supplier_intake_returns_company_subject(): void
{
$organisation = Organisation::factory()->create();
$company = Company::factory()->create(['organisation_id' => $organisation->id]);
$submission = FormSubmission::factory()->forOrganisation($organisation)->create([
'subject_type' => 'company',
'subject_id' => $company->id,
]);
$resolved = $this->app->make(SupplierIntakeSubjectResolver::class)
->resolveOrProvision($submission->fresh());
$this->assertInstanceOf(Company::class, $resolved);
$this->assertSame($company->id, $resolved->id);
}
public function test_supplier_intake_throws_without_company(): void
{
$submission = FormSubmission::factory()->create();
try {
$this->app->make(SupplierIntakeSubjectResolver::class)->resolveOrProvision($submission);
$this->fail('Expected PurposeSubjectResolutionException');
} catch (PurposeSubjectResolutionException $e) {
$this->assertSame('no_production_request', $e->reasonCode);
}
}
public function test_post_event_evaluation_resolves_person_via_user(): void
{
$event = Event::factory()->create();
$crowdType = CrowdType::factory()->create(['organisation_id' => $event->organisation_id]);
$user = User::factory()->create();
$person = Person::factory()->create([
'event_id' => $event->id,
'crowd_type_id' => $crowdType->id,
'user_id' => $user->id,
]);
$submission = FormSubmission::factory()->forEvent($event)->create([
'submitted_by_user_id' => $user->id,
]);
$resolved = $this->app->make(PostEventEvaluationSubjectResolver::class)
->resolveOrProvision($submission->fresh());
$this->assertInstanceOf(Person::class, $resolved);
$this->assertSame($person->id, $resolved->id);
}
public function test_post_event_evaluation_throws_when_no_auth(): void
{
$submission = FormSubmission::factory()->create(['submitted_by_user_id' => null]);
try {
$this->app->make(PostEventEvaluationSubjectResolver::class)->resolveOrProvision($submission);
$this->fail('Expected PurposeSubjectResolutionException');
} catch (PurposeSubjectResolutionException $e) {
$this->assertSame('no_auth', $e->reasonCode);
}
}
public function test_incident_report_returns_null_when_anonymous(): void
{
$submission = FormSubmission::factory()->create(['submitted_by_user_id' => null]);
$resolved = $this->app->make(IncidentReportSubjectResolver::class)
->resolveOrProvision($submission);
$this->assertNull($resolved);
}
public function test_signature_contract_returns_user_subject(): void
{
$user = User::factory()->create();
$submission = FormSubmission::factory()->create([
'submitted_by_user_id' => $user->id,
]);
$resolved = $this->app->make(SignatureContractSubjectResolver::class)
->resolveOrProvision($submission);
$this->assertInstanceOf(User::class, $resolved);
$this->assertSame($user->id, $resolved->id);
}
public function test_signature_contract_throws_without_auth(): void
{
$submission = FormSubmission::factory()->create(['submitted_by_user_id' => null]);
try {
$this->app->make(SignatureContractSubjectResolver::class)->resolveOrProvision($submission);
$this->fail('Expected PurposeSubjectResolutionException');
} catch (PurposeSubjectResolutionException $e) {
$this->assertSame('no_auth', $e->reasonCode);
}
}
public function test_user_profile_returns_user_subject(): void
{
$user = User::factory()->create();
$submission = FormSubmission::factory()->create([
'submitted_by_user_id' => $user->id,
]);
$resolved = $this->app->make(UserProfileSubjectResolver::class)
->resolveOrProvision($submission);
$this->assertInstanceOf(User::class, $resolved);
$this->assertSame($user->id, $resolved->id);
}
}