mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
Merge pull request #228 from ArnasDon/feat/contact-phone-dedup
feat(contacts): prevent duplicate phone numbers on create/import (#212)
This commit is contained in:
commit
ba8d653bff
20
CHANGELOG.md
20
CHANGELOG.md
@ -100,6 +100,18 @@ always did.
|
|||||||
|
|
||||||
### Added
|
### 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
|
- **Configurable default deal currency.** Each account can now pick
|
||||||
its default currency under **Settings → Deals** (admin+); the app
|
its default currency under **Settings → Deals** (admin+); the app
|
||||||
previously hardcoded USD throughout. New deals default to it, and
|
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
|
Idempotent; existing accounts backfill to `USD`. **Apply before
|
||||||
deploying** — the app now reads this column when loading the
|
deploying** — the app now reads this column when loading the
|
||||||
account, so an un-migrated database breaks account loading.
|
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
|
## [0.2.2] — 2026-05-29
|
||||||
|
|
||||||
|
|||||||
@ -446,6 +446,10 @@ export default function ContactsPage() {
|
|||||||
fetchContacts();
|
fetchContacts();
|
||||||
fetchTags();
|
fetchTags();
|
||||||
}}
|
}}
|
||||||
|
onViewExisting={(id) => {
|
||||||
|
setFormOpen(false);
|
||||||
|
openDetail(id);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Contact Detail Sheet */}
|
{/* Contact Detail Sheet */}
|
||||||
|
|||||||
@ -2,7 +2,8 @@ import { NextResponse } from 'next/server'
|
|||||||
import { createClient } from '@supabase/supabase-js'
|
import { createClient } from '@supabase/supabase-js'
|
||||||
import { decrypt, encrypt, isLegacyFormat } from '@/lib/whatsapp/encryption'
|
import { decrypt, encrypt, isLegacyFormat } from '@/lib/whatsapp/encryption'
|
||||||
import { getMediaUrl, downloadMedia } from '@/lib/whatsapp/meta-api'
|
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 { verifyMetaWebhookSignature } from '@/lib/whatsapp/webhook-signature'
|
||||||
import { runAutomationsForTrigger } from '@/lib/automations/engine'
|
import { runAutomationsForTrigger } from '@/lib/automations/engine'
|
||||||
import { dispatchInboundToFlows } from '@/lib/flows/engine'
|
import { dispatchInboundToFlows } from '@/lib/flows/engine'
|
||||||
@ -875,43 +876,17 @@ async function findOrCreateContact(
|
|||||||
phone: string,
|
phone: string,
|
||||||
name: string
|
name: string
|
||||||
): Promise<ContactOutcome | null> {
|
): Promise<ContactOutcome | null> {
|
||||||
// Look up existing contacts for this account. We pre-filter in SQL
|
// Find an existing contact for this account by phone. The shared
|
||||||
// by the phone's last-8-digit suffix so we don't ship every contact
|
// helper pre-filters in SQL by the last-8-digit suffix (so we don't
|
||||||
// in the account over the wire just to JS-filter to one row. This
|
// pull every contact on every inbound message) then applies the
|
||||||
// matters at scale: a shared account with 5 teammates × 100 contacts
|
// strict `phonesMatch` in JS on the small candidate set. The same
|
||||||
// each is 500 rows — the prior implementation pulled all of them on
|
// helper backs the manual contact form and CSV import, so all three
|
||||||
// every inbound message.
|
// paths agree on what "same number" means (issue #212).
|
||||||
//
|
const existingContact = await findExistingContact(
|
||||||
// The `phonesMatch` helper considers two phones equal if they share
|
supabaseAdmin(),
|
||||||
// the last 8 digits (trunk-prefix tolerance). We mirror that here as
|
accountId,
|
||||||
// a `like` pattern, then re-run the strict comparison in JS on the
|
phone,
|
||||||
// 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))
|
|
||||||
|
|
||||||
if (existingContact) {
|
if (existingContact) {
|
||||||
// Update name if it changed
|
// Update name if it changed
|
||||||
@ -940,6 +915,14 @@ async function findOrCreateContact(
|
|||||||
.single()
|
.single()
|
||||||
|
|
||||||
if (createError) {
|
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)
|
console.error('Error creating contact:', createError)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
@ -39,6 +39,7 @@ const contactFields = [
|
|||||||
const SAMPLE_CONTACT: Contact = {
|
const SAMPLE_CONTACT: Contact = {
|
||||||
id: 'sample',
|
id: 'sample',
|
||||||
user_id: '',
|
user_id: '',
|
||||||
|
account_id: '',
|
||||||
name: 'John Doe',
|
name: 'John Doe',
|
||||||
phone: '+1234567890',
|
phone: '+1234567890',
|
||||||
email: 'john@example.com',
|
email: 'john@example.com',
|
||||||
|
|||||||
@ -5,6 +5,12 @@ import { createClient } from '@/lib/supabase/client';
|
|||||||
import { useAuth } from '@/hooks/use-auth';
|
import { useAuth } from '@/hooks/use-auth';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import type { Contact, Tag, ContactTag } from '@/types';
|
import type { Contact, Tag, ContactTag } from '@/types';
|
||||||
|
import {
|
||||||
|
findExistingContact,
|
||||||
|
isExactMatch,
|
||||||
|
isUniqueViolation,
|
||||||
|
type ExistingContact,
|
||||||
|
} from '@/lib/contacts/dedupe';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@ -17,7 +23,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2, AlertTriangle } from 'lucide-react';
|
||||||
|
|
||||||
interface ContactFormProps {
|
interface ContactFormProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@ -25,6 +31,9 @@ interface ContactFormProps {
|
|||||||
contact?: Contact | null;
|
contact?: Contact | null;
|
||||||
contactTags?: ContactTag[];
|
contactTags?: ContactTag[];
|
||||||
onSaved: () => void;
|
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({
|
export function ContactForm({
|
||||||
@ -33,6 +42,7 @@ export function ContactForm({
|
|||||||
contact,
|
contact,
|
||||||
contactTags = [],
|
contactTags = [],
|
||||||
onSaved,
|
onSaved,
|
||||||
|
onViewExisting,
|
||||||
}: ContactFormProps) {
|
}: ContactFormProps) {
|
||||||
const supabase = createClient();
|
const supabase = createClient();
|
||||||
const { accountId } = useAuth();
|
const { accountId } = useAuth();
|
||||||
@ -44,6 +54,15 @@ export function ContactForm({
|
|||||||
const [company, setCompany] = useState('');
|
const [company, setCompany] = useState('');
|
||||||
const [saving, setSaving] = useState(false);
|
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 [tags, setTags] = useState<Tag[]>([]);
|
||||||
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
|
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
|
||||||
const [loadingTags, setLoadingTags] = useState(false);
|
const [loadingTags, setLoadingTags] = useState(false);
|
||||||
@ -55,10 +74,33 @@ export function ContactForm({
|
|||||||
setEmail(contact?.email ?? '');
|
setEmail(contact?.email ?? '');
|
||||||
setCompany(contact?.company ?? '');
|
setCompany(contact?.company ?? '');
|
||||||
setSelectedTagIds(contactTags.map((ct) => ct.tag_id));
|
setSelectedTagIds(contactTags.map((ct) => ct.tag_id));
|
||||||
|
setDupMatch(null);
|
||||||
fetchTags();
|
fetchTags();
|
||||||
}
|
}
|
||||||
}, [open, contact]);
|
}, [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() {
|
async function fetchTags() {
|
||||||
setLoadingTags(true);
|
setLoadingTags(true);
|
||||||
const { data } = await supabase
|
const { data } = await supabase
|
||||||
@ -85,6 +127,13 @@ export function ContactForm({
|
|||||||
return;
|
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);
|
setSaving(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -149,6 +198,22 @@ export function ContactForm({
|
|||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
onSaved();
|
onSaved();
|
||||||
} catch (err: unknown) {
|
} 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';
|
const message = err instanceof Error ? err.message : 'Failed to save contact';
|
||||||
toast.error(message);
|
toast.error(message);
|
||||||
} finally {
|
} finally {
|
||||||
@ -191,13 +256,45 @@ export function ContactForm({
|
|||||||
<Input
|
<Input
|
||||||
id="cf-phone"
|
id="cf-phone"
|
||||||
value={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"
|
placeholder="+1 234 567 8900"
|
||||||
className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-500"
|
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">
|
<p className="text-xs text-slate-500">
|
||||||
Include country code, e.g. +1 for US
|
Include country code, e.g. +1 for US
|
||||||
</p>
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@ -277,7 +374,7 @@ export function ContactForm({
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={saving}
|
disabled={saving || checkingDup || (!isEdit && !!dupMatch?.exact)}
|
||||||
className="bg-primary hover:bg-primary/90 text-primary-foreground"
|
className="bg-primary hover:bg-primary/90 text-primary-foreground"
|
||||||
>
|
>
|
||||||
{saving && <Loader2 className="size-4 animate-spin" />}
|
{saving && <Loader2 className="size-4 animate-spin" />}
|
||||||
|
|||||||
@ -3,6 +3,11 @@
|
|||||||
import { useState, useRef } from 'react';
|
import { useState, useRef } from 'react';
|
||||||
import { createClient } from '@/lib/supabase/client';
|
import { createClient } from '@/lib/supabase/client';
|
||||||
import { useAuth } from '@/hooks/use-auth';
|
import { useAuth } from '@/hooks/use-auth';
|
||||||
|
import {
|
||||||
|
dedupeByPhone,
|
||||||
|
isUniqueViolation,
|
||||||
|
normalizeKey,
|
||||||
|
} from '@/lib/contacts/dedupe';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@ -13,7 +18,7 @@ import {
|
|||||||
DialogFooter,
|
DialogFooter,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { Button } from '@/components/ui/button';
|
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 {
|
interface ImportModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@ -86,7 +91,11 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
|
|||||||
const [file, setFile] = useState<File | null>(null);
|
const [file, setFile] = useState<File | null>(null);
|
||||||
const [parsedRows, setParsedRows] = useState<ParsedRow[]>([]);
|
const [parsedRows, setParsedRows] = useState<ParsedRow[]>([]);
|
||||||
const [importing, setImporting] = useState(false);
|
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() {
|
function reset() {
|
||||||
setFile(null);
|
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.');
|
if (!accountId) throw new Error('Your profile is not linked to an account.');
|
||||||
|
|
||||||
let imported = 0;
|
let imported = 0;
|
||||||
|
let skipped = 0;
|
||||||
let failed = 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;
|
const chunkSize = 50;
|
||||||
for (let i = 0; i < parsedRows.length; i += chunkSize) {
|
for (let i = 0; i < toInsert.length; i += chunkSize) {
|
||||||
const chunk = parsedRows.slice(i, i + chunkSize);
|
const chunk = toInsert.slice(i, i + chunkSize);
|
||||||
const rows = chunk.map((row) => ({
|
const rows = chunk.map((row) => ({
|
||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
account_id: accountId,
|
account_id: accountId,
|
||||||
@ -153,13 +189,16 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
|
|||||||
.select('id');
|
.select('id');
|
||||||
|
|
||||||
if (error) {
|
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) {
|
for (const row of rows) {
|
||||||
const { error: singleErr } = await supabase.from('contacts').insert(row);
|
const { error: singleErr } = await supabase.from('contacts').insert(row);
|
||||||
if (singleErr) {
|
if (!singleErr) {
|
||||||
failed++;
|
|
||||||
} else {
|
|
||||||
imported++;
|
imported++;
|
||||||
|
} else if (isUniqueViolation(singleErr)) {
|
||||||
|
skipped++;
|
||||||
|
} else {
|
||||||
|
failed++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -167,11 +206,14 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setResult({ imported, failed });
|
setResult({ imported, skipped, failed });
|
||||||
if (imported > 0) {
|
if (imported > 0) {
|
||||||
toast.success(`${imported} contact${imported !== 1 ? 's' : ''} imported`);
|
toast.success(`${imported} contact${imported !== 1 ? 's' : ''} imported`);
|
||||||
onImported();
|
onImported();
|
||||||
}
|
}
|
||||||
|
if (skipped > 0) {
|
||||||
|
toast.info(`${skipped} duplicate${skipped !== 1 ? 's' : ''} skipped`);
|
||||||
|
}
|
||||||
if (failed > 0) {
|
if (failed > 0) {
|
||||||
toast.error(`${failed} contact${failed !== 1 ? 's' : ''} failed to import`);
|
toast.error(`${failed} contact${failed !== 1 ? 's' : ''} failed to import`);
|
||||||
}
|
}
|
||||||
@ -271,13 +313,19 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
|
|||||||
{result && (
|
{result && (
|
||||||
<div className="rounded-lg border border-slate-700 p-4 space-y-2">
|
<div className="rounded-lg border border-slate-700 p-4 space-y-2">
|
||||||
<p className="text-sm font-medium text-white">Import Complete</p>
|
<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 && (
|
{result.imported > 0 && (
|
||||||
<div className="flex items-center gap-1.5 text-primary text-sm">
|
<div className="flex items-center gap-1.5 text-primary text-sm">
|
||||||
<CheckCircle className="size-4" />
|
<CheckCircle className="size-4" />
|
||||||
{result.imported} imported
|
{result.imported} imported
|
||||||
</div>
|
</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 && (
|
{result.failed > 0 && (
|
||||||
<div className="flex items-center gap-1.5 text-red-400 text-sm">
|
<div className="flex items-center gap-1.5 text-red-400 text-sm">
|
||||||
<XCircle className="size-4" />
|
<XCircle className="size-4" />
|
||||||
|
|||||||
97
src/lib/contacts/dedupe.test.ts
Normal file
97
src/lib/contacts/dedupe.test.ts
Normal 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
105
src/lib/contacts/dedupe.ts
Normal 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 };
|
||||||
|
}
|
||||||
@ -89,7 +89,11 @@ export interface AccountInvitation {
|
|||||||
export interface Contact {
|
export interface Contact {
|
||||||
id: string;
|
id: string;
|
||||||
user_id: string;
|
user_id: string;
|
||||||
|
account_id: string;
|
||||||
phone: 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;
|
name?: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
company?: string;
|
company?: string;
|
||||||
|
|||||||
120
supabase/migrations/022_contact_phone_dedup.sql
Normal file
120
supabase/migrations/022_contact_phone_dedup.sql
Normal 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 <> '';
|
||||||
Loading…
Reference in New Issue
Block a user