Files
route-commerce/src/lib/column-detector.ts
T

342 lines
8.1 KiB
TypeScript

import type { ContactImportEntry } from "@/actions/communications/contacts";
import { normalizePhone, normalizeEmail } from "./csv-parser";
// ── Column name variant lists ────────────────────────────────────────────────
const EMAIL_COLUMNS = new Set([
"email",
"emailaddress",
"email_address",
"e-mail",
"contactemail",
"contact email",
"customeremail",
"customer email",
]);
const PHONE_COLUMNS = new Set([
"phone",
"phonenumber",
"phone_number",
"mobilephone",
"mobile",
"cell",
"cellphone",
"smsphone",
"sms phone",
"customerphone",
"customer phone",
"telephone",
"tel",
]);
const FIRST_NAME_COLUMNS = new Set([
"firstname",
"first_name",
"fname",
"givenname",
"given name",
]);
const LAST_NAME_COLUMNS = new Set([
"lastname",
"last_name",
"lname",
"surname",
"familyname",
"family name",
]);
const FULL_NAME_COLUMNS = new Set([
"name",
"fullname",
"full_name",
"contactname",
"contact name",
"customername",
"customer name",
]);
const TAGS_COLUMNS = new Set([
"tags",
"tag",
"groups",
"segment",
"segments",
"audience",
"category",
"categories",
"label",
"labels",
]);
const EMAIL_OPT_IN_COLUMNS = new Set([
"emailoptin",
"email_opt_in",
"subscribed",
"email subscribed",
"subscriptionstatus",
"subscription status",
"marketingpermission",
"marketing permission",
"acceptsmarketing",
"accepts marketing",
"emailmarketingconsent",
"email marketing consent",
"emailconsent",
"marketing_email",
]);
const SMS_OPT_IN_COLUMNS = new Set([
"smsoptin",
"sms_opt_in",
"smssubscribed",
"sms subscribed",
"smsmarketing",
"sms marketing",
"smsconsent",
"sms_consent",
"smsmarketingconsent",
"mobileoptin",
"mobile_opt_in",
]);
const EXTERNAL_ID_COLUMNS = new Set([
"externalid",
"external_id",
"shopifyid",
"shopify_id",
"woo_id",
"quickbooksid",
"quickbooks_id",
"customer_id",
]);
// ── Normalization ────────────────────────────────────────────────────────────
function normalizeHeader(header: string): string {
return header
.toLowerCase()
.replace(/[_\-]/g, " ")
.replace(/\s+/g, " ")
.trim();
}
export type ImportField =
| "email"
| "phone"
| "first_name"
| "last_name"
| "full_name"
| "tags"
| "email_opt_in"
| "sms_opt_in"
| "external_id"
| null;
export type ColumnMapping = {
csvColumn: string;
field: ImportField;
sampleValues: string[];
};
const FIELD_COLUMNS: Record<string, Set<string>> = {
email: EMAIL_COLUMNS,
phone: PHONE_COLUMNS,
first_name: FIRST_NAME_COLUMNS,
last_name: LAST_NAME_COLUMNS,
full_name: FULL_NAME_COLUMNS,
tags: TAGS_COLUMNS,
email_opt_in: EMAIL_OPT_IN_COLUMNS,
sms_opt_in: SMS_OPT_IN_COLUMNS,
external_id: EXTERNAL_ID_COLUMNS,
};
export function detectColumnMappings(
headers: string[],
rows: string[][]
): ColumnMapping[] {
const mappings: ColumnMapping[] = headers.map((csvColumn) => {
const normalized = normalizeHeader(csvColumn);
let field: ImportField = null;
let bestScore = 0;
for (const [f, variants] of Object.entries(FIELD_COLUMNS)) {
// Exact normalized match
if (variants.has(normalized)) {
const score = 1;
if (score > bestScore) {
bestScore = score;
field = f as ImportField;
}
}
}
return { csvColumn, field, sampleValues: [] };
});
// Collect sample values and handle disambiguation
for (const [colIdx, mapping] of mappings.entries()) {
if (mapping.field === null) continue;
const samples: string[] = [];
for (const row of rows) {
const val = row[colIdx] ?? "";
if (val !== "" && samples.length < 3) {
samples.push(val);
}
}
mapping.sampleValues = samples;
}
// Disambiguation: if two columns mapped to same field, keep the one with more samples
const fieldCounts = new Map<string, ColumnMapping[]>();
for (const m of mappings) {
if (m.field !== null) {
if (!fieldCounts.has(m.field)) fieldCounts.set(m.field, []);
fieldCounts.get(m.field)!.push(m);
}
}
for (const [, ms] of fieldCounts) {
if (ms.length > 1) {
ms.sort((a, b) => b.sampleValues.length - a.sampleValues.length);
for (let i = 1; i < ms.length; i++) {
ms[i].field = null; // demote secondary matches
}
}
}
return mappings;
}
export function mapRowToContactEntry(
row: string[],
mappings: ColumnMapping[]
): {
entry: ContactImportEntry;
normalizedEmail: string | null;
normalizedPhone: string | null;
} {
const entry: ContactImportEntry = {};
let normalizedEmail: string | null = null;
let normalizedPhone: string | null = null;
for (const [colIdx, mapping] of mappings.entries()) {
if (mapping.field === null) continue;
const raw = row[colIdx] ?? "";
switch (mapping.field) {
case "email": {
const cleaned = normalizeEmail(raw);
entry.email = cleaned || undefined;
normalizedEmail = cleaned || null;
break;
}
case "phone": {
const { normalized, original } = normalizePhone(raw);
entry.phone = original || undefined;
normalizedPhone = normalized || null;
break;
}
case "first_name":
entry.first_name = raw || undefined;
break;
case "last_name":
entry.last_name = raw || undefined;
break;
case "full_name":
entry.full_name = raw || undefined;
break;
case "tags":
entry.tags = raw
? raw.split(/[;:,]/).flatMap((t) => {
const trimmed = t.trim();
return trimmed ? [trimmed] : [];
})
: undefined;
break;
case "email_opt_in":
entry.email_opt_in =
raw === "true" || raw === "1" || raw === "yes" || raw === "y" || raw === "on";
break;
case "sms_opt_in":
entry.sms_opt_in =
raw === "true" || raw === "1" || raw === "yes" || raw === "y" || raw === "on";
break;
case "external_id":
entry.external_id = raw || undefined;
break;
}
}
return { entry, normalizedEmail, normalizedPhone };
}
export type ImportPreviewResult = {
mappings: ColumnMapping[];
totalRows: number;
validRows: number;
skippedRows: number;
duplicateRows: number;
skippedReasons: { rowIndex: number; reason: string }[];
sampleRows: ContactImportEntry[];
ignoredColumns: string[];
warnings: string[];
};
export function buildImportPreview(
headers: string[],
rows: string[][]
): ImportPreviewResult {
const mappings = detectColumnMappings(headers, rows);
const ignoredColumns = mappings
.filter((m) => m.field === null)
.map((m) => m.csvColumn);
const entries: ContactImportEntry[] = [];
const skippedReasons: { rowIndex: number; reason: string }[] = [];
const seenEmails = new Set<string>();
const seenPhones = new Set<string>();
let duplicateRows = 0;
for (let i = 0; i < rows.length; i++) {
const { entry, normalizedEmail, normalizedPhone } = mapRowToContactEntry(rows[i], mappings);
const hasEmail = !!(normalizedEmail ?? entry.email);
const hasPhone = !!normalizedPhone;
if (!hasEmail && !hasPhone) {
skippedReasons.push({ rowIndex: i, reason: "No email or phone" });
continue;
}
// Dedupe within file
let isDuplicate = false;
if (normalizedEmail && seenEmails.has(normalizedEmail)) isDuplicate = true;
if (normalizedPhone && seenPhones.has(normalizedPhone)) isDuplicate = true;
if (isDuplicate) {
duplicateRows++;
skippedReasons.push({ rowIndex: i, reason: "Duplicate in file" });
continue;
}
if (normalizedEmail) seenEmails.add(normalizedEmail);
if (normalizedPhone) seenPhones.add(normalizedPhone);
entries.push(entry);
}
return {
mappings,
totalRows: rows.length,
validRows: entries.length,
skippedRows: rows.length - entries.length,
duplicateRows,
skippedReasons,
sampleRows: entries.slice(0, 5),
ignoredColumns,
warnings: [],
};
}