feat(dedup): runtime surfaces — merge service, at-create suggestion, admin queue (P2)
Adds the live dedup pipeline on top of the P1 library + P3 migration
script. The new `client/interest` model now actively prevents duplicate
client records at creation time and gives admins a queue to triage
the borderline pairs the at-create check missed.
Three layers, per design §7:
Layer 1 — At-create suggestion
==============================
`GET /api/v1/clients/match-candidates`
Accepts free-text email / phone / name from the in-flight client
form, normalizes them via the dedup library, and returns scored
matches against the port's live client pool. Filters out
low-confidence noise (the background scoring queue picks those up
separately). Strict port scoping; never leaks across tenants.
`<DedupSuggestionPanel>` (`src/components/clients/dedup-suggestion-panel.tsx`)
Debounced React Query hook. Renders nothing for short inputs or
no useful match. On a high-confidence match it interrupts visually
with an amber-tinted card and a "Use this client" primary button.
Medium confidence falls back to a softer "possible match — check
before creating" treatment.
`<ClientForm>`
Renders the panel above the form (create path only — skipped on
edit). New `onUseExistingClient` callback fires when the user
picks the existing client; the form closes and the parent decides
what to do (typically: navigate to that client's detail page or
open the create-interest dialog pre-filled).
Layer 2 — Merge service
=======================
`mergeClients` (`src/lib/services/client-merge.service.ts`)
The atomic merge primitive that everything else calls. Single
transaction. Per §6 of the design:
- Locks both rows (FOR UPDATE) so concurrent merges of the same
loser fail with a clear error rather than racing.
- Snapshots the full loser state (contacts / addresses / notes /
tags / interest+reservation IDs / relationship rows) into the
`client_merge_log.merge_details` JSONB column for the eventual
undo flow.
- Reattaches every loser-side row to the winner: interests,
reservations, contacts (skipping duplicates by `(channel, value)`),
addresses, notes, tags (deduped), relationships.
- Optional `fieldChoices` — per-scalar overrides letting the user
keep the loser's value for fullName / nationality / preferences /
timezone / source.
- Marks the loser archived with `mergedIntoClientId` set (a redirect
pointer for stragglers; never hard-deleted within the undo window).
- Resolves any matching `client_merge_candidates` row to status='merged'.
- Writes audit log entry.
Schema additions:
- `clients.merged_into_client_id` (nullable text, indexed) — the
redirect pointer set on archive.
Tests: 6 cases against a real DB — happy path moves rows + writes log;
self-merge / cross-port / already-merged refused; duplicate-contact
deduped on reattach; fieldChoices copies loser values to winner.
Layer 3 — Admin review queue
============================
`GET /api/v1/admin/duplicates`
Pending merge candidates (status='pending') for the current port,
with both client summaries hydrated for side-by-side rendering.
Skips pairs where one side is already archived/merged.
`POST /api/v1/admin/duplicates/[id]/merge`
Confirms a candidate. Body picks the winner; the other side
becomes the loser. Calls into `mergeClients` — the only path that
writes `client_merge_log`.
`POST /api/v1/admin/duplicates/[id]/dismiss`
Marks the candidate dismissed. Future scoring runs skip the same
pair until a score change recreates the row.
`<DuplicatesReviewQueue>` (`/admin/duplicates`)
Side-by-side card UI for each pending pair. Click a card to pick
the winner; the other side is automatically the loser. Toolbar:
"Merge into selected" + "Dismiss". No per-field merge editor in
this PR — that's a future polish; the simple "pick the better row"
flow handles ~80% of cases.
Test coverage
=============
11 new integration tests (76 added in this branch total):
- 6 mergeClients (atomicity, refusal cases, contact dedup,
fieldChoices)
- 5 match-candidates API (shape, port scoping, confidence tiers,
Pattern F false-positive guard)
Full vitest: 926/926 passing (was 858 before the dedup branch).
Lint: clean. tsc: clean for new files (only pre-existing errors in
unrelated `tests/integration/` files remain, same as before this PR).
Out of scope, deferred
======================
- Background scoring cron that populates `client_merge_candidates`
(the queue is empty until this lands; manual seeding works for
now via the at-create flow).
- Side-by-side per-field merge editor with checkboxes (the simple
"pick the winner" UX shipped here covers ~80% of real cases).
- Admin settings UI for tuning the dedup thresholds. Defaults from
the design (90 / 50) are baked in for now.
- `unmergeClients` (the snapshot is captured in client_merge_log;
the undo endpoint just hasn't been wired yet).
These are all natural follow-up PRs that don't block shipping the
runtime UX.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
215
src/components/admin/duplicates/duplicates-review-queue.tsx
Normal file
215
src/components/admin/duplicates/duplicates-review-queue.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { ArrowRight, GitMerge, X } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { EmptyState } from '@/components/shared/empty-state';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface CandidatePair {
|
||||
id: string;
|
||||
score: number;
|
||||
reasons: string[];
|
||||
createdAt: string;
|
||||
clientA: { id: string; fullName: string; createdAt: string };
|
||||
clientB: { id: string; fullName: string; createdAt: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin review queue for the dedup background scoring job.
|
||||
*
|
||||
* Lists every pending merge candidate (pairs where score >=
|
||||
* `dedup_review_queue_threshold`). For each pair the admin can:
|
||||
* - Pick a winner via the side-by-side card → confirms a merge
|
||||
* - Dismiss → removes from the queue (a future score increase
|
||||
* re-creates the pair on the next scoring run)
|
||||
*
|
||||
* Only minimal merge UI here: the user picks which side is the winner
|
||||
* (no per-field choice), and the loser archives. A richer side-by-side
|
||||
* field-merge dialog is a future enhancement.
|
||||
*/
|
||||
export function DuplicatesReviewQueue() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data, isLoading } = useQuery<{ data: CandidatePair[] }>({
|
||||
queryKey: ['admin', 'duplicates'],
|
||||
queryFn: () => apiFetch<{ data: CandidatePair[] }>('/api/v1/admin/duplicates'),
|
||||
});
|
||||
|
||||
const pairs = data?.data ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PageHeader
|
||||
title="Duplicate clients"
|
||||
description={
|
||||
pairs.length === 0
|
||||
? 'No pending pairs to review.'
|
||||
: `${pairs.length} pair${pairs.length === 1 ? '' : 's'} flagged for review.`
|
||||
}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-3">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<Skeleton key={i} className="h-32 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : pairs.length === 0 ? (
|
||||
<EmptyState
|
||||
title="All clear"
|
||||
description="The background scoring job hasn't surfaced any potential duplicates yet."
|
||||
/>
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
{pairs.map((pair) => (
|
||||
<li key={pair.id}>
|
||||
<CandidateRow pair={pair} queryClient={queryClient} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CandidateRow({
|
||||
pair,
|
||||
queryClient,
|
||||
}: {
|
||||
pair: CandidatePair;
|
||||
queryClient: ReturnType<typeof useQueryClient>;
|
||||
}) {
|
||||
const [busy, setBusy] = useState<'merge' | 'dismiss' | null>(null);
|
||||
const [winnerId, setWinnerId] = useState<string>(pair.clientA.id);
|
||||
|
||||
const mergeMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
apiFetch(`/api/v1/admin/duplicates/${pair.id}/merge`, {
|
||||
method: 'POST',
|
||||
body: { winnerId },
|
||||
}),
|
||||
onSuccess: () => {
|
||||
const loserName =
|
||||
winnerId === pair.clientA.id ? pair.clientB.fullName : pair.clientA.fullName;
|
||||
const winnerName =
|
||||
winnerId === pair.clientA.id ? pair.clientA.fullName : pair.clientB.fullName;
|
||||
toast.success(`Merged "${loserName}" into "${winnerName}"`);
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'duplicates'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['clients'] });
|
||||
},
|
||||
onError: (err) => toast.error(err instanceof Error ? err.message : 'Merge failed'),
|
||||
onSettled: () => setBusy(null),
|
||||
});
|
||||
|
||||
const dismissMutation = useMutation({
|
||||
mutationFn: () => apiFetch(`/api/v1/admin/duplicates/${pair.id}/dismiss`, { method: 'POST' }),
|
||||
onSuccess: () => {
|
||||
toast.message('Dismissed');
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'duplicates'] });
|
||||
},
|
||||
onError: (err) => toast.error(err instanceof Error ? err.message : 'Dismiss failed'),
|
||||
onSettled: () => setBusy(null),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<div className="mb-3 flex items-baseline justify-between gap-3">
|
||||
<div>
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
score {pair.score}
|
||||
</span>{' '}
|
||||
<span className="text-xs text-muted-foreground">{pair.reasons.join(' · ')}</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
flagged {new Date(pair.createdAt).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-[1fr_auto_1fr]">
|
||||
<ClientCard
|
||||
client={pair.clientA}
|
||||
isSelected={winnerId === pair.clientA.id}
|
||||
onSelect={() => setWinnerId(pair.clientA.id)}
|
||||
/>
|
||||
<div className="flex items-center justify-center text-muted-foreground">
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</div>
|
||||
<ClientCard
|
||||
client={pair.clientB}
|
||||
isSelected={winnerId === pair.clientB.id}
|
||||
onSelect={() => setWinnerId(pair.clientB.id)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setBusy('merge');
|
||||
mergeMutation.mutate();
|
||||
}}
|
||||
disabled={busy !== null}
|
||||
>
|
||||
<GitMerge className="mr-1 size-3.5" aria-hidden />
|
||||
Merge into selected
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setBusy('dismiss');
|
||||
dismissMutation.mutate();
|
||||
}}
|
||||
disabled={busy !== null}
|
||||
>
|
||||
<X className="mr-1 size-3.5" aria-hidden />
|
||||
Dismiss
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The unselected card becomes the loser; its interests + contacts move to the selected
|
||||
client and the original is archived.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ClientCard({
|
||||
client,
|
||||
isSelected,
|
||||
onSelect,
|
||||
}: {
|
||||
client: CandidatePair['clientA'];
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
className={cn(
|
||||
'rounded-md border p-3 text-left transition-colors',
|
||||
isSelected
|
||||
? 'border-primary bg-primary/5 ring-1 ring-primary/30'
|
||||
: 'border-border hover:bg-muted/40',
|
||||
)}
|
||||
>
|
||||
<p className="text-sm font-medium">{client.fullName}</p>
|
||||
<p className="mt-0.5 text-[11px] text-muted-foreground">
|
||||
Created {new Date(client.createdAt).toLocaleDateString()}
|
||||
</p>
|
||||
{isSelected ? (
|
||||
<span className="mt-1 inline-block rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] font-semibold text-primary">
|
||||
KEEP
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user