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'); } }