mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 17:29:45 +00:00
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>
121 lines
5.2 KiB
PL/PgSQL
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 <> '';
|