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