WS-6 v1.3-delta D2 — Listener refactor + integration #11

Merged
bert.hausmans merged 8 commits from feat/ws-6-v1.3-delta-d2 into main 2026-05-08 08:25:52 +02:00
Showing only changes of commit 2a8f108b0e - Show all commits

View File

@@ -4,112 +4,139 @@ declare(strict_types=1);
namespace App\Listeners\FormBuilder;
use App\Enums\FormBuilder\ApplyStatus;
use App\Enums\FormBuilder\FormPurpose;
use App\Events\FormBuilder\FormSubmissionIdentityMatchResolved;
use App\Events\FormBuilder\FormSubmissionSubmitted;
use App\Exceptions\FormBuilder\IdentityMatchInvariantViolation;
use App\Models\FormBuilder\FormSubmission;
use App\Models\Person;
use App\Services\PersonIdentityService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Support\Facades\Log;
/**
* ARCH §31.1 trigger PersonIdentityService::detectMatches on
* event_registration submissions and record the outcome on the
* submission so the portal can tell the submitter what's happening.
* RFC-WS-6 §Q1 v1.3 (queued) + §Q2 (invariant) + §Q1 v1.3 addition 2 (broadcast).
*
* Runs asynchronously after ApplyBindingsOnFormSubmit commits. Identity
* matching joins the persons table scoped to organisation; in large orgs
* (10k+ Persons) this can take seconds at peak load. Keeping it sync would
* block PHP-FPM workers during public-form submission spikes operationally
* unacceptable for enterprise SaaS. The IdentityMatchBanner first-paint
* copy stays correct because ApplyBindingsOnFormSubmit writes
* identity_match_status='pending' inside its inner transaction; this
* listener writes the final state and broadcasts on the
* `submission.{id}` private channel so the frontend can refetch.
*
* Gating-invariant per ARCH-BINDINGS §5.6: skip unless apply_status is
* COMPLETED. PARTIAL and FAILED both fall through to the early-return
* sibling state is incoherent, identity-match against possibly-half-applied
* data is meaningless.
*
* The post-ApplyBindings invariant per §Q2: subject_type='person' AND
* subject_id IS NOT NULL, OR apply_status=FAILED. No third state exists.
* If we see subject_type='person' AND subject_id IS NULL AND
* apply_status=COMPLETED, that's a structural defect strict throw via
* IdentityMatchInvariantViolation routes to GlitchTip + (via the queue
* exception handler) form_submission_action_failures.
*
* States written to form_submissions.identity_match_status:
* - 'matched' the person is already linked to a user account
* - 'pending' one or more PersonIdentityMatch(pending) rows exist
* OR the submission is public (no subject yet; organiser
* will attach a person and matching runs later)
* - 'none' the person exists, is unlinked, and nothing matched
*
* Failure mode per §31.1: log at error level, never rethrow so sibling
* listeners on the same event (§31.10 tag sync, §31.3 shift provisioning)
* keep running.
*
* Runs synchronously (no ShouldQueue) so identity_match_status is
* already written by the time the HTTP submit-response serialises the
* submission the portal's IdentityMatchBanner then renders on first
* confirmation-page load instead of after a queue worker tick. When
* FORM-05 proper adds heavier value-based matching, that work will
* dispatch as a separate queued job from within this listener so the
* eager state transition stays sync and the slow resolution stays
* async.
* - 'pending' one or more PersonIdentityMatch(pending) candidates exist
* - 'none' no candidates and not linked
*/
final class TriggerPersonIdentityMatchOnFormSubmit
final class TriggerPersonIdentityMatchOnFormSubmit implements ShouldQueue
{
use InteractsWithQueue;
// Default queue connection — redis in production, sync in tests.
public string $queue = 'default';
public function __construct(
private readonly PersonIdentityService $identityService,
) {}
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;
}
$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(),
]);
// Gating-invariant first statement per ARCH-BINDINGS §5.6.
// The fresh() reload is required because the inner-txn commit +
// outer-txn failure-record write happens between dispatch and
// worker pickup; the in-memory event submission may be stale.
$submission = $event->submission->fresh(['schema']);
if (! $submission instanceof FormSubmission) {
return;
}
}
private function resolveStatus(FormSubmission $submission): string
{
if ($submission->subject_type !== 'person' || $submission->subject_id === null) {
// Post-WS-6 (RFC Q2): this path should be unreachable for
// event_registration submissions because ApplyBindingsOnFormSubmit
// provisions the Person synchronously before this listener fires.
// If we reach here, either:
// - ApplyBindings failed silently (check form_submission_action_failures)
// - Schema is misconfigured (no email binding, no identity-key)
// - Different purpose where subject genuinely is null
// Failsafe: preserve the existing 'pending' state so portal banner
// still renders sensibly, and surface the misconfiguration in logs.
Log::warning('form-builder.identity-match.no_person_subject_post_apply', [
if ($submission->apply_status !== ApplyStatus::COMPLETED) {
Log::info('form-builder.queued-listener.skipped_apply_failed', [
'listener' => self::class,
'submission_id' => (string) $submission->id,
'schema_id' => (string) $submission->form_schema_id,
'subject_type' => $submission->subject_type,
'apply_status' => $submission->apply_status?->value,
]);
return 'pending';
return;
}
// Non-event_registration purposes: no-op by design. Identity-match
// is a person-resolution flow specific to public registration.
$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;
}
// Non-person subjects on event_registration: also a no-op (defensive).
if ($submission->subject_type !== 'person') {
return;
}
// Invariant per §Q2: subject_id IS NOT NULL when apply_status=COMPLETED
// and subject_type='person'. Violation = structural defect.
if ($submission->subject_id === null) {
throw new IdentityMatchInvariantViolation(sprintf(
"subject_type='person' but subject_id=null after ApplyBindings COMPLETED. submission_id=%s",
$submission->id,
));
}
$person = Person::withoutGlobalScopes()->find($submission->subject_id);
if ($person === null) {
return 'none';
}
// The person was deleted between ApplyBindings COMPLETED and this
// worker pickup. Rare but possible; treat as a no-match terminal
// state so the banner shows a sensible final copy.
FormSubmission::query()
->whereKey($submission->id)
->update(['identity_match_status' => 'none']);
if ($person->user_id !== null) {
return 'matched';
return;
}
$matches = $this->identityService->detectMatches($person);
$status = match (true) {
$person->user_id !== null => 'matched',
$matches->isNotEmpty() => 'pending',
default => 'none',
};
return $matches->isNotEmpty() ? 'pending' : 'none';
FormSubmission::query()
->whereKey($submission->id)
->update(['identity_match_status' => $status]);
// Per RFC §Q1 v1.3 addition 2 — broadcast on the submission's
// private channel so the frontend portal IdentityMatchBanner can
// refetch the submission resource and transition copy from
// "we're checking matches…" to the final state without a manual
// reload. matchCount is an ephemeral DTO field; not persisted.
broadcast(new FormSubmissionIdentityMatchResolved(
submissionId: (string) $submission->id,
status: $status,
matchCount: $matches->count(),
))->toOthers();
}
}