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