From 3dce1b56cdc6743f18540ebf11d21ccccfc0ff2f Mon Sep 17 00:00:00 2001 From: Arnas Donauskas Date: Mon, 8 Jun 2026 15:52:38 +0300 Subject: [PATCH] feat(contacts): prevent duplicate phone numbers on create/import (#212) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CHANGELOG.md | 20 +++ src/app/(dashboard)/contacts/page.tsx | 4 + src/app/api/whatsapp/webhook/route.ts | 59 +++------ .../broadcasts/step3-personalize.tsx | 1 + src/components/contacts/contact-form.tsx | 109 +++++++++++++++- src/components/contacts/import-modal.tsx | 70 ++++++++-- src/lib/contacts/dedupe.test.ts | 97 ++++++++++++++ src/lib/contacts/dedupe.ts | 105 +++++++++++++++ src/types/index.ts | 4 + .../migrations/022_contact_phone_dedup.sql | 120 ++++++++++++++++++ 10 files changed, 534 insertions(+), 55 deletions(-) create mode 100644 src/lib/contacts/dedupe.test.ts create mode 100644 src/lib/contacts/dedupe.ts create mode 100644 supabase/migrations/022_contact_phone_dedup.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e5d361..8da7bf3 100644 --- a/CHANGELOG.md +++ b/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 diff --git a/src/app/(dashboard)/contacts/page.tsx b/src/app/(dashboard)/contacts/page.tsx index eb1450e..20c685f 100644 --- a/src/app/(dashboard)/contacts/page.tsx +++ b/src/app/(dashboard)/contacts/page.tsx @@ -446,6 +446,10 @@ export default function ContactsPage() { fetchContacts(); fetchTags(); }} + onViewExisting={(id) => { + setFormOpen(false); + openDetail(id); + }} /> {/* Contact Detail Sheet */} diff --git a/src/app/api/whatsapp/webhook/route.ts b/src/app/api/whatsapp/webhook/route.ts index e7098ef..ba404f8 100644 --- a/src/app/api/whatsapp/webhook/route.ts +++ b/src/app/api/whatsapp/webhook/route.ts @@ -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 { - // 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 } diff --git a/src/components/broadcasts/step3-personalize.tsx b/src/components/broadcasts/step3-personalize.tsx index e8b5785..5f9e524 100644 --- a/src/components/broadcasts/step3-personalize.tsx +++ b/src/components/broadcasts/step3-personalize.tsx @@ -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', diff --git a/src/components/contacts/contact-form.tsx b/src/components/contacts/contact-form.tsx index 93d7400..982f2ed 100644 --- a/src/components/contacts/contact-form.tsx +++ b/src/components/contacts/contact-form.tsx @@ -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([]); const [selectedTagIds, setSelectedTagIds] = useState([]); 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({ 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" /> -

- Include country code, e.g. +1 for US -

+ {dupMatch ? ( +
+ +
+

+ {dupMatch.exact + ? 'A contact with this phone number already exists.' + : 'A contact with a very similar number already exists.'} +

+ {onViewExisting && ( + + )} +
+
+ ) : ( +

+ Include country code, e.g. +1 for US +

+ )}
@@ -277,7 +374,7 @@ export function ContactForm({