diff --git a/src/components/contacts/import-modal.tsx b/src/components/contacts/import-modal.tsx index 8450b0f..0a05ef7 100644 --- a/src/components/contacts/import-modal.tsx +++ b/src/components/contacts/import-modal.tsx @@ -8,7 +8,10 @@ import { isUniqueViolation, normalizeKey, } from '@/lib/contacts/dedupe'; -import { parseContactCsv, type ParsedContactRow } from '@/lib/contacts/parse-contact-csv'; +import { + parseContactCsv, + type ParsedContactRow, +} from '@/lib/contacts/parse-contact-csv'; import { assignImportedContactTags, resolveImportTagIds, @@ -57,7 +60,11 @@ function PreviewCell({ }) { return ( {value} @@ -77,14 +84,15 @@ function ImportPreviewTags({ } return ( -
+
{tagNames.map((name) => { - const color = tagColorByKey.get(name.trim().toLowerCase()) ?? DEFAULT_TAG_COLOR; + const color = + tagColorByKey.get(name.trim().toLowerCase()) ?? DEFAULT_TAG_COLOR; const isKnown = tagColorByKey.has(name.trim().toLowerCase()); return ( void; } -export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps) { +export function ImportModal({ + open, + onOpenChange, + onImported, +}: ImportModalProps) { const supabase = createClient(); const { accountId, canEditSettings } = useAuth(); const fileInputRef = useRef(null); @@ -119,7 +131,9 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps const [parsedRows, setParsedRows] = useState([]); const [hasTagsColumn, setHasTagsColumn] = useState(false); const [hasCompanyColumn, setHasCompanyColumn] = useState(false); - const [tagColorByKey, setTagColorByKey] = useState>(new Map()); + const [tagColorByKey, setTagColorByKey] = useState>( + new Map() + ); const [importing, setImporting] = useState(false); const [result, setResult] = useState<{ imported: number; @@ -151,11 +165,16 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps setResult(null); const text = await selected.text(); - const { rows, hasTagsColumn: csvHasTags, hasCompanyColumn: csvHasCompany } = - parseContactCsv(text); + const { + rows, + hasTagsColumn: csvHasTags, + hasCompanyColumn: csvHasCompany, + } = parseContactCsv(text); if (rows.length === 0) { - toast.error('No valid rows found. Ensure CSV has a "phone" column header.'); + toast.error( + 'No valid rows found. Ensure CSV has a "phone" column header.' + ); setParsedRows([]); setHasTagsColumn(false); setHasCompanyColumn(false); @@ -194,23 +213,29 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps } = 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.'); + if (!accountId) + throw new Error('Your profile is not linked to an account.'); let imported = 0; let skipped = 0; let failed = 0; + // 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), + .map( + (r) => (r as { phone_normalized: string | null }).phone_normalized + ) + .filter((p): p is string => !!p) ); const toInsert = unique.filter((row) => { @@ -221,15 +246,25 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps return true; }); + // 3) Resolve tag names → ids (admin+ may auto-create missing tags). + // Skip the round-trip when the import carries no tag names. const allTagNames = toInsert.flatMap((row) => row.tagNames); - const { tagIdByKey, skippedNames } = await resolveImportTagIds(supabase, { - accountId, - userId: user.id, - tagNames: allTagNames, - canCreateTags: canEditSettings, - }); + let tagIdByKey = new Map(); + let skippedNames: string[] = []; + if (allTagNames.length > 0) { + ({ tagIdByKey, skippedNames } = await resolveImportTagIds(supabase, { + accountId, + userId: user.id, + tagNames: allTagNames, + canCreateTags: canEditSettings, + })); + } const tagAssignments: ContactTagAssignment[] = []; + + // 4) 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; for (let i = 0; i < toInsert.length; i += chunkSize) { @@ -249,6 +284,8 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps .select('id'); if (error) { + // Retry individually so one bad/duplicate row doesn't sink + // the whole chunk. for (let j = 0; j < rows.length; j++) { const row = rows[j]; const source = chunk[j]; @@ -275,6 +312,9 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps } else { const inserted = data ?? []; imported += inserted.length; + // inserted[j] ↔ chunk[j] only holds because a single INSERT + // preserves RETURNING order. If this path is ever split into + // parallel inserts, zip by phone or returned id instead. for (let j = 0; j < inserted.length; j++) { const source = chunk[j]; if (!source || source.tagNames.length === 0) continue; @@ -286,20 +326,29 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps } } - const tagsAssigned = await assignImportedContactTags( - supabase, - tagAssignments, - tagIdByKey, - ); + // 5) Wire tags onto the contacts we just created. Failure here must + // not mask a successful contact import. + let tagsAssigned = 0; + try { + tagsAssigned = await assignImportedContactTags( + supabase, + tagAssignments, + tagIdByKey + ); + } catch { + toast.warning('Contacts imported, but some tag assignments failed.'); + } setResult({ imported, skipped, failed, tagsAssigned }); if (imported > 0) { - toast.success(`${imported} contact${imported !== 1 ? 's' : ''} imported`); + toast.success( + `${imported} contact${imported !== 1 ? 's' : ''} imported` + ); onImported(); } if (tagsAssigned > 0) { toast.success( - `${tagsAssigned} tag assignment${tagsAssigned !== 1 ? 's' : ''} applied`, + `${tagsAssigned} tag assignment${tagsAssigned !== 1 ? 's' : ''} applied` ); } if (skippedNames.length > 0) { @@ -307,14 +356,16 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps const more = skippedNames.length > 3 ? ` (+${skippedNames.length - 3} more)` : ''; toast.info( - `Unknown tags skipped (create them in Settings first): ${sample}${more}`, + `Unknown tags skipped (create them in Settings first): ${sample}${more}` ); } if (skipped > 0) { toast.info(`${skipped} duplicate${skipped !== 1 ? 's' : ''} skipped`); } if (failed > 0) { - toast.error(`${failed} contact${failed !== 1 ? 's' : ''} failed to import`); + toast.error( + `${failed} contact${failed !== 1 ? 's' : ''} failed to import` + ); } } catch (err: unknown) { const message = err instanceof Error ? err.message : 'Import failed'; @@ -325,8 +376,12 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps } const preview = parsedRows.slice(0, PREVIEW_LIMIT); + // Tags: OR — show when the CSV declares a column or preview rows carry + // values, so an all-empty tags column still renders for validation. const previewHasTags = hasTagsColumn || preview.some((row) => row.tagNames.length > 0); + // Company: AND — hide unless the CSV declares it and preview has data, + // avoiding an all-dash column that wastes horizontal space. const previewHasCompany = hasCompanyColumn && preview.some((row) => row.company?.trim()); @@ -346,8 +401,10 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
- Import Contacts - + + Import Contacts + + Upload a CSV with a required{' '} phone @@ -377,19 +434,20 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps tabIndex={0} onClick={() => fileInputRef.current?.click()} onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') fileInputRef.current?.click(); + if (e.key === 'Enter' || e.key === ' ') + fileInputRef.current?.click(); }} className={cn( 'group flex cursor-pointer flex-col items-center justify-center gap-2 rounded-xl border border-dashed p-5 transition-all', file ? 'border-primary/35 bg-primary/[0.04]' - : 'border-slate-700/80 bg-slate-950/40 hover:border-primary/40 hover:bg-slate-950/70', + : 'hover:border-primary/40 border-slate-700/80 bg-slate-950/40 hover:bg-slate-950/70' )} > {file ? ( <> -
- +
+

- {parsedRows.length} row{parsedRows.length !== 1 ? 's' : ''} ready + {parsedRows.length} row{parsedRows.length !== 1 ? 's' : ''}{' '} + ready ) : ( @@ -406,8 +465,12 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps

-

Click to choose a CSV file

-

.csv up to your browser limit

+

+ Click to choose a CSV file +

+

+ .csv up to your browser limit +

)}
@@ -425,13 +488,13 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps {preview.length > 0 && !result && (
-

+

Preview · first {preview.length}

{tagStats.rowsWithTags > 0 && ( - + {tagStats.unique} tag{tagStats.unique !== 1 ? 's' : ''} ·{' '} {tagStats.rowsWithTags} contact {tagStats.rowsWithTags !== 1 ? 's' : ''} @@ -445,22 +508,22 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps - - - {previewHasCompany && ( - )} {previewHasTags && ( - )} @@ -472,8 +535,12 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps key={i} className="bg-slate-900/40 transition-colors hover:bg-slate-800/30" > -
+ Phone + Name + Email + Company + Tags - + + Import complete

{result.imported > 0 && ( -
+
{result.imported} imported
@@ -532,7 +599,8 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps {result.tagsAssigned > 0 && (
- {result.tagsAssigned} tag{result.tagsAssigned !== 1 ? 's' : ''} assigned + {result.tagsAssigned} tag + {result.tagsAssigned !== 1 ? 's' : ''} assigned
)} {result.skipped > 0 && ( diff --git a/src/lib/contacts/parse-contact-csv.test.ts b/src/lib/contacts/parse-contact-csv.test.ts index ac9a92e..d25670f 100644 --- a/src/lib/contacts/parse-contact-csv.test.ts +++ b/src/lib/contacts/parse-contact-csv.test.ts @@ -1,31 +1,31 @@ -import { describe, expect, it } from "vitest"; -import { parseContactCsv, parseTagCell } from "./parse-contact-csv"; +import { describe, expect, it } from 'vitest'; +import { parseContactCsv, parseTagCell } from './parse-contact-csv'; -describe("parseTagCell", () => { - it("splits comma-separated tags and trims whitespace", () => { - expect(parseTagCell(" VIP , Lead , ")).toEqual(["VIP", "Lead"]); +describe('parseTagCell', () => { + it('splits comma-separated tags and trims whitespace', () => { + expect(parseTagCell(' VIP , Lead , ')).toEqual(['VIP', 'Lead']); }); - it("splits semicolon-separated tags", () => { - expect(parseTagCell("VIP; Lead; Customer")).toEqual([ - "VIP", - "Lead", - "Customer", + it('splits semicolon-separated tags', () => { + expect(parseTagCell('VIP; Lead; Customer')).toEqual([ + 'VIP', + 'Lead', + 'Customer', ]); }); - it("de-dupes case-insensitively", () => { - expect(parseTagCell("vip, VIP, Lead")).toEqual(["vip", "Lead"]); + it('de-dupes case-insensitively', () => { + expect(parseTagCell('vip, VIP, Lead')).toEqual(['vip', 'Lead']); }); - it("returns empty for blank values", () => { - expect(parseTagCell("")).toEqual([]); + it('returns empty for blank values', () => { + expect(parseTagCell('')).toEqual([]); expect(parseTagCell(undefined)).toEqual([]); }); }); -describe("parseContactCsv", () => { - it("parses optional tags column", () => { +describe('parseContactCsv', () => { + it('parses optional tags column', () => { const csv = `phone,name,tags +15551234567,Alice,"VIP, Lead" +15559876543,Bob,Customer`; @@ -35,24 +35,24 @@ describe("parseContactCsv", () => { hasCompanyColumn: false, rows: [ { - phone: "+15551234567", - name: "Alice", + phone: '+15551234567', + name: 'Alice', email: undefined, company: undefined, - tagNames: ["VIP", "Lead"], + tagNames: ['VIP', 'Lead'], }, { - phone: "+15559876543", - name: "Bob", + phone: '+15559876543', + name: 'Bob', email: undefined, company: undefined, - tagNames: ["Customer"], + tagNames: ['Customer'], }, ], }); }); - it("returns empty tagNames when tags column is absent", () => { + it('returns empty tagNames when tags column is absent', () => { const csv = `phone,name +15551234567,Alice`; @@ -61,8 +61,8 @@ describe("parseContactCsv", () => { hasCompanyColumn: false, rows: [ { - phone: "+15551234567", - name: "Alice", + phone: '+15551234567', + name: 'Alice', email: undefined, company: undefined, tagNames: [], diff --git a/src/lib/contacts/parse-contact-csv.ts b/src/lib/contacts/parse-contact-csv.ts index 199b42c..fbeeaff 100644 --- a/src/lib/contacts/parse-contact-csv.ts +++ b/src/lib/contacts/parse-contact-csv.ts @@ -46,18 +46,18 @@ export function parseContactCsv(text: string): ParseContactCsvResult { } const headers = lines[0] - .split(",") - .map((h) => h.trim().toLowerCase().replace(/["']/g, "")); + .split(',') + .map((h) => h.trim().toLowerCase().replace(/["']/g, '')); - const phoneIdx = headers.indexOf("phone"); + const phoneIdx = headers.indexOf('phone'); if (phoneIdx === -1) { return { rows: [], hasTagsColumn: false, hasCompanyColumn: false }; } - const nameIdx = headers.indexOf("name"); - const emailIdx = headers.indexOf("email"); - const companyIdx = headers.indexOf("company"); - const tagsIdx = headers.indexOf("tags"); + const nameIdx = headers.indexOf('name'); + const emailIdx = headers.indexOf('email'); + const companyIdx = headers.indexOf('company'); + const tagsIdx = headers.indexOf('tags'); const rows: ParsedContactRow[] = []; @@ -66,25 +66,25 @@ export function parseContactCsv(text: string): ParseContactCsvResult { if (!line) continue; const values = parseCsvLine(line); - const phone = values[phoneIdx]?.replace(/["']/g, "").trim(); + const phone = values[phoneIdx]?.replace(/["']/g, '').trim(); if (!phone) continue; rows.push({ phone, name: nameIdx >= 0 - ? values[nameIdx]?.replace(/["']/g, "").trim() || undefined + ? values[nameIdx]?.replace(/["']/g, '').trim() || undefined : undefined, email: emailIdx >= 0 - ? values[emailIdx]?.replace(/["']/g, "").trim() || undefined + ? values[emailIdx]?.replace(/["']/g, '').trim() || undefined : undefined, company: companyIdx >= 0 - ? values[companyIdx]?.replace(/["']/g, "").trim() || undefined + ? values[companyIdx]?.replace(/["']/g, '').trim() || undefined : undefined, tagNames: - tagsIdx >= 0 ? parseTagCell(values[tagsIdx]?.replace(/["']/g, "")) : [], + tagsIdx >= 0 ? parseTagCell(values[tagsIdx]?.replace(/["']/g, '')) : [], }); } @@ -98,15 +98,15 @@ export function parseContactCsv(text: string): ParseContactCsvResult { /** Simple CSV line parse (handles quoted fields). */ function parseCsvLine(line: string): string[] { const values: string[] = []; - let current = ""; + let current = ''; let inQuotes = false; for (const char of line) { if (char === '"') { inQuotes = !inQuotes; - } else if (char === "," && !inQuotes) { + } else if (char === ',' && !inQuotes) { values.push(current.trim()); - current = ""; + current = ''; } else { current += char; } diff --git a/src/lib/contacts/resolve-import-tags.ts b/src/lib/contacts/resolve-import-tags.ts index 9a3f75d..618cc60 100644 --- a/src/lib/contacts/resolve-import-tags.ts +++ b/src/lib/contacts/resolve-import-tags.ts @@ -1,6 +1,6 @@ -import type { SupabaseClient } from "@supabase/supabase-js"; +import type { SupabaseClient } from '@supabase/supabase-js'; -const DEFAULT_TAG_COLOR = "#3b82f6"; +const DEFAULT_TAG_COLOR = '#3b82f6'; export interface ResolveImportTagsResult { /** Lowercase tag name → tag id. */ @@ -14,6 +14,9 @@ export interface ResolveImportTagsResult { * are matched case-insensitively. Missing names are created when * `canCreateTags` is true (admin+); otherwise they are reported in * `skippedNames`. + * + * Unlike the manual contact form (existing tags only), import may + * auto-create missing tag definitions for admin+ callers. */ export async function resolveImportTagIds( supabase: SupabaseClient, @@ -23,7 +26,7 @@ export async function resolveImportTagIds( tagNames: string[]; canCreateTags: boolean; defaultColor?: string; - }, + } ): Promise { const { accountId, userId, tagNames, canCreateTags } = params; const defaultColor = params.defaultColor ?? DEFAULT_TAG_COLOR; @@ -39,10 +42,14 @@ export async function resolveImportTagIds( uniqueNames.push(name); } + if (uniqueNames.length === 0) { + return { tagIdByKey: new Map(), skippedNames: [] }; + } + const { data: existing, error: fetchError } = await supabase - .from("tags") - .select("id, name") - .eq("account_id", accountId); + .from('tags') + .select('id, name') + .eq('account_id', accountId); if (fetchError) throw fetchError; @@ -64,16 +71,16 @@ export async function resolveImportTagIds( if (toCreate.length > 0) { const { data: created, error: createError } = await supabase - .from("tags") + .from('tags') .insert( toCreate.map((name) => ({ user_id: userId, account_id: accountId, name, color: defaultColor, - })), + })) ) - .select("id, name"); + .select('id, name'); if (createError) throw createError; @@ -90,11 +97,17 @@ export interface ContactTagAssignment { tagNames: string[]; } -/** Insert contact_tags rows for imported contacts (ignores duplicates). */ +/** + * Insert contact_tags rows for imported contacts (ignores duplicates). + * + * Returns the number of contact–tag pairs *requested* for upsert, not + * rows actually inserted — `ignoreDuplicates` can drop pairs that already + * exist without changing the returned count. + */ export async function assignImportedContactTags( supabase: SupabaseClient, assignments: ContactTagAssignment[], - tagIdByKey: Map, + tagIdByKey: Map ): Promise { const rows: { contact_id: string; tag_id: string }[] = []; @@ -115,8 +128,8 @@ export async function assignImportedContactTags( for (let i = 0; i < rows.length; i += chunkSize) { const chunk = rows.slice(i, i + chunkSize); - const { error } = await supabase.from("contact_tags").upsert(chunk, { - onConflict: "contact_id,tag_id", + const { error } = await supabase.from('contact_tags').upsert(chunk, { + onConflict: 'contact_id,tag_id', ignoreDuplicates: true, }); if (error) throw error;