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:
@@ -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.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,6 +44,8 @@ use App\Models\FormBuilder\FormValue;
|
|||||||
use App\Models\FormBuilder\FormValueOption;
|
use App\Models\FormBuilder\FormValueOption;
|
||||||
use App\Models\FormBuilder\FormWebhookDelivery;
|
use App\Models\FormBuilder\FormWebhookDelivery;
|
||||||
use App\Models\VolunteerAvailability;
|
use App\Models\VolunteerAvailability;
|
||||||
|
use App\Events\FormBuilder\FormSubmissionSubmitted;
|
||||||
|
use App\Listeners\FormBuilder\SyncTagPickerSelectionsOnSubmit;
|
||||||
use App\Observers\FormBuilder\FormValueObserver;
|
use App\Observers\FormBuilder\FormValueObserver;
|
||||||
use App\Observers\PersonObserver;
|
use App\Observers\PersonObserver;
|
||||||
use App\Observers\UserObserver;
|
use App\Observers\UserObserver;
|
||||||
@@ -122,6 +124,12 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
User::observe(UserObserver::class);
|
User::observe(UserObserver::class);
|
||||||
FormValue::observe(FormValueObserver::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) {
|
ResetPassword::createUrlUsing(function ($user, string $token) {
|
||||||
return config('crewli.portal_url') . '/wachtwoord-resetten?token=' . $token . '&email=' . urlencode($user->email);
|
return config('crewli.portal_url') . '/wachtwoord-resetten?token=' . $token . '&email=' . urlencode($user->email);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use App\Models\Event;
|
|||||||
use App\Models\Person;
|
use App\Models\Person;
|
||||||
use App\Models\PersonIdentityMatch;
|
use App\Models\PersonIdentityMatch;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Services\FormBuilder\FormTagSyncService;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
@@ -18,6 +19,11 @@ use Illuminate\Validation\ValidationException;
|
|||||||
|
|
||||||
final class PersonIdentityService
|
final class PersonIdentityService
|
||||||
{
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ?FormTagSyncService $tagSyncService = null,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculate whether two name strings are a fuzzy match using Levenshtein distance
|
* Calculate whether two name strings are a fuzzy match using Levenshtein distance
|
||||||
* with a length-adaptive threshold.
|
* with a length-adaptive threshold.
|
||||||
@@ -344,6 +350,12 @@ final class PersonIdentityService
|
|||||||
$person->user_id = $match->matched_user_id;
|
$person->user_id = $match->matched_user_id;
|
||||||
$person->save();
|
$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
|
// Dismiss other pending matches for this person
|
||||||
PersonIdentityMatch::where('person_id', $person->id)
|
PersonIdentityMatch::where('person_id', $person->id)
|
||||||
->where('id', '!=', $match->id)
|
->where('id', '!=', $match->id)
|
||||||
|
|||||||
@@ -4,11 +4,11 @@
|
|||||||
> Any discrepancy with SCHEMA.md is resolved in favour of this document
|
> 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.
|
> during the refactor. SCHEMA.md is updated at the end of the refactor.
|
||||||
>
|
>
|
||||||
> **Status:** Approved — about to enter S1 implementation
|
> **Status:** Approved — S2b in progress (service + API layer)
|
||||||
> **Version:** 1.2 (expanded with per-purpose lifecycles, integration
|
> **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,
|
> contracts, user guidance principles, documentation coverage requirements,
|
||||||
> in-app copy catalogue, and concrete gap fills from v1.1 review)
|
> in-app copy catalogue, and concrete gap fills from v1.1 review
|
||||||
> **Previous version:** 1.1 committed April 2026
|
|
||||||
> **Created:** April 2026
|
> **Created:** April 2026
|
||||||
> **Owner:** Architecture doc; every session reads this before starting
|
> **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)
|
### 31.10 Tag sync integration (BACKLOG FORM-02)
|
||||||
|
|
||||||
Replaces the S1-era `TagSyncService` that read the legacy
|
Replaces the S1-era `TagSyncService` that read the legacy
|
||||||
`person_field_values` table. Purged in S2a; rebuilt in S2b/S2c against
|
`person_field_values` table. Purged in S2a; rebuilt in S2b against the
|
||||||
the new FormBuilder.
|
new FormBuilder.
|
||||||
|
|
||||||
**Trigger:** `FormSubmissionSubmitted` event (ARCH §17.1) OR explicit
|
**Contract (authoritative):**
|
||||||
call from `PersonController::approve()` after status transitions to
|
|
||||||
`approved`.
|
|
||||||
|
|
||||||
**Listener:** `SyncTagPickerValuesToUserTagsListener` — for the given
|
```
|
||||||
submission, finds all `form_values` whose field has
|
Trigger: FormSubmissionSubmitted event where
|
||||||
`field_type=TAG_PICKER`, and upserts rows into `user_organisation_tags`
|
form_schema.purpose = 'event_registration' AND
|
||||||
with `source=self_reported`, respecting `person.user_id` (skip if null).
|
submission.subject_type = 'person' AND
|
||||||
Only syncs to the subject person's user account.
|
submission contains at least one TAG_PICKER form_value.
|
||||||
|
|
||||||
**Failure mode:** log at warning level; never throws into the submission
|
Listener: SyncTagPickerSelectionsOnSubmit
|
||||||
lifecycle. Reason: a tag-sync failure must not block registration.
|
|
||||||
|
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
|
**Call site removed in S2a:** `PersonController::approve()` and
|
||||||
`PersonIdentityService::syncRegistrationTags()` used to call
|
`PersonIdentityService::syncRegistrationTags()` used to call
|
||||||
`TagSyncService::syncFromRegistration($person)` directly. The rebuilt
|
`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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user