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:
@@ -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"], "..." : "..." }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user