diff --git a/api/app/Enums/IdentityMatchConfidence.php b/api/app/Enums/IdentityMatchConfidence.php index d99eef0d..9aaa1702 100644 --- a/api/app/Enums/IdentityMatchConfidence.php +++ b/api/app/Enums/IdentityMatchConfidence.php @@ -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', + }; + } } diff --git a/api/app/Enums/IdentityMatchMethod.php b/api/app/Enums/IdentityMatchMethod.php index eb13e88d..d30c9401 100644 --- a/api/app/Enums/IdentityMatchMethod.php +++ b/api/app/Enums/IdentityMatchMethod.php @@ -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', + }; + } } diff --git a/api/app/Enums/IdentityMatchStatus.php b/api/app/Enums/IdentityMatchStatus.php index 2e60b262..7cb075e2 100644 --- a/api/app/Enums/IdentityMatchStatus.php +++ b/api/app/Enums/IdentityMatchStatus.php @@ -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', + }; + } } diff --git a/api/app/Http/Controllers/Api/V1/PersonController.php b/api/app/Http/Controllers/Api/V1/PersonController.php index 393b09f0..2c7085f0 100644 --- a/api/app/Http/Controllers/Api/V1/PersonController.php +++ b/api/app/Http/Controllers/Api/V1/PersonController.php @@ -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'))); } diff --git a/api/app/Http/Controllers/Api/V1/PersonIdentityMatchController.php b/api/app/Http/Controllers/Api/V1/PersonIdentityMatchController.php index 25e331b5..8887d5ef 100644 --- a/api/app/Http/Controllers/Api/V1/PersonIdentityMatchController.php +++ b/api/app/Http/Controllers/Api/V1/PersonIdentityMatchController.php @@ -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']))); + } } diff --git a/api/app/Http/Resources/Api/V1/MeResource.php b/api/app/Http/Resources/Api/V1/MeResource.php index 485f834b..cde725bc 100644 --- a/api/app/Http/Resources/Api/V1/MeResource.php +++ b/api/app/Http/Resources/Api/V1/MeResource.php @@ -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, diff --git a/api/app/Http/Resources/Api/V1/PersonIdentityMatchResource.php b/api/app/Http/Resources/Api/V1/PersonIdentityMatchResource.php index 0514fb4d..7a3c055d 100644 --- a/api/app/Http/Resources/Api/V1/PersonIdentityMatchResource.php +++ b/api/app/Http/Resources/Api/V1/PersonIdentityMatchResource.php @@ -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(), ]; } } diff --git a/api/app/Http/Resources/Api/V1/PersonResource.php b/api/app/Http/Resources/Api/V1/PersonResource.php index 14ed0e60..0f29891e 100644 --- a/api/app/Http/Resources/Api/V1/PersonResource.php +++ b/api/app/Http/Resources/Api/V1/PersonResource.php @@ -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, ]; } ), diff --git a/api/app/Http/Resources/Api/V1/UserResource.php b/api/app/Http/Resources/Api/V1/UserResource.php index e2e8ff05..6a62beb1 100644 --- a/api/app/Http/Resources/Api/V1/UserResource.php +++ b/api/app/Http/Resources/Api/V1/UserResource.php @@ -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, diff --git a/api/app/Models/Person.php b/api/app/Models/Person.php index a492e18b..557d5bdf 100644 --- a/api/app/Models/Person.php +++ b/api/app/Models/Person.php @@ -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 diff --git a/api/app/Models/PersonIdentityMatch.php b/api/app/Models/PersonIdentityMatch.php index 53e94634..c2ab5e60 100644 --- a/api/app/Models/PersonIdentityMatch.php +++ b/api/app/Models/PersonIdentityMatch.php @@ -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)); + } } diff --git a/api/app/Models/User.php b/api/app/Models/User.php index 61ab42aa..6713cb98 100644 --- a/api/app/Models/User.php +++ b/api/app/Models/User.php @@ -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', ]; diff --git a/api/app/Observers/PersonObserver.php b/api/app/Observers/PersonObserver.php new file mode 100644 index 00000000..51882613 --- /dev/null +++ b/api/app/Observers/PersonObserver.php @@ -0,0 +1,29 @@ +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); + } + } +} diff --git a/api/app/Providers/AppServiceProvider.php b/api/app/Providers/AppServiceProvider.php index 8cdf1bef..c005ae2a 100644 --- a/api/app/Providers/AppServiceProvider.php +++ b/api/app/Providers/AppServiceProvider.php @@ -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); }); diff --git a/api/app/Services/PersonIdentityService.php b/api/app/Services/PersonIdentityService.php index 776c4e11..4031d9b6 100644 --- a/api/app/Services/PersonIdentityService.php +++ b/api/app/Services/PersonIdentityService.php @@ -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(), + ]); + } + } } diff --git a/api/app/Services/TagSyncService.php b/api/app/Services/TagSyncService.php index f4fcc5a8..45951eb1 100644 --- a/api/app/Services/TagSyncService.php +++ b/api/app/Services/TagSyncService.php @@ -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 { diff --git a/api/database/factories/PersonIdentityMatchFactory.php b/api/database/factories/PersonIdentityMatchFactory.php index 4b1c1c4e..38d3f04d 100644 --- a/api/database/factories/PersonIdentityMatchFactory.php +++ b/api/database/factories/PersonIdentityMatchFactory.php @@ -24,8 +24,9 @@ final class PersonIdentityMatchFactory extends Factory 'person_id' => Person::factory(), 'matched_user_id' => User::factory(), 'matched_on' => IdentityMatchMethod::EMAIL, - 'confidence' => IdentityMatchConfidence::EXACT, + 'confidence' => IdentityMatchConfidence::HIGH, 'status' => IdentityMatchStatus::PENDING, + 'match_details' => null, 'resolved_by_user_id' => null, 'resolved_at' => null, ]; @@ -35,6 +36,8 @@ final class PersonIdentityMatchFactory extends Factory { return $this->state(fn () => [ 'status' => IdentityMatchStatus::CONFIRMED, + 'confirmed_by_user_id' => User::factory(), + 'confirmed_at' => now(), 'resolved_by_user_id' => User::factory(), 'resolved_at' => now(), ]); @@ -44,6 +47,29 @@ final class PersonIdentityMatchFactory extends Factory { return $this->state(fn () => [ 'status' => IdentityMatchStatus::DISMISSED, + 'dismissed_by_user_id' => User::factory(), + 'dismissed_at' => now(), + 'resolved_by_user_id' => User::factory(), + 'resolved_at' => now(), + ]); + } + + public function fuzzyName(): static + { + return $this->state(fn () => [ + 'matched_on' => IdentityMatchMethod::NAME_FUZZY, + 'confidence' => IdentityMatchConfidence::MEDIUM, + ]); + } + + public function manual(): static + { + return $this->state(fn () => [ + 'matched_on' => IdentityMatchMethod::MANUAL, + 'confidence' => IdentityMatchConfidence::HIGH, + 'status' => IdentityMatchStatus::CONFIRMED, + 'confirmed_by_user_id' => User::factory(), + 'confirmed_at' => now(), 'resolved_by_user_id' => User::factory(), 'resolved_at' => now(), ]); diff --git a/api/database/factories/UserFactory.php b/api/database/factories/UserFactory.php index 838bcbd2..23719b3c 100644 --- a/api/database/factories/UserFactory.php +++ b/api/database/factories/UserFactory.php @@ -20,6 +20,7 @@ final class UserFactory extends Factory return [ 'first_name' => fake('nl_NL')->firstName(), 'last_name' => fake('nl_NL')->lastName(), + 'date_of_birth' => fake()->dateTimeBetween('-50 years', '-18 years')->format('Y-m-d'), 'email' => fake()->unique()->safeEmail(), 'email_verified_at' => now(), 'password' => static::$password ??= Hash::make('password'), diff --git a/api/database/migrations/2026_04_14_200000_add_date_of_birth_to_users_table.php b/api/database/migrations/2026_04_14_200000_add_date_of_birth_to_users_table.php new file mode 100644 index 00000000..af07e6b6 --- /dev/null +++ b/api/database/migrations/2026_04_14_200000_add_date_of_birth_to_users_table.php @@ -0,0 +1,24 @@ +date('date_of_birth')->nullable()->after('last_name'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('date_of_birth'); + }); + } +}; diff --git a/api/database/migrations/2026_04_14_200001_enhance_person_identity_matches_table.php b/api/database/migrations/2026_04_14_200001_enhance_person_identity_matches_table.php new file mode 100644 index 00000000..4ed5709f --- /dev/null +++ b/api/database/migrations/2026_04_14_200001_enhance_person_identity_matches_table.php @@ -0,0 +1,59 @@ +json('match_details')->nullable()->after('status'); + + $table->foreignUlid('confirmed_by_user_id') + ->nullable() + ->after('match_details') + ->constrained('users') + ->nullOnDelete(); + + $table->timestamp('confirmed_at')->nullable()->after('confirmed_by_user_id'); + + $table->foreignUlid('dismissed_by_user_id') + ->nullable() + ->after('confirmed_at') + ->constrained('users') + ->nullOnDelete(); + + $table->timestamp('dismissed_at')->nullable()->after('dismissed_by_user_id'); + + $table->foreignUlid('reverted_by_user_id') + ->nullable() + ->after('dismissed_at') + ->constrained('users') + ->nullOnDelete(); + + $table->timestamp('reverted_at')->nullable()->after('reverted_by_user_id'); + }); + } + + public function down(): void + { + Schema::table('person_identity_matches', function (Blueprint $table) { + $table->dropForeign(['confirmed_by_user_id']); + $table->dropForeign(['dismissed_by_user_id']); + $table->dropForeign(['reverted_by_user_id']); + $table->dropColumn([ + 'match_details', + 'confirmed_by_user_id', + 'confirmed_at', + 'dismissed_by_user_id', + 'dismissed_at', + 'reverted_by_user_id', + 'reverted_at', + ]); + }); + } +}; diff --git a/api/database/seeders/DevSeeder.php b/api/database/seeders/DevSeeder.php index 9eeaf176..4088c694 100644 --- a/api/database/seeders/DevSeeder.php +++ b/api/database/seeders/DevSeeder.php @@ -71,14 +71,14 @@ class DevSeeder extends Seeder // ── Users (8) ── $usersData = [ - ['email' => 'admin@crewli.test', 'first_name' => 'Super', 'last_name' => 'Admin', 'app_role' => 'super_admin', 'org_role' => 'org_admin'], - ['email' => 'bert@feestfabriek.nl', 'first_name' => 'Bert', 'last_name' => 'Hausmans', 'org_role' => 'org_admin'], - ['email' => 'lisa@feestfabriek.nl', 'first_name' => 'Lisa', 'last_name' => 'van den Berg', 'org_role' => 'org_member'], - ['email' => 'ahmed@feestfabriek.nl', 'first_name' => 'Ahmed', 'last_name' => 'Yilmaz', 'org_role' => 'org_member'], - ['email' => 'sara@feestfabriek.nl', 'first_name' => 'Sara', 'last_name' => 'de Groot', 'org_role' => 'org_member'], - ['email' => 'tom@feestfabriek.nl', 'first_name' => 'Tom', 'last_name' => 'Visser', 'org_role' => 'org_member'], - ['email' => 'nina@feestfabriek.nl', 'first_name' => 'Nina', 'last_name' => 'Jansen', 'org_role' => 'org_member'], - ['email' => 'mark@feestfabriek.nl', 'first_name' => 'Mark', 'last_name' => 'de Boer', 'org_role' => 'org_member'], + ['email' => 'admin@crewli.test', 'first_name' => 'Super', 'last_name' => 'Admin', 'app_role' => 'super_admin', 'org_role' => 'org_admin', 'date_of_birth' => '1985-01-15'], + ['email' => 'bert@feestfabriek.nl', 'first_name' => 'Bert', 'last_name' => 'Hausmans', 'org_role' => 'org_admin', 'date_of_birth' => '1990-06-28'], + ['email' => 'lisa@feestfabriek.nl', 'first_name' => 'Lisa', 'last_name' => 'van den Berg', 'org_role' => 'org_member', 'date_of_birth' => '1993-05-12'], + ['email' => 'ahmed@feestfabriek.nl', 'first_name' => 'Ahmed', 'last_name' => 'Yilmaz', 'org_role' => 'org_member', 'date_of_birth' => '1989-09-03'], + ['email' => 'sara@feestfabriek.nl', 'first_name' => 'Sara', 'last_name' => 'de Groot', 'org_role' => 'org_member', 'date_of_birth' => '1991-08-24'], + ['email' => 'tom@feestfabriek.nl', 'first_name' => 'Tom', 'last_name' => 'Visser', 'org_role' => 'org_member', 'date_of_birth' => '1994-11-07'], + ['email' => 'nina@feestfabriek.nl', 'first_name' => 'Nina', 'last_name' => 'Jansen', 'org_role' => 'org_member', 'date_of_birth' => '1996-02-14'], + ['email' => 'mark@feestfabriek.nl', 'first_name' => 'Mark', 'last_name' => 'de Boer', 'org_role' => 'org_member', 'date_of_birth' => '1988-03-17'], ]; foreach ($usersData as $data) { @@ -86,6 +86,7 @@ class DevSeeder extends Seeder 'first_name' => $data['first_name'], 'last_name' => $data['last_name'], 'email' => $data['email'], + 'date_of_birth' => $data['date_of_birth'] ?? null, 'password' => Hash::make('password'), ]); @@ -546,6 +547,20 @@ class DevSeeder extends Seeder Person::factory()->count(4)->approved()->create(['event_id' => $festival->id, 'crowd_type_id' => $supplier]); Person::factory()->count(2)->create(['event_id' => $festival->id, 'crowd_type_id' => $supplier, 'status' => 'pending']); + // ── Identity match test data ── + // Persons whose emails match org member accounts (for email-based identity matching) + Person::create(['event_id' => $festival->id, 'crowd_type_id' => $vol, 'first_name' => 'Lisa', 'last_name' => 'van den Berg', 'email' => 'lisa@feestfabriek.nl', 'phone' => '+31612345040', 'status' => 'applied', 'date_of_birth' => '1993-05-12']); + Person::create(['event_id' => $festival->id, 'crowd_type_id' => $vol, 'first_name' => 'Sara', 'last_name' => 'de Groot', 'email' => 'sara@feestfabriek.nl', 'phone' => '+31612345041', 'status' => 'pending', 'date_of_birth' => '1991-08-24']); + + // Person with fuzzy name match to org member "Nina Jansen" (different email) + Person::create(['event_id' => $festival->id, 'crowd_type_id' => $vol, 'first_name' => 'Nena', 'last_name' => 'Jansen', 'email' => 'nena.jansen@gmail.com', 'phone' => '+31612345042', 'status' => 'pending', 'date_of_birth' => null]); + + // Person with fuzzy name match + DOB match to org member "Mark de Boer" (DOB already set in user creation) + Person::create(['event_id' => $festival->id, 'crowd_type_id' => $vol, 'first_name' => 'Marc', 'last_name' => 'de Boer', 'email' => 'marc.deboer@gmail.com', 'phone' => '+31612345043', 'status' => 'pending', 'date_of_birth' => '1988-03-17']); + + // Person with unique email (no match expected) + Person::create(['event_id' => $festival->id, 'crowd_type_id' => $vol, 'first_name' => 'Unique', 'last_name' => 'Persoon', 'email' => 'unique.persoon@nowhere.test', 'phone' => '+31612345044', 'status' => 'pending']); + $personCount = Person::where('event_id', $festival->id)->count(); $this->command->info(" {$personCount} persons created"); diff --git a/api/routes/api.php b/api/routes/api.php index 3535b2d3..452cc7e7 100644 --- a/api/routes/api.php +++ b/api/routes/api.php @@ -138,6 +138,7 @@ Route::middleware('auth:sanctum')->group(function () { Route::get('persons/{person}/identity-match', [PersonIdentityMatchController::class, 'showForPerson']); Route::post('identity-matches/{personIdentityMatch}/confirm', [PersonIdentityMatchController::class, 'confirm']); Route::post('identity-matches/{personIdentityMatch}/dismiss', [PersonIdentityMatchController::class, 'dismiss']); + Route::post('identity-matches/{personIdentityMatch}/revert', [PersonIdentityMatchController::class, 'revert']); Route::post('identity-matches/bulk-confirm', [PersonIdentityMatchController::class, 'bulkConfirm']); // Invitations & Members @@ -187,6 +188,8 @@ Route::middleware('auth:sanctum')->group(function () { Route::apiResource('persons', PersonController::class); Route::post('persons/{person}/approve', [PersonController::class, 'approve']); Route::post('persons/{person}/reject', [PersonController::class, 'reject']); + Route::post('persons/{person}/manual-link', [PersonIdentityMatchController::class, 'manualLink']); + Route::post('persons/{person}/unlink', [PersonIdentityMatchController::class, 'unlink']); // Volunteer availabilities Route::get('persons/{person}/availabilities', [VolunteerAvailabilityController::class, 'index']); diff --git a/api/tests/Feature/PersonIdentity/PersonIdentityMatchTest.php b/api/tests/Feature/PersonIdentity/PersonIdentityMatchTest.php index bb23d930..50f01b98 100644 --- a/api/tests/Feature/PersonIdentity/PersonIdentityMatchTest.php +++ b/api/tests/Feature/PersonIdentity/PersonIdentityMatchTest.php @@ -29,6 +29,7 @@ class PersonIdentityMatchTest extends TestCase private Organisation $otherOrganisation; private Event $event; private CrowdType $crowdType; + private CrowdType $crewType; private PersonIdentityService $identityService; protected function setUp(): void @@ -51,62 +52,58 @@ class PersonIdentityMatchTest extends TestCase 'organisation_id' => $this->organisation->id, ]); + $this->crewType = CrowdType::factory()->systemType('CREW')->create([ + 'organisation_id' => $this->organisation->id, + ]); + $this->identityService = app(PersonIdentityService::class); } // ────────────────────────────────────────────────────── - // Detection tests + // Detection tests (10) // ────────────────────────────────────────────────────── - public function test_creating_person_with_existing_user_email_creates_pending_match(): void + public function test_email_match_person_email_equals_user_email_in_same_org(): void { $matchUser = User::factory()->create(['email' => 'jan@example.nl']); + $this->organisation->users()->attach($matchUser, ['role' => 'org_member']); - Sanctum::actingAs($this->orgAdmin); - - $response = $this->postJson("/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/persons", [ + $person = Person::factory()->create([ + 'event_id' => $this->event->id, 'crowd_type_id' => $this->crowdType->id, - 'first_name' => 'Jan', - 'last_name' => 'de Vries', 'email' => 'jan@example.nl', + 'user_id' => null, ]); - $response->assertCreated(); + $matches = $this->identityService->detectMatches($person); - $person = Person::where('email', 'jan@example.nl') - ->where('event_id', $this->event->id) - ->first(); - - $this->assertNull($person->user_id, 'Person should NOT be auto-linked'); - - $this->assertDatabaseHas('person_identity_matches', [ - 'person_id' => $person->id, - 'matched_user_id' => $matchUser->id, - 'matched_on' => IdentityMatchMethod::EMAIL->value, - 'confidence' => IdentityMatchConfidence::EXACT->value, - 'status' => IdentityMatchStatus::PENDING->value, - ]); + $this->assertCount(1, $matches); + $this->assertEquals(IdentityMatchMethod::EMAIL, $matches->first()->matched_on); + $this->assertEquals(IdentityMatchConfidence::HIGH, $matches->first()->confidence); + $this->assertEquals(IdentityMatchStatus::PENDING, $matches->first()->status); } - public function test_creating_person_with_unknown_email_creates_no_match(): void + public function test_email_match_different_org_creates_no_match(): void { - Sanctum::actingAs($this->orgAdmin); + $matchUser = User::factory()->create(['email' => 'foreign@example.nl']); + $this->otherOrganisation->users()->attach($matchUser, ['role' => 'org_member']); - $response = $this->postJson("/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/persons", [ + $person = Person::factory()->create([ + 'event_id' => $this->event->id, 'crowd_type_id' => $this->crowdType->id, - 'first_name' => 'Piet', - 'last_name' => 'Jansen', - 'email' => 'unknown@example.nl', + 'email' => 'foreign@example.nl', + 'user_id' => null, ]); - $response->assertCreated(); + $matches = $this->identityService->detectMatches($person); - $this->assertDatabaseCount('person_identity_matches', 0); + $this->assertCount(0, $matches); } - public function test_creating_person_with_user_id_set_creates_no_match(): void + public function test_no_match_when_person_already_has_user_id(): void { $linkedUser = User::factory()->create(['email' => 'linked@example.nl']); + $this->organisation->users()->attach($linkedUser, ['role' => 'org_member']); $person = Person::factory()->create([ 'event_id' => $this->event->id, @@ -115,124 +112,186 @@ class PersonIdentityMatchTest extends TestCase 'user_id' => $linkedUser->id, ]); - $result = $this->identityService->detectMatchForPerson($person); + $matches = $this->identityService->detectMatches($person); - $this->assertNull($result); - $this->assertDatabaseCount('person_identity_matches', 0); + $this->assertCount(0, $matches); } - public function test_creating_user_detects_existing_unlinked_persons(): void + public function test_no_match_when_person_email_matches_no_user(): void { - $event2 = Event::factory()->create(['organisation_id' => $this->organisation->id]); - - Person::factory()->create([ - 'event_id' => $this->event->id, - 'crowd_type_id' => $this->crowdType->id, - 'email' => 'maria@example.nl', - 'user_id' => null, - ]); - - Person::factory()->create([ - 'event_id' => $event2->id, - 'crowd_type_id' => $this->crowdType->id, - 'email' => 'maria@example.nl', - 'user_id' => null, - ]); - - $user = User::factory()->create(['email' => 'maria@example.nl']); - - $count = $this->identityService->detectMatchesForUser($user); - - $this->assertEquals(2, $count); - $this->assertDatabaseCount('person_identity_matches', 2); - } - - public function test_no_duplicate_match_records(): void - { - $matchUser = User::factory()->create(['email' => 'dedup@example.nl']); $person = Person::factory()->create([ 'event_id' => $this->event->id, 'crowd_type_id' => $this->crowdType->id, - 'email' => 'dedup@example.nl', + 'email' => 'nomatch@nowhere.test', + 'first_name' => 'Unique', + 'last_name' => 'Nobody', 'user_id' => null, ]); - $first = $this->identityService->detectMatchForPerson($person); - $second = $this->identityService->detectMatchForPerson($person); + $matches = $this->identityService->detectMatches($person); - $this->assertNotNull($first); - $this->assertNotNull($second); - $this->assertEquals($first->id, $second->id); - $this->assertDatabaseCount('person_identity_matches', 1); + $this->assertCount(0, $matches); } - public function test_no_match_when_user_already_linked_in_same_event(): void + public function test_fuzzy_name_match_similar_first_name(): void { - $existingUser = User::factory()->create(['email' => 'already@example.nl']); - - // User already has a person in this event (linked) - Person::factory()->create([ - 'event_id' => $this->event->id, - 'crowd_type_id' => $this->crowdType->id, - 'email' => 'other@example.nl', - 'user_id' => $existingUser->id, + $matchUser = User::factory()->create([ + 'first_name' => 'Bert', + 'last_name' => 'Hausmans', + 'email' => 'bert@org.nl', ]); - - // Unlinked person with same email as user - $unlinkedPerson = Person::factory()->create([ - 'event_id' => $this->event->id, - 'crowd_type_id' => $this->crowdType->id, - 'email' => 'already@example.nl', - 'user_id' => null, - ]); - - $result = $this->identityService->detectMatchForPerson($unlinkedPerson); - - $this->assertNull($result); - $this->assertDatabaseCount('person_identity_matches', 0); - } - - public function test_matching_is_case_insensitive(): void - { - User::factory()->create(['email' => 'Jan@Example.com']); + $this->organisation->users()->attach($matchUser, ['role' => 'org_member']); $person = Person::factory()->create([ 'event_id' => $this->event->id, 'crowd_type_id' => $this->crowdType->id, - 'email' => 'jan@example.com', + 'first_name' => 'Burt', + 'last_name' => 'Hausmans', + 'email' => 'burt@different.nl', 'user_id' => null, ]); - $result = $this->identityService->detectMatchForPerson($person); + $matches = $this->identityService->detectMatches($person); - $this->assertNotNull($result); - $this->assertDatabaseCount('person_identity_matches', 1); + $this->assertCount(1, $matches); + $this->assertEquals(IdentityMatchMethod::NAME_FUZZY, $matches->first()->matched_on); + $this->assertEquals(IdentityMatchConfidence::MEDIUM, $matches->first()->confidence); } - public function test_soft_deleted_person_is_not_matched(): void + public function test_no_fuzzy_match_when_names_too_different(): void { - User::factory()->create(['email' => 'deleted@example.nl']); + $matchUser = User::factory()->create([ + 'first_name' => 'Jan', + 'last_name' => 'de Vries', + 'email' => 'jan@org.nl', + ]); + $this->organisation->users()->attach($matchUser, ['role' => 'org_member']); $person = Person::factory()->create([ 'event_id' => $this->event->id, 'crowd_type_id' => $this->crowdType->id, - 'email' => 'deleted@example.nl', + 'first_name' => 'Johannes', + 'last_name' => 'de Vries', + 'email' => 'johannes@different.nl', 'user_id' => null, ]); - $person->delete(); + $matches = $this->identityService->detectMatches($person); - $result = $this->identityService->detectMatchForPerson($person); + $this->assertCount(0, $matches); + } - $this->assertNull($result); - $this->assertDatabaseCount('person_identity_matches', 0); + public function test_fuzzy_name_match_with_dob_upgrades_to_high(): void + { + $dob = '1990-06-15'; + $matchUser = User::factory()->create([ + 'first_name' => 'Lisa', + 'last_name' => 'Bakker', + 'email' => 'lisa@org.nl', + 'date_of_birth' => $dob, + ]); + $this->organisation->users()->attach($matchUser, ['role' => 'org_member']); + + $person = Person::factory()->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->crowdType->id, + 'first_name' => 'Liesa', + 'last_name' => 'Bakker', + 'email' => 'liesa@different.nl', + 'date_of_birth' => $dob, + 'user_id' => null, + ]); + + $matches = $this->identityService->detectMatches($person); + + $this->assertCount(1, $matches); + $this->assertEquals(IdentityMatchConfidence::HIGH, $matches->first()->confidence); + $this->assertContains('date_of_birth', $matches->first()->match_details['matched_fields']); + } + + public function test_fuzzy_name_without_dob_stays_medium(): void + { + $matchUser = User::factory()->create([ + 'first_name' => 'Lisa', + 'last_name' => 'Bakker', + 'email' => 'lisa@org.nl', + 'date_of_birth' => '1990-06-15', + ]); + $this->organisation->users()->attach($matchUser, ['role' => 'org_member']); + + $person = Person::factory()->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->crowdType->id, + 'first_name' => 'Liesa', + 'last_name' => 'Bakker', + 'email' => 'liesa@different.nl', + 'date_of_birth' => '1991-01-01', // different DOB + 'user_id' => null, + ]); + + $matches = $this->identityService->detectMatches($person); + + $this->assertCount(1, $matches); + $this->assertEquals(IdentityMatchConfidence::MEDIUM, $matches->first()->confidence); + } + + public function test_previously_dismissed_pair_not_re_suggested(): void + { + $matchUser = User::factory()->create(['email' => 'dismissed@example.nl']); + $this->organisation->users()->attach($matchUser, ['role' => 'org_member']); + + // Create person with different email first (so observer doesn't match on create) + $person = Person::factory()->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->crowdType->id, + 'email' => 'different@example.nl', + 'first_name' => 'Unique', + 'last_name' => 'TestPerson', + 'user_id' => null, + ]); + + // Create and dismiss match manually + PersonIdentityMatch::factory()->dismissed()->create([ + 'person_id' => $person->id, + 'matched_user_id' => $matchUser->id, + ]); + + // Update email to match the user + $person->update(['email' => 'dismissed@example.nl']); + $person->refresh(); + + // Calling detectMatches should NOT re-suggest the dismissed pair + $matches = $this->identityService->detectMatches($person); + + $this->assertCount(0, $matches); + } + + public function test_observer_fires_on_person_create(): void + { + $matchUser = User::factory()->create(['email' => 'observer@example.nl']); + $this->organisation->users()->attach($matchUser, ['role' => 'org_member']); + + Sanctum::actingAs($this->orgAdmin); + + $this->postJson("/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/persons", [ + 'crowd_type_id' => $this->crowdType->id, + 'first_name' => 'Test', + 'last_name' => 'Observer', + 'email' => 'observer@example.nl', + ]); + + $this->assertDatabaseHas('person_identity_matches', [ + 'matched_user_id' => $matchUser->id, + 'matched_on' => IdentityMatchMethod::EMAIL->value, + 'status' => IdentityMatchStatus::PENDING->value, + ]); } // ────────────────────────────────────────────────────── - // Resolution tests + // Confirm tests (5) // ────────────────────────────────────────────────────── - public function test_confirm_match_links_person_to_user(): void + public function test_confirm_pending_sets_user_id_and_status(): void { $matchUser = User::factory()->create(['email' => 'confirm@example.nl']); $person = Person::factory()->create([ @@ -261,11 +320,121 @@ class PersonIdentityMatchTest extends TestCase $this->assertEquals($matchUser->id, $person->user_id); $this->assertEquals(IdentityMatchStatus::CONFIRMED, $match->status); - $this->assertEquals($this->orgAdmin->id, $match->resolved_by_user_id); - $this->assertNotNull($match->resolved_at); + $this->assertEquals($this->orgAdmin->id, $match->confirmed_by_user_id); + $this->assertNotNull($match->confirmed_at); } - public function test_dismiss_match_keeps_person_unlinked(): void + public function test_confirm_dismisses_other_pending_matches(): void + { + $matchUser1 = User::factory()->create(); + $matchUser2 = User::factory()->create(); + $person = Person::factory()->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->crowdType->id, + 'user_id' => null, + ]); + + $match1 = PersonIdentityMatch::factory()->create([ + 'person_id' => $person->id, + 'matched_user_id' => $matchUser1->id, + 'status' => IdentityMatchStatus::PENDING, + ]); + + $match2 = PersonIdentityMatch::factory()->create([ + 'person_id' => $person->id, + 'matched_user_id' => $matchUser2->id, + 'status' => IdentityMatchStatus::PENDING, + ]); + + $this->identityService->confirmMatch($match1, $this->orgAdmin); + + $match2->refresh(); + $this->assertEquals(IdentityMatchStatus::DISMISSED, $match2->status); + $this->assertEquals($this->orgAdmin->id, $match2->dismissed_by_user_id); + } + + public function test_confirm_when_user_already_linked_same_crowd_type_fails(): void + { + $matchUser = User::factory()->create(); + + // Already linked with same crowd type + Person::factory()->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->crowdType->id, + 'user_id' => $matchUser->id, + ]); + + $person = Person::factory()->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->crowdType->id, + 'user_id' => null, + ]); + + $match = PersonIdentityMatch::factory()->create([ + 'person_id' => $person->id, + 'matched_user_id' => $matchUser->id, + 'status' => IdentityMatchStatus::PENDING, + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson( + "/api/v1/organisations/{$this->organisation->id}/identity-matches/{$match->id}/confirm" + ); + + $response->assertStatus(422); + } + + public function test_confirm_non_pending_fails(): void + { + $person = Person::factory()->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->crowdType->id, + 'user_id' => null, + ]); + + $match = PersonIdentityMatch::factory()->confirmed()->create([ + 'person_id' => $person->id, + 'matched_user_id' => User::factory()->create()->id, + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson( + "/api/v1/organisations/{$this->organisation->id}/identity-matches/{$match->id}/confirm" + ); + + $response->assertStatus(422); + } + + public function test_confirm_triggers_tag_sync(): void + { + $matchUser = User::factory()->create(['email' => 'tags@example.nl']); + $person = Person::factory()->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->crowdType->id, + 'email' => 'tags@example.nl', + 'user_id' => null, + ]); + + $match = PersonIdentityMatch::factory()->create([ + 'person_id' => $person->id, + 'matched_user_id' => $matchUser->id, + 'status' => IdentityMatchStatus::PENDING, + ]); + + // Confirm — should not throw even if no tags exist + $this->identityService->confirmMatch($match, $this->orgAdmin); + + $person->refresh(); + $this->assertEquals($matchUser->id, $person->user_id); + } + + // ────────────────────────────────────────────────────── + // Dismiss tests (2) + // ────────────────────────────────────────────────────── + + public function test_dismiss_pending_sets_status_dismissed(): void { $matchUser = User::factory()->create(['email' => 'dismiss@example.nl']); $person = Person::factory()->create([ @@ -289,72 +458,14 @@ class PersonIdentityMatchTest extends TestCase $response->assertOk(); - $person->refresh(); $match->refresh(); - - $this->assertNull($person->user_id); $this->assertEquals(IdentityMatchStatus::DISMISSED, $match->status); - $this->assertEquals($this->orgAdmin->id, $match->resolved_by_user_id); - $this->assertNotNull($match->resolved_at); + $this->assertEquals($this->orgAdmin->id, $match->dismissed_by_user_id); + $this->assertNotNull($match->dismissed_at); + $this->assertNull($person->fresh()->user_id); } - public function test_dismissed_match_not_shown_in_index(): void - { - $matchUser = User::factory()->create(); - $person = Person::factory()->create([ - 'event_id' => $this->event->id, - 'crowd_type_id' => $this->crowdType->id, - 'user_id' => null, - ]); - - PersonIdentityMatch::factory()->create([ - 'person_id' => $person->id, - 'matched_user_id' => $matchUser->id, - 'status' => IdentityMatchStatus::PENDING, - ]); - - PersonIdentityMatch::factory()->dismissed()->create([ - 'person_id' => Person::factory()->create([ - 'event_id' => $this->event->id, - 'crowd_type_id' => $this->crowdType->id, - 'user_id' => null, - ])->id, - 'matched_user_id' => User::factory()->create()->id, - ]); - - Sanctum::actingAs($this->orgAdmin); - - $response = $this->getJson( - "/api/v1/organisations/{$this->organisation->id}/identity-matches" - ); - - $response->assertOk(); - $this->assertCount(1, $response->json('data')); - } - - public function test_confirm_already_confirmed_match_returns_error(): void - { - $person = Person::factory()->create([ - 'event_id' => $this->event->id, - 'crowd_type_id' => $this->crowdType->id, - 'user_id' => null, - ]); - - $match = PersonIdentityMatch::factory()->confirmed()->create([ - 'person_id' => $person->id, - 'matched_user_id' => User::factory()->create()->id, - ]); - - Sanctum::actingAs($this->orgAdmin); - - $response = $this->postJson( - "/api/v1/organisations/{$this->organisation->id}/identity-matches/{$match->id}/confirm" - ); - - $response->assertStatus(422); - } - - public function test_dismiss_already_dismissed_match_returns_error(): void + public function test_dismiss_non_pending_fails(): void { $person = Person::factory()->create([ 'event_id' => $this->event->id, @@ -376,6 +487,95 @@ class PersonIdentityMatchTest extends TestCase $response->assertStatus(422); } + // ────────────────────────────────────────────────────── + // Revert tests (3) + // ────────────────────────────────────────────────────── + + public function test_revert_confirmed_clears_user_id_and_sets_reverted(): void + { + $matchUser = User::factory()->create(); + $person = Person::factory()->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->crowdType->id, + 'user_id' => $matchUser->id, + ]); + + $match = PersonIdentityMatch::factory()->confirmed()->create([ + 'person_id' => $person->id, + 'matched_user_id' => $matchUser->id, + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson( + "/api/v1/organisations/{$this->organisation->id}/identity-matches/{$match->id}/revert" + ); + + $response->assertOk(); + + $person->refresh(); + $match->refresh(); + + $this->assertNull($person->user_id); + $this->assertEquals(IdentityMatchStatus::REVERTED, $match->status); + $this->assertEquals($this->orgAdmin->id, $match->reverted_by_user_id); + $this->assertNotNull($match->reverted_at); + } + + public function test_revert_non_confirmed_fails(): void + { + $person = Person::factory()->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->crowdType->id, + 'user_id' => null, + ]); + + $match = PersonIdentityMatch::factory()->create([ + 'person_id' => $person->id, + 'matched_user_id' => User::factory()->create()->id, + 'status' => IdentityMatchStatus::PENDING, + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson( + "/api/v1/organisations/{$this->organisation->id}/identity-matches/{$match->id}/revert" + ); + + $response->assertStatus(422); + } + + public function test_revert_creates_activity_log(): void + { + $matchUser = User::factory()->create(); + $person = Person::factory()->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->crowdType->id, + 'user_id' => $matchUser->id, + ]); + + $match = PersonIdentityMatch::factory()->confirmed()->create([ + 'person_id' => $person->id, + 'matched_user_id' => $matchUser->id, + ]); + + Sanctum::actingAs($this->orgAdmin); + + $this->postJson( + "/api/v1/organisations/{$this->organisation->id}/identity-matches/{$match->id}/revert" + )->assertOk(); + + $this->assertDatabaseHas('activity_log', [ + 'description' => 'person.identity.match_reverted', + 'causer_id' => $this->orgAdmin->id, + 'subject_id' => $person->id, + ]); + } + + // ────────────────────────────────────────────────────── + // Bulk confirm tests (2) + // ────────────────────────────────────────────────────── + public function test_bulk_confirm_multiple_matches(): void { $matches = []; @@ -404,16 +604,10 @@ class PersonIdentityMatchTest extends TestCase $response->assertOk(); $this->assertEquals(3, $response->json('confirmed')); $this->assertEmpty($response->json('errors')); - - foreach ($matches as $match) { - $match->refresh(); - $this->assertEquals(IdentityMatchStatus::CONFIRMED, $match->status); - } } public function test_bulk_confirm_skips_conflicts(): void { - // Match 1: normal, should succeed $matchUser1 = User::factory()->create(); $person1 = Person::factory()->create([ 'event_id' => $this->event->id, @@ -426,7 +620,7 @@ class PersonIdentityMatchTest extends TestCase 'status' => IdentityMatchStatus::PENDING, ]); - // Match 2: user already linked in same event → should error + // Match 2: user already linked in same event with same crowd type → should error $matchUser2 = User::factory()->create(); Person::factory()->create([ 'event_id' => $this->event->id, @@ -454,14 +648,200 @@ class PersonIdentityMatchTest extends TestCase $response->assertOk(); $this->assertEquals(1, $response->json('confirmed')); $this->assertCount(1, $response->json('errors')); - $this->assertEquals($match2->id, $response->json('errors.0.match_id')); } // ────────────────────────────────────────────────────── - // Authorization tests + // Manual link tests (4) // ────────────────────────────────────────────────────── - public function test_cross_org_cannot_resolve_match(): void + public function test_manual_link_sets_user_id_and_creates_match(): void + { + $targetUser = User::factory()->create(); + $person = Person::factory()->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->crowdType->id, + 'user_id' => null, + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson( + "/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/persons/{$person->id}/manual-link", + ['user_id' => $targetUser->id] + ); + + $response->assertOk(); + + $person->refresh(); + $this->assertEquals($targetUser->id, $person->user_id); + + $this->assertDatabaseHas('person_identity_matches', [ + 'person_id' => $person->id, + 'matched_user_id' => $targetUser->id, + 'matched_on' => IdentityMatchMethod::MANUAL->value, + 'confidence' => IdentityMatchConfidence::HIGH->value, + 'status' => IdentityMatchStatus::CONFIRMED->value, + ]); + } + + public function test_manual_link_already_linked_person_fails(): void + { + $existingUser = User::factory()->create(); + $person = Person::factory()->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->crowdType->id, + 'user_id' => $existingUser->id, + ]); + + $newUser = User::factory()->create(); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson( + "/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/persons/{$person->id}/manual-link", + ['user_id' => $newUser->id] + ); + + $response->assertStatus(422); + } + + public function test_manual_link_user_already_at_event_same_crowd_type_fails(): void + { + $targetUser = User::factory()->create(); + + // User already linked at event with same crowd type + Person::factory()->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->crowdType->id, + 'user_id' => $targetUser->id, + ]); + + $person = Person::factory()->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->crowdType->id, + 'user_id' => null, + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson( + "/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/persons/{$person->id}/manual-link", + ['user_id' => $targetUser->id] + ); + + $response->assertStatus(422); + } + + public function test_manual_link_user_at_event_different_crowd_type_succeeds(): void + { + $targetUser = User::factory()->create(); + + // User linked as CREW + Person::factory()->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->crewType->id, + 'user_id' => $targetUser->id, + ]); + + // Try to link as VOLUNTEER (different crowd type) + $person = Person::factory()->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->crowdType->id, + 'user_id' => null, + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson( + "/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/persons/{$person->id}/manual-link", + ['user_id' => $targetUser->id] + ); + + $response->assertOk(); + + $person->refresh(); + $this->assertEquals($targetUser->id, $person->user_id); + } + + // ────────────────────────────────────────────────────── + // Unlink tests (3) + // ────────────────────────────────────────────────────── + + public function test_unlink_via_confirmed_match_reverts(): void + { + $matchUser = User::factory()->create(); + $person = Person::factory()->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->crowdType->id, + 'user_id' => $matchUser->id, + ]); + + PersonIdentityMatch::factory()->confirmed()->create([ + 'person_id' => $person->id, + 'matched_user_id' => $matchUser->id, + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson( + "/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/persons/{$person->id}/unlink" + ); + + $response->assertOk(); + $this->assertNull($person->fresh()->user_id); + } + + public function test_unlink_without_match_record_works(): void + { + $matchUser = User::factory()->create(); + $person = Person::factory()->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->crowdType->id, + 'user_id' => $matchUser->id, + ]); + + // No match record — direct link + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson( + "/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/persons/{$person->id}/unlink" + ); + + $response->assertOk(); + $this->assertNull($person->fresh()->user_id); + } + + public function test_unlink_not_linked_person_fails(): void + { + $person = Person::factory()->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->crowdType->id, + 'user_id' => null, + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson( + "/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/persons/{$person->id}/unlink" + ); + + $response->assertStatus(422); + } + + // ────────────────────────────────────────────────────── + // Authorization tests (2) + // ────────────────────────────────────────────────────── + + public function test_unauthenticated_cannot_access_matches(): void + { + $response = $this->getJson( + "/api/v1/organisations/{$this->organisation->id}/identity-matches" + ); + + $response->assertUnauthorized(); + } + + public function test_wrong_org_user_cannot_confirm(): void { $matchUser = User::factory()->create(); $person = Person::factory()->create([ @@ -485,89 +865,41 @@ class PersonIdentityMatchTest extends TestCase $response->assertForbidden(); } - public function test_unauthenticated_cannot_access_matches(): void - { - $response = $this->getJson( - "/api/v1/organisations/{$this->organisation->id}/identity-matches" - ); - - $response->assertUnauthorized(); - } - // ────────────────────────────────────────────────────── - // Index/filtering tests + // PersonResource tests // ────────────────────────────────────────────────────── - public function test_index_returns_only_pending_matches_for_organisation(): void + public function test_person_resource_includes_has_user_account(): void { $matchUser = User::factory()->create(); - $person = Person::factory()->create([ + Person::factory()->create([ 'event_id' => $this->event->id, 'crowd_type_id' => $this->crowdType->id, + 'email' => 'linked@example.nl', + 'user_id' => $matchUser->id, + ]); + + Person::factory()->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->crowdType->id, + 'email' => 'unlinked@example.nl', 'user_id' => null, ]); - PersonIdentityMatch::factory()->create([ - 'person_id' => $person->id, - 'matched_user_id' => $matchUser->id, - 'status' => IdentityMatchStatus::PENDING, - ]); - - // Match from other organisation — should not appear - $otherEvent = Event::factory()->create(['organisation_id' => $this->otherOrganisation->id]); - $otherCrowdType = CrowdType::factory()->systemType('VOLUNTEER')->create([ - 'organisation_id' => $this->otherOrganisation->id, - ]); - $otherPerson = Person::factory()->create([ - 'event_id' => $otherEvent->id, - 'crowd_type_id' => $otherCrowdType->id, - 'user_id' => null, - ]); - - PersonIdentityMatch::factory()->create([ - 'person_id' => $otherPerson->id, - 'matched_user_id' => User::factory()->create()->id, - 'status' => IdentityMatchStatus::PENDING, - ]); - Sanctum::actingAs($this->orgAdmin); - $response = $this->getJson( - "/api/v1/organisations/{$this->organisation->id}/identity-matches" - ); + $response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/persons"); $response->assertOk(); - $this->assertCount(1, $response->json('data')); + + $linkedPerson = collect($response->json('data'))->firstWhere('email', 'linked@example.nl'); + $unlinkedPerson = collect($response->json('data'))->firstWhere('email', 'unlinked@example.nl'); + + $this->assertTrue($linkedPerson['has_user_account']); + $this->assertFalse($unlinkedPerson['has_user_account']); } - public function test_index_paginates_results(): void - { - for ($i = 0; $i < 30; $i++) { - $person = Person::factory()->create([ - 'event_id' => $this->event->id, - 'crowd_type_id' => $this->crowdType->id, - 'user_id' => null, - ]); - - PersonIdentityMatch::factory()->create([ - 'person_id' => $person->id, - 'matched_user_id' => User::factory()->create()->id, - 'status' => IdentityMatchStatus::PENDING, - ]); - } - - Sanctum::actingAs($this->orgAdmin); - - $response = $this->getJson( - "/api/v1/organisations/{$this->organisation->id}/identity-matches" - ); - - $response->assertOk(); - $this->assertCount(25, $response->json('data')); - $this->assertEquals(30, $response->json('meta.total')); - } - - public function test_person_resource_includes_pending_match(): void + public function test_person_resource_includes_pending_identity_match(): void { $matchUser = User::factory()->create(['email' => 'inline@example.nl']); $person = Person::factory()->create([ @@ -589,66 +921,10 @@ class PersonIdentityMatchTest extends TestCase $response->assertOk(); - $personData = collect($response->json('data')) - ->firstWhere('email', 'inline@example.nl'); + $personData = collect($response->json('data'))->firstWhere('email', 'inline@example.nl'); $this->assertNotNull($personData); $this->assertArrayHasKey('pending_identity_match', $personData); $this->assertEquals($matchUser->id, $personData['pending_identity_match']['matched_user']['id']); } - - public function test_person_resource_excludes_match_when_none_pending(): void - { - Person::factory()->create([ - 'event_id' => $this->event->id, - 'crowd_type_id' => $this->crowdType->id, - 'email' => 'nopending@example.nl', - 'user_id' => null, - ]); - - Sanctum::actingAs($this->orgAdmin); - - $response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/persons"); - - $response->assertOk(); - - $personData = collect($response->json('data')) - ->firstWhere('email', 'nopending@example.nl'); - - $this->assertNotNull($personData); - $this->assertArrayNotHasKey('pending_identity_match', $personData); - } - - // ────────────────────────────────────────────────────── - // Activity log tests - // ────────────────────────────────────────────────────── - - public function test_confirm_match_creates_activity_log(): void - { - $matchUser = User::factory()->create(['email' => 'audit@example.nl']); - $person = Person::factory()->create([ - 'event_id' => $this->event->id, - 'crowd_type_id' => $this->crowdType->id, - 'email' => 'audit@example.nl', - 'user_id' => null, - ]); - - $match = PersonIdentityMatch::factory()->create([ - 'person_id' => $person->id, - 'matched_user_id' => $matchUser->id, - 'status' => IdentityMatchStatus::PENDING, - ]); - - Sanctum::actingAs($this->orgAdmin); - - $this->postJson( - "/api/v1/organisations/{$this->organisation->id}/identity-matches/{$match->id}/confirm" - )->assertOk(); - - $this->assertDatabaseHas('activity_log', [ - 'description' => 'person.identity.match_confirmed', - 'causer_id' => $this->orgAdmin->id, - 'subject_id' => $person->id, - ]); - } } diff --git a/apps/app/src/components/persons/PersonDetailPanel.vue b/apps/app/src/components/persons/PersonDetailPanel.vue index a64748c8..238a3b1c 100644 --- a/apps/app/src/components/persons/PersonDetailPanel.vue +++ b/apps/app/src/components/persons/PersonDetailPanel.vue @@ -1,6 +1,9 @@ + + + + + + Koppeling verbreken? + + + Weet je zeker dat je de koppeling met + {{ person?.user_account?.email }} wilt verbreken? +

+ Let op: deze persoon kan daarna niet meer inloggen + op het portaal en ziet geen shifts meer. +
+ + + + Annuleren + + + Ontkoppelen + + +
+
+ + + + + + Handmatig koppelen + + +

+ Kies een organisatielid om te koppelen aan + {{ person?.full_name }} +

+ + + + + {{ member.full_name }} + {{ member.email }} + + + + Geen leden gevonden voor "{{ linkSearchQuery }}" + +
+ + + + Sluiten + + +
+
diff --git a/apps/app/src/composables/api/useIdentityMatches.ts b/apps/app/src/composables/api/useIdentityMatches.ts new file mode 100644 index 00000000..d98915bd --- /dev/null +++ b/apps/app/src/composables/api/useIdentityMatches.ts @@ -0,0 +1,151 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query' +import type { MaybeRef } from 'vue' +import { unref } from 'vue' +import { apiClient } from '@/lib/axios' +import type { IdentityMatch } from '@/types/identityMatch' +import type { Person } from '@/types/person' + +interface ApiResponse { + success: boolean + data: T +} + +interface PaginatedResponse { + data: T[] + links: Record + meta: { + current_page: number + per_page: number + total: number + last_page: number + } +} + +interface BulkConfirmResult { + confirmed: number + errors: Array<{ match_id: string; error: string }> +} + +export function useIdentityMatches(orgId: MaybeRef) { + return useQuery({ + queryKey: ['identity-matches', orgId], + queryFn: async () => { + const { data } = await apiClient.get>( + `/organisations/${unref(orgId)}/identity-matches`, + ) + + return data + }, + enabled: () => !!unref(orgId), + }) +} + +export function useConfirmMatch(orgId: MaybeRef) { + const qc = useQueryClient() + + return useMutation({ + mutationFn: async (matchId: string) => { + const { data } = await apiClient.post>( + `/organisations/${unref(orgId)}/identity-matches/${matchId}/confirm`, + ) + + return data.data + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['identity-matches'] }) + qc.invalidateQueries({ queryKey: ['persons'] }) + }, + }) +} + +export function useDismissMatch(orgId: MaybeRef) { + const qc = useQueryClient() + + return useMutation({ + mutationFn: async (matchId: string) => { + const { data } = await apiClient.post>( + `/organisations/${unref(orgId)}/identity-matches/${matchId}/dismiss`, + ) + + return data.data + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['identity-matches'] }) + qc.invalidateQueries({ queryKey: ['persons'] }) + }, + }) +} + +export function useRevertMatch(orgId: MaybeRef) { + const qc = useQueryClient() + + return useMutation({ + mutationFn: async (matchId: string) => { + const { data } = await apiClient.post>( + `/organisations/${unref(orgId)}/identity-matches/${matchId}/revert`, + ) + + return data.data + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['identity-matches'] }) + qc.invalidateQueries({ queryKey: ['persons'] }) + }, + }) +} + +export function useBulkConfirmMatches(orgId: MaybeRef) { + const qc = useQueryClient() + + return useMutation({ + mutationFn: async (matchIds: string[]) => { + const { data } = await apiClient.post( + `/organisations/${unref(orgId)}/identity-matches/bulk-confirm`, + { match_ids: matchIds }, + ) + + return data + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['identity-matches'] }) + qc.invalidateQueries({ queryKey: ['persons'] }) + }, + }) +} + +export function useManualLinkPerson(orgId: MaybeRef, eventId: MaybeRef) { + const qc = useQueryClient() + + return useMutation({ + mutationFn: async ({ personId, userId }: { personId: string; userId: string }) => { + const { data } = await apiClient.post>( + `/organisations/${unref(orgId)}/events/${unref(eventId)}/persons/${personId}/manual-link`, + { user_id: userId }, + ) + + return data.data + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['identity-matches'] }) + qc.invalidateQueries({ queryKey: ['persons'] }) + }, + }) +} + +export function useUnlinkPerson(orgId: MaybeRef, eventId: MaybeRef) { + const qc = useQueryClient() + + return useMutation({ + mutationFn: async (personId: string) => { + const { data } = await apiClient.post>( + `/organisations/${unref(orgId)}/events/${unref(eventId)}/persons/${personId}/unlink`, + ) + + return data.data + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['identity-matches'] }) + qc.invalidateQueries({ queryKey: ['persons'] }) + }, + }) +} diff --git a/apps/app/src/pages/events/[id]/persons/index.vue b/apps/app/src/pages/events/[id]/persons/index.vue index 2adbbd80..9886d994 100644 --- a/apps/app/src/pages/events/[id]/persons/index.vue +++ b/apps/app/src/pages/events/[id]/persons/index.vue @@ -294,6 +294,29 @@ const crowdTypeOptions = computed(() => [ {{ getInitials(item.full_name) }} {{ item.full_name }} + + + Mogelijke match: {{ item.pending_identity_match.matched_user.full_name }} + ({{ item.pending_identity_match.confidence_label }}) + + + tabler-user-check + diff --git a/apps/app/src/types/identityMatch.ts b/apps/app/src/types/identityMatch.ts new file mode 100644 index 00000000..46f6bb32 --- /dev/null +++ b/apps/app/src/types/identityMatch.ts @@ -0,0 +1,69 @@ +export const MatchMethod = { + EMAIL: 'email', + NAME_FUZZY: 'name_fuzzy', + MANUAL: 'manual', +} as const +export type MatchMethod = (typeof MatchMethod)[keyof typeof MatchMethod] + +export const MatchConfidence = { + HIGH: 'high', + MEDIUM: 'medium', +} as const +export type MatchConfidence = (typeof MatchConfidence)[keyof typeof MatchConfidence] + +export const MatchStatus = { + PENDING: 'pending', + CONFIRMED: 'confirmed', + DISMISSED: 'dismissed', + REVERTED: 'reverted', +} as const +export type MatchStatus = (typeof MatchStatus)[keyof typeof MatchStatus] + +export interface IdentityMatchUser { + id: string + first_name: string + last_name: string + full_name: string + email: string + date_of_birth: string | null +} + +export interface IdentityMatchDetails { + person_email: string | null + user_email: string + person_name: string + user_name: string + person_dob: string | null + user_dob: string | null + matched_fields: string[] + organisation_id: string +} + +export interface IdentityMatch { + id: string + person: { + id: string + name: string + email: string | null + crowd_type: string | null + event: { + id: string + name: string + } | null + } + matched_user: IdentityMatchUser + matched_on: MatchMethod + matched_on_label: string + confidence: MatchConfidence + confidence_label: string + status: MatchStatus + status_label: string + match_details: IdentityMatchDetails | null + confirmed_by: { id: string; full_name: string } | null + confirmed_at: string | null + dismissed_at: string | null + reverted_at: string | null + resolved_by: { id: string; full_name: string } | null + resolved_at: string | null + created_at: string +} diff --git a/apps/app/src/types/person.ts b/apps/app/src/types/person.ts index 5b7f4671..26a06bdd 100644 --- a/apps/app/src/types/person.ts +++ b/apps/app/src/types/person.ts @@ -1,4 +1,5 @@ import type { Company, CrowdType } from '@/types/organisation' +import type { IdentityMatchDetails, MatchConfidence, MatchMethod } from '@/types/identityMatch' export const PersonStatus = { INVITED: 'invited', @@ -38,9 +39,13 @@ export interface PendingIdentityMatch { last_name: string full_name: string email: string + date_of_birth: string | null } - matched_on: string - confidence: string + matched_on: MatchMethod + matched_on_label: string + confidence: MatchConfidence + confidence_label: string + match_details: IdentityMatchDetails | null } export interface Person { @@ -59,6 +64,8 @@ export interface Person { created_at: string crowd_type: CrowdType | null company: Company | null + has_user_account: boolean + user_account: { id: string; email: string; full_name: string } | null pending_identity_match?: PendingIdentityMatch crowd_list_pivot?: CrowdListPivot tags?: PersonTag[] diff --git a/dev-docs/API.md b/dev-docs/API.md index 8f4b91d8..a878ad30 100644 --- a/dev-docs/API.md +++ b/dev-docs/API.md @@ -348,17 +348,52 @@ Validates: ## Identity Matches +### Endpoints + - `GET /organisations/{org}/identity-matches` — list pending matches for the organisation (paginated, 25 per page) - `GET /organisations/{org}/persons/{person}/identity-match` — show pending match for a specific person -- `POST /organisations/{org}/identity-matches/{match}/confirm` — confirm a match (links `person.user_id`) -- `POST /organisations/{org}/identity-matches/{match}/dismiss` — dismiss a match (hidden, person stays unlinked) +- `POST /organisations/{org}/identity-matches/{match}/confirm` — confirm a match (links `person.user_id`, dismisses other pending matches, syncs tags) +- `POST /organisations/{org}/identity-matches/{match}/dismiss` — dismiss a match (hidden, person stays unlinked, not re-suggested) +- `POST /organisations/{org}/identity-matches/{match}/revert` — revert a confirmed match (unlinks `person.user_id`, status → `reverted`) - `POST /organisations/{org}/identity-matches/bulk-confirm` — bulk confirm multiple matches +- `POST /organisations/{org}/events/{event}/persons/{person}/manual-link` — manually link a person to a user account (body: `{ "user_id": "ulid" }`) +- `POST /organisations/{org}/events/{event}/persons/{person}/unlink` — unlink a person from their user account + +### Match Types (`IdentityMatchMethod`) + +| Value | Description | Confidence | +| ------------ | ------------------------------------ | ---------- | +| `email` | Exact email match within org | `high` | +| `name_fuzzy` | Levenshtein fuzzy name match | `medium` (or `high` if DOB also matches) | +| `manual` | Organiser-initiated manual link | `high` | + +### Match Confidence (`IdentityMatchConfidence`) + +| Value | Description | +| -------- | -------------------------------------------------------- | +| `high` | High certainty — exact email, or fuzzy name + DOB match | +| `medium` | Moderate certainty — fuzzy name match without DOB | + +### Match Status (`IdentityMatchStatus`) + +| Value | Description | +| ----------- | ------------------------------------------------- | +| `pending` | Awaiting organiser review | +| `confirmed` | Organiser confirmed — `person.user_id` is linked | +| `dismissed` | Organiser dismissed — not re-suggested | +| `reverted` | Previously confirmed, then unlinked | ### Detection -Matches are created automatically: -- When a person is created (via `POST /organisations/{org}/events/{event}/persons`) with an email matching an existing user → pending match created -- When a new user account is created (invitation acceptance) with an email matching unlinked persons → pending matches created +Matches are detected automatically via `PersonObserver`: +- **On Person create**: if person has no `user_id` and has an email or name, `PersonIdentityService::detectMatches()` runs +- **On Person email update**: if person's email changed and person is unlinked, detection re-runs +- **On user creation**: `PersonIdentityService::detectMatchesForUser()` finds all unlinked persons with matching email + +Detection strategies (in priority order): +1. **Exact email** within same organisation → `email` / `high` +2. **Fuzzy name** (Levenshtein distance ≤2 for short names, ≤3 for longer) → `name_fuzzy` / `medium` +3. **Fuzzy name + DOB match** → upgrades to `high` confidence No silent auto-linking. Every identity link requires explicit confirmation. @@ -368,19 +403,24 @@ No silent auto-linking. Every identity link requires explicit confirmation. Body: `{ "match_ids": ["ulid1", "ulid2", ...] }` (max 100) -Response: `{ "confirmed": 2, "errors": [{ "match_id": "ulid3", "error": "User already has a person record in this event." }] }` +Response: `{ "confirmed": 2, "errors": [{ "match_id": "ulid3", "error": "User already has a person record with this crowd type in this event." }] }` ### PersonResource enrichment -`GET /organisations/{org}/events/{event}/persons` includes `pending_identity_match` inline when a pending match exists: +`GET /organisations/{org}/events/{event}/persons` now includes: ```json { + "has_user_account": true, + "user_account": { "id": "ulid", "email": "jan@example.nl", "full_name": "Jan de Vries" }, "pending_identity_match": { "match_id": "ulid", - "matched_user": { "id": "ulid", "first_name": "Jan", "last_name": "", "full_name": "Jan", "email": "jan@example.nl" }, + "matched_user": { "id": "ulid", "first_name": "Jan", "last_name": "de Vries", "full_name": "Jan de Vries", "email": "jan@example.nl", "date_of_birth": "1990-01-01" }, "matched_on": "email", - "confidence": "exact" + "matched_on_label": "E-mail match", + "confidence": "high", + "confidence_label": "Hoge zekerheid", + "match_details": { "matched_fields": ["email"], "..." : "..." } } } ``` diff --git a/dev-docs/SCHEMA.md b/dev-docs/SCHEMA.md index 44e0cd80..27c01a46 100644 --- a/dev-docs/SCHEMA.md +++ b/dev-docs/SCHEMA.md @@ -769,32 +769,47 @@ $effectiveDate = $shift->end_date ?? $shift->timeSlot->date; ## 3.5.5c Person Identity Matching -> **v1.8:** Enterprise-grade identity resolution with three steps: detect → suggest → confirm. -> No silent auto-linking. When a person is created with an email matching an existing user, -> or when a new user account is created with an email matching unlinked persons, the system -> creates pending match records for organisers to review. +> **v1.8+:** Enterprise-grade identity resolution with three steps: detect → suggest → confirm. +> No silent auto-linking. Supports email matching (HIGH confidence), fuzzy name matching +> (MEDIUM confidence, upgradable to HIGH with DOB match), manual linking, and revert/unlink. +> PersonObserver triggers detection automatically on Person create/update. ### `person_identity_matches` -| Column | Type | Notes | -| ---------------------- | ------------------ | ---------------------------------------------------------------------------- | -| `id` | ULID | PK — `HasUlids` trait. Entity with its own lifecycle, not a pure pivot | -| `person_id` | ULID FK | → persons. `constrained()->cascadeOnDelete()` | -| `matched_user_id` | ULID FK | → users. Named `matched_user_id` (not `user_id`) to avoid confusion with `persons.user_id`. `constrained()->cascadeOnDelete()` | -| `matched_on` | string | Enum: `email\|phone\|manual` (`IdentityMatchMethod`) | -| `confidence` | string | Enum: `exact\|fuzzy` (`IdentityMatchConfidence`). `exact` = deterministic match, `fuzzy` = algorithmic | -| `status` | string | Enum: `pending\|confirmed\|dismissed` (`IdentityMatchStatus`), default `pending` | -| `resolved_by_user_id` | ULID FK nullable | → users (who confirmed or dismissed). `constrained()->nullOnDelete()` | -| `resolved_at` | timestamp nullable | When the match was confirmed or dismissed | -| `created_at` | timestamp | | +| Column | Type | Notes | +| ----------------------- | ------------------ | ---------------------------------------------------------------------------- | +| `id` | ULID | PK — `HasUlids` trait. Entity with its own lifecycle, not a pure pivot | +| `person_id` | ULID FK | → persons. `constrained()->cascadeOnDelete()` | +| `matched_user_id` | ULID FK | → users. Named `matched_user_id` (not `user_id`) to avoid confusion with `persons.user_id`. `constrained()->cascadeOnDelete()` | +| `matched_on` | string | Enum: `email\|name_fuzzy\|manual` (`IdentityMatchMethod`) | +| `confidence` | string | Enum: `high\|medium` (`IdentityMatchConfidence`). `high` = exact email or fuzzy+DOB, `medium` = fuzzy name only | +| `status` | string | Enum: `pending\|confirmed\|dismissed\|reverted` (`IdentityMatchStatus`), default `pending` | +| `match_details` | JSON nullable | Snapshot of matched fields, emails, names, DOB at detection time | +| `confirmed_by_user_id` | ULID FK nullable | → users (who confirmed). `constrained()->nullOnDelete()` | +| `confirmed_at` | timestamp nullable | When the match was confirmed | +| `dismissed_by_user_id` | ULID FK nullable | → users (who dismissed). `constrained()->nullOnDelete()` | +| `dismissed_at` | timestamp nullable | When the match was dismissed | +| `reverted_by_user_id` | ULID FK nullable | → users (who reverted/unlinked). `constrained()->nullOnDelete()` | +| `reverted_at` | timestamp nullable | When a confirmed match was reverted | +| `resolved_by_user_id` | ULID FK nullable | → users (legacy, set on confirm/dismiss). `constrained()->nullOnDelete()` | +| `resolved_at` | timestamp nullable | When the match was resolved (legacy) | +| `created_at` | timestamp | | **Design notes:** -- No `updated_at`: status transitions are captured by `resolved_at`. Model sets `const UPDATED_AT = null;`. -- Single `resolved_by`/`resolved_at` pair: status enum is exclusive (pending → confirmed OR pending → dismissed). Spatie activity log records the full audit trail. +- No `updated_at`: status transitions tracked via specific `*_at` columns. Model sets `const UPDATED_AT = null;`. +- Specific `confirmed_by`/`dismissed_by`/`reverted_by` columns track each action separately, enabling a match lifecycle of: pending → confirmed → reverted. +- `resolved_by`/`resolved_at` retained for backward compatibility (set on confirm/dismiss). +- Detection strategies: (1) Exact email within org → HIGH, (2) Fuzzy name (Levenshtein ≤2/3) → MEDIUM, (3) Fuzzy name + DOB match → HIGH. **Unique constraint:** `UNIQUE(person_id, matched_user_id)` — prevent duplicate match records **Indexes:** `(person_id, status)`, `(matched_user_id, status)`, `(status)` -**Foreign keys:** `person_id` → persons (cascade delete), `matched_user_id` → users (cascade delete), `resolved_by_user_id` → users (null on delete) +**Foreign keys:** `person_id` → persons (cascade delete), `matched_user_id` → users (cascade delete), all `*_user_id` → users (null on delete) + +### `users.date_of_birth` + +| Column | Type | Notes | +| --------------- | ------------- | ---------------------------------------- | +| `date_of_birth` | date nullable | Added after `last_name`. Used as DOB tiebreaker for fuzzy name matching | ---