feat(contacts): enhance CSV import functionality with tag assignment

- Introduced `parseContactCsv` for improved CSV parsing, replacing the previous inline parsing logic.
- Added support for importing tags alongside contacts, allowing users to assign tags during the import process.
- Implemented `assignImportedContactTags` to handle tag assignments after contact creation.
- Updated the import result state to include the number of tags assigned and added relevant success and info notifications for users.
- Enhanced the import modal description to clarify the format for tag input in CSV files.
This commit is contained in:
Abhishek Sonwal 2026-06-15 13:00:18 +05:30
parent 5b6fe3d677
commit 3a416832a7
4 changed files with 379 additions and 69 deletions

View File

@ -8,6 +8,12 @@ import {
isUniqueViolation, isUniqueViolation,
normalizeKey, normalizeKey,
} from '@/lib/contacts/dedupe'; } from '@/lib/contacts/dedupe';
import { parseContactCsv } from '@/lib/contacts/parse-contact-csv';
import {
assignImportedContactTags,
resolveImportTagIds,
type ContactTagAssignment,
} from '@/lib/contacts/resolve-import-tags';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { import {
Dialog, Dialog,
@ -26,75 +32,19 @@ interface ImportModalProps {
onImported: () => void; onImported: () => void;
} }
interface ParsedRow {
phone: string;
name?: string;
email?: string;
company?: string;
}
function parseCSV(text: string): ParsedRow[] {
const lines = text.trim().split(/\r?\n/);
if (lines.length < 2) return [];
const headerLine = lines[0];
const headers = headerLine.split(',').map((h) => h.trim().toLowerCase().replace(/["']/g, ''));
const phoneIdx = headers.indexOf('phone');
if (phoneIdx === -1) return [];
const nameIdx = headers.indexOf('name');
const emailIdx = headers.indexOf('email');
const companyIdx = headers.indexOf('company');
const rows: ParsedRow[] = [];
for (let i = 1; i < lines.length; i++) {
const line = lines[i].trim();
if (!line) continue;
// Simple CSV parse (handles quoted fields)
const values: string[] = [];
let current = '';
let inQuotes = false;
for (const char of line) {
if (char === '"') {
inQuotes = !inQuotes;
} else if (char === ',' && !inQuotes) {
values.push(current.trim());
current = '';
} else {
current += char;
}
}
values.push(current.trim());
const phone = values[phoneIdx]?.replace(/["']/g, '').trim();
if (!phone) continue;
rows.push({
phone,
name: nameIdx >= 0 ? values[nameIdx]?.replace(/["']/g, '').trim() || undefined : undefined,
email: emailIdx >= 0 ? values[emailIdx]?.replace(/["']/g, '').trim() || undefined : undefined,
company:
companyIdx >= 0 ? values[companyIdx]?.replace(/["']/g, '').trim() || undefined : undefined,
});
}
return rows;
}
export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps) { export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps) {
const supabase = createClient(); const supabase = createClient();
const { accountId } = useAuth(); const { accountId, canEditSettings } = useAuth();
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
const [parsedRows, setParsedRows] = useState<ParsedRow[]>([]); const [parsedRows, setParsedRows] = useState<ReturnType<typeof parseContactCsv>>([]);
const [importing, setImporting] = useState(false); const [importing, setImporting] = useState(false);
const [result, setResult] = useState<{ const [result, setResult] = useState<{
imported: number; imported: number;
skipped: number; skipped: number;
failed: number; failed: number;
tagsAssigned: number;
} | null>(null); } | null>(null);
function reset() { function reset() {
@ -117,7 +67,7 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
setResult(null); setResult(null);
const text = await selected.text(); const text = await selected.text();
const rows = parseCSV(text); const rows = 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.');
@ -168,9 +118,19 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
return true; return true;
}); });
// 3) Batch insert the genuinely-new rows in chunks of 50. The DB // 3) Resolve tag names → ids (create missing tags for admin+).
// unique index is the backstop: a 23505 (race, or a format const allTagNames = toInsert.flatMap((row) => row.tagNames);
// that normalizes equal) counts as skipped, not failed. const { tagIdByKey, skippedNames } = await resolveImportTagIds(supabase, {
accountId,
userId: user.id,
tagNames: allTagNames,
canCreateTags: canEditSettings,
});
const tagAssignments: ContactTagAssignment[] = [];
// 4) Batch insert contacts in chunks of 50. The DB unique index is
// the backstop: a 23505 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) {
const chunk = toInsert.slice(i, i + chunkSize); const chunk = toInsert.slice(i, i + chunkSize);
@ -191,10 +151,23 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
if (error) { if (error) {
// Retry individually so one bad/duplicate row doesn't sink // Retry individually so one bad/duplicate row doesn't sink
// the whole chunk. // the whole chunk.
for (const row of rows) { for (let j = 0; j < rows.length; j++) {
const { error: singleErr } = await supabase.from('contacts').insert(row); const row = rows[j];
if (!singleErr) { const source = chunk[j];
const { data: singleData, error: singleErr } = await supabase
.from('contacts')
.insert(row)
.select('id')
.single();
if (!singleErr && singleData) {
imported++; imported++;
if (source.tagNames.length > 0) {
tagAssignments.push({
contactId: singleData.id,
tagNames: source.tagNames,
});
}
} else if (isUniqueViolation(singleErr)) { } else if (isUniqueViolation(singleErr)) {
skipped++; skipped++;
} else { } else {
@ -202,15 +175,44 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
} }
} }
} else { } else {
imported += data?.length ?? chunk.length; const inserted = data ?? [];
imported += inserted.length;
for (let j = 0; j < inserted.length; j++) {
const source = chunk[j];
if (!source || source.tagNames.length === 0) continue;
tagAssignments.push({
contactId: inserted[j].id,
tagNames: source.tagNames,
});
}
} }
} }
setResult({ imported, skipped, failed }); // 5) Wire tags onto the contacts we just created.
const tagsAssigned = await assignImportedContactTags(
supabase,
tagAssignments,
tagIdByKey,
);
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) {
toast.success(
`${tagsAssigned} tag assignment${tagsAssigned !== 1 ? 's' : ''} applied`,
);
}
if (skippedNames.length > 0) {
const sample = skippedNames.slice(0, 3).join(', ');
const more =
skippedNames.length > 3 ? ` (+${skippedNames.length - 3} more)` : '';
toast.info(
`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`);
} }
@ -226,6 +228,7 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
} }
const preview = parsedRows.slice(0, 5); const preview = parsedRows.slice(0, 5);
const hasTagsColumn = parsedRows.some((row) => row.tagNames.length > 0);
return ( return (
<Dialog open={open} onOpenChange={handleOpenChange}> <Dialog open={open} onOpenChange={handleOpenChange}>
@ -234,7 +237,8 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
<DialogTitle className="text-white">Import Contacts</DialogTitle> <DialogTitle className="text-white">Import Contacts</DialogTitle>
<DialogDescription className="text-slate-400"> <DialogDescription className="text-slate-400">
Upload a CSV file with a &quot;phone&quot; column (required). Optional columns: Upload a CSV file with a &quot;phone&quot; column (required). Optional columns:
name, email, company. name, email, company, tags (comma-separated tag names — quote the
cell if a tag list contains commas, e.g. &quot;VIP, Lead&quot;).
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@ -287,6 +291,9 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
<th className="px-3 py-1.5 text-left text-slate-400 font-medium">Name</th> <th className="px-3 py-1.5 text-left text-slate-400 font-medium">Name</th>
<th className="px-3 py-1.5 text-left text-slate-400 font-medium">Email</th> <th className="px-3 py-1.5 text-left text-slate-400 font-medium">Email</th>
<th className="px-3 py-1.5 text-left text-slate-400 font-medium">Company</th> <th className="px-3 py-1.5 text-left text-slate-400 font-medium">Company</th>
{hasTagsColumn && (
<th className="px-3 py-1.5 text-left text-slate-400 font-medium">Tags</th>
)}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -296,6 +303,11 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
<td className="px-3 py-1.5 text-slate-300">{row.name || '-'}</td> <td className="px-3 py-1.5 text-slate-300">{row.name || '-'}</td>
<td className="px-3 py-1.5 text-slate-300">{row.email || '-'}</td> <td className="px-3 py-1.5 text-slate-300">{row.email || '-'}</td>
<td className="px-3 py-1.5 text-slate-300">{row.company || '-'}</td> <td className="px-3 py-1.5 text-slate-300">{row.company || '-'}</td>
{hasTagsColumn && (
<td className="px-3 py-1.5 text-slate-300">
{row.tagNames.length > 0 ? row.tagNames.join(', ') : '-'}
</td>
)}
</tr> </tr>
))} ))}
</tbody> </tbody>
@ -320,6 +332,12 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
{result.imported} imported {result.imported} imported
</div> </div>
)} )}
{result.tagsAssigned > 0 && (
<div className="flex items-center gap-1.5 text-cyan-400 text-sm">
<CheckCircle className="size-4" />
{result.tagsAssigned} tag{result.tagsAssigned !== 1 ? 's' : ''} assigned
</div>
)}
{result.skipped > 0 && ( {result.skipped > 0 && (
<div className="flex items-center gap-1.5 text-amber-400 text-sm"> <div className="flex items-center gap-1.5 text-amber-400 text-sm">
<AlertTriangle className="size-4" /> <AlertTriangle className="size-4" />

View File

@ -0,0 +1,65 @@
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"]);
});
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("returns empty for blank values", () => {
expect(parseTagCell("")).toEqual([]);
expect(parseTagCell(undefined)).toEqual([]);
});
});
describe("parseContactCsv", () => {
it("parses optional tags column", () => {
const csv = `phone,name,tags
+15551234567,Alice,"VIP, Lead"
+15559876543,Bob,Customer`;
expect(parseContactCsv(csv)).toEqual([
{
phone: "+15551234567",
name: "Alice",
email: undefined,
company: undefined,
tagNames: ["VIP", "Lead"],
},
{
phone: "+15559876543",
name: "Bob",
email: undefined,
company: undefined,
tagNames: ["Customer"],
},
]);
});
it("returns empty tagNames when tags column is absent", () => {
const csv = `phone,name
+15551234567,Alice`;
expect(parseContactCsv(csv)).toEqual([
{
phone: "+15551234567",
name: "Alice",
email: undefined,
company: undefined,
tagNames: [],
},
]);
});
});

View File

@ -0,0 +1,100 @@
/**
* CSV parsing for the contacts import modal. Shared + unit-tested so
* tag-column handling stays aligned with phone/name/email/company.
*/
export interface ParsedContactRow {
phone: string;
name?: string;
email?: string;
company?: string;
/** Tag names from the optional `tags` column (comma/semicolon separated). */
tagNames: string[];
}
/** Split a CSV cell into unique tag names (case-insensitive de-dupe). */
export function parseTagCell(value: string | undefined): string[] {
if (!value?.trim()) return [];
const seen = new Set<string>();
const names: string[] = [];
for (const part of value.split(/[,;]/)) {
const name = part.trim();
if (!name) continue;
const key = name.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
names.push(name);
}
return names;
}
export function parseContactCsv(text: string): ParsedContactRow[] {
const lines = text.trim().split(/\r?\n/);
if (lines.length < 2) return [];
const headers = lines[0]
.split(",")
.map((h) => h.trim().toLowerCase().replace(/["']/g, ""));
const phoneIdx = headers.indexOf("phone");
if (phoneIdx === -1) return [];
const nameIdx = headers.indexOf("name");
const emailIdx = headers.indexOf("email");
const companyIdx = headers.indexOf("company");
const tagsIdx = headers.indexOf("tags");
const rows: ParsedContactRow[] = [];
for (let i = 1; i < lines.length; i++) {
const line = lines[i].trim();
if (!line) continue;
const values = parseCsvLine(line);
const phone = values[phoneIdx]?.replace(/["']/g, "").trim();
if (!phone) continue;
rows.push({
phone,
name:
nameIdx >= 0
? values[nameIdx]?.replace(/["']/g, "").trim() || undefined
: undefined,
email:
emailIdx >= 0
? values[emailIdx]?.replace(/["']/g, "").trim() || undefined
: undefined,
company:
companyIdx >= 0
? values[companyIdx]?.replace(/["']/g, "").trim() || undefined
: undefined,
tagNames:
tagsIdx >= 0 ? parseTagCell(values[tagsIdx]?.replace(/["']/g, "")) : [],
});
}
return rows;
}
/** Simple CSV line parse (handles quoted fields). */
function parseCsvLine(line: string): string[] {
const values: string[] = [];
let current = "";
let inQuotes = false;
for (const char of line) {
if (char === '"') {
inQuotes = !inQuotes;
} else if (char === "," && !inQuotes) {
values.push(current.trim());
current = "";
} else {
current += char;
}
}
values.push(current.trim());
return values;
}

View File

@ -0,0 +1,127 @@
import type { SupabaseClient } from "@supabase/supabase-js";
const DEFAULT_TAG_COLOR = "#3b82f6";
export interface ResolveImportTagsResult {
/** Lowercase tag name → tag id. */
tagIdByKey: Map<string, string>;
/** Names that could not be matched and were not created. */
skippedNames: string[];
}
/**
* Resolve tag names from a CSV import to tag ids. Existing account tags
* are matched case-insensitively. Missing names are created when
* `canCreateTags` is true (admin+); otherwise they are reported in
* `skippedNames`.
*/
export async function resolveImportTagIds(
supabase: SupabaseClient,
params: {
accountId: string;
userId: string;
tagNames: string[];
canCreateTags: boolean;
defaultColor?: string;
},
): Promise<ResolveImportTagsResult> {
const { accountId, userId, tagNames, canCreateTags } = params;
const defaultColor = params.defaultColor ?? DEFAULT_TAG_COLOR;
const uniqueNames: string[] = [];
const seen = new Set<string>();
for (const raw of tagNames) {
const name = raw.trim();
if (!name) continue;
const key = name.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
uniqueNames.push(name);
}
const { data: existing, error: fetchError } = await supabase
.from("tags")
.select("id, name")
.eq("account_id", accountId);
if (fetchError) throw fetchError;
const tagIdByKey = new Map<string, string>();
for (const tag of existing ?? []) {
const key = tag.name.trim().toLowerCase();
if (!tagIdByKey.has(key)) tagIdByKey.set(key, tag.id);
}
const skippedNames: string[] = [];
const toCreate: string[] = [];
for (const name of uniqueNames) {
const key = name.toLowerCase();
if (tagIdByKey.has(key)) continue;
if (canCreateTags) toCreate.push(name);
else skippedNames.push(name);
}
if (toCreate.length > 0) {
const { data: created, error: createError } = await supabase
.from("tags")
.insert(
toCreate.map((name) => ({
user_id: userId,
account_id: accountId,
name,
color: defaultColor,
})),
)
.select("id, name");
if (createError) throw createError;
for (const tag of created ?? []) {
tagIdByKey.set(tag.name.trim().toLowerCase(), tag.id);
}
}
return { tagIdByKey, skippedNames };
}
export interface ContactTagAssignment {
contactId: string;
tagNames: string[];
}
/** Insert contact_tags rows for imported contacts (ignores duplicates). */
export async function assignImportedContactTags(
supabase: SupabaseClient,
assignments: ContactTagAssignment[],
tagIdByKey: Map<string, string>,
): Promise<number> {
const rows: { contact_id: string; tag_id: string }[] = [];
for (const { contactId, tagNames } of assignments) {
const assignedTagIds = new Set<string>();
for (const name of tagNames) {
const tagId = tagIdByKey.get(name.trim().toLowerCase());
if (!tagId || assignedTagIds.has(tagId)) continue;
assignedTagIds.add(tagId);
rows.push({ contact_id: contactId, tag_id: tagId });
}
}
if (rows.length === 0) return 0;
const chunkSize = 100;
let assigned = 0;
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",
ignoreDuplicates: true,
});
if (error) throw error;
assigned += chunk.length;
}
return assigned;
}