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>
224 lines
7.5 KiB
PHP
224 lines
7.5 KiB
PHP
<?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');
|
|
}
|
|
}
|