wacrm/src/components/contacts/contact-form.tsx
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

389 lines
13 KiB
TypeScript

'use client';
import { useState, useEffect } from 'react';
import { createClient } from '@/lib/supabase/client';
import { useAuth } from '@/hooks/use-auth';
import { toast } from 'sonner';
import type { Contact, Tag, ContactTag } from '@/types';
import {
findExistingContact,
isExactMatch,
isUniqueViolation,
type ExistingContact,
} from '@/lib/contacts/dedupe';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Loader2, AlertTriangle } from 'lucide-react';
interface ContactFormProps {
open: boolean;
onOpenChange: (open: boolean) => void;
contact?: Contact | null;
contactTags?: ContactTag[];
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({
open,
onOpenChange,
contact,
contactTags = [],
onSaved,
onViewExisting,
}: ContactFormProps) {
const supabase = createClient();
const { accountId } = useAuth();
const isEdit = !!contact;
const [name, setName] = useState('');
const [phone, setPhone] = useState('');
const [email, setEmail] = useState('');
const [company, setCompany] = useState('');
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 [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
const [loadingTags, setLoadingTags] = useState(false);
useEffect(() => {
if (open) {
setName(contact?.name ?? '');
setPhone(contact?.phone ?? '');
setEmail(contact?.email ?? '');
setCompany(contact?.company ?? '');
setSelectedTagIds(contactTags.map((ct) => ct.tag_id));
setDupMatch(null);
fetchTags();
}
}, [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() {
setLoadingTags(true);
const { data } = await supabase
.from('tags')
.select('*')
.order('name');
if (data) setTags(data);
setLoadingTags(false);
}
function toggleTag(tagId: string) {
setSelectedTagIds((prev) =>
prev.includes(tagId)
? prev.filter((id) => id !== tagId)
: [...prev, tagId]
);
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!phone.trim()) {
toast.error('Phone number is required');
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);
try {
const {
data: { session },
} = await supabase.auth.getSession();
const user = session?.user;
if (!user) throw new Error('Not authenticated');
if (!accountId) throw new Error('Your profile is not linked to an account.');
let contactId = contact?.id;
if (isEdit && contactId) {
const { error } = await supabase
.from('contacts')
.update({
name: name.trim() || null,
phone: phone.trim(),
email: email.trim() || null,
company: company.trim() || null,
updated_at: new Date().toISOString(),
})
.eq('id', contactId);
if (error) throw error;
} else {
const { data, error } = await supabase
.from('contacts')
.insert({
user_id: user.id,
account_id: accountId,
name: name.trim() || null,
phone: phone.trim(),
email: email.trim() || null,
company: company.trim() || null,
})
.select('id')
.single();
if (error) throw error;
contactId = data.id;
}
// Sync tags
if (contactId) {
await supabase
.from('contact_tags')
.delete()
.eq('contact_id', contactId);
if (selectedTagIds.length > 0) {
const tagRows = selectedTagIds.map((tag_id) => ({
contact_id: contactId!,
tag_id,
}));
const { error: tagError } = await supabase
.from('contact_tags')
.insert(tagRows);
if (tagError) throw tagError;
}
}
toast.success(isEdit ? 'Contact updated' : 'Contact created');
onOpenChange(false);
onSaved();
} 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';
toast.error(message);
} finally {
setSaving(false);
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="bg-slate-900 border-slate-700 text-slate-200 sm:max-w-md">
<DialogHeader>
<DialogTitle className="text-white">
{isEdit ? 'Edit Contact' : 'Add Contact'}
</DialogTitle>
<DialogDescription className="text-slate-400">
{isEdit
? 'Update the contact details below.'
: 'Fill in the details to create a new contact.'}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="cf-name" className="text-slate-300">
Name
</Label>
<Input
id="cf-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="John Doe"
className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-500"
/>
</div>
<div className="space-y-2">
<Label htmlFor="cf-phone" className="text-slate-300">
Phone <span className="text-red-400">*</span>
</Label>
<Input
id="cf-phone"
value={phone}
onChange={(e) => {
setPhone(e.target.value);
if (dupMatch) setDupMatch(null);
}}
onBlur={checkDuplicate}
placeholder="+1 234 567 8900"
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">
Include country code, e.g. +1 for US
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="cf-email" className="text-slate-300">
Email
</Label>
<Input
id="cf-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="john@example.com"
className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-500"
/>
</div>
<div className="space-y-2">
<Label htmlFor="cf-company" className="text-slate-300">
Company
</Label>
<Input
id="cf-company"
value={company}
onChange={(e) => setCompany(e.target.value)}
placeholder="Acme Inc."
className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-500"
/>
</div>
<div className="space-y-2">
<Label className="text-slate-300">Tags</Label>
{loadingTags ? (
<div className="flex items-center gap-2 text-slate-500 text-sm">
<Loader2 className="size-3 animate-spin" />
Loading tags...
</div>
) : tags.length === 0 ? (
<p className="text-xs text-slate-500">
No tags available. Create tags in Settings.
</p>
) : (
<div className="flex flex-wrap gap-1.5">
{tags.map((tag) => {
const selected = selectedTagIds.includes(tag.id);
return (
<button
key={tag.id}
type="button"
onClick={() => toggleTag(tag.id)}
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium transition-colors cursor-pointer ${
selected
? 'ring-2 ring-primary ring-offset-1 ring-offset-slate-900'
: 'opacity-60 hover:opacity-100'
}`}
style={{
backgroundColor: tag.color + '20',
color: tag.color,
borderColor: tag.color,
}}
>
{tag.name}
</button>
);
})}
</div>
)}
</div>
<DialogFooter className="bg-slate-900 border-slate-700">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
className="border-slate-700 text-slate-300 hover:bg-slate-800"
>
Cancel
</Button>
<Button
type="submit"
disabled={saving || checkingDup || (!isEdit && !!dupMatch?.exact)}
className="bg-primary hover:bg-primary/90 text-primary-foreground"
>
{saving && <Loader2 className="size-4 animate-spin" />}
{isEdit ? 'Update' : 'Create'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}