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(); // ARCH §31.10 — deferred TAG_PICKER sync: previously submitted // event_registration forms now have a user account to attach // self_reported tags to. No-op if null path surfaces. ($this->tagSyncService ?? app(FormTagSyncService::class)) ->rebuildForPerson($person->fresh() ?? $person); // 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(); } }