diff --git a/api/app/Exceptions/FormBuilder/PurposeSubjectResolutionException.php b/api/app/Exceptions/FormBuilder/PurposeSubjectResolutionException.php new file mode 100644 index 00000000..a0620020 --- /dev/null +++ b/api/app/Exceptions/FormBuilder/PurposeSubjectResolutionException.php @@ -0,0 +1,29 @@ + */ private array $guardProviderCache = []; + /** @var array>|null */ + private ?array $resolverClassCache = null; + + /** @var array */ + private array $resolverInstanceCache = []; + public function __construct(private readonly ConfigRepository $config) {} /** @return array 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> $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(); diff --git a/api/app/FormBuilder/Purposes/PurposeSubjectResolver.php b/api/app/FormBuilder/Purposes/PurposeSubjectResolver.php new file mode 100644 index 00000000..b1d173db --- /dev/null +++ b/api/app/FormBuilder/Purposes/PurposeSubjectResolver.php @@ -0,0 +1,27 @@ +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', + ); + } +} diff --git a/api/app/FormBuilder/Purposes/Resolvers/EventRegistrationSubjectResolver.php b/api/app/FormBuilder/Purposes/Resolvers/EventRegistrationSubjectResolver.php new file mode 100644 index 00000000..f4835325 --- /dev/null +++ b/api/app/FormBuilder/Purposes/Resolvers/EventRegistrationSubjectResolver.php @@ -0,0 +1,24 @@ +provisioner->provisionFromSubmission($submission); + } +} diff --git a/api/app/FormBuilder/Purposes/Resolvers/IncidentReportSubjectResolver.php b/api/app/FormBuilder/Purposes/Resolvers/IncidentReportSubjectResolver.php new file mode 100644 index 00000000..22f3f242 --- /dev/null +++ b/api/app/FormBuilder/Purposes/Resolvers/IncidentReportSubjectResolver.php @@ -0,0 +1,46 @@ +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(); + } +} diff --git a/api/app/FormBuilder/Purposes/Resolvers/PostEventEvaluationSubjectResolver.php b/api/app/FormBuilder/Purposes/Resolvers/PostEventEvaluationSubjectResolver.php new file mode 100644 index 00000000..c49c31eb --- /dev/null +++ b/api/app/FormBuilder/Purposes/Resolvers/PostEventEvaluationSubjectResolver.php @@ -0,0 +1,63 @@ +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', + ); + } +} diff --git a/api/app/FormBuilder/Purposes/Resolvers/SignatureContractSubjectResolver.php b/api/app/FormBuilder/Purposes/Resolvers/SignatureContractSubjectResolver.php new file mode 100644 index 00000000..b0d145f5 --- /dev/null +++ b/api/app/FormBuilder/Purposes/Resolvers/SignatureContractSubjectResolver.php @@ -0,0 +1,48 @@ +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; + } +} diff --git a/api/app/FormBuilder/Purposes/Resolvers/SupplierIntakeSubjectResolver.php b/api/app/FormBuilder/Purposes/Resolvers/SupplierIntakeSubjectResolver.php new file mode 100644 index 00000000..24f574fe --- /dev/null +++ b/api/app/FormBuilder/Purposes/Resolvers/SupplierIntakeSubjectResolver.php @@ -0,0 +1,42 @@ +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', + ); + } +} diff --git a/api/app/FormBuilder/Purposes/Resolvers/UserProfileSubjectResolver.php b/api/app/FormBuilder/Purposes/Resolvers/UserProfileSubjectResolver.php new file mode 100644 index 00000000..41b89934 --- /dev/null +++ b/api/app/FormBuilder/Purposes/Resolvers/UserProfileSubjectResolver.php @@ -0,0 +1,48 @@ +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; + } +} diff --git a/api/config/form_builder/purposes.php b/api/config/form_builder/purposes.php index 8f9e76ee..195f2875 100644 --- a/api/config/form_builder/purposes.php +++ b/api/config/form_builder/purposes.php @@ -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, ], ]; diff --git a/api/tests/Feature/FormBuilder/Purposes/AllPurposesSubjectResolverWiringTest.php b/api/tests/Feature/FormBuilder/Purposes/AllPurposesSubjectResolverWiringTest.php new file mode 100644 index 00000000..c92de2bb --- /dev/null +++ b/api/tests/Feature/FormBuilder/Purposes/AllPurposesSubjectResolverWiringTest.php @@ -0,0 +1,37 @@ + $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); + } + } +} diff --git a/api/tests/Unit/FormBuilder/Purposes/Resolvers/PurposeSubjectResolversTest.php b/api/tests/Unit/FormBuilder/Purposes/Resolvers/PurposeSubjectResolversTest.php new file mode 100644 index 00000000..073c5393 --- /dev/null +++ b/api/tests/Unit/FormBuilder/Purposes/Resolvers/PurposeSubjectResolversTest.php @@ -0,0 +1,153 @@ +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); + } +}