feat: person identity matching with detection, confirmation and audit trail

Implements enterprise-grade identity resolution (detect → suggest → confirm)
for Person ↔ User linking. Matches are detected automatically on person
creation and user account creation, then surfaced to organisers for explicit
confirmation or dismissal. No silent auto-linking.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-10 12:50:25 +02:00
parent 239fe57a11
commit 4b182b449a
20 changed files with 1463 additions and 2 deletions

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace Database\Factories;
use App\Enums\IdentityMatchConfidence;
use App\Enums\IdentityMatchMethod;
use App\Enums\IdentityMatchStatus;
use App\Models\Person;
use App\Models\PersonIdentityMatch;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/** @extends Factory<PersonIdentityMatch> */
final class PersonIdentityMatchFactory extends Factory
{
protected $model = PersonIdentityMatch::class;
/** @return array<string, mixed> */
public function definition(): array
{
return [
'person_id' => Person::factory(),
'matched_user_id' => User::factory(),
'matched_on' => IdentityMatchMethod::EMAIL,
'confidence' => IdentityMatchConfidence::EXACT,
'status' => IdentityMatchStatus::PENDING,
'resolved_by_user_id' => null,
'resolved_at' => null,
];
}
public function confirmed(): static
{
return $this->state(fn () => [
'status' => IdentityMatchStatus::CONFIRMED,
'resolved_by_user_id' => User::factory(),
'resolved_at' => now(),
]);
}
public function dismissed(): static
{
return $this->state(fn () => [
'status' => IdentityMatchStatus::DISMISSED,
'resolved_by_user_id' => User::factory(),
'resolved_at' => now(),
]);
}
}

View File

@@ -0,0 +1,50 @@
<?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::create('person_identity_matches', function (Blueprint $table) {
$table->ulid('id')->primary();
$table->foreignUlid('person_id')
->constrained('persons')
->cascadeOnDelete();
$table->foreignUlid('matched_user_id')
->constrained('users')
->cascadeOnDelete();
$table->string('matched_on');
$table->string('confidence');
$table->string('status')->default('pending');
$table->foreignUlid('resolved_by_user_id')
->nullable()
->constrained('users')
->nullOnDelete();
$table->timestamp('resolved_at')->nullable();
$table->timestamp('created_at')->nullable();
// Prevent duplicate match records for the same person+user pair
$table->unique(['person_id', 'matched_user_id']);
// Query indexes
$table->index(['person_id', 'status']);
$table->index(['matched_user_id', 'status']);
$table->index('status');
});
}
public function down(): void
{
Schema::dropIfExists('person_identity_matches');
}
};