refactor(contacts): improve code readability and formatting in import modal and CSV parsing

- Reformatted code in `import-modal.tsx` for better readability, including consistent indentation and line breaks.
- Enhanced `parse-contact-csv.ts` and `parse-contact-csv.test.ts` with consistent string delimiters and improved whitespace handling.
- Updated comments for clarity and consistency across the import functionality.
- Ensured consistent use of single quotes for strings throughout the files.
This commit is contained in:
Abhishek Sonwal 2026-06-16 12:59:03 +05:30
parent a3ce8fcf9b
commit f39ca60629
4 changed files with 182 additions and 101 deletions

View File

@ -8,7 +8,10 @@ import {
isUniqueViolation, isUniqueViolation,
normalizeKey, normalizeKey,
} from '@/lib/contacts/dedupe'; } 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 { import {
assignImportedContactTags, assignImportedContactTags,
resolveImportTagIds, resolveImportTagIds,
@ -57,7 +60,11 @@ function PreviewCell({
}) { }) {
return ( return (
<span <span
className={cn('block truncate', maxWidth, mono && 'font-mono text-[11px]')} className={cn(
'block truncate',
maxWidth,
mono && 'font-mono text-[11px]'
)}
title={value} title={value}
> >
{value} {value}
@ -77,14 +84,15 @@ function ImportPreviewTags({
} }
return ( return (
<div className="flex flex-wrap gap-1 min-w-[4.5rem]"> <div className="flex min-w-[4.5rem] flex-wrap gap-1">
{tagNames.map((name) => { {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()); const isKnown = tagColorByKey.has(name.trim().toLowerCase());
return ( return (
<span <span
key={name} key={name}
className="inline-flex max-w-full items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium leading-none" className="inline-flex max-w-full items-center gap-1 rounded-full px-2 py-0.5 text-[10px] leading-none font-medium"
style={{ style={{
backgroundColor: `${color}18`, backgroundColor: `${color}18`,
color, color,
@ -110,7 +118,11 @@ interface ImportModalProps {
onImported: () => void; onImported: () => void;
} }
export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps) { export function ImportModal({
open,
onOpenChange,
onImported,
}: ImportModalProps) {
const supabase = createClient(); const supabase = createClient();
const { accountId, canEditSettings } = useAuth(); const { accountId, canEditSettings } = useAuth();
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
@ -119,7 +131,9 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
const [parsedRows, setParsedRows] = useState<ParsedContactRow[]>([]); const [parsedRows, setParsedRows] = useState<ParsedContactRow[]>([]);
const [hasTagsColumn, setHasTagsColumn] = useState(false); const [hasTagsColumn, setHasTagsColumn] = useState(false);
const [hasCompanyColumn, setHasCompanyColumn] = useState(false); const [hasCompanyColumn, setHasCompanyColumn] = useState(false);
const [tagColorByKey, setTagColorByKey] = useState<Map<string, string>>(new Map()); const [tagColorByKey, setTagColorByKey] = useState<Map<string, string>>(
new Map()
);
const [importing, setImporting] = useState(false); const [importing, setImporting] = useState(false);
const [result, setResult] = useState<{ const [result, setResult] = useState<{
imported: number; imported: number;
@ -151,11 +165,16 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
setResult(null); setResult(null);
const text = await selected.text(); const text = await selected.text();
const { rows, hasTagsColumn: csvHasTags, hasCompanyColumn: csvHasCompany } = const {
parseContactCsv(text); rows,
hasTagsColumn: csvHasTags,
hasCompanyColumn: csvHasCompany,
} = parseContactCsv(text);
if (rows.length === 0) { 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([]); setParsedRows([]);
setHasTagsColumn(false); setHasTagsColumn(false);
setHasCompanyColumn(false); setHasCompanyColumn(false);
@ -194,23 +213,29 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
} = await supabase.auth.getSession(); } = await supabase.auth.getSession();
const user = session?.user; const user = session?.user;
if (!user) throw new Error('Not authenticated'); 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 imported = 0;
let skipped = 0; let skipped = 0;
let failed = 0; let failed = 0;
// 1) De-dupe within the file by normalized phone (keep first).
const { unique, duplicates: inFileDupes } = dedupeByPhone(parsedRows); const { unique, duplicates: inFileDupes } = dedupeByPhone(parsedRows);
skipped += inFileDupes; 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 const { data: existingRows } = await supabase
.from('contacts') .from('contacts')
.select('phone_normalized') .select('phone_normalized')
.eq('account_id', accountId); .eq('account_id', accountId);
const existing = new Set( const existing = new Set(
(existingRows ?? []) (existingRows ?? [])
.map((r) => (r as { phone_normalized: string | null }).phone_normalized) .map(
.filter((p): p is string => !!p), (r) => (r as { phone_normalized: string | null }).phone_normalized
)
.filter((p): p is string => !!p)
); );
const toInsert = unique.filter((row) => { const toInsert = unique.filter((row) => {
@ -221,15 +246,25 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
return true; 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 allTagNames = toInsert.flatMap((row) => row.tagNames);
const { tagIdByKey, skippedNames } = await resolveImportTagIds(supabase, { let tagIdByKey = new Map<string, string>();
accountId, let skippedNames: string[] = [];
userId: user.id, if (allTagNames.length > 0) {
tagNames: allTagNames, ({ tagIdByKey, skippedNames } = await resolveImportTagIds(supabase, {
canCreateTags: canEditSettings, accountId,
}); userId: user.id,
tagNames: allTagNames,
canCreateTags: canEditSettings,
}));
}
const tagAssignments: ContactTagAssignment[] = []; 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; const chunkSize = 50;
for (let i = 0; i < toInsert.length; i += chunkSize) { for (let i = 0; i < toInsert.length; i += chunkSize) {
@ -249,6 +284,8 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
.select('id'); .select('id');
if (error) { if (error) {
// Retry individually so one bad/duplicate row doesn't sink
// the whole chunk.
for (let j = 0; j < rows.length; j++) { for (let j = 0; j < rows.length; j++) {
const row = rows[j]; const row = rows[j];
const source = chunk[j]; const source = chunk[j];
@ -275,6 +312,9 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
} else { } else {
const inserted = data ?? []; const inserted = data ?? [];
imported += inserted.length; 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++) { for (let j = 0; j < inserted.length; j++) {
const source = chunk[j]; const source = chunk[j];
if (!source || source.tagNames.length === 0) continue; if (!source || source.tagNames.length === 0) continue;
@ -286,20 +326,29 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
} }
} }
const tagsAssigned = await assignImportedContactTags( // 5) Wire tags onto the contacts we just created. Failure here must
supabase, // not mask a successful contact import.
tagAssignments, let tagsAssigned = 0;
tagIdByKey, try {
); tagsAssigned = await assignImportedContactTags(
supabase,
tagAssignments,
tagIdByKey
);
} catch {
toast.warning('Contacts imported, but some tag assignments failed.');
}
setResult({ imported, skipped, failed, tagsAssigned }); setResult({ imported, skipped, failed, tagsAssigned });
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 (tagsAssigned > 0) { if (tagsAssigned > 0) {
toast.success( toast.success(
`${tagsAssigned} tag assignment${tagsAssigned !== 1 ? 's' : ''} applied`, `${tagsAssigned} tag assignment${tagsAssigned !== 1 ? 's' : ''} applied`
); );
} }
if (skippedNames.length > 0) { if (skippedNames.length > 0) {
@ -307,14 +356,16 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
const more = const more =
skippedNames.length > 3 ? ` (+${skippedNames.length - 3} more)` : ''; skippedNames.length > 3 ? ` (+${skippedNames.length - 3} more)` : '';
toast.info( 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) { if (skipped > 0) {
toast.info(`${skipped} duplicate${skipped !== 1 ? 's' : ''} skipped`); 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`
);
} }
} catch (err: unknown) { } catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Import failed'; 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); 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 = const previewHasTags =
hasTagsColumn || preview.some((row) => row.tagNames.length > 0); 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 = const previewHasCompany =
hasCompanyColumn && preview.some((row) => row.company?.trim()); hasCompanyColumn && preview.some((row) => row.company?.trim());
@ -346,8 +401,10 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
<DialogContent className="flex max-h-[min(90vh,720px)] flex-col gap-0 overflow-hidden border-slate-700/80 bg-slate-900 p-0 text-slate-200 sm:max-w-2xl"> <DialogContent className="flex max-h-[min(90vh,720px)] flex-col gap-0 overflow-hidden border-slate-700/80 bg-slate-900 p-0 text-slate-200 sm:max-w-2xl">
<div className="shrink-0 space-y-4 border-b border-slate-800/80 px-6 pt-6 pb-5"> <div className="shrink-0 space-y-4 border-b border-slate-800/80 px-6 pt-6 pb-5">
<DialogHeader className="gap-1.5"> <DialogHeader className="gap-1.5">
<DialogTitle className="text-lg text-white">Import Contacts</DialogTitle> <DialogTitle className="text-lg text-white">
<DialogDescription className="text-slate-400 leading-relaxed"> Import Contacts
</DialogTitle>
<DialogDescription className="leading-relaxed text-slate-400">
Upload a CSV with a required{' '} Upload a CSV with a required{' '}
<code className="rounded bg-slate-800 px-1 py-0.5 text-[11px] text-slate-300"> <code className="rounded bg-slate-800 px-1 py-0.5 text-[11px] text-slate-300">
phone phone
@ -377,19 +434,20 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
tabIndex={0} tabIndex={0}
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') fileInputRef.current?.click(); if (e.key === 'Enter' || e.key === ' ')
fileInputRef.current?.click();
}} }}
className={cn( className={cn(
'group flex cursor-pointer flex-col items-center justify-center gap-2 rounded-xl border border-dashed p-5 transition-all', 'group flex cursor-pointer flex-col items-center justify-center gap-2 rounded-xl border border-dashed p-5 transition-all',
file file
? 'border-primary/35 bg-primary/[0.04]' ? '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 ? ( {file ? (
<> <>
<div className="flex size-10 items-center justify-center rounded-lg bg-primary/15 ring-1 ring-primary/25"> <div className="bg-primary/15 ring-primary/25 flex size-10 items-center justify-center rounded-lg ring-1">
<FileText className="size-5 text-primary" /> <FileText className="text-primary size-5" />
</div> </div>
<p <p
className="max-w-full truncate px-2 text-sm font-medium text-slate-200" className="max-w-full truncate px-2 text-sm font-medium text-slate-200"
@ -398,7 +456,8 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
{truncateFilename(file.name)} {truncateFilename(file.name)}
</p> </p>
<span className="rounded-full bg-slate-800 px-2.5 py-0.5 text-[11px] font-medium text-slate-400"> <span className="rounded-full bg-slate-800 px-2.5 py-0.5 text-[11px] font-medium text-slate-400">
{parsedRows.length} row{parsedRows.length !== 1 ? 's' : ''} ready {parsedRows.length} row{parsedRows.length !== 1 ? 's' : ''}{' '}
ready
</span> </span>
</> </>
) : ( ) : (
@ -406,8 +465,12 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
<div className="flex size-10 items-center justify-center rounded-lg bg-slate-800/80 ring-1 ring-slate-700/80 transition-colors group-hover:bg-slate-800"> <div className="flex size-10 items-center justify-center rounded-lg bg-slate-800/80 ring-1 ring-slate-700/80 transition-colors group-hover:bg-slate-800">
<Upload className="size-5 text-slate-500 group-hover:text-slate-400" /> <Upload className="size-5 text-slate-500 group-hover:text-slate-400" />
</div> </div>
<p className="text-sm text-slate-400">Click to choose a CSV file</p> <p className="text-sm text-slate-400">
<p className="text-[11px] text-slate-600">.csv up to your browser limit</p> Click to choose a CSV file
</p>
<p className="text-[11px] text-slate-600">
.csv up to your browser limit
</p>
</> </>
)} )}
</div> </div>
@ -425,13 +488,13 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
{preview.length > 0 && !result && ( {preview.length > 0 && !result && (
<div className="space-y-3"> <div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2"> <div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-500"> <p className="text-[11px] font-semibold tracking-[0.14em] text-slate-500 uppercase">
Preview · first {preview.length} Preview · first {preview.length}
</p> </p>
<div className="flex flex-wrap items-center gap-1.5"> <div className="flex flex-wrap items-center gap-1.5">
{tagStats.rowsWithTags > 0 && ( {tagStats.rowsWithTags > 0 && (
<span className="inline-flex items-center gap-1 rounded-md bg-slate-800/90 px-2 py-0.5 text-[11px] text-slate-400"> <span className="inline-flex items-center gap-1 rounded-md bg-slate-800/90 px-2 py-0.5 text-[11px] text-slate-400">
<Tag className="size-3 text-primary/80" /> <Tag className="text-primary/80 size-3" />
{tagStats.unique} tag{tagStats.unique !== 1 ? 's' : ''} ·{' '} {tagStats.unique} tag{tagStats.unique !== 1 ? 's' : ''} ·{' '}
{tagStats.rowsWithTags} contact {tagStats.rowsWithTags} contact
{tagStats.rowsWithTags !== 1 ? 's' : ''} {tagStats.rowsWithTags !== 1 ? 's' : ''}
@ -445,22 +508,22 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
<table className="w-full min-w-[32rem] text-xs"> <table className="w-full min-w-[32rem] text-xs">
<thead> <thead>
<tr className="border-b border-slate-800 bg-slate-950/60"> <tr className="border-b border-slate-800 bg-slate-950/60">
<th className="whitespace-nowrap px-3 py-2 text-left font-medium text-slate-500"> <th className="px-3 py-2 text-left font-medium whitespace-nowrap text-slate-500">
Phone Phone
</th> </th>
<th className="whitespace-nowrap px-3 py-2 text-left font-medium text-slate-500"> <th className="px-3 py-2 text-left font-medium whitespace-nowrap text-slate-500">
Name Name
</th> </th>
<th className="whitespace-nowrap px-3 py-2 text-left font-medium text-slate-500"> <th className="px-3 py-2 text-left font-medium whitespace-nowrap text-slate-500">
Email Email
</th> </th>
{previewHasCompany && ( {previewHasCompany && (
<th className="whitespace-nowrap px-3 py-2 text-left font-medium text-slate-500"> <th className="px-3 py-2 text-left font-medium whitespace-nowrap text-slate-500">
Company Company
</th> </th>
)} )}
{previewHasTags && ( {previewHasTags && (
<th className="whitespace-nowrap px-3 py-2 text-left font-medium text-slate-500"> <th className="px-3 py-2 text-left font-medium whitespace-nowrap text-slate-500">
Tags Tags
</th> </th>
)} )}
@ -472,8 +535,12 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
key={i} key={i}
className="bg-slate-900/40 transition-colors hover:bg-slate-800/30" className="bg-slate-900/40 transition-colors hover:bg-slate-800/30"
> >
<td className="whitespace-nowrap px-3 py-2 text-slate-300"> <td className="px-3 py-2 whitespace-nowrap text-slate-300">
<PreviewCell value={row.phone} mono maxWidth="max-w-[7.5rem]" /> <PreviewCell
value={row.phone}
mono
maxWidth="max-w-[7.5rem]"
/>
</td> </td>
<td className="px-3 py-2 text-slate-200"> <td className="px-3 py-2 text-slate-200">
<PreviewCell <PreviewCell
@ -524,7 +591,7 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
<p className="text-sm font-medium text-white">Import complete</p> <p className="text-sm font-medium text-white">Import complete</p>
<div className="mt-3 flex flex-wrap gap-3"> <div className="mt-3 flex flex-wrap gap-3">
{result.imported > 0 && ( {result.imported > 0 && (
<div className="flex items-center gap-1.5 text-sm text-primary"> <div className="text-primary flex items-center gap-1.5 text-sm">
<CheckCircle className="size-4 shrink-0" /> <CheckCircle className="size-4 shrink-0" />
{result.imported} imported {result.imported} imported
</div> </div>
@ -532,7 +599,8 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
{result.tagsAssigned > 0 && ( {result.tagsAssigned > 0 && (
<div className="flex items-center gap-1.5 text-sm text-cyan-400"> <div className="flex items-center gap-1.5 text-sm text-cyan-400">
<CheckCircle className="size-4 shrink-0" /> <CheckCircle className="size-4 shrink-0" />
{result.tagsAssigned} tag{result.tagsAssigned !== 1 ? 's' : ''} assigned {result.tagsAssigned} tag
{result.tagsAssigned !== 1 ? 's' : ''} assigned
</div> </div>
)} )}
{result.skipped > 0 && ( {result.skipped > 0 && (

View File

@ -1,31 +1,31 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from 'vitest';
import { parseContactCsv, parseTagCell } from "./parse-contact-csv"; import { parseContactCsv, parseTagCell } from './parse-contact-csv';
describe("parseTagCell", () => { describe('parseTagCell', () => {
it("splits comma-separated tags and trims whitespace", () => { it('splits comma-separated tags and trims whitespace', () => {
expect(parseTagCell(" VIP , Lead , ")).toEqual(["VIP", "Lead"]); expect(parseTagCell(' VIP , Lead , ')).toEqual(['VIP', 'Lead']);
}); });
it("splits semicolon-separated tags", () => { it('splits semicolon-separated tags', () => {
expect(parseTagCell("VIP; Lead; Customer")).toEqual([ expect(parseTagCell('VIP; Lead; Customer')).toEqual([
"VIP", 'VIP',
"Lead", 'Lead',
"Customer", 'Customer',
]); ]);
}); });
it("de-dupes case-insensitively", () => { it('de-dupes case-insensitively', () => {
expect(parseTagCell("vip, VIP, Lead")).toEqual(["vip", "Lead"]); expect(parseTagCell('vip, VIP, Lead')).toEqual(['vip', 'Lead']);
}); });
it("returns empty for blank values", () => { it('returns empty for blank values', () => {
expect(parseTagCell("")).toEqual([]); expect(parseTagCell('')).toEqual([]);
expect(parseTagCell(undefined)).toEqual([]); expect(parseTagCell(undefined)).toEqual([]);
}); });
}); });
describe("parseContactCsv", () => { describe('parseContactCsv', () => {
it("parses optional tags column", () => { it('parses optional tags column', () => {
const csv = `phone,name,tags const csv = `phone,name,tags
+15551234567,Alice,"VIP, Lead" +15551234567,Alice,"VIP, Lead"
+15559876543,Bob,Customer`; +15559876543,Bob,Customer`;
@ -35,24 +35,24 @@ describe("parseContactCsv", () => {
hasCompanyColumn: false, hasCompanyColumn: false,
rows: [ rows: [
{ {
phone: "+15551234567", phone: '+15551234567',
name: "Alice", name: 'Alice',
email: undefined, email: undefined,
company: undefined, company: undefined,
tagNames: ["VIP", "Lead"], tagNames: ['VIP', 'Lead'],
}, },
{ {
phone: "+15559876543", phone: '+15559876543',
name: "Bob", name: 'Bob',
email: undefined, email: undefined,
company: 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 const csv = `phone,name
+15551234567,Alice`; +15551234567,Alice`;
@ -61,8 +61,8 @@ describe("parseContactCsv", () => {
hasCompanyColumn: false, hasCompanyColumn: false,
rows: [ rows: [
{ {
phone: "+15551234567", phone: '+15551234567',
name: "Alice", name: 'Alice',
email: undefined, email: undefined,
company: undefined, company: undefined,
tagNames: [], tagNames: [],

View File

@ -46,18 +46,18 @@ export function parseContactCsv(text: string): ParseContactCsvResult {
} }
const headers = lines[0] const headers = lines[0]
.split(",") .split(',')
.map((h) => h.trim().toLowerCase().replace(/["']/g, "")); .map((h) => h.trim().toLowerCase().replace(/["']/g, ''));
const phoneIdx = headers.indexOf("phone"); const phoneIdx = headers.indexOf('phone');
if (phoneIdx === -1) { if (phoneIdx === -1) {
return { rows: [], hasTagsColumn: false, hasCompanyColumn: false }; return { rows: [], hasTagsColumn: false, hasCompanyColumn: false };
} }
const nameIdx = headers.indexOf("name"); const nameIdx = headers.indexOf('name');
const emailIdx = headers.indexOf("email"); const emailIdx = headers.indexOf('email');
const companyIdx = headers.indexOf("company"); const companyIdx = headers.indexOf('company');
const tagsIdx = headers.indexOf("tags"); const tagsIdx = headers.indexOf('tags');
const rows: ParsedContactRow[] = []; const rows: ParsedContactRow[] = [];
@ -66,25 +66,25 @@ export function parseContactCsv(text: string): ParseContactCsvResult {
if (!line) continue; if (!line) continue;
const values = parseCsvLine(line); const values = parseCsvLine(line);
const phone = values[phoneIdx]?.replace(/["']/g, "").trim(); const phone = values[phoneIdx]?.replace(/["']/g, '').trim();
if (!phone) continue; if (!phone) continue;
rows.push({ rows.push({
phone, phone,
name: name:
nameIdx >= 0 nameIdx >= 0
? values[nameIdx]?.replace(/["']/g, "").trim() || undefined ? values[nameIdx]?.replace(/["']/g, '').trim() || undefined
: undefined, : undefined,
email: email:
emailIdx >= 0 emailIdx >= 0
? values[emailIdx]?.replace(/["']/g, "").trim() || undefined ? values[emailIdx]?.replace(/["']/g, '').trim() || undefined
: undefined, : undefined,
company: company:
companyIdx >= 0 companyIdx >= 0
? values[companyIdx]?.replace(/["']/g, "").trim() || undefined ? values[companyIdx]?.replace(/["']/g, '').trim() || undefined
: undefined, : undefined,
tagNames: 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). */ /** Simple CSV line parse (handles quoted fields). */
function parseCsvLine(line: string): string[] { function parseCsvLine(line: string): string[] {
const values: string[] = []; const values: string[] = [];
let current = ""; let current = '';
let inQuotes = false; let inQuotes = false;
for (const char of line) { for (const char of line) {
if (char === '"') { if (char === '"') {
inQuotes = !inQuotes; inQuotes = !inQuotes;
} else if (char === "," && !inQuotes) { } else if (char === ',' && !inQuotes) {
values.push(current.trim()); values.push(current.trim());
current = ""; current = '';
} else { } else {
current += char; current += char;
} }

View File

@ -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 { export interface ResolveImportTagsResult {
/** Lowercase tag name → tag id. */ /** Lowercase tag name → tag id. */
@ -14,6 +14,9 @@ export interface ResolveImportTagsResult {
* are matched case-insensitively. Missing names are created when * are matched case-insensitively. Missing names are created when
* `canCreateTags` is true (admin+); otherwise they are reported in * `canCreateTags` is true (admin+); otherwise they are reported in
* `skippedNames`. * `skippedNames`.
*
* Unlike the manual contact form (existing tags only), import may
* auto-create missing tag definitions for admin+ callers.
*/ */
export async function resolveImportTagIds( export async function resolveImportTagIds(
supabase: SupabaseClient, supabase: SupabaseClient,
@ -23,7 +26,7 @@ export async function resolveImportTagIds(
tagNames: string[]; tagNames: string[];
canCreateTags: boolean; canCreateTags: boolean;
defaultColor?: string; defaultColor?: string;
}, }
): Promise<ResolveImportTagsResult> { ): Promise<ResolveImportTagsResult> {
const { accountId, userId, tagNames, canCreateTags } = params; const { accountId, userId, tagNames, canCreateTags } = params;
const defaultColor = params.defaultColor ?? DEFAULT_TAG_COLOR; const defaultColor = params.defaultColor ?? DEFAULT_TAG_COLOR;
@ -39,10 +42,14 @@ export async function resolveImportTagIds(
uniqueNames.push(name); uniqueNames.push(name);
} }
if (uniqueNames.length === 0) {
return { tagIdByKey: new Map(), skippedNames: [] };
}
const { data: existing, error: fetchError } = await supabase const { data: existing, error: fetchError } = await supabase
.from("tags") .from('tags')
.select("id, name") .select('id, name')
.eq("account_id", accountId); .eq('account_id', accountId);
if (fetchError) throw fetchError; if (fetchError) throw fetchError;
@ -64,16 +71,16 @@ export async function resolveImportTagIds(
if (toCreate.length > 0) { if (toCreate.length > 0) {
const { data: created, error: createError } = await supabase const { data: created, error: createError } = await supabase
.from("tags") .from('tags')
.insert( .insert(
toCreate.map((name) => ({ toCreate.map((name) => ({
user_id: userId, user_id: userId,
account_id: accountId, account_id: accountId,
name, name,
color: defaultColor, color: defaultColor,
})), }))
) )
.select("id, name"); .select('id, name');
if (createError) throw createError; if (createError) throw createError;
@ -90,11 +97,17 @@ export interface ContactTagAssignment {
tagNames: string[]; 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( export async function assignImportedContactTags(
supabase: SupabaseClient, supabase: SupabaseClient,
assignments: ContactTagAssignment[], assignments: ContactTagAssignment[],
tagIdByKey: Map<string, string>, tagIdByKey: Map<string, string>
): Promise<number> { ): Promise<number> {
const rows: { contact_id: string; tag_id: string }[] = []; 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) { for (let i = 0; i < rows.length; i += chunkSize) {
const chunk = rows.slice(i, i + chunkSize); const chunk = rows.slice(i, i + chunkSize);
const { error } = await supabase.from("contact_tags").upsert(chunk, { const { error } = await supabase.from('contact_tags').upsert(chunk, {
onConflict: "contact_id,tag_id", onConflict: 'contact_id,tag_id',
ignoreDuplicates: true, ignoreDuplicates: true,
}); });
if (error) throw error; if (error) throw error;