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>
389 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|