diff --git a/api/app/FormBuilder/Bindings/FormBindingApplicator.php b/api/app/FormBuilder/Bindings/FormBindingApplicator.php index a8c42045..d25391e7 100644 --- a/api/app/FormBuilder/Bindings/FormBindingApplicator.php +++ b/api/app/FormBuilder/Bindings/FormBindingApplicator.php @@ -27,13 +27,17 @@ use Throwable; * - Q10: optional sectionId for future section-level apply. * - Q12: hierarchical activity log via BindingActivityLogger. */ -final readonly class FormBindingApplicator +// Not final + not readonly: listener tests need to override `apply()` for +// throw-path coverage (Mockery can't mock final classes; PHP doesn't allow +// extending readonly with non-readonly child). Properties stay readonly +// individually to preserve immutability. +class FormBindingApplicator { public function __construct( - private PurposeRegistry $purposeRegistry, - private BindingConflictResolver $conflictResolver, - private BindingTypeRegistry $typeRegistry, - private BindingActivityLogger $activityLogger, + private readonly PurposeRegistry $purposeRegistry, + private readonly BindingConflictResolver $conflictResolver, + private readonly BindingTypeRegistry $typeRegistry, + private readonly BindingActivityLogger $activityLogger, ) {} /** diff --git a/api/app/Listeners/FormBuilder/ApplyBindingsOnFormSectionSubmitted.php b/api/app/Listeners/FormBuilder/ApplyBindingsOnFormSectionSubmitted.php new file mode 100644 index 00000000..f40e2f67 --- /dev/null +++ b/api/app/Listeners/FormBuilder/ApplyBindingsOnFormSectionSubmitted.php @@ -0,0 +1,44 @@ +applicator->apply( + $event->submission, + sectionId: (string) $event->sectionStatus->form_schema_section_id, + ); + }); + } +} diff --git a/api/app/Listeners/FormBuilder/ApplyBindingsOnFormSubmit.php b/api/app/Listeners/FormBuilder/ApplyBindingsOnFormSubmit.php new file mode 100644 index 00000000..aba9952f --- /dev/null +++ b/api/app/Listeners/FormBuilder/ApplyBindingsOnFormSubmit.php @@ -0,0 +1,92 @@ +submission->fresh(['schema']); + if (! $submission instanceof FormSubmission) { + return; + } + + try { + DB::transaction(function () use ($submission): void { + $result = $this->applicator->apply($submission); + + FormSubmission::query() + ->whereKey($submission->id) + ->update([ + 'apply_status' => $result->applyStatus()->value, + 'apply_completed_at' => now(), + ]); + + if ($result->provisionedSubjectType !== null && $submission->subject_type === null) { + // ApplyBindings just provisioned a Person; reflect it + // on the submission so TriggerPersonIdentityMatch (next + // sync listener) can find it. + FormSubmission::query() + ->whereKey($submission->id) + ->update([ + 'subject_type' => $result->provisionedSubjectType, + 'subject_id' => $result->provisionedSubjectId, + ]); + } + }); + } catch (Throwable $e) { + // OUTSIDE the failed transaction — survives rollback. + DB::transaction(function () use ($submission, $e): void { + $schema = $submission->schema; + $purposeValue = $schema instanceof FormSchema ? $schema->purpose->value : null; + FormSubmissionActionFailure::query()->create([ + 'form_submission_id' => $submission->id, + 'listener_class' => self::class, + 'failed_at' => now(), + 'exception_class' => $e::class, + 'exception_message' => $e->getMessage(), + 'context' => [ + 'purpose' => $purposeValue, + ], + ]); + FormSubmission::query() + ->whereKey($submission->id) + ->update([ + 'apply_status' => ApplyStatus::FAILED->value, + 'apply_completed_at' => now(), + ]); + }); + Log::error('form-builder.apply.transaction_rolled_back', [ + 'submission_id' => (string) $submission->id, + 'exception' => $e::class, + 'message' => $e->getMessage(), + ]); + } + } +} diff --git a/api/app/Providers/AppServiceProvider.php b/api/app/Providers/AppServiceProvider.php index 4f34331f..c6be0adc 100644 --- a/api/app/Providers/AppServiceProvider.php +++ b/api/app/Providers/AppServiceProvider.php @@ -50,7 +50,10 @@ use App\Models\UserInvitation; use App\Models\UserOrganisationTag; use App\Models\UserProfile; use App\Models\VolunteerAvailability; +use App\Events\FormBuilder\FormSubmissionSectionSubmitted; use App\Events\FormBuilder\FormSubmissionSubmitted; +use App\Listeners\FormBuilder\ApplyBindingsOnFormSectionSubmitted; +use App\Listeners\FormBuilder\ApplyBindingsOnFormSubmit; use App\Listeners\FormBuilder\SyncTagPickerSelectionsOnSubmit; use App\Listeners\FormBuilder\TriggerPersonIdentityMatchOnFormSubmit; use App\Observers\FormBuilder\FormFieldChildTablesCascadeObserver; @@ -129,18 +132,39 @@ class AppServiceProvider extends ServiceProvider FormField::observe(FormFieldChildTablesCascadeObserver::class); FormFieldLibrary::observe(FormFieldChildTablesCascadeObserver::class); - // ARCH §31.10 — FORM-02 TAG_PICKER sync listener. + // RFC-WS-6 §3 (Q1) — sync chain on FormSubmissionSubmitted, in + // this exact order: + // 1. ApplyBindingsOnFormSubmit (sync) + // 2. TriggerPersonIdentityMatchOnFormSubmit (sync) + // Queued listeners on the same event (SyncTagPickerSelectionsOnSubmit, + // future webhook dispatcher, mailables) run in parallel after the + // sync chain via the queue. Their relative registration position + // is irrelevant. + + // RFC Q1 — applies bindings sync before identity match runs. + \Illuminate\Support\Facades\Event::listen( + FormSubmissionSubmitted::class, + ApplyBindingsOnFormSubmit::class, + ); + + // ARCH §31.10 — FORM-02 TAG_PICKER sync listener (queued). \Illuminate\Support\Facades\Event::listen( FormSubmissionSubmitted::class, SyncTagPickerSelectionsOnSubmit::class, ); - // ARCH §31.1 — identity-match trigger on event_registration. + // ARCH §31.1 — identity-match trigger on event_registration (sync). \Illuminate\Support\Facades\Event::listen( FormSubmissionSubmitted::class, TriggerPersonIdentityMatchOnFormSubmit::class, ); + // RFC Q10 — section-level apply stub. Runtime gated by feature flag. + \Illuminate\Support\Facades\Event::listen( + FormSubmissionSectionSubmitted::class, + ApplyBindingsOnFormSectionSubmitted::class, + ); + ResetPassword::createUrlUsing(function ($user, string $token) { return config('crewli.portal_url') . '/wachtwoord-resetten?token=' . $token . '&email=' . urlencode($user->email); }); diff --git a/api/config/form_builder.php b/api/config/form_builder.php index d74e18c6..a1b99e0f 100644 --- a/api/config/form_builder.php +++ b/api/config/form_builder.php @@ -71,4 +71,17 @@ return [ 'retention_job' => false, // scheduler task later ], + /** + * RFC-WS-6 §3 (Q10) — section-level binding apply runtime gate. + * + * REMOVAL TRIGGER: enable when ARTIST_ADVANCE feature work begins + * (post-S5). At enablement: set FORM_BUILDER_SECTION_APPLY=true, + * write section-scoped tests, activate the dispatch path in + * FormSubmissionService, remove this flag and the early-return + * guard from ApplyBindingsOnFormSectionSubmitted::handle(). + * + * Tracking: BACKLOG.md → ARTIST-ADV-SECTION-APPLY + */ + 'section_apply_enabled' => env('FORM_BUILDER_SECTION_APPLY', false), + ]; diff --git a/api/tests/Feature/FormBuilder/Listeners/ApplyBindingsOnFormSectionSubmittedTest.php b/api/tests/Feature/FormBuilder/Listeners/ApplyBindingsOnFormSectionSubmittedTest.php new file mode 100644 index 00000000..8e3d198c --- /dev/null +++ b/api/tests/Feature/FormBuilder/Listeners/ApplyBindingsOnFormSectionSubmittedTest.php @@ -0,0 +1,89 @@ +trackingApplicator(); + $listener = new ApplyBindingsOnFormSectionSubmitted($tracker); + $listener->handle($this->makeEvent()); + + $this->assertSame(0, $tracker->callCount); + } + + public function test_feature_flag_on_forwards_to_applicator(): void + { + Config::set('form_builder.section_apply_enabled', true); + + $event = $this->makeEvent(); + $tracker = $this->trackingApplicator(); + + $listener = new ApplyBindingsOnFormSectionSubmitted($tracker); + $listener->handle($event); + + $this->assertSame(1, $tracker->callCount); + $this->assertSame((string) $event->submission->id, $tracker->lastSubmissionId); + $this->assertSame((string) $event->sectionStatus->form_schema_section_id, $tracker->lastSectionId); + } + + private function trackingApplicator(): TrackingApplicator + { + return new TrackingApplicator( + $this->app->make(\App\FormBuilder\Purposes\PurposeRegistry::class), + $this->app->make(\App\FormBuilder\Bindings\BindingConflictResolver::class), + $this->app->make(\App\FormBuilder\Bindings\BindingTypeRegistry::class), + $this->app->make(\App\FormBuilder\Bindings\BindingActivityLogger::class), + ); + } + + private function makeEvent(): FormSubmissionSectionSubmitted + { + $submission = FormSubmission::factory()->create(); + $sectionStatus = FormSubmissionSectionStatus::factory()->create([ + 'form_submission_id' => $submission->id, + ]); + + return new FormSubmissionSectionSubmitted($submission, $sectionStatus); + } +} + +final class TrackingApplicator extends FormBindingApplicator +{ + public int $callCount = 0; + + public ?string $lastSubmissionId = null; + + public ?string $lastSectionId = null; + + public function apply(FormSubmission $submission, ?string $sectionId = null): BindingPassResult + { + $this->callCount++; + $this->lastSubmissionId = (string) $submission->id; + $this->lastSectionId = $sectionId; + + return new BindingPassResult( + formSubmissionId: (string) $submission->id, + provisionedSubjectType: null, + provisionedSubjectId: null, + applications: [], + ); + } +} diff --git a/api/tests/Feature/FormBuilder/Listeners/ApplyBindingsOnFormSubmitTest.php b/api/tests/Feature/FormBuilder/Listeners/ApplyBindingsOnFormSubmitTest.php new file mode 100644 index 00000000..cb3f0bf5 --- /dev/null +++ b/api/tests/Feature/FormBuilder/Listeners/ApplyBindingsOnFormSubmitTest.php @@ -0,0 +1,200 @@ +makeSubmission(); + + $listener = $this->app->make(ApplyBindingsOnFormSubmit::class); + $listener->handle(new FormSubmissionSubmitted($submission)); + + $reloaded = FormSubmission::query()->withoutGlobalScopes()->find($submission->id); + $this->assertSame(ApplyStatus::COMPLETED, $reloaded->apply_status); + $this->assertNotNull($reloaded->apply_completed_at); + } + + public function test_exception_path_records_failure_and_marks_failed(): void + { + $submission = $this->makeSubmission(); + + $applicator = $this->throwingApplicator(); + + Log::shouldReceive('error')->once(); + + $listener = new ApplyBindingsOnFormSubmit($applicator); + $listener->handle(new FormSubmissionSubmitted($submission)); + + $reloaded = FormSubmission::query()->withoutGlobalScopes()->find($submission->id); + $this->assertSame(ApplyStatus::FAILED, $reloaded->apply_status); + + $failure = FormSubmissionActionFailure::query() + ->where('form_submission_id', $submission->id) + ->first(); + $this->assertNotNull($failure); + $this->assertSame(\RuntimeException::class, $failure->exception_class); + $this->assertSame('boom', $failure->exception_message); + } + + public function test_listener_does_not_rethrow(): void + { + $submission = $this->makeSubmission(); + + $applicator = $this->throwingApplicator(); + + Log::shouldReceive('error')->once(); + + $listener = new ApplyBindingsOnFormSubmit($applicator); + $threw = false; + try { + $listener->handle(new FormSubmissionSubmitted($submission)); + } catch (\Throwable) { + $threw = true; + } + + $this->assertFalse($threw, 'Listener must swallow throws so siblings keep running'); + } + + private function throwingApplicator(): ThrowingApplicator + { + return new ThrowingApplicator( + $this->app->make(\App\FormBuilder\Purposes\PurposeRegistry::class), + $this->app->make(\App\FormBuilder\Bindings\BindingConflictResolver::class), + $this->app->make(\App\FormBuilder\Bindings\BindingTypeRegistry::class), + $this->app->make(\App\FormBuilder\Bindings\BindingActivityLogger::class), + ); + } + + private function makeSubmission(): FormSubmission + { + $event = Event::factory()->create(); + CrowdType::factory()->create([ + 'organisation_id' => $event->organisation_id, + 'is_active' => true, + ]); + + $schema = FormSchema::factory()->create([ + 'organisation_id' => $event->organisation_id, + 'purpose' => FormPurpose::EVENT_REGISTRATION->value, + ]); + + $emailField = FormField::factory()->create([ + 'form_schema_id' => $schema->id, + 'field_type' => FormFieldType::EMAIL->value, + 'slug' => 'email', + ]); + $emailBinding = FormFieldBinding::factory()->forField($emailField) + ->entityOwned('person', 'email') + ->create([ + 'is_identity_key' => true, + 'merge_strategy' => FormFieldBindingMergeStrategy::Overwrite->value, + 'trust_level' => 80, + ]); + + $firstNameField = FormField::factory()->create([ + 'form_schema_id' => $schema->id, + 'slug' => 'first_name', + ]); + $firstNameBinding = FormFieldBinding::factory()->forField($firstNameField) + ->entityOwned('person', 'first_name') + ->create(['trust_level' => 70]); + + $lastNameField = FormField::factory()->create([ + 'form_schema_id' => $schema->id, + 'slug' => 'last_name', + ]); + $lastNameBinding = FormFieldBinding::factory()->forField($lastNameField) + ->entityOwned('person', 'last_name') + ->create(['trust_level' => 60]); + + $submission = FormSubmission::factory()->forEvent($event)->create([ + 'form_schema_id' => $schema->id, + 'subject_type' => 'person', + ]); + $submission->schema_snapshot = [ + 'fields' => [ + $this->snapshotField($emailField, $emailBinding, 'person', 'email', identityKey: true, trustLevel: 80), + $this->snapshotField($firstNameField, $firstNameBinding, 'person', 'first_name', trustLevel: 70), + $this->snapshotField($lastNameField, $lastNameBinding, 'person', 'last_name', trustLevel: 60), + ], + ]; + $submission->save(); + + $this->writeValue($submission->id, $emailField->id, 'jan@example.nl'); + $this->writeValue($submission->id, $firstNameField->id, 'Jan'); + $this->writeValue($submission->id, $lastNameField->id, 'Jansen'); + + return $submission->fresh(); + } + + private function writeValue(string $submissionId, string $fieldId, mixed $value): void + { + $row = new FormValue(); + $row->form_submission_id = $submissionId; + $row->form_field_id = $fieldId; + $row->setAttribute('value', $value); + $row->value_anonymised = false; + $row->save(); + } + + /** + * @return array + */ + private function snapshotField( + FormField $field, + FormFieldBinding $binding, + string $entity, + string $column, + bool $identityKey = false, + int $trustLevel = 50, + ): array { + return [ + 'id' => (string) $field->id, + 'slug' => (string) $field->slug, + 'sort_order' => (int) $field->sort_order, + 'bindings' => [[ + 'id' => (string) $binding->id, + 'mode' => 'entity_owned', + 'entity' => $entity, + 'column' => $column, + 'merge_strategy' => 'overwrite', + 'trust_level' => $trustLevel, + 'is_identity_key' => $identityKey, + ]], + ]; + } +} + +final class ThrowingApplicator extends FormBindingApplicator +{ + public function apply(FormSubmission $submission, ?string $sectionId = null): BindingPassResult + { + throw new \RuntimeException('boom'); + } +} diff --git a/api/tests/Feature/FormBuilder/Listeners/FormSubmissionSubmittedListenerOrderTest.php b/api/tests/Feature/FormBuilder/Listeners/FormSubmissionSubmittedListenerOrderTest.php new file mode 100644 index 00000000..68799002 --- /dev/null +++ b/api/tests/Feature/FormBuilder/Listeners/FormSubmissionSubmittedListenerOrderTest.php @@ -0,0 +1,68 @@ + is_string($listener) ? $listener : get_debug_type($listener), + $listeners, + ); + + $applyIndex = array_search(ApplyBindingsOnFormSubmit::class, $listenerClasses, true); + $identityIndex = array_search(TriggerPersonIdentityMatchOnFormSubmit::class, $listenerClasses, true); + + $this->assertNotFalse($applyIndex, 'ApplyBindingsOnFormSubmit must be registered'); + $this->assertNotFalse($identityIndex, 'TriggerPersonIdentityMatchOnFormSubmit must be registered'); + $this->assertLessThan( + $identityIndex, + $applyIndex, + 'ApplyBindings must run before IdentityMatch in registration order', + ); + } + + public function test_apply_bindings_listener_is_synchronous(): void + { + $reflection = new \ReflectionClass(ApplyBindingsOnFormSubmit::class); + $this->assertFalse( + $reflection->implementsInterface(ShouldQueue::class), + 'ApplyBindingsOnFormSubmit must be sync (no ShouldQueue)', + ); + } + + public function test_identity_match_listener_is_synchronous(): void + { + $reflection = new \ReflectionClass(TriggerPersonIdentityMatchOnFormSubmit::class); + $this->assertFalse( + $reflection->implementsInterface(ShouldQueue::class), + 'TriggerPersonIdentityMatchOnFormSubmit must be sync (no ShouldQueue)', + ); + } + + public function test_tag_picker_sync_listener_is_queued(): void + { + $reflection = new \ReflectionClass(SyncTagPickerSelectionsOnSubmit::class); + $this->assertTrue( + $reflection->implementsInterface(ShouldQueue::class), + 'SyncTagPickerSelectionsOnSubmit must be queued (ShouldQueue)', + ); + } +}