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:
Arnas Donauskas 2026-06-08 15:52:38 +03:00
parent b8972fd905
commit 3dce1b56cd
No known key found for this signature in database
10 changed files with 534 additions and 55 deletions

View File

@ -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

View File

@ -446,6 +446,10 @@ export default function ContactsPage() {
fetchContacts();
fetchTags();
}}
onViewExisting={(id) => {
setFormOpen(false);
openDetail(id);
}}
/>
{/* Contact Detail Sheet */}

View File

@ -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
}

View File

@ -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',

View File

@ -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" />}

View File

@ -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" />

View 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
View 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 };
}

View File

@ -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;

View 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 <> '';