wacrm/supabase/migrations/022_contact_phone_dedup.sql
Arnas Donauskas 3dce1b56cd
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>
2026-06-08 15:52:38 +03:00

121 lines
5.2 KiB
PL/PgSQL

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