mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 17:29:45 +00:00
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:
parent
5b6fe3d677
commit
3a416832a7
@ -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 "phone" column (required). Optional columns:
|
Upload a CSV file with a "phone" 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. "VIP, Lead").
|
||||||
</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" />
|
||||||
|
|||||||
65
src/lib/contacts/parse-contact-csv.test.ts
Normal file
65
src/lib/contacts/parse-contact-csv.test.ts
Normal 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: [],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
100
src/lib/contacts/parse-contact-csv.ts
Normal file
100
src/lib/contacts/parse-contact-csv.ts
Normal 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;
|
||||||
|
}
|
||||||
127
src/lib/contacts/resolve-import-tags.ts
Normal file
127
src/lib/contacts/resolve-import-tags.ts
Normal 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;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user