feat: complete person identity matching system with fuzzy detection, revert, and manual link

Implements the full identity matching engine: email matching (HIGH confidence),
fuzzy name matching with Levenshtein distance (MEDIUM confidence, upgradable to
HIGH with DOB tiebreaker), manual link/unlink, revert confirmed matches, and
automatic detection via PersonObserver. Includes 33 comprehensive tests, frontend
integration with confirm/dismiss/unlink UI, and match indicators in the persons list.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-14 08:44:24 +02:00
parent 7932e53daf
commit eb1a0ac666
30 changed files with 1941 additions and 399 deletions

View File

@@ -6,6 +6,14 @@ namespace App\Enums;
enum IdentityMatchConfidence: string
{
case EXACT = 'exact';
case FUZZY = 'fuzzy';
case HIGH = 'high';
case MEDIUM = 'medium';
public function label(): string
{
return match ($this) {
self::HIGH => 'Hoge zekerheid',
self::MEDIUM => 'Gemiddelde zekerheid',
};
}
}

View File

@@ -7,6 +7,15 @@ namespace App\Enums;
enum IdentityMatchMethod: string
{
case EMAIL = 'email';
case PHONE = 'phone';
case NAME_FUZZY = 'name_fuzzy';
case MANUAL = 'manual';
public function label(): string
{
return match ($this) {
self::EMAIL => 'E-mail match',
self::NAME_FUZZY => 'Naam match (vergelijkbaar)',
self::MANUAL => 'Handmatig gekoppeld',
};
}
}

View File

@@ -9,4 +9,20 @@ enum IdentityMatchStatus: string
case PENDING = 'pending';
case CONFIRMED = 'confirmed';
case DISMISSED = 'dismissed';
case REVERTED = 'reverted';
public function isTerminal(): bool
{
return in_array($this, [self::DISMISSED, self::REVERTED]);
}
public function label(): string
{
return match ($this) {
self::PENDING => 'In afwachting',
self::CONFIRMED => 'Bevestigd',
self::DISMISSED => 'Afgewezen',
self::REVERTED => 'Ontkoppeld',
};
}
}

View File

@@ -36,7 +36,7 @@ final class PersonController extends Controller
$this->verifyEventBelongsToOrganisation($organisation, $event);
Gate::authorize('viewAny', [Person::class, $event]);
$query = $event->persons()->with(['crowdType', 'pendingIdentityMatch.matchedUser']);
$query = $event->persons()->with(['crowdType', 'pendingIdentityMatch.matchedUser', 'user']);
if ($request->filled('crowd_type_id')) {
$query->where('crowd_type_id', $request->input('crowd_type_id'));
@@ -77,7 +77,7 @@ final class PersonController extends Controller
$this->verifyEventBelongsToOrganisation($organisation, $event);
Gate::authorize('view', [$person, $event]);
$person->load(['crowdType', 'company', 'user']);
$person->load(['crowdType', 'company', 'user', 'pendingIdentityMatch.matchedUser']);
return $this->success(new PersonResource($person));
}
@@ -89,7 +89,7 @@ final class PersonController extends Controller
$person = $event->persons()->create($request->validated());
$this->identityService->detectMatchForPerson($person);
// Identity match detection is handled automatically by PersonObserver
return $this->created(new PersonResource($person->fresh()->load('crowdType')));
}

View File

@@ -7,14 +7,18 @@ namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\V1\BulkConfirmIdentityMatchesRequest;
use App\Http\Resources\Api\V1\PersonIdentityMatchResource;
use App\Http\Resources\Api\V1\PersonResource;
use App\Models\Event;
use App\Models\Organisation;
use App\Models\Person;
use App\Models\PersonIdentityMatch;
use App\Models\User;
use App\Services\PersonIdentityService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Facades\Gate;
use Illuminate\Validation\ValidationException;
final class PersonIdentityMatchController extends Controller
{
@@ -63,7 +67,7 @@ final class PersonIdentityMatchController extends Controller
return $this->error($e->getMessage(), 422);
}
$personIdentityMatch->refresh()->load(['person.crowdType', 'person.event', 'matchedUser', 'resolvedBy']);
$personIdentityMatch->refresh()->load(['person.crowdType', 'person.event', 'matchedUser', 'confirmedBy', 'resolvedBy']);
return $this->success(new PersonIdentityMatchResource($personIdentityMatch));
}
@@ -88,6 +92,26 @@ final class PersonIdentityMatchController extends Controller
return $this->success(new PersonIdentityMatchResource($personIdentityMatch));
}
public function revert(Request $request, Organisation $organisation, PersonIdentityMatch $personIdentityMatch): JsonResponse
{
// Verify match belongs to this organisation
if ($personIdentityMatch->person->event->organisation_id !== $organisation->id) {
return $this->notFound('Match not found.');
}
Gate::authorize('confirm', $personIdentityMatch);
try {
$this->identityService->revertMatch($personIdentityMatch, $request->user());
} catch (\DomainException $e) {
return $this->error($e->getMessage(), 422);
}
$personIdentityMatch->refresh()->load(['person.crowdType', 'person.event', 'matchedUser', 'revertedBy']);
return $this->success(new PersonIdentityMatchResource($personIdentityMatch));
}
public function bulkConfirm(BulkConfirmIdentityMatchesRequest $request, Organisation $organisation): JsonResponse
{
Gate::authorize('bulkConfirm', [PersonIdentityMatch::class, $organisation]);
@@ -107,12 +131,14 @@ final class PersonIdentityMatchController extends Controller
if ($match === null) {
$errors[] = ['match_id' => $matchId, 'error' => 'Match not found.'];
continue;
}
$response = Gate::inspect('update', [$match->person, $match->person->event]);
if ($response->denied()) {
$errors[] = ['match_id' => $matchId, 'error' => 'Unauthorized.'];
continue;
}
@@ -129,4 +155,35 @@ final class PersonIdentityMatchController extends Controller
'errors' => $errors,
]);
}
public function manualLink(Request $request, Organisation $organisation, Event $event, Person $person): JsonResponse
{
Gate::authorize('update', [$person, $event]);
$validated = $request->validate([
'user_id' => ['required', 'string', 'exists:users,id'],
]);
try {
$user = User::findOrFail($validated['user_id']);
$match = $this->identityService->manualLink($person, $user, $request->user());
} catch (ValidationException $e) {
return $this->error($e->getMessage(), 422);
}
return $this->success(new PersonIdentityMatchResource($match->load(['person.crowdType', 'matchedUser'])));
}
public function unlink(Request $request, Organisation $organisation, Event $event, Person $person): JsonResponse
{
Gate::authorize('update', [$person, $event]);
try {
$person = $this->identityService->unlinkDirect($person, $request->user());
} catch (ValidationException $e) {
return $this->error($e->getMessage(), 422);
}
return $this->success(new PersonResource($person->load(['crowdType', 'user'])));
}
}

View File

@@ -17,6 +17,7 @@ final class MeResource extends JsonResource
'first_name' => $this->first_name,
'last_name' => $this->last_name,
'full_name' => $this->full_name,
'date_of_birth' => $this->date_of_birth?->toDateString(),
'email' => $this->email,
'timezone' => $this->timezone,
'locale' => $this->locale,

View File

@@ -27,18 +27,32 @@ final class PersonIdentityMatchResource extends JsonResource
],
'matched_user' => [
'id' => $this->matchedUser->id,
'name' => $this->matchedUser->name,
'first_name' => $this->matchedUser->first_name,
'last_name' => $this->matchedUser->last_name,
'full_name' => $this->matchedUser->full_name,
'email' => $this->matchedUser->email,
'date_of_birth' => $this->matchedUser->date_of_birth?->toDateString(),
],
'matched_on' => $this->matched_on->value,
'matched_on_label' => $this->matched_on->label(),
'confidence' => $this->confidence->value,
'confidence_label' => $this->confidence->label(),
'status' => $this->status->value,
'status_label' => $this->status->label(),
'match_details' => $this->match_details,
'confirmed_by' => $this->when($this->confirmedBy, fn () => [
'id' => $this->confirmedBy->id,
'full_name' => $this->confirmedBy->full_name,
]),
'confirmed_at' => $this->confirmed_at?->toIso8601String(),
'dismissed_at' => $this->dismissed_at?->toIso8601String(),
'reverted_at' => $this->reverted_at?->toIso8601String(),
'resolved_by' => $this->when($this->resolvedBy, fn () => [
'id' => $this->resolvedBy->id,
'name' => $this->resolvedBy->name,
'full_name' => $this->resolvedBy->full_name,
]),
'resolved_at' => $this->resolved_at?->toISOString(),
'created_at' => $this->created_at->toISOString(),
'resolved_at' => $this->resolved_at?->toIso8601String(),
'created_at' => $this->created_at->toIso8601String(),
];
}
}

View File

@@ -28,6 +28,12 @@ final class PersonResource extends JsonResource
'created_at' => $this->created_at->toIso8601String(),
'crowd_type' => new CrowdTypeResource($this->whenLoaded('crowdType')),
'company' => new CompanyResource($this->whenLoaded('company')),
'has_user_account' => (bool) $this->user_id,
'user_account' => $this->when($this->user_id && $this->relationLoaded('user') && $this->user, fn () => [
'id' => $this->user->id,
'email' => $this->user->email,
'full_name' => $this->user->full_name,
]),
'pending_identity_match' => $this->when(
$this->relationLoaded('pendingIdentityMatch') && $this->pendingIdentityMatch,
function () {
@@ -41,9 +47,13 @@ final class PersonResource extends JsonResource
'last_name' => $match->matchedUser->last_name,
'full_name' => $match->matchedUser->full_name,
'email' => $match->matchedUser->email,
'date_of_birth' => $match->matchedUser->date_of_birth?->toDateString(),
],
'matched_on' => $match->matched_on->value,
'matched_on_label' => $match->matched_on->label(),
'confidence' => $match->confidence->value,
'confidence_label' => $match->confidence->label(),
'match_details' => $match->match_details,
];
}
),

View File

@@ -16,6 +16,7 @@ final class UserResource extends JsonResource
'first_name' => $this->first_name,
'last_name' => $this->last_name,
'full_name' => $this->full_name,
'date_of_birth' => $this->date_of_birth?->toDateString(),
'email' => $this->email,
'roles' => $this->getRoleNames()->values()->all(),
'timezone' => $this->timezone,

View File

@@ -121,7 +121,15 @@ final class Person extends Model
public function pendingIdentityMatch(): HasOne
{
return $this->hasOne(PersonIdentityMatch::class)
->where('status', IdentityMatchStatus::PENDING);
->where('status', IdentityMatchStatus::PENDING)
->latest();
}
public function confirmedIdentityMatch(): HasOne
{
return $this->hasOne(PersonIdentityMatch::class)
->where('status', IdentityMatchStatus::CONFIRMED)
->latest();
}
public function scopeApproved(Builder $query): Builder

View File

@@ -26,6 +26,13 @@ final class PersonIdentityMatch extends Model
'matched_on',
'confidence',
'status',
'match_details',
'confirmed_by_user_id',
'confirmed_at',
'dismissed_by_user_id',
'dismissed_at',
'reverted_by_user_id',
'reverted_at',
'resolved_by_user_id',
'resolved_at',
];
@@ -36,6 +43,10 @@ final class PersonIdentityMatch extends Model
'matched_on' => IdentityMatchMethod::class,
'confidence' => IdentityMatchConfidence::class,
'status' => IdentityMatchStatus::class,
'match_details' => 'array',
'confirmed_at' => 'datetime',
'dismissed_at' => 'datetime',
'reverted_at' => 'datetime',
'resolved_at' => 'datetime',
];
}
@@ -55,6 +66,21 @@ final class PersonIdentityMatch extends Model
return $this->belongsTo(User::class, 'resolved_by_user_id');
}
public function confirmedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'confirmed_by_user_id');
}
public function dismissedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'dismissed_by_user_id');
}
public function revertedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'reverted_by_user_id');
}
public function scopePending(Builder $query): Builder
{
return $query->where('status', IdentityMatchStatus::PENDING);
@@ -69,4 +95,9 @@ final class PersonIdentityMatch extends Model
{
return $query->where('status', IdentityMatchStatus::DISMISSED);
}
public function scopeForEvent(Builder $query, string $eventId): Builder
{
return $query->whereHas('person', fn (Builder $q) => $q->where('event_id', $eventId));
}
}

View File

@@ -26,6 +26,7 @@ final class User extends Authenticatable
protected $fillable = [
'first_name',
'last_name',
'date_of_birth',
'email',
'password',
'timezone',
@@ -51,6 +52,7 @@ final class User extends Authenticatable
protected function casts(): array
{
return [
'date_of_birth' => 'date',
'email_verified_at' => 'datetime',
'password' => 'hashed',
];

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Observers;
use App\Models\Person;
use App\Services\PersonIdentityService;
final class PersonObserver
{
public function __construct(
private readonly PersonIdentityService $identityService,
) {}
public function created(Person $person): void
{
if ($person->user_id === null && ($person->email || $person->first_name)) {
$this->identityService->detectMatches($person);
}
}
public function updated(Person $person): void
{
if ($person->wasChanged('email') && $person->user_id === null && $person->email) {
$this->identityService->detectMatches($person);
}
}
}

View File

@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace App\Providers;
use App\Models\Person;
use App\Observers\PersonObserver;
use Illuminate\Auth\Notifications\ResetPassword;
use Illuminate\Support\ServiceProvider;
@@ -16,6 +18,8 @@ class AppServiceProvider extends ServiceProvider
public function boot(): void
{
Person::observe(PersonObserver::class);
ResetPassword::createUrlUsing(function ($user, string $token) {
return config('crewli.portal_url') . '/wachtwoord-resetten?token=' . $token . '&email=' . urlencode($user->email);
});

View File

@@ -7,78 +7,219 @@ 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
{
/**
* Detect if a person's email matches an existing user account.
* Called after a person is created. Does NOT auto-link.
* 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
{
// Guard 1: Person already linked to a user
$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 null;
return collect();
}
// 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;
return collect();
}
// 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;
if (! $person->email && ! $person->first_name) {
return collect();
}
// 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)
$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) ===
if ($matches->isEmpty() && $person->first_name && $person->last_name) {
$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 ($alreadyLinkedInEvent) {
if ($alreadyLinked) {
return null;
}
// Guard 6: Match record already exists — return existing (idempotent)
$existing = PersonIdentityMatch::where('person_id', $person->id)
// 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;
}
if ($existing !== null) {
return $existing;
// 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' => IdentityMatchMethod::EMAIL,
'confidence' => IdentityMatchConfidence::EXACT,
'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' => IdentityMatchMethod::EMAIL->value,
'confidence' => IdentityMatchConfidence::EXACT->value,
'matched_on' => $method->value,
'confidence' => $confidence->value,
'matched_fields' => $matchedFields,
]);
if (auth()->user()) {
@@ -98,7 +239,6 @@ final class PersonIdentityService
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])
@@ -109,7 +249,8 @@ final class PersonIdentityService
}
// 2. Batch-check which events already have this user linked (no N+1)
$alreadyLinkedEventIds = Person::where('user_id', $user->id)
$alreadyLinkedEventIds = Person::withoutGlobalScopes()
->where('user_id', $user->id)
->whereIn('event_id', $persons->pluck('event_id'))
->pluck('event_id')
->toArray();
@@ -131,8 +272,16 @@ final class PersonIdentityService
'person_id' => $person->id,
'matched_user_id' => $user->id,
'matched_on' => IdentityMatchMethod::EMAIL,
'confidence' => IdentityMatchConfidence::EXACT,
'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')
@@ -140,7 +289,7 @@ final class PersonIdentityService
->withProperties([
'matched_user_id' => $user->id,
'matched_on' => IdentityMatchMethod::EMAIL->value,
'confidence' => IdentityMatchConfidence::EXACT->value,
'confidence' => IdentityMatchConfidence::HIGH->value,
]);
if (auth()->user()) {
@@ -155,6 +304,7 @@ final class PersonIdentityService
/**
* 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
*/
@@ -164,19 +314,24 @@ final class PersonIdentityService
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)
// 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 in this event.');
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(),
]);
@@ -184,8 +339,23 @@ final class PersonIdentityService
// 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(),
]);
});
// Sync registration tags
$this->syncRegistrationTags($person);
activity('identity')
->causedBy($resolvedBy)
->performedOn($person)
@@ -210,6 +380,8 @@ final class PersonIdentityService
$match->update([
'status' => IdentityMatchStatus::DISMISSED,
'dismissed_by_user_id' => $resolvedBy->id,
'dismissed_at' => now(),
'resolved_by_user_id' => $resolvedBy->id,
'resolved_at' => now(),
]);
@@ -220,4 +392,177 @@ final class PersonIdentityService
->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(),
]);
});
// Sync registration tags
$this->syncRegistrationTags($person);
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();
}
/**
* Sync registration tags when identity is confirmed.
*/
private function syncRegistrationTags(Person $person): void
{
if ($person->user_id === null) {
return;
}
try {
app(TagSyncService::class)->syncFromRegistration($person);
} catch (\Exception $e) {
Log::warning('Failed to sync registration tags on identity confirm', [
'person_id' => $person->id,
'user_id' => $person->user_id,
'error' => $e->getMessage(),
]);
}
}
}

View File

@@ -14,9 +14,10 @@ use Illuminate\Support\Facades\DB;
/**
* Syncs tag_picker registration field values to user_organisation_tags.
*
* TODO: Additional trigger points to wire when those services are built:
* - PersonService::approve() when account is created and user_id is set
* Called from:
* - PersonController::approve() when a person is approved
* - PersonIdentityService::confirmMatch() when user_id is linked via identity matching
* - PersonIdentityService::manualLink() when user_id is linked manually
*/
final class TagSyncService
{