diff --git a/api/app/Enums/IdentityMatchConfidence.php b/api/app/Enums/IdentityMatchConfidence.php new file mode 100644 index 00000000..d99eef0d --- /dev/null +++ b/api/app/Enums/IdentityMatchConfidence.php @@ -0,0 +1,11 @@ +persons()->with('crowdType'); + $query = $event->persons()->with(['crowdType', 'pendingIdentityMatch.matchedUser']); if ($request->filled('crowd_type_id')) { $query->where('crowd_type_id', $request->input('crowd_type_id')); @@ -72,6 +77,8 @@ final class PersonController extends Controller $person = $event->persons()->create($request->validated()); + $this->identityService->detectMatchForPerson($person); + 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 new file mode 100644 index 00000000..d4310590 --- /dev/null +++ b/api/app/Http/Controllers/Api/V1/PersonIdentityMatchController.php @@ -0,0 +1,120 @@ +events()->pluck('id'); + + $matches = PersonIdentityMatch::pending() + ->whereHas('person', fn ($q) => $q->whereIn('event_id', $eventIds)) + ->with(['person.crowdType', 'person.event', 'matchedUser']) + ->orderBy('created_at', 'desc') + ->paginate(25); + + return PersonIdentityMatchResource::collection($matches); + } + + public function showForPerson(Organisation $organisation, Person $person): PersonIdentityMatchResource + { + Gate::authorize('view', [$person, $person->event]); + + $match = $person->pendingIdentityMatch() + ->with('matchedUser') + ->firstOrFail(); + + return new PersonIdentityMatchResource($match); + } + + public function confirm(Request $request, Organisation $organisation, PersonIdentityMatch $personIdentityMatch): JsonResponse + { + Gate::authorize('confirm', $personIdentityMatch); + + try { + $this->identityService->confirmMatch($personIdentityMatch, $request->user()); + } catch (\DomainException $e) { + return $this->error($e->getMessage(), 422); + } + + $personIdentityMatch->refresh()->load(['person.crowdType', 'person.event', 'matchedUser', 'resolvedBy']); + + return $this->success(new PersonIdentityMatchResource($personIdentityMatch)); + } + + public function dismiss(Request $request, Organisation $organisation, PersonIdentityMatch $personIdentityMatch): JsonResponse + { + Gate::authorize('dismiss', $personIdentityMatch); + + try { + $this->identityService->dismissMatch($personIdentityMatch, $request->user()); + } catch (\DomainException $e) { + return $this->error($e->getMessage(), 422); + } + + $personIdentityMatch->refresh()->load(['person.crowdType', 'person.event', 'matchedUser', 'resolvedBy']); + + return $this->success(new PersonIdentityMatchResource($personIdentityMatch)); + } + + public function bulkConfirm(BulkConfirmIdentityMatchesRequest $request, Organisation $organisation): JsonResponse + { + Gate::authorize('bulkConfirm', [PersonIdentityMatch::class, $organisation]); + + $matches = PersonIdentityMatch::whereIn('id', $request->validated('match_ids')) + ->with('person') + ->get() + ->keyBy('id'); + + $confirmed = 0; + $errors = []; + + foreach ($request->validated('match_ids') as $matchId) { + $match = $matches->get($matchId); + + 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; + } + + try { + $this->identityService->confirmMatch($match, $request->user()); + $confirmed++; + } catch (\DomainException $e) { + $errors[] = ['match_id' => $matchId, 'error' => $e->getMessage()]; + } + } + + return response()->json([ + 'confirmed' => $confirmed, + 'errors' => $errors, + ]); + } +} diff --git a/api/app/Http/Requests/Api/V1/BulkConfirmIdentityMatchesRequest.php b/api/app/Http/Requests/Api/V1/BulkConfirmIdentityMatchesRequest.php new file mode 100644 index 00000000..595ab9fa --- /dev/null +++ b/api/app/Http/Requests/Api/V1/BulkConfirmIdentityMatchesRequest.php @@ -0,0 +1,24 @@ + */ + public function rules(): array + { + return [ + 'match_ids' => ['required', 'array', 'min:1', 'max:100'], + 'match_ids.*' => ['required', 'string', 'exists:person_identity_matches,id'], + ]; + } +} diff --git a/api/app/Http/Resources/Api/V1/PersonIdentityMatchResource.php b/api/app/Http/Resources/Api/V1/PersonIdentityMatchResource.php new file mode 100644 index 00000000..0514fb4d --- /dev/null +++ b/api/app/Http/Resources/Api/V1/PersonIdentityMatchResource.php @@ -0,0 +1,44 @@ + $this->id, + 'person' => [ + 'id' => $this->person->id, + 'name' => $this->person->name, + 'email' => $this->person->email, + 'crowd_type' => $this->whenLoaded('person', fn () => + $this->person->crowdType?->name + ), + 'event' => $this->whenLoaded('person', fn () => [ + 'id' => $this->person->event_id, + 'name' => $this->person->event?->name, + ]), + ], + 'matched_user' => [ + 'id' => $this->matchedUser->id, + 'name' => $this->matchedUser->name, + 'email' => $this->matchedUser->email, + ], + 'matched_on' => $this->matched_on->value, + 'confidence' => $this->confidence->value, + 'status' => $this->status->value, + 'resolved_by' => $this->when($this->resolvedBy, fn () => [ + 'id' => $this->resolvedBy->id, + 'name' => $this->resolvedBy->name, + ]), + 'resolved_at' => $this->resolved_at?->toISOString(), + 'created_at' => $this->created_at->toISOString(), + ]; + } +} diff --git a/api/app/Http/Resources/Api/V1/PersonResource.php b/api/app/Http/Resources/Api/V1/PersonResource.php index c512c802..54eeacfb 100644 --- a/api/app/Http/Resources/Api/V1/PersonResource.php +++ b/api/app/Http/Resources/Api/V1/PersonResource.php @@ -24,6 +24,23 @@ final class PersonResource extends JsonResource 'created_at' => $this->created_at->toIso8601String(), 'crowd_type' => new CrowdTypeResource($this->whenLoaded('crowdType')), 'company' => new CompanyResource($this->whenLoaded('company')), + 'pending_identity_match' => $this->when( + $this->relationLoaded('pendingIdentityMatch') && $this->pendingIdentityMatch, + function () { + $match = $this->pendingIdentityMatch; + + return [ + 'match_id' => $match->id, + 'matched_user' => [ + 'id' => $match->matchedUser->id, + 'name' => $match->matchedUser->name, + 'email' => $match->matchedUser->email, + ], + 'matched_on' => $match->matched_on->value, + 'confidence' => $match->confidence->value, + ]; + } + ), 'tags' => $this->when( $this->user_id && $this->relationLoaded('user'), function () { diff --git a/api/app/Models/Person.php b/api/app/Models/Person.php index 2077ad55..ed1ecf2d 100644 --- a/api/app/Models/Person.php +++ b/api/app/Models/Person.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Models; +use App\Enums\IdentityMatchStatus; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Concerns\HasUlids; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -11,6 +12,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\SoftDeletes; final class Person extends Model @@ -74,6 +76,17 @@ final class Person extends Model return $this->hasMany(ShiftAssignment::class); } + public function identityMatches(): HasMany + { + return $this->hasMany(PersonIdentityMatch::class); + } + + public function pendingIdentityMatch(): HasOne + { + return $this->hasOne(PersonIdentityMatch::class) + ->where('status', IdentityMatchStatus::PENDING); + } + public function scopeApproved(Builder $query): Builder { return $query->where('status', 'approved'); diff --git a/api/app/Models/PersonIdentityMatch.php b/api/app/Models/PersonIdentityMatch.php new file mode 100644 index 00000000..53e94634 --- /dev/null +++ b/api/app/Models/PersonIdentityMatch.php @@ -0,0 +1,72 @@ + IdentityMatchMethod::class, + 'confidence' => IdentityMatchConfidence::class, + 'status' => IdentityMatchStatus::class, + 'resolved_at' => 'datetime', + ]; + } + + public function person(): BelongsTo + { + return $this->belongsTo(Person::class); + } + + public function matchedUser(): BelongsTo + { + return $this->belongsTo(User::class, 'matched_user_id'); + } + + public function resolvedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'resolved_by_user_id'); + } + + public function scopePending(Builder $query): Builder + { + return $query->where('status', IdentityMatchStatus::PENDING); + } + + public function scopeConfirmed(Builder $query): Builder + { + return $query->where('status', IdentityMatchStatus::CONFIRMED); + } + + public function scopeDismissed(Builder $query): Builder + { + return $query->where('status', IdentityMatchStatus::DISMISSED); + } +} diff --git a/api/app/Models/User.php b/api/app/Models/User.php index 65c31de8..9252f4e0 100644 --- a/api/app/Models/User.php +++ b/api/app/Models/User.php @@ -64,6 +64,11 @@ final class User extends Authenticatable return $this->hasMany(UserInvitation::class, 'invited_by_user_id'); } + public function identityMatches(): HasMany + { + return $this->hasMany(PersonIdentityMatch::class, 'matched_user_id'); + } + public function organisationTags(): HasMany { return $this->hasMany(UserOrganisationTag::class); diff --git a/api/app/Policies/PersonIdentityMatchPolicy.php b/api/app/Policies/PersonIdentityMatchPolicy.php new file mode 100644 index 00000000..1425ff81 --- /dev/null +++ b/api/app/Policies/PersonIdentityMatchPolicy.php @@ -0,0 +1,67 @@ +hasRole('super_admin') + || $organisation->users()->where('user_id', $user->id)->exists(); + } + + public function view(User $user, PersonIdentityMatch $match): bool + { + $match->loadMissing('person.event.organisation'); + $person = $match->person; + + return $user->hasRole('super_admin') + || $person->event->organisation->users()->where('user_id', $user->id)->exists(); + } + + public function confirm(User $user, PersonIdentityMatch $match): bool + { + return $this->canManageMatch($user, $match); + } + + public function dismiss(User $user, PersonIdentityMatch $match): bool + { + return $this->canManageMatch($user, $match); + } + + public function bulkConfirm(User $user, Organisation $organisation): bool + { + return $user->hasRole('super_admin') + || $organisation->users()->where('user_id', $user->id)->exists(); + } + + private function canManageMatch(User $user, PersonIdentityMatch $match): bool + { + $match->loadMissing('person.event.organisation'); + $event = $match->person->event; + + if ($user->hasRole('super_admin')) { + return true; + } + + $isOrgAdmin = $event->organisation->users() + ->where('user_id', $user->id) + ->wherePivot('role', 'org_admin') + ->exists(); + + if ($isOrgAdmin) { + return true; + } + + return $event->users() + ->where('user_id', $user->id) + ->wherePivot('role', 'event_manager') + ->exists(); + } +} diff --git a/api/app/Services/InvitationService.php b/api/app/Services/InvitationService.php index 1f5d47e9..a5c0c5c1 100644 --- a/api/app/Services/InvitationService.php +++ b/api/app/Services/InvitationService.php @@ -81,6 +81,8 @@ final class InvitationService 'password' => $password, 'email_verified_at' => now(), ]); + + app(PersonIdentityService::class)->detectMatchesForUser($user); } $organisation = $invitation->organisation; diff --git a/api/app/Services/PersonIdentityService.php b/api/app/Services/PersonIdentityService.php new file mode 100644 index 00000000..9d798242 --- /dev/null +++ b/api/app/Services/PersonIdentityService.php @@ -0,0 +1,223 @@ +user_id !== null) { + return null; + } + + // 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; + } + + // 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; + } + + // 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) + ->where('user_id', $user->id) + ->exists(); + + if ($alreadyLinkedInEvent) { + return null; + } + + // Guard 6: Match record already exists — return existing (idempotent) + $existing = PersonIdentityMatch::where('person_id', $person->id) + ->where('matched_user_id', $user->id) + ->first(); + + if ($existing !== null) { + return $existing; + } + + $match = PersonIdentityMatch::create([ + 'person_id' => $person->id, + 'matched_user_id' => $user->id, + 'matched_on' => IdentityMatchMethod::EMAIL, + 'confidence' => IdentityMatchConfidence::EXACT, + 'status' => IdentityMatchStatus::PENDING, + ]); + + $activityLogger = activity('identity') + ->performedOn($person) + ->withProperties([ + 'matched_user_id' => $user->id, + 'matched_on' => IdentityMatchMethod::EMAIL->value, + 'confidence' => IdentityMatchConfidence::EXACT->value, + ]); + + if (auth()->user()) { + $activityLogger->causedBy(auth()->user()); + } + + $activityLogger->log('person.identity.match_detected'); + + return $match; + } + + /** + * Detect all unlinked persons matching a user's email. + * Called after a user account is created. Creates pending matches. + * Returns the number of matches created. + */ + 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]) + ->get(); + + if ($persons->isEmpty()) { + return 0; + } + + // 2. Batch-check which events already have this user linked (no N+1) + $alreadyLinkedEventIds = Person::where('user_id', $user->id) + ->whereIn('event_id', $persons->pluck('event_id')) + ->pluck('event_id') + ->toArray(); + + // 3. Batch-check existing match records (no N+1) + $existingMatchPersonIds = PersonIdentityMatch::where('matched_user_id', $user->id) + ->whereIn('person_id', $persons->pluck('id')) + ->pluck('person_id') + ->toArray(); + + // 4. Filter — no queries inside this loop + $toCreate = $persons + ->reject(fn (Person $p) => in_array($p->event_id, $alreadyLinkedEventIds)) + ->reject(fn (Person $p) => in_array($p->id, $existingMatchPersonIds)); + + // 5. Create matches + foreach ($toCreate as $person) { + PersonIdentityMatch::create([ + 'person_id' => $person->id, + 'matched_user_id' => $user->id, + 'matched_on' => IdentityMatchMethod::EMAIL, + 'confidence' => IdentityMatchConfidence::EXACT, + 'status' => IdentityMatchStatus::PENDING, + ]); + + $activityLogger = activity('identity') + ->performedOn($person) + ->withProperties([ + 'matched_user_id' => $user->id, + 'matched_on' => IdentityMatchMethod::EMAIL->value, + 'confidence' => IdentityMatchConfidence::EXACT->value, + ]); + + if (auth()->user()) { + $activityLogger->causedBy(auth()->user()); + } + + $activityLogger->log('person.identity.match_detected'); + } + + return $toCreate->count(); + } + + /** + * Confirm a match: link the person to the matched user. + * + * @throws \DomainException if match is not pending or would violate uniqueness + */ + public function confirmMatch(PersonIdentityMatch $match, User $resolvedBy): void + { + if ($match->status !== IdentityMatchStatus::PENDING) { + 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) + ->where('user_id', $match->matched_user_id) + ->exists(); + + if ($conflict) { + throw new \DomainException('User already has a person record in this event.'); + } + + DB::transaction(function () use ($match, $person, $resolvedBy): void { + $match->update([ + 'status' => IdentityMatchStatus::CONFIRMED, + 'resolved_by_user_id' => $resolvedBy->id, + 'resolved_at' => now(), + ]); + + $person->update([ + 'user_id' => $match->matched_user_id, + ]); + }); + + activity('identity') + ->causedBy($resolvedBy) + ->performedOn($person) + ->withProperties([ + 'match_id' => $match->id, + 'old' => ['user_id' => null], + 'new' => ['user_id' => $match->matched_user_id], + ]) + ->log('person.identity.match_confirmed'); + } + + /** + * Dismiss a match: mark as dismissed so it's not shown again. + * + * @throws \DomainException if match is not pending + */ + public function dismissMatch(PersonIdentityMatch $match, User $resolvedBy): void + { + if ($match->status !== IdentityMatchStatus::PENDING) { + throw new \DomainException('Match is not pending and cannot be dismissed.'); + } + + $match->update([ + 'status' => IdentityMatchStatus::DISMISSED, + 'resolved_by_user_id' => $resolvedBy->id, + 'resolved_at' => now(), + ]); + + activity('identity') + ->causedBy($resolvedBy) + ->performedOn($match->person) + ->withProperties(['match_id' => $match->id]) + ->log('person.identity.match_dismissed'); + } +} diff --git a/api/database/factories/PersonIdentityMatchFactory.php b/api/database/factories/PersonIdentityMatchFactory.php new file mode 100644 index 00000000..4b1c1c4e --- /dev/null +++ b/api/database/factories/PersonIdentityMatchFactory.php @@ -0,0 +1,51 @@ + */ +final class PersonIdentityMatchFactory extends Factory +{ + protected $model = PersonIdentityMatch::class; + + /** @return array */ + 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(), + ]); + } +} diff --git a/api/database/migrations/2026_04_10_200000_create_person_identity_matches_table.php b/api/database/migrations/2026_04_10_200000_create_person_identity_matches_table.php new file mode 100644 index 00000000..2b45bb65 --- /dev/null +++ b/api/database/migrations/2026_04_10_200000_create_person_identity_matches_table.php @@ -0,0 +1,50 @@ +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'); + } +}; diff --git a/api/routes/api.php b/api/routes/api.php index 512f1f71..b4cbb1be 100644 --- a/api/routes/api.php +++ b/api/routes/api.php @@ -15,6 +15,7 @@ use App\Http\Controllers\Api\V1\MeController; use App\Http\Controllers\Api\V1\MemberController; use App\Http\Controllers\Api\V1\OrganisationController; use App\Http\Controllers\Api\V1\PersonController; +use App\Http\Controllers\Api\V1\PersonIdentityMatchController; use App\Http\Controllers\Api\V1\PersonTagController; use App\Http\Controllers\Api\V1\ShiftController; use App\Http\Controllers\Api\V1\TimeSlotController; @@ -94,6 +95,13 @@ Route::middleware('auth:sanctum')->group(function () { Route::put('users/{user}/tags/sync', [UserOrganisationTagController::class, 'sync']); Route::delete('users/{user}/tags/{userOrganisationTag}', [UserOrganisationTagController::class, 'destroy']); + // Identity matches + Route::get('identity-matches', [PersonIdentityMatchController::class, 'index']); + 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/bulk-confirm', [PersonIdentityMatchController::class, 'bulkConfirm']); + // Invitations & Members Route::post('invite', [InvitationController::class, 'invite']); Route::delete('invitations/{invitation}', [InvitationController::class, 'revoke']); diff --git a/api/tests/Feature/PersonIdentity/PersonIdentityMatchTest.php b/api/tests/Feature/PersonIdentity/PersonIdentityMatchTest.php new file mode 100644 index 00000000..3417603b --- /dev/null +++ b/api/tests/Feature/PersonIdentity/PersonIdentityMatchTest.php @@ -0,0 +1,652 @@ +seed(RoleSeeder::class); + + $this->organisation = Organisation::factory()->create(); + $this->otherOrganisation = Organisation::factory()->create(); + + $this->orgAdmin = User::factory()->create(); + $this->organisation->users()->attach($this->orgAdmin, ['role' => 'org_admin']); + + $this->outsider = User::factory()->create(); + $this->otherOrganisation->users()->attach($this->outsider, ['role' => 'org_admin']); + + $this->event = Event::factory()->create(['organisation_id' => $this->organisation->id]); + + $this->crowdType = CrowdType::factory()->systemType('VOLUNTEER')->create([ + 'organisation_id' => $this->organisation->id, + ]); + + $this->identityService = app(PersonIdentityService::class); + } + + // ────────────────────────────────────────────────────── + // Detection tests + // ────────────────────────────────────────────────────── + + public function test_creating_person_with_existing_user_email_creates_pending_match(): void + { + $matchUser = User::factory()->create(['email' => 'jan@example.nl']); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson("/api/v1/events/{$this->event->id}/persons", [ + 'crowd_type_id' => $this->crowdType->id, + 'name' => 'Jan de Vries', + 'email' => 'jan@example.nl', + ]); + + $response->assertCreated(); + + $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, + ]); + } + + public function test_creating_person_with_unknown_email_creates_no_match(): void + { + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson("/api/v1/events/{$this->event->id}/persons", [ + 'crowd_type_id' => $this->crowdType->id, + 'name' => 'Piet Jansen', + 'email' => 'unknown@example.nl', + ]); + + $response->assertCreated(); + + $this->assertDatabaseCount('person_identity_matches', 0); + } + + public function test_creating_person_with_user_id_set_creates_no_match(): void + { + $linkedUser = User::factory()->create(['email' => 'linked@example.nl']); + + $person = Person::factory()->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->crowdType->id, + 'email' => 'linked@example.nl', + 'user_id' => $linkedUser->id, + ]); + + $result = $this->identityService->detectMatchForPerson($person); + + $this->assertNull($result); + $this->assertDatabaseCount('person_identity_matches', 0); + } + + public function test_creating_user_detects_existing_unlinked_persons(): 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', + 'user_id' => null, + ]); + + $first = $this->identityService->detectMatchForPerson($person); + $second = $this->identityService->detectMatchForPerson($person); + + $this->assertNotNull($first); + $this->assertNotNull($second); + $this->assertEquals($first->id, $second->id); + $this->assertDatabaseCount('person_identity_matches', 1); + } + + public function test_no_match_when_user_already_linked_in_same_event(): 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, + ]); + + // 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']); + + $person = Person::factory()->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->crowdType->id, + 'email' => 'jan@example.com', + 'user_id' => null, + ]); + + $result = $this->identityService->detectMatchForPerson($person); + + $this->assertNotNull($result); + $this->assertDatabaseCount('person_identity_matches', 1); + } + + public function test_soft_deleted_person_is_not_matched(): void + { + User::factory()->create(['email' => 'deleted@example.nl']); + + $person = Person::factory()->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->crowdType->id, + 'email' => 'deleted@example.nl', + 'user_id' => null, + ]); + + $person->delete(); + + $result = $this->identityService->detectMatchForPerson($person); + + $this->assertNull($result); + $this->assertDatabaseCount('person_identity_matches', 0); + } + + // ────────────────────────────────────────────────────── + // Resolution tests + // ────────────────────────────────────────────────────── + + public function test_confirm_match_links_person_to_user(): void + { + $matchUser = User::factory()->create(['email' => 'confirm@example.nl']); + $person = Person::factory()->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->crowdType->id, + 'email' => 'confirm@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); + + $response = $this->postJson( + "/api/v1/organisations/{$this->organisation->id}/identity-matches/{$match->id}/confirm" + ); + + $response->assertOk(); + + $person->refresh(); + $match->refresh(); + + $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); + } + + public function test_dismiss_match_keeps_person_unlinked(): void + { + $matchUser = User::factory()->create(['email' => 'dismiss@example.nl']); + $person = Person::factory()->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->crowdType->id, + 'email' => 'dismiss@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); + + $response = $this->postJson( + "/api/v1/organisations/{$this->organisation->id}/identity-matches/{$match->id}/dismiss" + ); + + $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); + } + + 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 + { + $person = Person::factory()->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->crowdType->id, + 'user_id' => null, + ]); + + $match = PersonIdentityMatch::factory()->dismissed()->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}/dismiss" + ); + + $response->assertStatus(422); + } + + public function test_bulk_confirm_multiple_matches(): void + { + $matches = []; + for ($i = 0; $i < 3; $i++) { + $matchUser = User::factory()->create(); + $person = Person::factory()->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->crowdType->id, + 'email' => "bulk{$i}@example.nl", + 'user_id' => null, + ]); + $matches[] = 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/bulk-confirm", + ['match_ids' => collect($matches)->pluck('id')->toArray()] + ); + + $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, + 'crowd_type_id' => $this->crowdType->id, + 'user_id' => null, + ]); + $match1 = PersonIdentityMatch::factory()->create([ + 'person_id' => $person1->id, + 'matched_user_id' => $matchUser1->id, + 'status' => IdentityMatchStatus::PENDING, + ]); + + // Match 2: user already linked in same event → should error + $matchUser2 = User::factory()->create(); + Person::factory()->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->crowdType->id, + 'user_id' => $matchUser2->id, + ]); + $person2 = Person::factory()->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->crowdType->id, + 'user_id' => null, + ]); + $match2 = PersonIdentityMatch::factory()->create([ + 'person_id' => $person2->id, + 'matched_user_id' => $matchUser2->id, + 'status' => IdentityMatchStatus::PENDING, + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson( + "/api/v1/organisations/{$this->organisation->id}/identity-matches/bulk-confirm", + ['match_ids' => [$match1->id, $match2->id]] + ); + + $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 + // ────────────────────────────────────────────────────── + + public function test_cross_org_cannot_resolve_match(): void + { + $matchUser = User::factory()->create(); + $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->outsider); + + $response = $this->postJson( + "/api/v1/organisations/{$this->organisation->id}/identity-matches/{$match->id}/confirm" + ); + + $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 + // ────────────────────────────────────────────────────── + + public function test_index_returns_only_pending_matches_for_organisation(): 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, + ]); + + // 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->assertOk(); + $this->assertCount(1, $response->json('data')); + } + + 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 + { + $matchUser = User::factory()->create(['email' => 'inline@example.nl']); + $person = Person::factory()->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->crowdType->id, + 'email' => 'inline@example.nl', + 'user_id' => null, + ]); + + PersonIdentityMatch::factory()->create([ + 'person_id' => $person->id, + 'matched_user_id' => $matchUser->id, + 'status' => IdentityMatchStatus::PENDING, + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->getJson("/api/v1/events/{$this->event->id}/persons"); + + $response->assertOk(); + + $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/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/dev-docs/API.md b/dev-docs/API.md index fd415fcd..106d4a79 100644 --- a/dev-docs/API.md +++ b/dev-docs/API.md @@ -122,6 +122,45 @@ Returns 422 with `errors`, `current_status`, `requested_status`, and `allowed_tr - `POST /events/{event}/persons/{person}/approve` - `DELETE /events/{event}/persons/{person}` +## Identity Matches + +- `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/bulk-confirm` — bulk confirm multiple matches + +### Detection + +Matches are created automatically: +- When a person is created (via `POST /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 + +No silent auto-linking. Every identity link requires explicit confirmation. + +### Bulk Confirm + +`POST /organisations/{org}/identity-matches/bulk-confirm` + +Body: `{ "match_ids": ["ulid1", "ulid2", ...] }` (max 100) + +Response: `{ "confirmed": 2, "errors": [{ "match_id": "ulid3", "error": "User already has a person record in this event." }] }` + +### PersonResource enrichment + +`GET /events/{event}/persons` includes `pending_identity_match` inline when a pending match exists: + +```json +{ + "pending_identity_match": { + "match_id": "ulid", + "matched_user": { "id": "ulid", "name": "Jan", "email": "jan@example.nl" }, + "matched_on": "email", + "confidence": "exact" + } +} +``` + ## Crowd Lists - `GET /events/{event}/crowd-lists` diff --git a/dev-docs/SCHEMA.md b/dev-docs/SCHEMA.md index 256d7d8f..09461b19 100644 --- a/dev-docs/SCHEMA.md +++ b/dev-docs/SCHEMA.md @@ -37,7 +37,8 @@ 3. [3.5.3 Festival Sections, Time Slots & Shifts](#353-festival-sections-time-slots--shifts) 4. [3.5.4 Volunteer Profile & History](#354-volunteer-profile--history) 5. [3.5.5 Crowd Types, Persons & Crowd Lists](#355-crowd-types-persons--crowd-lists) -6. [3.5.6 Accreditation Engine](#356-accreditation-engine) +6. [3.5.5b Person Identity Matching](#355b-person-identity-matching) +7. [3.5.6 Accreditation Engine](#356-accreditation-engine) 7. [3.5.7 Artists & Advancing](#357-artists--advancing) 8. [3.5.8 Communication & Briefings](#358-communication--briefings) 9. [3.5.9 Forms, Check-In & Operational](#359-forms-check-in--operational) @@ -743,6 +744,37 @@ $effectiveDate = $shift->end_date ?? $shift->timeSlot->date; --- +## 3.5.5b 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. + +### `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 | | + +**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. + +**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) + +--- + ## 3.5.6 Accreditation Engine ### `accreditation_categories`