From a3f35e533f4199ca790dd96a984878c2c258d69b Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Fri, 17 Apr 2026 22:55:35 +0200 Subject: [PATCH] feat(form-builder): identity-match listener + identity_match_status column MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit S2c D9. Implements ARCH §31.1 — identity matching triggered on FormSubmissionSubmitted for event_registration schemas. - Migration 2026_04_22_100000: add form_submissions.identity_match_status (nullable string(20), pending|matched|none) + index (form_schema_id, identity_match_status). - Migration 2026_04_22_100001: replace the composite index on (form_schema_id, idempotency_key) with a UNIQUE constraint so the DB itself is the race-safe backstop behind the application-level idempotency replay. - Listener TriggerPersonIdentityMatchOnFormSubmit: runs only when form_schema.purpose === event_registration. For person-subject submissions it calls PersonIdentityService::detectMatches and writes matched/pending/none; for public (subject=null) it records 'pending' so the portal can message the submitter that matching will complete when the organiser attaches a person. Failures log at error level and never rethrow — sibling listeners on the same event (§31.10 TAG_PICKER sync) still run. - AppServiceProvider wires the listener alongside SyncTagPickerSelectionsOnSubmit. - FormSubmission.$fillable gains identity_match_status. Rationale for a dedicated column (over JSON on submission.metadata): the matrix is a hard-typed 3-state enum that the public API surfaces directly, and we want to index it to show organiser dashboards "how many submissions are pending identity-confirmation". Co-Authored-By: Claude Opus 4.7 (1M context) --- ...TriggerPersonIdentityMatchOnFormSubmit.php | 100 ++++++++++++++++++ api/app/Models/FormBuilder/FormSubmission.php | 1 + api/app/Providers/AppServiceProvider.php | 7 ++ ...ntity_match_status_to_form_submissions.php | 34 ++++++ ...potency_key_unique_to_form_submissions.php | 43 ++++++++ 5 files changed, 185 insertions(+) create mode 100644 api/app/Listeners/FormBuilder/TriggerPersonIdentityMatchOnFormSubmit.php create mode 100644 api/database/migrations/2026_04_22_100000_add_identity_match_status_to_form_submissions.php create mode 100644 api/database/migrations/2026_04_22_100001_add_idempotency_key_unique_to_form_submissions.php diff --git a/api/app/Listeners/FormBuilder/TriggerPersonIdentityMatchOnFormSubmit.php b/api/app/Listeners/FormBuilder/TriggerPersonIdentityMatchOnFormSubmit.php new file mode 100644 index 00000000..193ef4b9 --- /dev/null +++ b/api/app/Listeners/FormBuilder/TriggerPersonIdentityMatchOnFormSubmit.php @@ -0,0 +1,100 @@ +submission->fresh(['schema']); + if ($submission === null) { + return; + } + + $schema = $submission->schema; + if ($schema === null) { + return; + } + + $purpose = $schema->purpose instanceof \BackedEnum + ? $schema->purpose->value + : (string) $schema->purpose; + + if ($purpose !== FormPurpose::EVENT_REGISTRATION->value) { + return; + } + + $status = $this->resolveStatus($submission); + + // Use a raw UPDATE so we don't re-fire Eloquent events / an + // observer cascade on the submission itself. + FormSubmission::query() + ->whereKey($submission->id) + ->update(['identity_match_status' => $status]); + } catch (\Throwable $e) { + Log::error('form-builder.identity-match.listener_failed', [ + 'submission_id' => $event->submission->id, + 'message' => $e->getMessage(), + ]); + } + } + + private function resolveStatus(FormSubmission $submission): string + { + // Public submission without a person subject — mark pending so the + // portal shows the "we koppelen je gegevens zodra..." message. + // Real matching happens when the organiser attaches a person. + if ($submission->subject_type !== 'person' || $submission->subject_id === null) { + return 'pending'; + } + + $person = Person::withoutGlobalScopes()->find($submission->subject_id); + if ($person === null) { + return 'none'; + } + + if ($person->user_id !== null) { + return 'matched'; + } + + $matches = $this->identityService->detectMatches($person); + + return $matches->isNotEmpty() ? 'pending' : 'none'; + } +} diff --git a/api/app/Models/FormBuilder/FormSubmission.php b/api/app/Models/FormBuilder/FormSubmission.php index b4c72099..12a88dca 100644 --- a/api/app/Models/FormBuilder/FormSubmission.php +++ b/api/app/Models/FormBuilder/FormSubmission.php @@ -50,6 +50,7 @@ final class FormSubmission extends Model 'opened_at', 'first_interacted_at', 'idempotency_key', + 'identity_match_status', ]; /** @var array */ diff --git a/api/app/Providers/AppServiceProvider.php b/api/app/Providers/AppServiceProvider.php index bfa11ff2..8e54cdf8 100644 --- a/api/app/Providers/AppServiceProvider.php +++ b/api/app/Providers/AppServiceProvider.php @@ -46,6 +46,7 @@ use App\Models\FormBuilder\FormWebhookDelivery; use App\Models\VolunteerAvailability; use App\Events\FormBuilder\FormSubmissionSubmitted; use App\Listeners\FormBuilder\SyncTagPickerSelectionsOnSubmit; +use App\Listeners\FormBuilder\TriggerPersonIdentityMatchOnFormSubmit; use App\Observers\FormBuilder\FormValueObserver; use App\Observers\PersonObserver; use App\Observers\UserObserver; @@ -130,6 +131,12 @@ class AppServiceProvider extends ServiceProvider SyncTagPickerSelectionsOnSubmit::class, ); + // ARCH §31.1 — identity-match trigger on event_registration. + \Illuminate\Support\Facades\Event::listen( + FormSubmissionSubmitted::class, + TriggerPersonIdentityMatchOnFormSubmit::class, + ); + ResetPassword::createUrlUsing(function ($user, string $token) { return config('crewli.portal_url') . '/wachtwoord-resetten?token=' . $token . '&email=' . urlencode($user->email); }); diff --git a/api/database/migrations/2026_04_22_100000_add_identity_match_status_to_form_submissions.php b/api/database/migrations/2026_04_22_100000_add_identity_match_status_to_form_submissions.php new file mode 100644 index 00000000..db14a639 --- /dev/null +++ b/api/database/migrations/2026_04_22_100000_add_identity_match_status_to_form_submissions.php @@ -0,0 +1,34 @@ +string('identity_match_status', 20)->nullable()->after('anonymised_at'); + $table->index( + ['form_schema_id', 'identity_match_status'], + 'fs_schema_identity_match_idx', + ); + }); + } + + public function down(): void + { + Schema::table('form_submissions', function (Blueprint $table): void { + $table->dropIndex('fs_schema_identity_match_idx'); + $table->dropColumn('identity_match_status'); + }); + } +}; diff --git a/api/database/migrations/2026_04_22_100001_add_idempotency_key_unique_to_form_submissions.php b/api/database/migrations/2026_04_22_100001_add_idempotency_key_unique_to_form_submissions.php new file mode 100644 index 00000000..4d783826 --- /dev/null +++ b/api/database/migrations/2026_04_22_100001_add_idempotency_key_unique_to_form_submissions.php @@ -0,0 +1,43 @@ +dropIndex('fs_idempotency_idx'); + }); + + Schema::table('form_submissions', function (Blueprint $table): void { + $table->unique( + ['form_schema_id', 'idempotency_key'], + 'fs_idempotency_unique', + ); + }); + } + + public function down(): void + { + Schema::table('form_submissions', function (Blueprint $table): void { + $table->dropUnique('fs_idempotency_unique'); + }); + + Schema::table('form_submissions', function (Blueprint $table): void { + $table->index( + ['form_schema_id', 'idempotency_key'], + 'fs_idempotency_idx', + ); + }); + } +};