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:
@@ -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");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user