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>