mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
feat(contacts): prevent duplicate phone numbers on create/import (#212)
The same phone could become multiple contacts: only the WhatsApp webhook de-duped, while manual create and CSV import inserted raw un-normalized phones, and the DB had no uniqueness. That fragmented conversations, deals, and tags across duplicates. - Migration 022: generated `contacts.phone_normalized` (digits-only), a one-shot `merge_duplicate_contacts()` that collapses existing dupes into the oldest row (re-pointing conversations, deals, notes, tags, custom values, broadcast recipients — no data loss), then a UNIQUE (account_id, phone_normalized) index as the authoritative guarantee for every write path. - Shared `src/lib/contacts/dedupe.ts`: findExistingContact (suffix prefilter + phonesMatch), isExactMatch, isUniqueViolation, dedupeByPhone. The webhook now reuses findExistingContact (and recovers from a unique-violation race instead of dropping the message). - Contact form: on-blur duplicate check — hard-block + "view existing" link on an exact match, soft warning on a trunk-variant near-match; unique-violation backstop on submit. - CSV import: de-dupes within the file and against existing contacts, reports "imported / skipped / failed", treats 23505 as skipped. - Contact type gains account_id (was missing) + phone_normalized. - Unit tests for the dedupe helpers. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b8972fd905
commit
3dce1b56cd
20
CHANGELOG.md
20
CHANGELOG.md
@ -100,6 +100,18 @@ always did.
|
||||
|
||||
### Added
|
||||
|
||||
- **Duplicate phone numbers are now prevented across contacts.** A
|
||||
phone number can no longer become more than one contact in the same
|
||||
account. Adding a contact whose number already exists is blocked
|
||||
with a link to the existing record (and a softer warning for
|
||||
near-matches that share their last 8 digits); CSV import de-dupes
|
||||
within the file and against existing contacts, reporting
|
||||
"X imported, Y duplicates skipped". The rule is enforced by a
|
||||
database unique index on the normalized number, so the WhatsApp
|
||||
webhook, the form, import, and any future path all agree. Existing
|
||||
duplicates are merged into the oldest contact on upgrade (their
|
||||
conversations, deals, notes, and tags are re-pointed, nothing is
|
||||
lost). Closes #212.
|
||||
- **Configurable default deal currency.** Each account can now pick
|
||||
its default currency under **Settings → Deals** (admin+); the app
|
||||
previously hardcoded USD throughout. New deals default to it, and
|
||||
@ -184,6 +196,14 @@ Apply against your Supabase project before deploying this version:
|
||||
Idempotent; existing accounts backfill to `USD`. **Apply before
|
||||
deploying** — the app now reads this column when loading the
|
||||
account, so an un-migrated database breaks account loading.
|
||||
- `supabase/migrations/022_contact_phone_dedup.sql` — adds the
|
||||
generated `contacts.phone_normalized` column, **merges existing
|
||||
duplicate contacts into the oldest** (re-pointing conversations,
|
||||
deals, notes, tags, custom values, and broadcast recipients — no
|
||||
data loss), then adds a `UNIQUE (account_id, phone_normalized)`
|
||||
index. Idempotent. **Apply before deploying** — CSV import reads
|
||||
`phone_normalized`, and the index is what enforces de-duplication
|
||||
for every write path. The one-shot merge runs inside the migration.
|
||||
|
||||
## [0.2.2] — 2026-05-29
|
||||
|
||||
|
||||
@ -446,6 +446,10 @@ export default function ContactsPage() {
|
||||
fetchContacts();
|
||||
fetchTags();
|
||||
}}
|
||||
onViewExisting={(id) => {
|
||||
setFormOpen(false);
|
||||
openDetail(id);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Contact Detail Sheet */}
|
||||
|
||||
@ -2,7 +2,8 @@ import { NextResponse } from 'next/server'
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
import { decrypt, encrypt, isLegacyFormat } from '@/lib/whatsapp/encryption'
|
||||
import { getMediaUrl, downloadMedia } from '@/lib/whatsapp/meta-api'
|
||||
import { normalizePhone, phonesMatch } from '@/lib/whatsapp/phone-utils'
|
||||
import { normalizePhone } from '@/lib/whatsapp/phone-utils'
|
||||
import { findExistingContact, isUniqueViolation } from '@/lib/contacts/dedupe'
|
||||
import { verifyMetaWebhookSignature } from '@/lib/whatsapp/webhook-signature'
|
||||
import { runAutomationsForTrigger } from '@/lib/automations/engine'
|
||||
import { dispatchInboundToFlows } from '@/lib/flows/engine'
|
||||
@ -875,43 +876,17 @@ async function findOrCreateContact(
|
||||
phone: string,
|
||||
name: string
|
||||
): Promise<ContactOutcome | null> {
|
||||
// Look up existing contacts for this account. We pre-filter in SQL
|
||||
// by the phone's last-8-digit suffix so we don't ship every contact
|
||||
// in the account over the wire just to JS-filter to one row. This
|
||||
// matters at scale: a shared account with 5 teammates × 100 contacts
|
||||
// each is 500 rows — the prior implementation pulled all of them on
|
||||
// every inbound message.
|
||||
//
|
||||
// The `phonesMatch` helper considers two phones equal if they share
|
||||
// the last 8 digits (trunk-prefix tolerance). We mirror that here as
|
||||
// a `like` pattern, then re-run the strict comparison in JS on the
|
||||
// narrowed candidate set. The candidate set is typically 0-2 rows,
|
||||
// so the JS pass is effectively free.
|
||||
//
|
||||
// The trailing-suffix `like` cannot use a B-tree index, but the
|
||||
// `account_id` filter on top of `idx_contacts_account` (017) means
|
||||
// we sequential-scan a small, account-scoped subset.
|
||||
const normalizedSender = phone.replace(/\D/g, '')
|
||||
const phoneSuffix =
|
||||
normalizedSender.length >= 8
|
||||
? normalizedSender.slice(-8)
|
||||
: normalizedSender
|
||||
|
||||
const { data: contacts, error: contactsError } = await supabaseAdmin()
|
||||
.from('contacts')
|
||||
.select('*')
|
||||
.eq('account_id', accountId)
|
||||
.like('phone', `%${phoneSuffix}`)
|
||||
|
||||
if (contactsError) {
|
||||
console.error('Error fetching contacts:', contactsError)
|
||||
return null
|
||||
}
|
||||
|
||||
// Re-apply phonesMatch on the candidate set for correctness — the
|
||||
// SQL `like` is a coarse pre-filter; phonesMatch handles edge cases
|
||||
// like leading-`+` and explicit trunk-zero handling.
|
||||
const existingContact = contacts?.find((c: ContactRow) => phonesMatch(c.phone, phone))
|
||||
// Find an existing contact for this account by phone. The shared
|
||||
// helper pre-filters in SQL by the last-8-digit suffix (so we don't
|
||||
// pull every contact on every inbound message) then applies the
|
||||
// strict `phonesMatch` in JS on the small candidate set. The same
|
||||
// helper backs the manual contact form and CSV import, so all three
|
||||
// paths agree on what "same number" means (issue #212).
|
||||
const existingContact = await findExistingContact(
|
||||
supabaseAdmin(),
|
||||
accountId,
|
||||
phone,
|
||||
)
|
||||
|
||||
if (existingContact) {
|
||||
// Update name if it changed
|
||||
@ -940,6 +915,14 @@ async function findOrCreateContact(
|
||||
.single()
|
||||
|
||||
if (createError) {
|
||||
// Lost a race: a concurrent inbound delivery (or another path)
|
||||
// created this contact between our lookup and insert, and the
|
||||
// unique index (migration 022) rejected the duplicate. Re-resolve
|
||||
// the existing row instead of dropping the message.
|
||||
if (isUniqueViolation(createError)) {
|
||||
const raced = await findExistingContact(supabaseAdmin(), accountId, phone)
|
||||
if (raced) return { contact: raced, wasCreated: false }
|
||||
}
|
||||
console.error('Error creating contact:', createError)
|
||||
return null
|
||||
}
|
||||
|
||||
@ -39,6 +39,7 @@ const contactFields = [
|
||||
const SAMPLE_CONTACT: Contact = {
|
||||
id: 'sample',
|
||||
user_id: '',
|
||||
account_id: '',
|
||||
name: 'John Doe',
|
||||
phone: '+1234567890',
|
||||
email: 'john@example.com',
|
||||
|
||||
@ -5,6 +5,12 @@ import { createClient } from '@/lib/supabase/client';
|
||||
import { useAuth } from '@/hooks/use-auth';
|
||||
import { toast } from 'sonner';
|
||||
import type { Contact, Tag, ContactTag } from '@/types';
|
||||
import {
|
||||
findExistingContact,
|
||||
isExactMatch,
|
||||
isUniqueViolation,
|
||||
type ExistingContact,
|
||||
} from '@/lib/contacts/dedupe';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@ -17,7 +23,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { Loader2, AlertTriangle } from 'lucide-react';
|
||||
|
||||
interface ContactFormProps {
|
||||
open: boolean;
|
||||
@ -25,6 +31,9 @@ interface ContactFormProps {
|
||||
contact?: Contact | null;
|
||||
contactTags?: ContactTag[];
|
||||
onSaved: () => void;
|
||||
/** Open an existing contact's detail view — used by the duplicate
|
||||
* notice to jump to the contact that already owns this number. */
|
||||
onViewExisting?: (contactId: string) => void;
|
||||
}
|
||||
|
||||
export function ContactForm({
|
||||
@ -33,6 +42,7 @@ export function ContactForm({
|
||||
contact,
|
||||
contactTags = [],
|
||||
onSaved,
|
||||
onViewExisting,
|
||||
}: ContactFormProps) {
|
||||
const supabase = createClient();
|
||||
const { accountId } = useAuth();
|
||||
@ -44,6 +54,15 @@ export function ContactForm({
|
||||
const [company, setCompany] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Duplicate-phone detection for NEW contacts. `exact` (same digits)
|
||||
// hard-blocks the save; a fuzzy trunk-variant match only warns. The
|
||||
// DB unique index (migration 022) is the real backstop — this is the
|
||||
// friendly heads-up before we get there.
|
||||
const [dupMatch, setDupMatch] = useState<
|
||||
{ contact: ExistingContact; exact: boolean } | null
|
||||
>(null);
|
||||
const [checkingDup, setCheckingDup] = useState(false);
|
||||
|
||||
const [tags, setTags] = useState<Tag[]>([]);
|
||||
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
|
||||
const [loadingTags, setLoadingTags] = useState(false);
|
||||
@ -55,10 +74,33 @@ export function ContactForm({
|
||||
setEmail(contact?.email ?? '');
|
||||
setCompany(contact?.company ?? '');
|
||||
setSelectedTagIds(contactTags.map((ct) => ct.tag_id));
|
||||
setDupMatch(null);
|
||||
fetchTags();
|
||||
}
|
||||
}, [open, contact]);
|
||||
|
||||
// Look up an existing contact with this number (new contacts only).
|
||||
// Runs on blur so we don't query on every keystroke.
|
||||
async function checkDuplicate() {
|
||||
if (isEdit || !accountId) return;
|
||||
const value = phone.trim();
|
||||
if (!value) {
|
||||
setDupMatch(null);
|
||||
return;
|
||||
}
|
||||
setCheckingDup(true);
|
||||
try {
|
||||
const existing = await findExistingContact(supabase, accountId, value);
|
||||
setDupMatch(
|
||||
existing
|
||||
? { contact: existing, exact: isExactMatch(existing, value) }
|
||||
: null,
|
||||
);
|
||||
} finally {
|
||||
setCheckingDup(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchTags() {
|
||||
setLoadingTags(true);
|
||||
const { data } = await supabase
|
||||
@ -85,6 +127,13 @@ export function ContactForm({
|
||||
return;
|
||||
}
|
||||
|
||||
// Hard-block an exact duplicate on create (the DB unique index is
|
||||
// the real backstop; this avoids a round-trip + a raw error toast).
|
||||
if (!isEdit && dupMatch?.exact) {
|
||||
toast.error('A contact with this phone number already exists');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
@ -149,6 +198,22 @@ export function ContactForm({
|
||||
onOpenChange(false);
|
||||
onSaved();
|
||||
} catch (err: unknown) {
|
||||
// The unique index (migration 022) rejects a duplicate phone that
|
||||
// slipped past the on-blur check (race, or a format that
|
||||
// normalizes equal). Surface it as the friendly duplicate notice
|
||||
// and, for new contacts, point the user at the existing record.
|
||||
if (isUniqueViolation(err)) {
|
||||
toast.error('A contact with this phone number already exists');
|
||||
if (!isEdit && accountId) {
|
||||
const existing = await findExistingContact(
|
||||
supabase,
|
||||
accountId,
|
||||
phone.trim(),
|
||||
);
|
||||
if (existing) setDupMatch({ contact: existing, exact: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
const message = err instanceof Error ? err.message : 'Failed to save contact';
|
||||
toast.error(message);
|
||||
} finally {
|
||||
@ -191,13 +256,45 @@ export function ContactForm({
|
||||
<Input
|
||||
id="cf-phone"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setPhone(e.target.value);
|
||||
if (dupMatch) setDupMatch(null);
|
||||
}}
|
||||
onBlur={checkDuplicate}
|
||||
placeholder="+1 234 567 8900"
|
||||
className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-500"
|
||||
/>
|
||||
{dupMatch ? (
|
||||
<div
|
||||
className={`flex items-start gap-2 rounded-md border px-2.5 py-2 text-xs ${
|
||||
dupMatch.exact
|
||||
? 'border-red-500/40 bg-red-500/10 text-red-300'
|
||||
: 'border-amber-500/40 bg-amber-500/10 text-amber-300'
|
||||
}`}
|
||||
>
|
||||
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
|
||||
<div className="space-y-1">
|
||||
<p>
|
||||
{dupMatch.exact
|
||||
? 'A contact with this phone number already exists.'
|
||||
: 'A contact with a very similar number already exists.'}
|
||||
</p>
|
||||
{onViewExisting && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onViewExisting(dupMatch.contact.id)}
|
||||
className="font-medium underline underline-offset-2 hover:no-underline"
|
||||
>
|
||||
View {dupMatch.contact.name || dupMatch.contact.phone}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-slate-500">
|
||||
Include country code, e.g. +1 for US
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
@ -277,7 +374,7 @@ export function ContactForm({
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
disabled={saving || checkingDup || (!isEdit && !!dupMatch?.exact)}
|
||||
className="bg-primary hover:bg-primary/90 text-primary-foreground"
|
||||
>
|
||||
{saving && <Loader2 className="size-4 animate-spin" />}
|
||||
|
||||
@ -3,6 +3,11 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
import { useAuth } from '@/hooks/use-auth';
|
||||
import {
|
||||
dedupeByPhone,
|
||||
isUniqueViolation,
|
||||
normalizeKey,
|
||||
} from '@/lib/contacts/dedupe';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
Dialog,
|
||||
@ -13,7 +18,7 @@ import {
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Upload, FileText, Loader2, CheckCircle, XCircle } from 'lucide-react';
|
||||
import { Upload, FileText, Loader2, CheckCircle, XCircle, AlertTriangle } from 'lucide-react';
|
||||
|
||||
interface ImportModalProps {
|
||||
open: boolean;
|
||||
@ -86,7 +91,11 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [parsedRows, setParsedRows] = useState<ParsedRow[]>([]);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [result, setResult] = useState<{ imported: number; failed: number } | null>(null);
|
||||
const [result, setResult] = useState<{
|
||||
imported: number;
|
||||
skipped: number;
|
||||
failed: number;
|
||||
} | null>(null);
|
||||
|
||||
function reset() {
|
||||
setFile(null);
|
||||
@ -132,12 +141,39 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
|
||||
if (!accountId) throw new Error('Your profile is not linked to an account.');
|
||||
|
||||
let imported = 0;
|
||||
let skipped = 0;
|
||||
let failed = 0;
|
||||
|
||||
// Batch insert in chunks of 50
|
||||
// 1) De-dupe within the file by normalized phone (keep first).
|
||||
const { unique, duplicates: inFileDupes } = dedupeByPhone(parsedRows);
|
||||
skipped += inFileDupes;
|
||||
|
||||
// 2) Skip numbers already in this account. One read of the
|
||||
// generated `phone_normalized` column (migration 022) → Set.
|
||||
const { data: existingRows } = await supabase
|
||||
.from('contacts')
|
||||
.select('phone_normalized')
|
||||
.eq('account_id', accountId);
|
||||
const existing = new Set(
|
||||
(existingRows ?? [])
|
||||
.map((r) => (r as { phone_normalized: string | null }).phone_normalized)
|
||||
.filter((p): p is string => !!p),
|
||||
);
|
||||
|
||||
const toInsert = unique.filter((row) => {
|
||||
if (existing.has(normalizeKey(row.phone))) {
|
||||
skipped++;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// 3) Batch insert the genuinely-new rows in chunks of 50. The DB
|
||||
// unique index is the backstop: a 23505 (race, or a format
|
||||
// that normalizes equal) counts as skipped, not failed.
|
||||
const chunkSize = 50;
|
||||
for (let i = 0; i < parsedRows.length; i += chunkSize) {
|
||||
const chunk = parsedRows.slice(i, i + chunkSize);
|
||||
for (let i = 0; i < toInsert.length; i += chunkSize) {
|
||||
const chunk = toInsert.slice(i, i + chunkSize);
|
||||
const rows = chunk.map((row) => ({
|
||||
user_id: user.id,
|
||||
account_id: accountId,
|
||||
@ -153,13 +189,16 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
|
||||
.select('id');
|
||||
|
||||
if (error) {
|
||||
// Try individual inserts for this chunk
|
||||
// Retry individually so one bad/duplicate row doesn't sink
|
||||
// the whole chunk.
|
||||
for (const row of rows) {
|
||||
const { error: singleErr } = await supabase.from('contacts').insert(row);
|
||||
if (singleErr) {
|
||||
failed++;
|
||||
} else {
|
||||
if (!singleErr) {
|
||||
imported++;
|
||||
} else if (isUniqueViolation(singleErr)) {
|
||||
skipped++;
|
||||
} else {
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@ -167,11 +206,14 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
|
||||
}
|
||||
}
|
||||
|
||||
setResult({ imported, failed });
|
||||
setResult({ imported, skipped, failed });
|
||||
if (imported > 0) {
|
||||
toast.success(`${imported} contact${imported !== 1 ? 's' : ''} imported`);
|
||||
onImported();
|
||||
}
|
||||
if (skipped > 0) {
|
||||
toast.info(`${skipped} duplicate${skipped !== 1 ? 's' : ''} skipped`);
|
||||
}
|
||||
if (failed > 0) {
|
||||
toast.error(`${failed} contact${failed !== 1 ? 's' : ''} failed to import`);
|
||||
}
|
||||
@ -271,13 +313,19 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
|
||||
{result && (
|
||||
<div className="rounded-lg border border-slate-700 p-4 space-y-2">
|
||||
<p className="text-sm font-medium text-white">Import Complete</p>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
{result.imported > 0 && (
|
||||
<div className="flex items-center gap-1.5 text-primary text-sm">
|
||||
<CheckCircle className="size-4" />
|
||||
{result.imported} imported
|
||||
</div>
|
||||
)}
|
||||
{result.skipped > 0 && (
|
||||
<div className="flex items-center gap-1.5 text-amber-400 text-sm">
|
||||
<AlertTriangle className="size-4" />
|
||||
{result.skipped} duplicate{result.skipped !== 1 ? 's' : ''} skipped
|
||||
</div>
|
||||
)}
|
||||
{result.failed > 0 && (
|
||||
<div className="flex items-center gap-1.5 text-red-400 text-sm">
|
||||
<XCircle className="size-4" />
|
||||
|
||||
97
src/lib/contacts/dedupe.test.ts
Normal file
97
src/lib/contacts/dedupe.test.ts
Normal file
@ -0,0 +1,97 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
import {
|
||||
dedupeByPhone,
|
||||
findExistingContact,
|
||||
isExactMatch,
|
||||
isUniqueViolation,
|
||||
normalizeKey,
|
||||
} from "./dedupe";
|
||||
|
||||
describe("normalizeKey", () => {
|
||||
it("strips every non-digit", () => {
|
||||
expect(normalizeKey("+1 (555) 123-4567")).toBe("15551234567");
|
||||
expect(normalizeKey("15551234567")).toBe("15551234567");
|
||||
});
|
||||
|
||||
it("collapses different formats of the same number to one key", () => {
|
||||
expect(normalizeKey("+44 7911 123456")).toBe(normalizeKey("447911123456"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("isExactMatch", () => {
|
||||
it("treats different formatting of the same digits as exact", () => {
|
||||
expect(isExactMatch({ id: "1", phone: "+1 555-123-4567" }, "15551234567")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("is false for a trunk-variant (fuzzy) match", () => {
|
||||
// last-8 match but not the same full number
|
||||
expect(isExactMatch({ id: "1", phone: "37063949836" }, "370063949836")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isUniqueViolation", () => {
|
||||
it("detects Postgres 23505", () => {
|
||||
expect(isUniqueViolation({ code: "23505" })).toBe(true);
|
||||
});
|
||||
it("is false for other errors / non-objects", () => {
|
||||
expect(isUniqueViolation({ code: "23502" })).toBe(false);
|
||||
expect(isUniqueViolation(null)).toBe(false);
|
||||
expect(isUniqueViolation("boom")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dedupeByPhone", () => {
|
||||
it("keeps the first occurrence and counts in-file duplicates", () => {
|
||||
const { unique, duplicates } = dedupeByPhone([
|
||||
{ phone: "+1 555-1111", name: "A" },
|
||||
{ phone: "15551111", name: "B" }, // same digits as #1
|
||||
{ phone: "+1 555-2222", name: "C" },
|
||||
]);
|
||||
expect(unique.map((r) => r.name)).toEqual(["A", "C"]);
|
||||
expect(duplicates).toBe(1);
|
||||
});
|
||||
|
||||
it("drops rows with no digits", () => {
|
||||
const { unique, duplicates } = dedupeByPhone([
|
||||
{ phone: " " },
|
||||
{ phone: "+1 555-3333" },
|
||||
]);
|
||||
expect(unique).toHaveLength(1);
|
||||
expect(duplicates).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findExistingContact", () => {
|
||||
// Minimal SupabaseClient stub: resolves the .from().select().eq().like()
|
||||
// chain to a fixed candidate set.
|
||||
function stubDb(rows: Array<{ id: string; phone: string }>): SupabaseClient {
|
||||
const builder = {
|
||||
select: () => builder,
|
||||
eq: () => builder,
|
||||
like: () => Promise.resolve({ data: rows, error: null }),
|
||||
};
|
||||
return { from: () => builder } as unknown as SupabaseClient;
|
||||
}
|
||||
|
||||
it("returns a trunk-variant match via phonesMatch", async () => {
|
||||
const db = stubDb([{ id: "c1", phone: "37063949836" }]);
|
||||
const hit = await findExistingContact(db, "acct", "+370 063 949 836");
|
||||
expect(hit?.id).toBe("c1");
|
||||
});
|
||||
|
||||
it("returns null when no candidate matches", async () => {
|
||||
const db = stubDb([{ id: "c1", phone: "15559999999" }]);
|
||||
const hit = await findExistingContact(db, "acct", "+1 555-123-4567");
|
||||
expect(hit).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for an empty phone without querying", async () => {
|
||||
const db = stubDb([{ id: "c1", phone: "15551234567" }]);
|
||||
expect(await findExistingContact(db, "acct", " ")).toBeNull();
|
||||
});
|
||||
});
|
||||
105
src/lib/contacts/dedupe.ts
Normal file
105
src/lib/contacts/dedupe.ts
Normal file
@ -0,0 +1,105 @@
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
import { normalizePhone, phonesMatch } from "@/lib/whatsapp/phone-utils";
|
||||
|
||||
/**
|
||||
* Contact de-duplication helpers, shared by the WhatsApp webhook, the
|
||||
* manual contact form, and CSV import so all paths agree on what
|
||||
* "same number" means (issue #212).
|
||||
*
|
||||
* The canonical key is `normalizePhone` (digits-only) — the same form
|
||||
* the DB stores in the generated `contacts.phone_normalized` column
|
||||
* and enforces unique per account. `phonesMatch` adds trunk-prefix
|
||||
* tolerance (last-8-digit match) for the softer "possible duplicate"
|
||||
* surfaces.
|
||||
*/
|
||||
|
||||
/** Canonical de-dup key for a phone string (digits only). */
|
||||
export function normalizeKey(phone: string): string {
|
||||
return normalizePhone(phone);
|
||||
}
|
||||
|
||||
/** Minimal shape we need back from a contacts lookup. */
|
||||
export interface ExistingContact {
|
||||
id: string;
|
||||
phone: string;
|
||||
name?: string | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find an existing contact in `accountId` whose phone matches `phone`,
|
||||
* or null. Pre-filters in SQL by the last-8-digit suffix (so we don't
|
||||
* pull every contact), then applies the strict `phonesMatch` in JS on
|
||||
* the small candidate set — the exact approach the webhook has used.
|
||||
*/
|
||||
export async function findExistingContact(
|
||||
db: SupabaseClient,
|
||||
accountId: string,
|
||||
phone: string,
|
||||
): Promise<ExistingContact | null> {
|
||||
const normalized = normalizePhone(phone);
|
||||
if (!normalized) return null;
|
||||
|
||||
const suffix = normalized.length >= 8 ? normalized.slice(-8) : normalized;
|
||||
|
||||
const { data, error } = await db
|
||||
.from("contacts")
|
||||
.select("*")
|
||||
.eq("account_id", accountId)
|
||||
.like("phone", `%${suffix}`);
|
||||
|
||||
if (error || !data) return null;
|
||||
|
||||
return (
|
||||
(data as ExistingContact[]).find((c) => phonesMatch(c.phone, phone)) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when an existing contact is an *exact* normalized match for
|
||||
* `phone` (vs only a fuzzy trunk-variant match). The form hard-blocks
|
||||
* exact matches but only warns on fuzzy ones.
|
||||
*/
|
||||
export function isExactMatch(existing: ExistingContact, phone: string): boolean {
|
||||
return normalizeKey(existing.phone) === normalizeKey(phone);
|
||||
}
|
||||
|
||||
/**
|
||||
* True for a Postgres unique-constraint violation (SQLSTATE 23505).
|
||||
* Used as the backstop when the DB unique index rejects a racing or
|
||||
* format-equal insert that slipped past the in-app check.
|
||||
*/
|
||||
export function isUniqueViolation(error: unknown): boolean {
|
||||
if (!error || typeof error !== "object") return false;
|
||||
return (error as { code?: string }).code === "23505";
|
||||
}
|
||||
|
||||
/**
|
||||
* De-duplicate parsed CSV rows by normalized phone, keeping the first
|
||||
* occurrence of each. Rows with an empty normalized phone are dropped
|
||||
* (they can't be a valid contact). Returns the unique rows plus the
|
||||
* count removed as in-file duplicates.
|
||||
*/
|
||||
export function dedupeByPhone<T extends { phone: string }>(
|
||||
rows: T[],
|
||||
): { unique: T[]; duplicates: number } {
|
||||
const seen = new Set<string>();
|
||||
const unique: T[] = [];
|
||||
let duplicates = 0;
|
||||
|
||||
for (const row of rows) {
|
||||
const key = normalizeKey(row.phone);
|
||||
if (!key) {
|
||||
duplicates++;
|
||||
continue;
|
||||
}
|
||||
if (seen.has(key)) {
|
||||
duplicates++;
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
unique.push(row);
|
||||
}
|
||||
|
||||
return { unique, duplicates };
|
||||
}
|
||||
@ -89,7 +89,11 @@ export interface AccountInvitation {
|
||||
export interface Contact {
|
||||
id: string;
|
||||
user_id: string;
|
||||
account_id: string;
|
||||
phone: string;
|
||||
/** Digits-only form of `phone`, generated by the DB (migration 022)
|
||||
* and unique per account. Read-only. */
|
||||
phone_normalized?: string;
|
||||
name?: string;
|
||||
email?: string;
|
||||
company?: string;
|
||||
|
||||
120
supabase/migrations/022_contact_phone_dedup.sql
Normal file
120
supabase/migrations/022_contact_phone_dedup.sql
Normal file
@ -0,0 +1,120 @@
|
||||
-- ============================================================
|
||||
-- 022_contact_phone_dedup
|
||||
--
|
||||
-- Prevent the same phone number from becoming multiple contacts
|
||||
-- within one account (issue #212).
|
||||
--
|
||||
-- Until now `contacts.phone` had only a non-unique index, phone was
|
||||
-- stored un-normalized ("+1 555-123-4567" vs "15551234567" are
|
||||
-- distinct strings), and only the WhatsApp webhook de-duped. Manual
|
||||
-- create and CSV import inserted freely, fragmenting conversations,
|
||||
-- deals, and tags across duplicate rows.
|
||||
--
|
||||
-- This migration, in order:
|
||||
-- 1. adds a generated `phone_normalized` column (digits-only,
|
||||
-- mirroring the app's normalizePhone) that can never drift;
|
||||
-- 2. merges existing duplicates into the oldest row, re-pointing
|
||||
-- all child records first so nothing is lost;
|
||||
-- 3. adds a UNIQUE index on (account_id, phone_normalized) — the
|
||||
-- authoritative guarantee that covers every write path.
|
||||
--
|
||||
-- Idempotent. **No data loss** — duplicate rows are merged, not
|
||||
-- dropped: child rows (conversations, messages, deals, notes, tags,
|
||||
-- custom values, broadcast recipients, automation/flow records) are
|
||||
-- re-pointed to the surviving (oldest) contact before deletion.
|
||||
-- ============================================================
|
||||
|
||||
-- 1) Normalized phone — STORED generated column, kept in lockstep
|
||||
-- with `phone` by Postgres. Matches normalizePhone()
|
||||
-- (src/lib/whatsapp/phone-utils.ts): strip every non-digit.
|
||||
ALTER TABLE contacts
|
||||
ADD COLUMN IF NOT EXISTS phone_normalized TEXT
|
||||
GENERATED ALWAYS AS (regexp_replace(phone, '\D', '', 'g')) STORED;
|
||||
|
||||
-- 2) One-time (re-runnable) merge of existing duplicates.
|
||||
-- SECURITY DEFINER so it can re-point rows across tables
|
||||
-- regardless of the caller's RLS; it only ever collapses exact
|
||||
-- normalized duplicates within the same account.
|
||||
CREATE OR REPLACE FUNCTION public.merge_duplicate_contacts()
|
||||
RETURNS INTEGER
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_group RECORD;
|
||||
v_survivor UUID;
|
||||
v_losers UUID[];
|
||||
v_merged INTEGER := 0;
|
||||
BEGIN
|
||||
FOR v_group IN
|
||||
SELECT account_id,
|
||||
phone_normalized,
|
||||
array_agg(id ORDER BY created_at ASC, id ASC) AS ids
|
||||
FROM contacts
|
||||
WHERE phone_normalized <> ''
|
||||
GROUP BY account_id, phone_normalized
|
||||
HAVING count(*) > 1
|
||||
LOOP
|
||||
v_survivor := v_group.ids[1];
|
||||
v_losers := v_group.ids[2:array_length(v_group.ids, 1)];
|
||||
|
||||
-- Plain re-point: these tables have no contact-scoped unique
|
||||
-- constraint. `conversations` is ON DELETE CASCADE, so this
|
||||
-- re-point is what saves its rows (and their messages) from
|
||||
-- being deleted with the loser contact.
|
||||
UPDATE conversations SET contact_id = v_survivor WHERE contact_id = ANY(v_losers);
|
||||
UPDATE contact_notes SET contact_id = v_survivor WHERE contact_id = ANY(v_losers);
|
||||
UPDATE deals SET contact_id = v_survivor WHERE contact_id = ANY(v_losers);
|
||||
UPDATE broadcast_recipients SET contact_id = v_survivor WHERE contact_id = ANY(v_losers);
|
||||
UPDATE automation_logs SET contact_id = v_survivor WHERE contact_id = ANY(v_losers);
|
||||
UPDATE automation_pending_executions SET contact_id = v_survivor WHERE contact_id = ANY(v_losers);
|
||||
|
||||
-- Conflict-guarded re-point for UNIQUE(contact_id, tag_id):
|
||||
-- move only tags the survivor doesn't already have, drop the rest.
|
||||
UPDATE contact_tags ct SET contact_id = v_survivor
|
||||
WHERE ct.contact_id = ANY(v_losers)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM contact_tags s
|
||||
WHERE s.contact_id = v_survivor AND s.tag_id = ct.tag_id
|
||||
);
|
||||
DELETE FROM contact_tags WHERE contact_id = ANY(v_losers);
|
||||
|
||||
-- Same guard for UNIQUE(contact_id, custom_field_id). Survivor's
|
||||
-- own value wins on conflict.
|
||||
UPDATE contact_custom_values cv SET contact_id = v_survivor
|
||||
WHERE cv.contact_id = ANY(v_losers)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM contact_custom_values s
|
||||
WHERE s.contact_id = v_survivor AND s.custom_field_id = cv.custom_field_id
|
||||
);
|
||||
DELETE FROM contact_custom_values WHERE contact_id = ANY(v_losers);
|
||||
|
||||
-- flow_runs has a partial UNIQUE on active runs per contact.
|
||||
-- Re-point only NON-active runs (exempt from the partial index)
|
||||
-- to preserve history; any active loser run is left to be
|
||||
-- NULLed by its FK's ON DELETE SET NULL when the loser is
|
||||
-- removed below — avoids colliding with the survivor's active run.
|
||||
UPDATE flow_runs SET contact_id = v_survivor
|
||||
WHERE contact_id = ANY(v_losers) AND status <> 'active';
|
||||
|
||||
DELETE FROM contacts WHERE id = ANY(v_losers);
|
||||
|
||||
v_merged := v_merged + COALESCE(array_length(v_losers, 1), 0);
|
||||
END LOOP;
|
||||
|
||||
RETURN v_merged;
|
||||
END;
|
||||
$$;
|
||||
|
||||
ALTER FUNCTION public.merge_duplicate_contacts() OWNER TO postgres;
|
||||
REVOKE ALL ON FUNCTION public.merge_duplicate_contacts() FROM PUBLIC;
|
||||
|
||||
-- Collapse whatever duplicates exist right now.
|
||||
SELECT public.merge_duplicate_contacts();
|
||||
|
||||
-- 3) Authoritative guarantee. Partial index defends against any
|
||||
-- empty normalized value (phone is NOT NULL, but belt-and-braces).
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_contacts_account_phone_normalized
|
||||
ON contacts (account_id, phone_normalized)
|
||||
WHERE phone_normalized <> '';
|
||||
Loading…
Reference in New Issue
Block a user