feat: person identity matching with detection, confirmation and audit trail
Implements enterprise-grade identity resolution (detect → suggest → confirm) for Person ↔ User linking. Matches are detected automatically on person creation and user account creation, then surfaced to organisers for explicit confirmation or dismissal. No silent auto-linking. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
223
api/app/Services/PersonIdentityService.php
Normal file
223
api/app/Services/PersonIdentityService.php
Normal file
@@ -0,0 +1,223 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Enums\IdentityMatchConfidence;
|
||||
use App\Enums\IdentityMatchMethod;
|
||||
use App\Enums\IdentityMatchStatus;
|
||||
use App\Models\Person;
|
||||
use App\Models\PersonIdentityMatch;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class PersonIdentityService
|
||||
{
|
||||
/**
|
||||
* Detect if a person's email matches an existing user account.
|
||||
* Called after a person is created. Does NOT auto-link.
|
||||
*/
|
||||
public function detectMatchForPerson(Person $person): ?PersonIdentityMatch
|
||||
{
|
||||
// Guard 1: Person already linked to a user
|
||||
if ($person->user_id !== null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Guard 2: Person has no email
|
||||
if ($person->email === null || trim($person->email) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Guard 3: Person is soft-deleted
|
||||
if ($person->trashed()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Guard 4: Find user with matching normalised email.
|
||||
// StorePersonRequest validates 'email' as required + email format but does not
|
||||
// enforce lowercase. User emails are also not guaranteed lowercase. We use
|
||||
// LOWER() on both sides as a safety net for case-insensitive matching.
|
||||
$normalised = strtolower(trim($person->email));
|
||||
$user = User::whereRaw('LOWER(email) = ?', [$normalised])->first();
|
||||
|
||||
if ($user === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Guard 5: User already has a person record in the same event
|
||||
// (would violate UNIQUE(event_id, user_id) WHERE user_id IS NOT NULL)
|
||||
$alreadyLinkedInEvent = Person::where('event_id', $person->event_id)
|
||||
->where('user_id', $user->id)
|
||||
->exists();
|
||||
|
||||
if ($alreadyLinkedInEvent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Guard 6: Match record already exists — return existing (idempotent)
|
||||
$existing = PersonIdentityMatch::where('person_id', $person->id)
|
||||
->where('matched_user_id', $user->id)
|
||||
->first();
|
||||
|
||||
if ($existing !== null) {
|
||||
return $existing;
|
||||
}
|
||||
|
||||
$match = PersonIdentityMatch::create([
|
||||
'person_id' => $person->id,
|
||||
'matched_user_id' => $user->id,
|
||||
'matched_on' => IdentityMatchMethod::EMAIL,
|
||||
'confidence' => IdentityMatchConfidence::EXACT,
|
||||
'status' => IdentityMatchStatus::PENDING,
|
||||
]);
|
||||
|
||||
$activityLogger = activity('identity')
|
||||
->performedOn($person)
|
||||
->withProperties([
|
||||
'matched_user_id' => $user->id,
|
||||
'matched_on' => IdentityMatchMethod::EMAIL->value,
|
||||
'confidence' => IdentityMatchConfidence::EXACT->value,
|
||||
]);
|
||||
|
||||
if (auth()->user()) {
|
||||
$activityLogger->causedBy(auth()->user());
|
||||
}
|
||||
|
||||
$activityLogger->log('person.identity.match_detected');
|
||||
|
||||
return $match;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect all unlinked persons matching a user's email.
|
||||
* Called after a user account is created. Creates pending matches.
|
||||
* Returns the number of matches created.
|
||||
*/
|
||||
public function detectMatchesForUser(User $user): int
|
||||
{
|
||||
// 1. Fetch all matching unlinked persons
|
||||
// Person uses SoftDeletes, so trashed records are automatically excluded by Eloquent.
|
||||
$normalised = strtolower(trim($user->email));
|
||||
$persons = Person::whereNull('user_id')
|
||||
->whereRaw('LOWER(email) = ?', [$normalised])
|
||||
->get();
|
||||
|
||||
if ($persons->isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 2. Batch-check which events already have this user linked (no N+1)
|
||||
$alreadyLinkedEventIds = Person::where('user_id', $user->id)
|
||||
->whereIn('event_id', $persons->pluck('event_id'))
|
||||
->pluck('event_id')
|
||||
->toArray();
|
||||
|
||||
// 3. Batch-check existing match records (no N+1)
|
||||
$existingMatchPersonIds = PersonIdentityMatch::where('matched_user_id', $user->id)
|
||||
->whereIn('person_id', $persons->pluck('id'))
|
||||
->pluck('person_id')
|
||||
->toArray();
|
||||
|
||||
// 4. Filter — no queries inside this loop
|
||||
$toCreate = $persons
|
||||
->reject(fn (Person $p) => in_array($p->event_id, $alreadyLinkedEventIds))
|
||||
->reject(fn (Person $p) => in_array($p->id, $existingMatchPersonIds));
|
||||
|
||||
// 5. Create matches
|
||||
foreach ($toCreate as $person) {
|
||||
PersonIdentityMatch::create([
|
||||
'person_id' => $person->id,
|
||||
'matched_user_id' => $user->id,
|
||||
'matched_on' => IdentityMatchMethod::EMAIL,
|
||||
'confidence' => IdentityMatchConfidence::EXACT,
|
||||
'status' => IdentityMatchStatus::PENDING,
|
||||
]);
|
||||
|
||||
$activityLogger = activity('identity')
|
||||
->performedOn($person)
|
||||
->withProperties([
|
||||
'matched_user_id' => $user->id,
|
||||
'matched_on' => IdentityMatchMethod::EMAIL->value,
|
||||
'confidence' => IdentityMatchConfidence::EXACT->value,
|
||||
]);
|
||||
|
||||
if (auth()->user()) {
|
||||
$activityLogger->causedBy(auth()->user());
|
||||
}
|
||||
|
||||
$activityLogger->log('person.identity.match_detected');
|
||||
}
|
||||
|
||||
return $toCreate->count();
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm a match: link the person to the matched user.
|
||||
*
|
||||
* @throws \DomainException if match is not pending or would violate uniqueness
|
||||
*/
|
||||
public function confirmMatch(PersonIdentityMatch $match, User $resolvedBy): void
|
||||
{
|
||||
if ($match->status !== IdentityMatchStatus::PENDING) {
|
||||
throw new \DomainException('Match is not pending and cannot be confirmed.');
|
||||
}
|
||||
|
||||
// Safety check: no duplicate user_id in the same event
|
||||
$person = $match->person;
|
||||
$conflict = Person::where('event_id', $person->event_id)
|
||||
->where('user_id', $match->matched_user_id)
|
||||
->exists();
|
||||
|
||||
if ($conflict) {
|
||||
throw new \DomainException('User already has a person record in this event.');
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($match, $person, $resolvedBy): void {
|
||||
$match->update([
|
||||
'status' => IdentityMatchStatus::CONFIRMED,
|
||||
'resolved_by_user_id' => $resolvedBy->id,
|
||||
'resolved_at' => now(),
|
||||
]);
|
||||
|
||||
$person->update([
|
||||
'user_id' => $match->matched_user_id,
|
||||
]);
|
||||
});
|
||||
|
||||
activity('identity')
|
||||
->causedBy($resolvedBy)
|
||||
->performedOn($person)
|
||||
->withProperties([
|
||||
'match_id' => $match->id,
|
||||
'old' => ['user_id' => null],
|
||||
'new' => ['user_id' => $match->matched_user_id],
|
||||
])
|
||||
->log('person.identity.match_confirmed');
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss a match: mark as dismissed so it's not shown again.
|
||||
*
|
||||
* @throws \DomainException if match is not pending
|
||||
*/
|
||||
public function dismissMatch(PersonIdentityMatch $match, User $resolvedBy): void
|
||||
{
|
||||
if ($match->status !== IdentityMatchStatus::PENDING) {
|
||||
throw new \DomainException('Match is not pending and cannot be dismissed.');
|
||||
}
|
||||
|
||||
$match->update([
|
||||
'status' => IdentityMatchStatus::DISMISSED,
|
||||
'resolved_by_user_id' => $resolvedBy->id,
|
||||
'resolved_at' => now(),
|
||||
]);
|
||||
|
||||
activity('identity')
|
||||
->causedBy($resolvedBy)
|
||||
->performedOn($match->person)
|
||||
->withProperties(['match_id' => $match->id])
|
||||
->log('person.identity.match_dismissed');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user