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