mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +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>
98 lines
3.1 KiB
TypeScript
98 lines
3.1 KiB
TypeScript
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();
|
|
});
|
|
});
|