548 lines
20 KiB
PHP
548 lines
20 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\Event;
|
|
use App\Models\Person;
|
|
use App\Models\PersonIdentityMatch;
|
|
use App\Models\User;
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Validation\ValidationException;
|
|
|
|
final class PersonIdentityService
|
|
{
|
|
/**
|
|
* Calculate whether two name strings are a fuzzy match using Levenshtein distance
|
|
* with a length-adaptive threshold.
|
|
*/
|
|
private function isFuzzyNameMatch(string $a, string $b): bool
|
|
{
|
|
$a = mb_strtolower(trim($a));
|
|
$b = mb_strtolower(trim($b));
|
|
|
|
if ($a === $b) {
|
|
return true;
|
|
}
|
|
|
|
$maxLen = max(strlen($a), strlen($b));
|
|
|
|
// For very short names (< 4 chars), require exact match
|
|
if ($maxLen < 4) {
|
|
return false;
|
|
}
|
|
|
|
// Adaptive threshold: 2 for names ≤ 10 chars, 3 for longer
|
|
$threshold = $maxLen <= 10 ? 2 : 3;
|
|
|
|
return levenshtein($a, $b) <= $threshold;
|
|
}
|
|
|
|
/**
|
|
* Detect potential matches for a person.
|
|
* Called when a Person is created or email is updated.
|
|
*
|
|
* Detection strategy (in order of confidence):
|
|
* 1. Exact email match within org → HIGH confidence
|
|
* 2. Fuzzy name match + optional DOB tiebreaker → MEDIUM (or HIGH if DOB matches)
|
|
*/
|
|
public function detectMatchForPerson(Person $person): ?PersonIdentityMatch
|
|
{
|
|
$matches = $this->detectMatches($person);
|
|
|
|
return $matches->first();
|
|
}
|
|
|
|
/**
|
|
* Detect all potential matches for a person.
|
|
*/
|
|
public function detectMatches(Person $person): Collection
|
|
{
|
|
if ($person->user_id !== null) {
|
|
return collect();
|
|
}
|
|
|
|
if ($person->trashed()) {
|
|
return collect();
|
|
}
|
|
|
|
if (! $person->email && ! $person->first_name) {
|
|
return collect();
|
|
}
|
|
|
|
$event = $person->event;
|
|
if (! $event) {
|
|
return collect();
|
|
}
|
|
|
|
$organisationId = $event->organisation_id;
|
|
$matches = collect();
|
|
|
|
// Get all users in this organisation (one query, reuse for all checks)
|
|
$orgUsers = User::whereHas('organisations', fn ($q) => $q->where('organisations.id', $organisationId))->get();
|
|
|
|
// === Strategy 1: Exact email match ===
|
|
if ($person->email) {
|
|
$normalised = strtolower(trim($person->email));
|
|
$emailMatches = $orgUsers->filter(
|
|
fn (User $u) => strtolower(trim($u->email)) === $normalised
|
|
);
|
|
|
|
foreach ($emailMatches as $user) {
|
|
$match = $this->createMatchIfEligible(
|
|
$person, $user, IdentityMatchMethod::EMAIL, IdentityMatchConfidence::HIGH,
|
|
['email'], $organisationId
|
|
);
|
|
if ($match) {
|
|
$matches->push($match);
|
|
}
|
|
}
|
|
}
|
|
|
|
// === Strategy 2: Fuzzy name match (only if no email match found) ===
|
|
// Skip fuzzy matching for self-registered persons — they provided their
|
|
// own email, so that's the canonical identity. Fuzzy name matching with
|
|
// a different user would be confusing.
|
|
if ($matches->isEmpty() && $person->first_name && $person->last_name
|
|
&& ($person->registration_source ?? 'organizer') !== 'self') {
|
|
$nameMatches = $orgUsers->filter(function (User $user) use ($person) {
|
|
// Skip if same email (already handled above, or would be email match)
|
|
if ($person->email && strtolower(trim($user->email)) === strtolower(trim($person->email))) {
|
|
return false;
|
|
}
|
|
if (! $user->first_name || ! $user->last_name) {
|
|
return false;
|
|
}
|
|
|
|
return $this->isFuzzyNameMatch($person->first_name, $user->first_name)
|
|
&& $this->isFuzzyNameMatch($person->last_name, $user->last_name);
|
|
});
|
|
|
|
foreach ($nameMatches as $user) {
|
|
$confidence = IdentityMatchConfidence::MEDIUM;
|
|
$matchedFields = [];
|
|
|
|
// Check exact name match vs fuzzy
|
|
if (mb_strtolower(trim($person->first_name)) === mb_strtolower(trim($user->first_name))
|
|
&& mb_strtolower(trim($person->last_name)) === mb_strtolower(trim($user->last_name))) {
|
|
$matchedFields[] = 'first_name';
|
|
$matchedFields[] = 'last_name';
|
|
} else {
|
|
$matchedFields[] = 'first_name_fuzzy';
|
|
$matchedFields[] = 'last_name_fuzzy';
|
|
}
|
|
|
|
// DOB tiebreaker: upgrades medium → high
|
|
if ($person->date_of_birth && $user->date_of_birth
|
|
&& $person->date_of_birth->equalTo($user->date_of_birth)) {
|
|
$confidence = IdentityMatchConfidence::HIGH;
|
|
$matchedFields[] = 'date_of_birth';
|
|
}
|
|
|
|
$match = $this->createMatchIfEligible(
|
|
$person, $user, IdentityMatchMethod::NAME_FUZZY, $confidence,
|
|
$matchedFields, $organisationId
|
|
);
|
|
if ($match) {
|
|
$matches->push($match);
|
|
}
|
|
}
|
|
}
|
|
|
|
return $matches;
|
|
}
|
|
|
|
/**
|
|
* Create a match record if eligible (no existing match, no conflict).
|
|
*/
|
|
private function createMatchIfEligible(
|
|
Person $person,
|
|
User $user,
|
|
IdentityMatchMethod $method,
|
|
IdentityMatchConfidence $confidence,
|
|
array $matchedFields,
|
|
string $organisationId,
|
|
): ?PersonIdentityMatch {
|
|
// Skip if user already linked to a person at this event with same crowd type
|
|
$alreadyLinked = Person::withoutGlobalScopes()
|
|
->where('event_id', $person->event_id)
|
|
->where('user_id', $user->id)
|
|
->where('crowd_type_id', $person->crowd_type_id)
|
|
->where('id', '!=', $person->id)
|
|
->exists();
|
|
if ($alreadyLinked) {
|
|
return null;
|
|
}
|
|
|
|
// Skip if there's already a pending or confirmed match for this person+user pair
|
|
$existingMatch = PersonIdentityMatch::where('person_id', $person->id)
|
|
->where('matched_user_id', $user->id)
|
|
->whereIn('status', [IdentityMatchStatus::PENDING, IdentityMatchStatus::CONFIRMED])
|
|
->first();
|
|
if ($existingMatch) {
|
|
return $existingMatch;
|
|
}
|
|
|
|
// Skip if previously dismissed (don't re-suggest)
|
|
$wasDismissed = PersonIdentityMatch::where('person_id', $person->id)
|
|
->where('matched_user_id', $user->id)
|
|
->where('status', IdentityMatchStatus::DISMISSED)
|
|
->exists();
|
|
if ($wasDismissed) {
|
|
return null;
|
|
}
|
|
|
|
$match = PersonIdentityMatch::create([
|
|
'person_id' => $person->id,
|
|
'matched_user_id' => $user->id,
|
|
'matched_on' => $method,
|
|
'confidence' => $confidence,
|
|
'status' => IdentityMatchStatus::PENDING,
|
|
'match_details' => [
|
|
'person_email' => $person->email,
|
|
'user_email' => $user->email,
|
|
'person_name' => $person->full_name,
|
|
'user_name' => $user->full_name,
|
|
'person_dob' => $person->date_of_birth?->toDateString(),
|
|
'user_dob' => $user->date_of_birth?->toDateString(),
|
|
'matched_fields' => $matchedFields,
|
|
'organisation_id' => $organisationId,
|
|
],
|
|
]);
|
|
|
|
$activityLogger = activity('identity')
|
|
->performedOn($person)
|
|
->withProperties([
|
|
'match_id' => $match->id,
|
|
'matched_user_id' => $user->id,
|
|
'matched_on' => $method->value,
|
|
'confidence' => $confidence->value,
|
|
'matched_fields' => $matchedFields,
|
|
]);
|
|
|
|
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
|
|
$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::withoutGlobalScopes()
|
|
->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::HIGH,
|
|
'status' => IdentityMatchStatus::PENDING,
|
|
'match_details' => [
|
|
'person_email' => $person->email,
|
|
'user_email' => $user->email,
|
|
'person_name' => $person->full_name,
|
|
'user_name' => $user->full_name,
|
|
'matched_fields' => ['email'],
|
|
'organisation_id' => $person->event?->organisation_id,
|
|
],
|
|
]);
|
|
|
|
$activityLogger = activity('identity')
|
|
->performedOn($person)
|
|
->withProperties([
|
|
'matched_user_id' => $user->id,
|
|
'matched_on' => IdentityMatchMethod::EMAIL->value,
|
|
'confidence' => IdentityMatchConfidence::HIGH->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.
|
|
* Also syncs registration tags and dismisses other pending matches.
|
|
*
|
|
* @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.');
|
|
}
|
|
|
|
$person = $match->person;
|
|
|
|
// Safety check: no duplicate user_id in the same event + crowd type
|
|
$conflict = Person::withoutGlobalScopes()
|
|
->where('event_id', $person->event_id)
|
|
->where('user_id', $match->matched_user_id)
|
|
->where('crowd_type_id', $person->crowd_type_id)
|
|
->exists();
|
|
|
|
if ($conflict) {
|
|
throw new \DomainException('User already has a person record with this crowd type in this event.');
|
|
}
|
|
|
|
DB::transaction(function () use ($match, $person, $resolvedBy): void {
|
|
$match->update([
|
|
'status' => IdentityMatchStatus::CONFIRMED,
|
|
'confirmed_by_user_id' => $resolvedBy->id,
|
|
'confirmed_at' => now(),
|
|
'resolved_by_user_id' => $resolvedBy->id,
|
|
'resolved_at' => now(),
|
|
]);
|
|
|
|
// Set user_id explicitly (not mass-assignable)
|
|
$person->user_id = $match->matched_user_id;
|
|
$person->save();
|
|
|
|
// Dismiss other pending matches for this person
|
|
PersonIdentityMatch::where('person_id', $person->id)
|
|
->where('id', '!=', $match->id)
|
|
->where('status', IdentityMatchStatus::PENDING)
|
|
->update([
|
|
'status' => IdentityMatchStatus::DISMISSED->value,
|
|
'dismissed_by_user_id' => $resolvedBy->id,
|
|
'dismissed_at' => now(),
|
|
'resolved_by_user_id' => $resolvedBy->id,
|
|
'resolved_at' => now(),
|
|
]);
|
|
});
|
|
|
|
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,
|
|
'dismissed_by_user_id' => $resolvedBy->id,
|
|
'dismissed_at' => now(),
|
|
'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');
|
|
}
|
|
|
|
/**
|
|
* Revert a confirmed match — unlinks person.user_id (the "split" action).
|
|
*
|
|
* @throws \DomainException if match is not confirmed
|
|
*/
|
|
public function revertMatch(PersonIdentityMatch $match, User $revertedBy): void
|
|
{
|
|
if ($match->status !== IdentityMatchStatus::CONFIRMED) {
|
|
throw new \DomainException('Only confirmed matches can be reverted.');
|
|
}
|
|
|
|
$person = $match->person;
|
|
$previousUserId = $person->user_id;
|
|
|
|
DB::transaction(function () use ($match, $person, $revertedBy): void {
|
|
$person->user_id = null;
|
|
$person->save();
|
|
|
|
$match->update([
|
|
'status' => IdentityMatchStatus::REVERTED,
|
|
'reverted_by_user_id' => $revertedBy->id,
|
|
'reverted_at' => now(),
|
|
]);
|
|
});
|
|
|
|
activity('identity')
|
|
->causedBy($revertedBy)
|
|
->performedOn($person)
|
|
->withProperties([
|
|
'match_id' => $match->id,
|
|
'unlinked_user_id' => $previousUserId,
|
|
])
|
|
->log('person.identity.match_reverted');
|
|
}
|
|
|
|
/**
|
|
* Manually link a person to a user (organiser-initiated, no detection).
|
|
*
|
|
* @throws ValidationException if person already linked or conflict exists
|
|
*/
|
|
public function manualLink(Person $person, User $user, User $linkedBy): PersonIdentityMatch
|
|
{
|
|
if ($person->user_id !== null) {
|
|
throw ValidationException::withMessages([
|
|
'person_id' => ['Deze persoon is al gekoppeld aan een platformaccount. Ontkoppel eerst.'],
|
|
]);
|
|
}
|
|
|
|
// Check for conflict at event level (same crowd type)
|
|
$alreadyLinked = Person::withoutGlobalScopes()
|
|
->where('event_id', $person->event_id)
|
|
->where('user_id', $user->id)
|
|
->where('crowd_type_id', $person->crowd_type_id)
|
|
->where('id', '!=', $person->id)
|
|
->exists();
|
|
|
|
if ($alreadyLinked) {
|
|
throw ValidationException::withMessages([
|
|
'user_id' => ['Dit platformaccount is al gekoppeld aan een andere deelnemer met hetzelfde type bij dit evenement.'],
|
|
]);
|
|
}
|
|
|
|
$match = null;
|
|
|
|
DB::transaction(function () use ($person, $user, $linkedBy, &$match): void {
|
|
$match = PersonIdentityMatch::create([
|
|
'person_id' => $person->id,
|
|
'matched_user_id' => $user->id,
|
|
'matched_on' => IdentityMatchMethod::MANUAL,
|
|
'confidence' => IdentityMatchConfidence::HIGH,
|
|
'status' => IdentityMatchStatus::CONFIRMED,
|
|
'confirmed_by_user_id' => $linkedBy->id,
|
|
'confirmed_at' => now(),
|
|
'resolved_by_user_id' => $linkedBy->id,
|
|
'resolved_at' => now(),
|
|
'match_details' => [
|
|
'person_email' => $person->email,
|
|
'user_email' => $user->email,
|
|
'person_name' => $person->full_name,
|
|
'user_name' => $user->full_name,
|
|
'matched_fields' => ['manual'],
|
|
'organisation_id' => $person->event?->organisation_id,
|
|
],
|
|
]);
|
|
|
|
$person->user_id = $user->id;
|
|
$person->save();
|
|
|
|
// Dismiss pending matches
|
|
PersonIdentityMatch::where('person_id', $person->id)
|
|
->where('id', '!=', $match->id)
|
|
->where('status', IdentityMatchStatus::PENDING)
|
|
->update([
|
|
'status' => IdentityMatchStatus::DISMISSED->value,
|
|
'dismissed_by_user_id' => $linkedBy->id,
|
|
'dismissed_at' => now(),
|
|
'resolved_by_user_id' => $linkedBy->id,
|
|
'resolved_at' => now(),
|
|
]);
|
|
});
|
|
|
|
activity('identity')
|
|
->causedBy($linkedBy)
|
|
->performedOn($person)
|
|
->withProperties([
|
|
'match_id' => $match->id,
|
|
'linked_user_id' => $user->id,
|
|
])
|
|
->log('person.identity.manually_linked');
|
|
|
|
return $match;
|
|
}
|
|
|
|
/**
|
|
* Unlink a person that was linked (with or without a match record).
|
|
*
|
|
* @throws ValidationException if person is not linked
|
|
*/
|
|
public function unlinkDirect(Person $person, User $unlinkedBy): Person
|
|
{
|
|
if ($person->user_id === null) {
|
|
throw ValidationException::withMessages([
|
|
'person_id' => ['Deze persoon is niet gekoppeld aan een platformaccount.'],
|
|
]);
|
|
}
|
|
|
|
// Check for confirmed match record first
|
|
$confirmedMatch = PersonIdentityMatch::where('person_id', $person->id)
|
|
->where('status', IdentityMatchStatus::CONFIRMED)
|
|
->latest()
|
|
->first();
|
|
|
|
if ($confirmedMatch) {
|
|
$this->revertMatch($confirmedMatch, $unlinkedBy);
|
|
} else {
|
|
// Direct unlink (no match record exists)
|
|
$previousUserId = $person->user_id;
|
|
$person->user_id = null;
|
|
$person->save();
|
|
|
|
activity('identity')
|
|
->causedBy($unlinkedBy)
|
|
->performedOn($person)
|
|
->withProperties(['previous_user_id' => $previousUserId])
|
|
->log('person.identity.unlinked_directly');
|
|
}
|
|
|
|
return $person->fresh();
|
|
}
|
|
|
|
}
|