diff --git a/src/components/contacts/import-modal.tsx b/src/components/contacts/import-modal.tsx index 392a33d..0a05ef7 100644 --- a/src/components/contacts/import-modal.tsx +++ b/src/components/contacts/import-modal.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useRef } from 'react'; +import { useMemo, useRef, useState } from 'react'; import { createClient } from '@/lib/supabase/client'; import { useAuth } from '@/hooks/use-auth'; import { @@ -8,6 +8,16 @@ import { isUniqueViolation, normalizeKey, } from '@/lib/contacts/dedupe'; +import { + parseContactCsv, + type ParsedContactRow, +} from '@/lib/contacts/parse-contact-csv'; +import { + assignImportedContactTags, + resolveImportTagIds, + type ContactTagAssignment, +} from '@/lib/contacts/resolve-import-tags'; +import { cn } from '@/lib/utils'; import { toast } from 'sonner'; import { Dialog, @@ -18,7 +28,89 @@ import { DialogFooter, } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; -import { Upload, FileText, Loader2, CheckCircle, XCircle, AlertTriangle } from 'lucide-react'; +import { + Upload, + FileText, + Loader2, + CheckCircle, + XCircle, + AlertTriangle, + Tag, +} from 'lucide-react'; + +const DEFAULT_TAG_COLOR = '#3b82f6'; +const PREVIEW_LIMIT = 5; + +function truncateFilename(name: string, max = 48): string { + if (name.length <= max) return name; + const ext = name.includes('.') ? name.slice(name.lastIndexOf('.')) : ''; + const base = name.slice(0, name.length - ext.length); + const keep = max - ext.length - 1; + return `${base.slice(0, Math.max(keep, 12))}…${ext}`; +} + +function PreviewCell({ + value, + mono, + maxWidth = 'max-w-[9rem]', +}: { + value: string; + mono?: boolean; + maxWidth?: string; +}) { + return ( + + {value} + + ); +} + +function ImportPreviewTags({ + tagNames, + tagColorByKey, +}: { + tagNames: string[]; + tagColorByKey: Map; +}) { + if (tagNames.length === 0) { + return —; + } + + return ( +
+ {tagNames.map((name) => { + const color = + tagColorByKey.get(name.trim().toLowerCase()) ?? DEFAULT_TAG_COLOR; + const isKnown = tagColorByKey.has(name.trim().toLowerCase()); + return ( + + + {name} + + ); + })} +
+ ); +} interface ImportModalProps { open: boolean; @@ -26,87 +118,43 @@ interface ImportModalProps { 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 { accountId } = useAuth(); + const { accountId, canEditSettings } = useAuth(); const fileInputRef = useRef(null); const [file, setFile] = useState(null); - const [parsedRows, setParsedRows] = useState([]); + const [parsedRows, setParsedRows] = useState([]); + const [hasTagsColumn, setHasTagsColumn] = useState(false); + const [hasCompanyColumn, setHasCompanyColumn] = useState(false); + const [tagColorByKey, setTagColorByKey] = useState>( + new Map() + ); const [importing, setImporting] = useState(false); const [result, setResult] = useState<{ imported: number; skipped: number; failed: number; + tagsAssigned: number; } | null>(null); function reset() { setFile(null); setParsedRows([]); + setHasTagsColumn(false); + setHasCompanyColumn(false); + setTagColorByKey(new Map()); setResult(null); if (fileInputRef.current) fileInputRef.current.value = ''; } - function handleOpenChange(open: boolean) { - if (!open) reset(); - onOpenChange(open); + function handleOpenChange(next: boolean) { + if (!next) reset(); + onOpenChange(next); } async function handleFileChange(e: React.ChangeEvent) { @@ -117,15 +165,42 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps setResult(null); const text = await selected.text(); - const rows = parseCSV(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); + setTagColorByKey(new Map()); return; } setParsedRows(rows); + setHasTagsColumn(csvHasTags); + setHasCompanyColumn(csvHasCompany); + + if (csvHasTags && accountId) { + const { data: tags } = await supabase + .from('tags') + .select('name, color') + .eq('account_id', accountId); + + const colors = new Map(); + for (const tag of tags ?? []) { + const key = tag.name.trim().toLowerCase(); + if (!colors.has(key)) colors.set(key, tag.color); + } + setTagColorByKey(colors); + } else { + setTagColorByKey(new Map()); + } } async function handleImport() { @@ -138,7 +213,8 @@ 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; @@ -156,8 +232,10 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps .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) => { @@ -168,10 +246,27 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps return true; }); - // 3) Batch insert the genuinely-new rows in chunks of 50. The DB + // 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); + 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) { const chunk = toInsert.slice(i, i + chunkSize); const rows = chunk.map((row) => ({ @@ -191,10 +286,23 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps if (error) { // Retry individually so one bad/duplicate row doesn't sink // the whole chunk. - for (const row of rows) { - const { error: singleErr } = await supabase.from('contacts').insert(row); - if (!singleErr) { + for (let j = 0; j < rows.length; j++) { + const row = rows[j]; + const source = chunk[j]; + const { data: singleData, error: singleErr } = await supabase + .from('contacts') + .insert(row) + .select('id') + .single(); + + if (!singleErr && singleData) { imported++; + if (source.tagNames.length > 0) { + tagAssignments.push({ + contactId: singleData.id, + tagNames: source.tagNames, + }); + } } else if (isUniqueViolation(singleErr)) { skipped++; } else { @@ -202,20 +310,62 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps } } } else { - imported += data?.length ?? chunk.length; + 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; + tagAssignments.push({ + contactId: inserted[j].id, + tagNames: source.tagNames, + }); + } } } - setResult({ imported, skipped, failed }); + // 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` + ); + } + 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) { 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'; @@ -225,41 +375,101 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps } } - const preview = parsedRows.slice(0, 5); + 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()); + + const tagStats = useMemo(() => { + const names = new Set(); + let rowsWithTags = 0; + for (const row of parsedRows) { + if (row.tagNames.length === 0) continue; + rowsWithTags++; + for (const name of row.tagNames) names.add(name.trim().toLowerCase()); + } + return { unique: names.size, rowsWithTags }; + }, [parsedRows]); return ( - - - Import Contacts - - Upload a CSV file with a "phone" column (required). Optional columns: - name, email, company. - - + +
+ + + Import Contacts + + + Upload a CSV with a required{' '} + + phone + {' '} + column. Optional:{' '} + + name + + ,{' '} + + email + + ,{' '} + + company + + ,{' '} + + tags + {' '} + (comma-separated; quote multi-tag cells). + + -
- {/* Upload area */}
fileInputRef.current?.click()} - className="flex flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed border-slate-700 p-6 cursor-pointer hover:border-primary/50 transition-colors" + onKeyDown={(e) => { + 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]' + : 'hover:border-primary/40 border-slate-700/80 bg-slate-950/40 hover:bg-slate-950/70' + )} > {file ? ( <> - -

{file.name}

-

- {parsedRows.length} row{parsedRows.length !== 1 ? 's' : ''} detected +

+ +
+

+ {truncateFilename(file.name)}

+ + {parsedRows.length} row{parsedRows.length !== 1 ? 's' : ''}{' '} + ready + ) : ( <> - +
+ +

- Click to upload CSV file + Click to choose a CSV file

-

- CSV with "phone" column required +

+ .csv up to your browser limit

)} @@ -272,63 +482,136 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps onChange={handleFileChange} className="hidden" /> +
- {/* Preview table */} +
{preview.length > 0 && !result && ( -
-

- Preview (first {preview.length} rows) -

-
- - - - - - - - - - - {preview.map((row, i) => ( - - - - - - - ))} - -
PhoneNameEmailCompany
{row.phone}{row.name || '-'}{row.email || '-'}{row.company || '-'}
+
+
+

+ Preview · first {preview.length} +

+
+ {tagStats.rowsWithTags > 0 && ( + + + {tagStats.unique} tag{tagStats.unique !== 1 ? 's' : ''} ·{' '} + {tagStats.rowsWithTags} contact + {tagStats.rowsWithTags !== 1 ? 's' : ''} + + )} +
- {parsedRows.length > 5 && ( -

- ...and {parsedRows.length - 5} more rows + +

+
+ + + + + + + {previewHasCompany && ( + + )} + {previewHasTags && ( + + )} + + + + {preview.map((row, i) => ( + + + + + {previewHasCompany && ( + + )} + {previewHasTags && ( + + )} + + ))} + +
+ Phone + + Name + + Email + + Company + + Tags +
+ + + + + + + + + +
+
+
+ + {parsedRows.length > PREVIEW_LIMIT && ( +

+ + {parsedRows.length - PREVIEW_LIMIT} more row + {parsedRows.length - PREVIEW_LIMIT !== 1 ? 's' : ''} not shown

)}
)} - {/* Results */} {result && ( -
-

Import Complete

-
+
+

Import complete

+
{result.imported > 0 && ( -
- +
+ {result.imported} imported
)} + {result.tagsAssigned > 0 && ( +
+ + {result.tagsAssigned} tag + {result.tagsAssigned !== 1 ? 's' : ''} assigned +
+ )} {result.skipped > 0 && ( -
- - {result.skipped} duplicate{result.skipped !== 1 ? 's' : ''} skipped +
+ + {result.skipped} skipped
)} {result.failed > 0 && ( -
- +
+ {result.failed} failed
)} @@ -337,7 +620,7 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps )}
- + )} diff --git a/src/lib/contacts/parse-contact-csv.test.ts b/src/lib/contacts/parse-contact-csv.test.ts new file mode 100644 index 0000000..d25670f --- /dev/null +++ b/src/lib/contacts/parse-contact-csv.test.ts @@ -0,0 +1,73 @@ +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({ + hasTagsColumn: true, + hasCompanyColumn: false, + rows: [ + { + 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({ + hasTagsColumn: false, + hasCompanyColumn: false, + rows: [ + { + 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 new file mode 100644 index 0000000..fbeeaff --- /dev/null +++ b/src/lib/contacts/parse-contact-csv.ts @@ -0,0 +1,116 @@ +/** + * 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(); + 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 interface ParseContactCsvResult { + rows: ParsedContactRow[]; + /** True when the CSV header includes a `tags` column. */ + hasTagsColumn: boolean; + /** True when the CSV header includes a `company` column. */ + hasCompanyColumn: boolean; +} + +export function parseContactCsv(text: string): ParseContactCsvResult { + const lines = text.trim().split(/\r?\n/); + if (lines.length < 2) { + return { rows: [], hasTagsColumn: false, hasCompanyColumn: false }; + } + + const headers = lines[0] + .split(',') + .map((h) => h.trim().toLowerCase().replace(/["']/g, '')); + + 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 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, + hasTagsColumn: tagsIdx >= 0, + hasCompanyColumn: companyIdx >= 0, + }; +} + +/** 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; +} diff --git a/src/lib/contacts/resolve-import-tags.ts b/src/lib/contacts/resolve-import-tags.ts new file mode 100644 index 0000000..618cc60 --- /dev/null +++ b/src/lib/contacts/resolve-import-tags.ts @@ -0,0 +1,140 @@ +import type { SupabaseClient } from '@supabase/supabase-js'; + +const DEFAULT_TAG_COLOR = '#3b82f6'; + +export interface ResolveImportTagsResult { + /** Lowercase tag name → tag id. */ + tagIdByKey: Map; + /** 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`. + * + * Unlike the manual contact form (existing tags only), import may + * auto-create missing tag definitions for admin+ callers. + */ +export async function resolveImportTagIds( + supabase: SupabaseClient, + params: { + accountId: string; + userId: string; + tagNames: string[]; + canCreateTags: boolean; + defaultColor?: string; + } +): Promise { + const { accountId, userId, tagNames, canCreateTags } = params; + const defaultColor = params.defaultColor ?? DEFAULT_TAG_COLOR; + + const uniqueNames: string[] = []; + const seen = new Set(); + 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); + } + + 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); + + if (fetchError) throw fetchError; + + const tagIdByKey = new Map(); + 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). + * + * 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 +): Promise { + const rows: { contact_id: string; tag_id: string }[] = []; + + for (const { contactId, tagNames } of assignments) { + const assignedTagIds = new Set(); + 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; +}