feat: complete person identity matching system with fuzzy detection, revert, and manual link
Implements the full identity matching engine: email matching (HIGH confidence), fuzzy name matching with Levenshtein distance (MEDIUM confidence, upgradable to HIGH with DOB tiebreaker), manual link/unlink, revert confirmed matches, and automatic detection via PersonObserver. Includes 33 comprehensive tests, frontend integration with confirm/dismiss/unlink UI, and match indicators in the persons list. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')));
|
||||
}
|
||||
|
||||
@@ -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'])));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
|
||||
29
api/app/Observers/PersonObserver.php
Normal file
29
api/app/Observers/PersonObserver.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Observers;
|
||||
|
||||
use App\Models\Person;
|
||||
use App\Services\PersonIdentityService;
|
||||
|
||||
final class PersonObserver
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PersonIdentityService $identityService,
|
||||
) {}
|
||||
|
||||
public function created(Person $person): void
|
||||
{
|
||||
if ($person->user_id === null && ($person->email || $person->first_name)) {
|
||||
$this->identityService->detectMatches($person);
|
||||
}
|
||||
}
|
||||
|
||||
public function updated(Person $person): void
|
||||
{
|
||||
if ($person->wasChanged('email') && $person->user_id === null && $person->email) {
|
||||
$this->identityService->detectMatches($person);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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(),
|
||||
]);
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->date('date_of_birth')->nullable()->after('last_name');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropColumn('date_of_birth');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('person_identity_matches', function (Blueprint $table) {
|
||||
$table->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',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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']);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { usePersonDetail, useUpdatePerson } from '@/composables/api/usePersons'
|
||||
import { useConfirmMatch, useDismissMatch, useManualLinkPerson, useUnlinkPerson } from '@/composables/api/useIdentityMatches'
|
||||
import { useMemberList } from '@/composables/api/useMembers'
|
||||
import type { Person, PersonStatus } from '@/types/person'
|
||||
import type { Member } from '@/types/member'
|
||||
|
||||
const props = defineProps<{
|
||||
eventId: string
|
||||
@@ -21,6 +24,15 @@ const personIdRef = computed(() => props.personId ?? '')
|
||||
const { data: person, isLoading } = usePersonDetail(orgIdRef, eventIdRef, personIdRef)
|
||||
const { mutate: updatePerson } = useUpdatePerson(orgIdRef, eventIdRef)
|
||||
|
||||
// Identity matching mutations
|
||||
const { mutate: confirmMatch, isPending: isConfirming } = useConfirmMatch(orgIdRef)
|
||||
const { mutate: dismissMatch, isPending: isDismissing } = useDismissMatch(orgIdRef)
|
||||
const { mutate: manualLink, isPending: isManualLinking } = useManualLinkPerson(orgIdRef, eventIdRef)
|
||||
const { mutate: unlinkPerson, isPending: isUnlinking } = useUnlinkPerson(orgIdRef, eventIdRef)
|
||||
|
||||
// Members for manual link
|
||||
const { data: membersResponse } = useMemberList(orgIdRef)
|
||||
|
||||
const activeTab = ref('info')
|
||||
|
||||
const statusColor: Record<PersonStatus, string> = {
|
||||
@@ -87,6 +99,57 @@ function onBlacklistToggle(val: boolean | null) {
|
||||
is_blacklisted: !!val,
|
||||
})
|
||||
}
|
||||
|
||||
// Identity matching handlers
|
||||
function handleConfirm() {
|
||||
if (!person.value?.pending_identity_match) return
|
||||
confirmMatch(person.value.pending_identity_match.match_id)
|
||||
}
|
||||
|
||||
function handleDismiss() {
|
||||
if (!person.value?.pending_identity_match) return
|
||||
dismissMatch(person.value.pending_identity_match.match_id)
|
||||
}
|
||||
|
||||
// Unlink confirmation
|
||||
const showUnlinkConfirm = ref(false)
|
||||
|
||||
function handleUnlink() {
|
||||
if (!person.value) return
|
||||
unlinkPerson(person.value.id, {
|
||||
onSuccess: () => {
|
||||
showUnlinkConfirm.value = false
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Manual link dialog
|
||||
const showManualLinkDialog = ref(false)
|
||||
const linkSearchQuery = ref('')
|
||||
|
||||
const filteredOrgMembers = computed<Member[]>(() => {
|
||||
const members = membersResponse.value?.data ?? []
|
||||
const query = linkSearchQuery.value.toLowerCase().trim()
|
||||
|
||||
if (!query) return members
|
||||
|
||||
return members.filter(
|
||||
m => m.full_name.toLowerCase().includes(query) || m.email.toLowerCase().includes(query),
|
||||
)
|
||||
})
|
||||
|
||||
function handleManualLink(userId: string) {
|
||||
if (!person.value) return
|
||||
manualLink(
|
||||
{ personId: person.value.id, userId },
|
||||
{
|
||||
onSuccess: () => {
|
||||
showManualLinkDialog.value = false
|
||||
linkSearchQuery.value = ''
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -157,6 +220,134 @@ function onBlacklistToggle(val: boolean | null) {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Identity matching banners -->
|
||||
<!-- State 1: Pending match detected -->
|
||||
<VAlert
|
||||
v-if="person.pending_identity_match"
|
||||
:type="person.pending_identity_match.confidence === 'high' ? 'info' : 'warning'"
|
||||
variant="tonal"
|
||||
class="mb-3"
|
||||
>
|
||||
<div class="d-flex align-center justify-space-between flex-wrap ga-2">
|
||||
<div>
|
||||
<div class="font-weight-medium">
|
||||
Mogelijke match gevonden
|
||||
<VChip
|
||||
size="x-small"
|
||||
class="ml-1"
|
||||
variant="outlined"
|
||||
>
|
||||
{{ person.pending_identity_match.confidence_label }}
|
||||
</VChip>
|
||||
</div>
|
||||
<div class="text-body-2 mt-1">
|
||||
{{ person.pending_identity_match.matched_on_label }}:
|
||||
<strong>{{ person.pending_identity_match.matched_user.full_name }}</strong>
|
||||
({{ person.pending_identity_match.matched_user.email }})
|
||||
</div>
|
||||
<div
|
||||
v-if="person.pending_identity_match.match_details?.matched_fields?.includes('date_of_birth')"
|
||||
class="text-caption mt-1 text-success"
|
||||
>
|
||||
Geboortedatum komt overeen
|
||||
</div>
|
||||
<div
|
||||
v-if="person.pending_identity_match.match_details?.matched_fields?.some((f: string) => f.includes('fuzzy'))"
|
||||
class="text-caption mt-1 text-warning"
|
||||
>
|
||||
Naam is vergelijkbaar maar niet exact
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex ga-2">
|
||||
<VBtn
|
||||
size="small"
|
||||
color="success"
|
||||
variant="flat"
|
||||
:loading="isConfirming"
|
||||
@click="handleConfirm"
|
||||
>
|
||||
<VIcon
|
||||
start
|
||||
size="16"
|
||||
>
|
||||
tabler-check
|
||||
</VIcon>
|
||||
Koppelen
|
||||
</VBtn>
|
||||
<VBtn
|
||||
size="small"
|
||||
color="grey"
|
||||
variant="tonal"
|
||||
:loading="isDismissing"
|
||||
@click="handleDismiss"
|
||||
>
|
||||
Niet dezelfde persoon
|
||||
</VBtn>
|
||||
</div>
|
||||
</div>
|
||||
</VAlert>
|
||||
|
||||
<!-- State 2: Linked (has_user_account) -->
|
||||
<VAlert
|
||||
v-else-if="person.has_user_account"
|
||||
type="success"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
class="mb-3"
|
||||
>
|
||||
<div class="d-flex align-center justify-space-between">
|
||||
<div>
|
||||
<VIcon
|
||||
start
|
||||
size="16"
|
||||
>
|
||||
tabler-link
|
||||
</VIcon>
|
||||
Gekoppeld aan platformaccount:
|
||||
<strong>{{ person.user_account?.full_name }}</strong>
|
||||
({{ person.user_account?.email }})
|
||||
</div>
|
||||
<VBtn
|
||||
size="x-small"
|
||||
variant="text"
|
||||
color="error"
|
||||
@click="showUnlinkConfirm = true"
|
||||
>
|
||||
Ontkoppelen
|
||||
</VBtn>
|
||||
</div>
|
||||
</VAlert>
|
||||
|
||||
<!-- State 3: Not linked -->
|
||||
<VAlert
|
||||
v-else
|
||||
type="warning"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
class="mb-3"
|
||||
>
|
||||
<div class="d-flex align-center justify-space-between">
|
||||
<div>
|
||||
<VIcon
|
||||
start
|
||||
size="16"
|
||||
>
|
||||
tabler-unlink
|
||||
</VIcon>
|
||||
Niet gekoppeld aan een platformaccount.
|
||||
Kan niet inloggen op het portaal.
|
||||
</div>
|
||||
<VBtn
|
||||
size="x-small"
|
||||
variant="tonal"
|
||||
color="primary"
|
||||
@click="showManualLinkDialog = true"
|
||||
>
|
||||
Handmatig koppelen
|
||||
</VBtn>
|
||||
</div>
|
||||
</VAlert>
|
||||
</div>
|
||||
|
||||
<VDivider />
|
||||
@@ -291,5 +482,111 @@ function onBlacklistToggle(val: boolean | null) {
|
||||
</VTabsWindowItem>
|
||||
</VTabsWindow>
|
||||
</template>
|
||||
|
||||
<!-- Unlink confirmation dialog -->
|
||||
<VDialog
|
||||
v-model="showUnlinkConfirm"
|
||||
max-width="420"
|
||||
>
|
||||
<VCard>
|
||||
<VCardTitle class="text-h6 pt-5 px-5">
|
||||
Koppeling verbreken?
|
||||
</VCardTitle>
|
||||
<VCardText class="px-5">
|
||||
Weet je zeker dat je de koppeling met
|
||||
<strong>{{ person?.user_account?.email }}</strong> wilt verbreken?
|
||||
<br><br>
|
||||
<strong>Let op:</strong> deze persoon kan daarna niet meer inloggen
|
||||
op het portaal en ziet geen shifts meer.
|
||||
</VCardText>
|
||||
<VCardActions class="px-5 pb-5">
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
variant="tonal"
|
||||
@click="showUnlinkConfirm = false"
|
||||
>
|
||||
Annuleren
|
||||
</VBtn>
|
||||
<VBtn
|
||||
color="error"
|
||||
variant="flat"
|
||||
:loading="isUnlinking"
|
||||
@click="handleUnlink"
|
||||
>
|
||||
Ontkoppelen
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
|
||||
<!-- Manual link dialog -->
|
||||
<VDialog
|
||||
v-model="showManualLinkDialog"
|
||||
max-width="520"
|
||||
>
|
||||
<VCard>
|
||||
<VCardTitle class="text-h6 pt-5 px-5">
|
||||
Handmatig koppelen
|
||||
</VCardTitle>
|
||||
<VCardText class="px-5">
|
||||
<p class="text-body-2 text-medium-emphasis mb-4">
|
||||
Kies een organisatielid om te koppelen aan
|
||||
<strong>{{ person?.full_name }}</strong>
|
||||
</p>
|
||||
<VTextField
|
||||
v-model="linkSearchQuery"
|
||||
prepend-inner-icon="tabler-search"
|
||||
placeholder="Zoek op naam of e-mail..."
|
||||
density="compact"
|
||||
hide-details
|
||||
clearable
|
||||
class="mb-3"
|
||||
/>
|
||||
<VList
|
||||
v-if="filteredOrgMembers.length"
|
||||
density="compact"
|
||||
class="border rounded-lg"
|
||||
style="max-height: 250px; overflow-y: auto"
|
||||
>
|
||||
<VListItem
|
||||
v-for="member in filteredOrgMembers"
|
||||
:key="member.id"
|
||||
class="cursor-pointer"
|
||||
:disabled="isManualLinking"
|
||||
@click="handleManualLink(member.id)"
|
||||
>
|
||||
<template #prepend>
|
||||
<VAvatar
|
||||
size="32"
|
||||
color="primary"
|
||||
variant="tonal"
|
||||
>
|
||||
{{ (member.first_name[0] + member.last_name[0]).toUpperCase() }}
|
||||
</VAvatar>
|
||||
</template>
|
||||
<VListItemTitle>{{ member.full_name }}</VListItemTitle>
|
||||
<VListItemSubtitle>{{ member.email }}</VListItemSubtitle>
|
||||
</VListItem>
|
||||
</VList>
|
||||
<VAlert
|
||||
v-else-if="linkSearchQuery"
|
||||
type="info"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
>
|
||||
Geen leden gevonden voor "{{ linkSearchQuery }}"
|
||||
</VAlert>
|
||||
</VCardText>
|
||||
<VCardActions class="px-5 pb-5">
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
variant="tonal"
|
||||
@click="showManualLinkDialog = false"
|
||||
>
|
||||
Sluiten
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</VNavigationDrawer>
|
||||
</template>
|
||||
|
||||
151
apps/app/src/composables/api/useIdentityMatches.ts
Normal file
151
apps/app/src/composables/api/useIdentityMatches.ts
Normal file
@@ -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<T> {
|
||||
success: boolean
|
||||
data: T
|
||||
}
|
||||
|
||||
interface PaginatedResponse<T> {
|
||||
data: T[]
|
||||
links: Record<string, string | null>
|
||||
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<string>) {
|
||||
return useQuery({
|
||||
queryKey: ['identity-matches', orgId],
|
||||
queryFn: async () => {
|
||||
const { data } = await apiClient.get<PaginatedResponse<IdentityMatch>>(
|
||||
`/organisations/${unref(orgId)}/identity-matches`,
|
||||
)
|
||||
|
||||
return data
|
||||
},
|
||||
enabled: () => !!unref(orgId),
|
||||
})
|
||||
}
|
||||
|
||||
export function useConfirmMatch(orgId: MaybeRef<string>) {
|
||||
const qc = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (matchId: string) => {
|
||||
const { data } = await apiClient.post<ApiResponse<IdentityMatch>>(
|
||||
`/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<string>) {
|
||||
const qc = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (matchId: string) => {
|
||||
const { data } = await apiClient.post<ApiResponse<IdentityMatch>>(
|
||||
`/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<string>) {
|
||||
const qc = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (matchId: string) => {
|
||||
const { data } = await apiClient.post<ApiResponse<IdentityMatch>>(
|
||||
`/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<string>) {
|
||||
const qc = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (matchIds: string[]) => {
|
||||
const { data } = await apiClient.post<BulkConfirmResult>(
|
||||
`/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<string>, eventId: MaybeRef<string>) {
|
||||
const qc = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ personId, userId }: { personId: string; userId: string }) => {
|
||||
const { data } = await apiClient.post<ApiResponse<IdentityMatch>>(
|
||||
`/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<string>, eventId: MaybeRef<string>) {
|
||||
const qc = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (personId: string) => {
|
||||
const { data } = await apiClient.post<ApiResponse<Person>>(
|
||||
`/organisations/${unref(orgId)}/events/${unref(eventId)}/persons/${personId}/unlink`,
|
||||
)
|
||||
|
||||
return data.data
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['identity-matches'] })
|
||||
qc.invalidateQueries({ queryKey: ['persons'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -294,6 +294,29 @@ const crowdTypeOptions = computed(() => [
|
||||
<span class="text-caption">{{ getInitials(item.full_name) }}</span>
|
||||
</VAvatar>
|
||||
<span>{{ item.full_name }}</span>
|
||||
<VTooltip
|
||||
v-if="item.pending_identity_match"
|
||||
location="top"
|
||||
>
|
||||
<template #activator="{ props: tooltipProps }">
|
||||
<VIcon
|
||||
v-bind="tooltipProps"
|
||||
size="16"
|
||||
color="warning"
|
||||
>
|
||||
tabler-link
|
||||
</VIcon>
|
||||
</template>
|
||||
Mogelijke match: {{ item.pending_identity_match.matched_user.full_name }}
|
||||
({{ item.pending_identity_match.confidence_label }})
|
||||
</VTooltip>
|
||||
<VIcon
|
||||
v-else-if="item.has_user_account"
|
||||
size="16"
|
||||
color="success"
|
||||
>
|
||||
tabler-user-check
|
||||
</VIcon>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
69
apps/app/src/types/identityMatch.ts
Normal file
69
apps/app/src/types/identityMatch.ts
Normal file
@@ -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
|
||||
}
|
||||
@@ -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[]
|
||||
|
||||
@@ -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"], "..." : "..." }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user