feat(form-builder): FORM-02 TAG_PICKER sync listener (ARCH §31.10)

Rebuilds the tag-sync flow purged in S2a, now listener-driven against the
universal FormBuilder (ARCH §31.10).

- SyncTagPickerSelectionsOnSubmit listener: ShouldQueue on connection=redis
  queue=default. Filters to event_registration + person subjects with at
  least one TAG_PICKER form_value. Logs on failure, never rethrows so
  sibling listeners keep running.
- AppServiceProvider registers the listener via Event::listen alongside
  the existing S1 observers.
- PersonIdentityService::confirmMatch now calls
  FormTagSyncService::rebuildForPerson after setting person.user_id — the
  deferred-sync path for persons who filled in TAG_PICKER fields before
  their account was linked.
- ARCH-FORM-BUILDER.md §31.10 rewritten with the authoritative contract
  block from this session. Header bumped to v1.2.1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-17 21:00:17 +02:00
parent b3eab6e0c8
commit 4495ab017e
4 changed files with 158 additions and 17 deletions

View File

@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace App\Listeners\FormBuilder;
use App\Enums\FormBuilder\FormFieldType;
use App\Enums\FormBuilder\FormPurpose;
use App\Events\FormBuilder\FormSubmissionSubmitted;
use App\Models\FormBuilder\FormField;
use App\Models\FormBuilder\FormValue;
use App\Models\Person;
use App\Services\FormBuilder\FormTagSyncService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Support\Facades\Log;
/**
* ARCH §31.10 FORM-02 TAG_PICKER sync contract.
*
* Listens for FormSubmissionSubmitted on event_registration schemas with
* a person subject; when the submission contains at least one TAG_PICKER
* form_value, rebuilds self_reported user_organisation_tags for that
* person's linked user. No-ops when person.user_id is null (deferred
* sync runs on PersonIdentityService::confirmMatch).
*
* Failure mode: log at error level; never throw. Event propagation must
* reach sibling listeners (§31.1 identity, §31.3 shifts, §31.8 crowd lists).
*/
final class SyncTagPickerSelectionsOnSubmit implements ShouldQueue
{
use InteractsWithQueue;
public string $connection = 'redis';
public string $queue = 'default';
public function __construct(
private readonly FormTagSyncService $tagSyncService,
) {}
public function handle(FormSubmissionSubmitted $event): void
{
try {
$submission = $event->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.
}
}
}

View File

@@ -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);
});

View File

@@ -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)