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:
@@ -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>
|
||||
|
||||
151
apps/app/src/composables/api/useIdentityMatches.ts
Normal file
151
apps/app/src/composables/api/useIdentityMatches.ts
Normal 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'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
69
apps/app/src/types/identityMatch.ts
Normal file
69
apps/app/src/types/identityMatch.ts
Normal 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
|
||||
}
|
||||
@@ -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[]
|
||||
|
||||
Reference in New Issue
Block a user