diff --git a/api/app/Listeners/FormBuilder/SyncTagPickerSelectionsOnSubmit.php b/api/app/Listeners/FormBuilder/SyncTagPickerSelectionsOnSubmit.php new file mode 100644 index 00000000..2a00c603 --- /dev/null +++ b/api/app/Listeners/FormBuilder/SyncTagPickerSelectionsOnSubmit.php @@ -0,0 +1,102 @@ +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; + } + + if ($submission->subject_type !== 'person' || $submission->subject_id === null) { + return; + } + + $hasTagPickerValue = FormValue::query() + ->where('form_submission_id', $submission->id) + ->whereIn('form_field_id', FormField::query() + ->where('form_schema_id', $schema->id) + ->where('field_type', FormFieldType::TAG_PICKER->value) + ->select('id')) + ->exists(); + + if (! $hasTagPickerValue) { + return; + } + + $person = Person::withoutGlobalScopes()->find($submission->subject_id); + if ($person === null) { + return; + } + + if ($person->user_id === null) { + Log::info('form-builder.tag-sync.deferred', [ + 'person_id' => $person->id, + 'submission_id' => $submission->id, + ]); + + return; + } + + $this->tagSyncService->rebuildForPerson($person); + } catch (\Throwable $e) { + Log::error('form-builder.tag-sync.listener_failed', [ + 'submission_id' => $event->submission->id, + 'message' => $e->getMessage(), + ]); + // Do not rethrow: §31.10 requires sibling listeners still run. + } + } +} diff --git a/api/app/Providers/AppServiceProvider.php b/api/app/Providers/AppServiceProvider.php index 4a547843..bfa11ff2 100644 --- a/api/app/Providers/AppServiceProvider.php +++ b/api/app/Providers/AppServiceProvider.php @@ -44,6 +44,8 @@ use App\Models\FormBuilder\FormValue; use App\Models\FormBuilder\FormValueOption; use App\Models\FormBuilder\FormWebhookDelivery; use App\Models\VolunteerAvailability; +use App\Events\FormBuilder\FormSubmissionSubmitted; +use App\Listeners\FormBuilder\SyncTagPickerSelectionsOnSubmit; use App\Observers\FormBuilder\FormValueObserver; use App\Observers\PersonObserver; use App\Observers\UserObserver; @@ -122,6 +124,12 @@ class AppServiceProvider extends ServiceProvider User::observe(UserObserver::class); FormValue::observe(FormValueObserver::class); + // ARCH §31.10 — FORM-02 TAG_PICKER sync listener. + \Illuminate\Support\Facades\Event::listen( + FormSubmissionSubmitted::class, + SyncTagPickerSelectionsOnSubmit::class, + ); + ResetPassword::createUrlUsing(function ($user, string $token) { return config('crewli.portal_url') . '/wachtwoord-resetten?token=' . $token . '&email=' . urlencode($user->email); }); diff --git a/api/app/Services/PersonIdentityService.php b/api/app/Services/PersonIdentityService.php index 843e530e..2c696fa0 100644 --- a/api/app/Services/PersonIdentityService.php +++ b/api/app/Services/PersonIdentityService.php @@ -11,6 +11,7 @@ use App\Models\Event; use App\Models\Person; use App\Models\PersonIdentityMatch; use App\Models\User; +use App\Services\FormBuilder\FormTagSyncService; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; @@ -18,6 +19,11 @@ use Illuminate\Validation\ValidationException; final class PersonIdentityService { + public function __construct( + private readonly ?FormTagSyncService $tagSyncService = null, + ) {} + + /** * Calculate whether two name strings are a fuzzy match using Levenshtein distance * with a length-adaptive threshold. @@ -344,6 +350,12 @@ final class PersonIdentityService $person->user_id = $match->matched_user_id; $person->save(); + // ARCH §31.10 — deferred TAG_PICKER sync: previously submitted + // event_registration forms now have a user account to attach + // self_reported tags to. No-op if null path surfaces. + ($this->tagSyncService ?? app(FormTagSyncService::class)) + ->rebuildForPerson($person->fresh() ?? $person); + // Dismiss other pending matches for this person PersonIdentityMatch::where('person_id', $person->id) ->where('id', '!=', $match->id) diff --git a/dev-docs/ARCH-FORM-BUILDER.md b/dev-docs/ARCH-FORM-BUILDER.md index 8afa1794..e3a01938 100644 --- a/dev-docs/ARCH-FORM-BUILDER.md +++ b/dev-docs/ARCH-FORM-BUILDER.md @@ -4,11 +4,11 @@ > Any discrepancy with SCHEMA.md is resolved in favour of this document > during the refactor. SCHEMA.md is updated at the end of the refactor. > -> **Status:** Approved — about to enter S1 implementation -> **Version:** 1.2 (expanded with per-purpose lifecycles, integration +> **Status:** Approved — S2b in progress (service + API layer) +> **Version:** 1.2.1 (§31.10 FORM-02 contract rewritten authoritatively) +> **Previous version:** 1.2 April 2026 — per-purpose lifecycles, integration > contracts, user guidance principles, documentation coverage requirements, -> in-app copy catalogue, and concrete gap fills from v1.1 review) -> **Previous version:** 1.1 committed April 2026 +> in-app copy catalogue, and concrete gap fills from v1.1 review > **Created:** April 2026 > **Owner:** Architecture doc; every session reads this before starting > @@ -2958,26 +2958,45 @@ ARCH section update) before merge. ### 31.10 Tag sync integration (BACKLOG FORM-02) Replaces the S1-era `TagSyncService` that read the legacy -`person_field_values` table. Purged in S2a; rebuilt in S2b/S2c against -the new FormBuilder. +`person_field_values` table. Purged in S2a; rebuilt in S2b against the +new FormBuilder. -**Trigger:** `FormSubmissionSubmitted` event (ARCH §17.1) OR explicit -call from `PersonController::approve()` after status transitions to -`approved`. +**Contract (authoritative):** -**Listener:** `SyncTagPickerValuesToUserTagsListener` — for the given -submission, finds all `form_values` whose field has -`field_type=TAG_PICKER`, and upserts rows into `user_organisation_tags` -with `source=self_reported`, respecting `person.user_id` (skip if null). -Only syncs to the subject person's user account. +``` +Trigger: FormSubmissionSubmitted event where + form_schema.purpose = 'event_registration' AND + submission.subject_type = 'person' AND + submission contains at least one TAG_PICKER form_value. -**Failure mode:** log at warning level; never throws into the submission -lifecycle. Reason: a tag-sync failure must not block registration. +Listener: SyncTagPickerSelectionsOnSubmit + +Behaviour: + 1. Resolve the Person from submission.subject_id. + 2. If person.user_id is null → no-op (log at info, tag sync will + trigger on future identity link). + 3. Call FormTagSyncService::rebuildForPerson($person). + 4. Never mutates organiser_assigned tags. Only rebuilds + source = self_reported to match the union of TAG_PICKER values + across that person's submitted event_registration submissions. + +Re-trigger on identity link: PersonIdentityService::confirmMatch must, +after setting person.user_id, call FormTagSyncService::rebuildForPerson +($person). This is the deferred sync path for persons who filled in +TAG_PICKER fields before their user account was linked. + +Failure mode: listener logs at error level and does NOT fail the event +propagation (other listeners — e.g. §31.3 shift provisioning, §31.1 +identity matching, §31.8 crowd list auto-add — must still run). + +Idempotent: safe to run multiple times for the same person. +``` **Call site removed in S2a:** `PersonController::approve()` and `PersonIdentityService::syncRegistrationTags()` used to call `TagSyncService::syncFromRegistration($person)` directly. The rebuilt -flow is listener-driven — no direct service injection required. +flow is listener-driven plus the targeted call inside +`PersonIdentityService::confirmMatch` — no ad-hoc cross-module coupling. ---