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:
2026-04-14 08:44:24 +02:00
parent 7932e53daf
commit eb1a0ac666
30 changed files with 1941 additions and 399 deletions

View File

@@ -1,6 +1,9 @@
<script setup lang="ts">
import { usePersonDetail, useUpdatePerson } from '@/composables/api/usePersons'
import { useConfirmMatch, useDismissMatch, useManualLinkPerson, useUnlinkPerson } from '@/composables/api/useIdentityMatches'
import { useMemberList } from '@/composables/api/useMembers'
import type { Person, PersonStatus } from '@/types/person'
import type { Member } from '@/types/member'
const props = defineProps<{
eventId: string
@@ -21,6 +24,15 @@ const personIdRef = computed(() => props.personId ?? '')
const { data: person, isLoading } = usePersonDetail(orgIdRef, eventIdRef, personIdRef)
const { mutate: updatePerson } = useUpdatePerson(orgIdRef, eventIdRef)
// Identity matching mutations
const { mutate: confirmMatch, isPending: isConfirming } = useConfirmMatch(orgIdRef)
const { mutate: dismissMatch, isPending: isDismissing } = useDismissMatch(orgIdRef)
const { mutate: manualLink, isPending: isManualLinking } = useManualLinkPerson(orgIdRef, eventIdRef)
const { mutate: unlinkPerson, isPending: isUnlinking } = useUnlinkPerson(orgIdRef, eventIdRef)
// Members for manual link
const { data: membersResponse } = useMemberList(orgIdRef)
const activeTab = ref('info')
const statusColor: Record<PersonStatus, string> = {
@@ -87,6 +99,57 @@ function onBlacklistToggle(val: boolean | null) {
is_blacklisted: !!val,
})
}
// Identity matching handlers
function handleConfirm() {
if (!person.value?.pending_identity_match) return
confirmMatch(person.value.pending_identity_match.match_id)
}
function handleDismiss() {
if (!person.value?.pending_identity_match) return
dismissMatch(person.value.pending_identity_match.match_id)
}
// Unlink confirmation
const showUnlinkConfirm = ref(false)
function handleUnlink() {
if (!person.value) return
unlinkPerson(person.value.id, {
onSuccess: () => {
showUnlinkConfirm.value = false
},
})
}
// Manual link dialog
const showManualLinkDialog = ref(false)
const linkSearchQuery = ref('')
const filteredOrgMembers = computed<Member[]>(() => {
const members = membersResponse.value?.data ?? []
const query = linkSearchQuery.value.toLowerCase().trim()
if (!query) return members
return members.filter(
m => m.full_name.toLowerCase().includes(query) || m.email.toLowerCase().includes(query),
)
})
function handleManualLink(userId: string) {
if (!person.value) return
manualLink(
{ personId: person.value.id, userId },
{
onSuccess: () => {
showManualLinkDialog.value = false
linkSearchQuery.value = ''
},
},
)
}
</script>
<template>
@@ -157,6 +220,134 @@ function onBlacklistToggle(val: boolean | null) {
/>
</div>
</div>
<!-- Identity matching banners -->
<!-- State 1: Pending match detected -->
<VAlert
v-if="person.pending_identity_match"
:type="person.pending_identity_match.confidence === 'high' ? 'info' : 'warning'"
variant="tonal"
class="mb-3"
>
<div class="d-flex align-center justify-space-between flex-wrap ga-2">
<div>
<div class="font-weight-medium">
Mogelijke match gevonden
<VChip
size="x-small"
class="ml-1"
variant="outlined"
>
{{ person.pending_identity_match.confidence_label }}
</VChip>
</div>
<div class="text-body-2 mt-1">
{{ person.pending_identity_match.matched_on_label }}:
<strong>{{ person.pending_identity_match.matched_user.full_name }}</strong>
({{ person.pending_identity_match.matched_user.email }})
</div>
<div
v-if="person.pending_identity_match.match_details?.matched_fields?.includes('date_of_birth')"
class="text-caption mt-1 text-success"
>
Geboortedatum komt overeen
</div>
<div
v-if="person.pending_identity_match.match_details?.matched_fields?.some((f: string) => f.includes('fuzzy'))"
class="text-caption mt-1 text-warning"
>
Naam is vergelijkbaar maar niet exact
</div>
</div>
<div class="d-flex ga-2">
<VBtn
size="small"
color="success"
variant="flat"
:loading="isConfirming"
@click="handleConfirm"
>
<VIcon
start
size="16"
>
tabler-check
</VIcon>
Koppelen
</VBtn>
<VBtn
size="small"
color="grey"
variant="tonal"
:loading="isDismissing"
@click="handleDismiss"
>
Niet dezelfde persoon
</VBtn>
</div>
</div>
</VAlert>
<!-- State 2: Linked (has_user_account) -->
<VAlert
v-else-if="person.has_user_account"
type="success"
variant="tonal"
density="compact"
class="mb-3"
>
<div class="d-flex align-center justify-space-between">
<div>
<VIcon
start
size="16"
>
tabler-link
</VIcon>
Gekoppeld aan platformaccount:
<strong>{{ person.user_account?.full_name }}</strong>
({{ person.user_account?.email }})
</div>
<VBtn
size="x-small"
variant="text"
color="error"
@click="showUnlinkConfirm = true"
>
Ontkoppelen
</VBtn>
</div>
</VAlert>
<!-- State 3: Not linked -->
<VAlert
v-else
type="warning"
variant="tonal"
density="compact"
class="mb-3"
>
<div class="d-flex align-center justify-space-between">
<div>
<VIcon
start
size="16"
>
tabler-unlink
</VIcon>
Niet gekoppeld aan een platformaccount.
Kan niet inloggen op het portaal.
</div>
<VBtn
size="x-small"
variant="tonal"
color="primary"
@click="showManualLinkDialog = true"
>
Handmatig koppelen
</VBtn>
</div>
</VAlert>
</div>
<VDivider />
@@ -291,5 +482,111 @@ function onBlacklistToggle(val: boolean | null) {
</VTabsWindowItem>
</VTabsWindow>
</template>
<!-- Unlink confirmation dialog -->
<VDialog
v-model="showUnlinkConfirm"
max-width="420"
>
<VCard>
<VCardTitle class="text-h6 pt-5 px-5">
Koppeling verbreken?
</VCardTitle>
<VCardText class="px-5">
Weet je zeker dat je de koppeling met
<strong>{{ person?.user_account?.email }}</strong> wilt verbreken?
<br><br>
<strong>Let op:</strong> deze persoon kan daarna niet meer inloggen
op het portaal en ziet geen shifts meer.
</VCardText>
<VCardActions class="px-5 pb-5">
<VSpacer />
<VBtn
variant="tonal"
@click="showUnlinkConfirm = false"
>
Annuleren
</VBtn>
<VBtn
color="error"
variant="flat"
:loading="isUnlinking"
@click="handleUnlink"
>
Ontkoppelen
</VBtn>
</VCardActions>
</VCard>
</VDialog>
<!-- Manual link dialog -->
<VDialog
v-model="showManualLinkDialog"
max-width="520"
>
<VCard>
<VCardTitle class="text-h6 pt-5 px-5">
Handmatig koppelen
</VCardTitle>
<VCardText class="px-5">
<p class="text-body-2 text-medium-emphasis mb-4">
Kies een organisatielid om te koppelen aan
<strong>{{ person?.full_name }}</strong>
</p>
<VTextField
v-model="linkSearchQuery"
prepend-inner-icon="tabler-search"
placeholder="Zoek op naam of e-mail..."
density="compact"
hide-details
clearable
class="mb-3"
/>
<VList
v-if="filteredOrgMembers.length"
density="compact"
class="border rounded-lg"
style="max-height: 250px; overflow-y: auto"
>
<VListItem
v-for="member in filteredOrgMembers"
:key="member.id"
class="cursor-pointer"
:disabled="isManualLinking"
@click="handleManualLink(member.id)"
>
<template #prepend>
<VAvatar
size="32"
color="primary"
variant="tonal"
>
{{ (member.first_name[0] + member.last_name[0]).toUpperCase() }}
</VAvatar>
</template>
<VListItemTitle>{{ member.full_name }}</VListItemTitle>
<VListItemSubtitle>{{ member.email }}</VListItemSubtitle>
</VListItem>
</VList>
<VAlert
v-else-if="linkSearchQuery"
type="info"
variant="tonal"
density="compact"
>
Geen leden gevonden voor "{{ linkSearchQuery }}"
</VAlert>
</VCardText>
<VCardActions class="px-5 pb-5">
<VSpacer />
<VBtn
variant="tonal"
@click="showManualLinkDialog = false"
>
Sluiten
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</VNavigationDrawer>
</template>

View File

@@ -0,0 +1,151 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import type { MaybeRef } from 'vue'
import { unref } from 'vue'
import { apiClient } from '@/lib/axios'
import type { IdentityMatch } from '@/types/identityMatch'
import type { Person } from '@/types/person'
interface ApiResponse<T> {
success: boolean
data: T
}
interface PaginatedResponse<T> {
data: T[]
links: Record<string, string | null>
meta: {
current_page: number
per_page: number
total: number
last_page: number
}
}
interface BulkConfirmResult {
confirmed: number
errors: Array<{ match_id: string; error: string }>
}
export function useIdentityMatches(orgId: MaybeRef<string>) {
return useQuery({
queryKey: ['identity-matches', orgId],
queryFn: async () => {
const { data } = await apiClient.get<PaginatedResponse<IdentityMatch>>(
`/organisations/${unref(orgId)}/identity-matches`,
)
return data
},
enabled: () => !!unref(orgId),
})
}
export function useConfirmMatch(orgId: MaybeRef<string>) {
const qc = useQueryClient()
return useMutation({
mutationFn: async (matchId: string) => {
const { data } = await apiClient.post<ApiResponse<IdentityMatch>>(
`/organisations/${unref(orgId)}/identity-matches/${matchId}/confirm`,
)
return data.data
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['identity-matches'] })
qc.invalidateQueries({ queryKey: ['persons'] })
},
})
}
export function useDismissMatch(orgId: MaybeRef<string>) {
const qc = useQueryClient()
return useMutation({
mutationFn: async (matchId: string) => {
const { data } = await apiClient.post<ApiResponse<IdentityMatch>>(
`/organisations/${unref(orgId)}/identity-matches/${matchId}/dismiss`,
)
return data.data
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['identity-matches'] })
qc.invalidateQueries({ queryKey: ['persons'] })
},
})
}
export function useRevertMatch(orgId: MaybeRef<string>) {
const qc = useQueryClient()
return useMutation({
mutationFn: async (matchId: string) => {
const { data } = await apiClient.post<ApiResponse<IdentityMatch>>(
`/organisations/${unref(orgId)}/identity-matches/${matchId}/revert`,
)
return data.data
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['identity-matches'] })
qc.invalidateQueries({ queryKey: ['persons'] })
},
})
}
export function useBulkConfirmMatches(orgId: MaybeRef<string>) {
const qc = useQueryClient()
return useMutation({
mutationFn: async (matchIds: string[]) => {
const { data } = await apiClient.post<BulkConfirmResult>(
`/organisations/${unref(orgId)}/identity-matches/bulk-confirm`,
{ match_ids: matchIds },
)
return data
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['identity-matches'] })
qc.invalidateQueries({ queryKey: ['persons'] })
},
})
}
export function useManualLinkPerson(orgId: MaybeRef<string>, eventId: MaybeRef<string>) {
const qc = useQueryClient()
return useMutation({
mutationFn: async ({ personId, userId }: { personId: string; userId: string }) => {
const { data } = await apiClient.post<ApiResponse<IdentityMatch>>(
`/organisations/${unref(orgId)}/events/${unref(eventId)}/persons/${personId}/manual-link`,
{ user_id: userId },
)
return data.data
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['identity-matches'] })
qc.invalidateQueries({ queryKey: ['persons'] })
},
})
}
export function useUnlinkPerson(orgId: MaybeRef<string>, eventId: MaybeRef<string>) {
const qc = useQueryClient()
return useMutation({
mutationFn: async (personId: string) => {
const { data } = await apiClient.post<ApiResponse<Person>>(
`/organisations/${unref(orgId)}/events/${unref(eventId)}/persons/${personId}/unlink`,
)
return data.data
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['identity-matches'] })
qc.invalidateQueries({ queryKey: ['persons'] })
},
})
}

View File

@@ -294,6 +294,29 @@ const crowdTypeOptions = computed(() => [
<span class="text-caption">{{ getInitials(item.full_name) }}</span>
</VAvatar>
<span>{{ item.full_name }}</span>
<VTooltip
v-if="item.pending_identity_match"
location="top"
>
<template #activator="{ props: tooltipProps }">
<VIcon
v-bind="tooltipProps"
size="16"
color="warning"
>
tabler-link
</VIcon>
</template>
Mogelijke match: {{ item.pending_identity_match.matched_user.full_name }}
({{ item.pending_identity_match.confidence_label }})
</VTooltip>
<VIcon
v-else-if="item.has_user_account"
size="16"
color="success"
>
tabler-user-check
</VIcon>
</div>
</template>

View File

@@ -0,0 +1,69 @@
export const MatchMethod = {
EMAIL: 'email',
NAME_FUZZY: 'name_fuzzy',
MANUAL: 'manual',
} as const
export type MatchMethod = (typeof MatchMethod)[keyof typeof MatchMethod]
export const MatchConfidence = {
HIGH: 'high',
MEDIUM: 'medium',
} as const
export type MatchConfidence = (typeof MatchConfidence)[keyof typeof MatchConfidence]
export const MatchStatus = {
PENDING: 'pending',
CONFIRMED: 'confirmed',
DISMISSED: 'dismissed',
REVERTED: 'reverted',
} as const
export type MatchStatus = (typeof MatchStatus)[keyof typeof MatchStatus]
export interface IdentityMatchUser {
id: string
first_name: string
last_name: string
full_name: string
email: string
date_of_birth: string | null
}
export interface IdentityMatchDetails {
person_email: string | null
user_email: string
person_name: string
user_name: string
person_dob: string | null
user_dob: string | null
matched_fields: string[]
organisation_id: string
}
export interface IdentityMatch {
id: string
person: {
id: string
name: string
email: string | null
crowd_type: string | null
event: {
id: string
name: string
} | null
}
matched_user: IdentityMatchUser
matched_on: MatchMethod
matched_on_label: string
confidence: MatchConfidence
confidence_label: string
status: MatchStatus
status_label: string
match_details: IdentityMatchDetails | null
confirmed_by: { id: string; full_name: string } | null
confirmed_at: string | null
dismissed_at: string | null
reverted_at: string | null
resolved_by: { id: string; full_name: string } | null
resolved_at: string | null
created_at: string
}

View File

@@ -1,4 +1,5 @@
import type { Company, CrowdType } from '@/types/organisation'
import type { IdentityMatchDetails, MatchConfidence, MatchMethod } from '@/types/identityMatch'
export const PersonStatus = {
INVITED: 'invited',
@@ -38,9 +39,13 @@ export interface PendingIdentityMatch {
last_name: string
full_name: string
email: string
date_of_birth: string | null
}
matched_on: string
confidence: string
matched_on: MatchMethod
matched_on_label: string
confidence: MatchConfidence
confidence_label: string
match_details: IdentityMatchDetails | null
}
export interface Person {
@@ -59,6 +64,8 @@ export interface Person {
created_at: string
crowd_type: CrowdType | null
company: Company | null
has_user_account: boolean
user_account: { id: string; email: string; full_name: string } | null
pending_identity_match?: PendingIdentityMatch
crowd_list_pivot?: CrowdListPivot
tags?: PersonTag[]