WS-6 v1.3-delta D2 — Listener refactor + integration #11
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user