108 lines
3.3 KiB
TypeScript
108 lines
3.3 KiB
TypeScript
export type CsvRow = Record<string, string>;
|
|
|
|
export type ParsedCsv = { header: string[]; rows: CsvRow[] };
|
|
|
|
export type CustomerInput = { name: string; email: string; company: string; status: string };
|
|
export type ImportResult = {
|
|
total: number;
|
|
valid: CustomerInput[];
|
|
errors: { row: number; message: string }[];
|
|
duplicatesInFile: number[];
|
|
};
|
|
|
|
const KNOWN_STATUSES = ['active', 'lead', 'churned'];
|
|
|
|
/** Minimal RFC4180-ish CSV parser: quoted fields, escaped quotes, CRLF. */
|
|
export function parseCsv(text: string): ParsedCsv {
|
|
const rows: string[][] = [];
|
|
let field = '';
|
|
let row: string[] = [];
|
|
let inQuotes = false;
|
|
const src = text.replace(/\r\n?/g, '\n');
|
|
|
|
for (let i = 0; i < src.length; i++) {
|
|
const ch = src[i];
|
|
if (inQuotes) {
|
|
if (ch === '"') {
|
|
if (src[i + 1] === '"') {
|
|
field += '"';
|
|
i++;
|
|
} else inQuotes = false;
|
|
} else field += ch;
|
|
} else if (ch === '"') {
|
|
inQuotes = true;
|
|
} else if (ch === ',') {
|
|
row.push(field);
|
|
field = '';
|
|
} else if (ch === '\n') {
|
|
row.push(field);
|
|
rows.push(row);
|
|
row = [];
|
|
field = '';
|
|
} else field += ch;
|
|
}
|
|
if (field.length > 0 || row.length > 0) {
|
|
row.push(field);
|
|
rows.push(row);
|
|
}
|
|
|
|
const cleaned = rows.filter((r) => r.some((c) => c.trim() !== ''));
|
|
const first = cleaned[0];
|
|
if (!first) return { header: [], rows: [] };
|
|
const header = first.map((h) => h.trim().toLowerCase());
|
|
const out: CsvRow[] = cleaned.slice(1).map((r) => {
|
|
const obj: CsvRow = {};
|
|
header.forEach((h, idx) => (obj[h] = (r[idx] ?? '').trim()));
|
|
return obj;
|
|
});
|
|
return { header, rows: out };
|
|
}
|
|
|
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
|
|
/** Validate parsed rows for the customer import: required columns, email shape, status enum. */
|
|
export function validateCustomerRows(rows: CsvRow[]): ImportResult {
|
|
const errors: { row: number; message: string }[] = [];
|
|
const valid: CustomerInput[] = [];
|
|
const seen = new Map<string, number>();
|
|
const duplicatesInFile: number[] = [];
|
|
|
|
rows.forEach((row, idx) => {
|
|
const line = idx + 2; // 1-based + header
|
|
const name = (row.name ?? '').trim();
|
|
const email = (row.email ?? '').trim().toLowerCase();
|
|
const company = (row.company ?? '').trim();
|
|
const status = (row.status ?? 'active').trim().toLowerCase() || 'active';
|
|
|
|
if (!name) {
|
|
errors.push({ row: line, message: 'name is required' });
|
|
return;
|
|
}
|
|
if (!email || !EMAIL_RE.test(email)) {
|
|
errors.push({ row: line, message: `invalid email "${row.email ?? ''}"` });
|
|
return;
|
|
}
|
|
if (!KNOWN_STATUSES.includes(status)) {
|
|
errors.push({ row: line, message: `unknown status "${status}" (use ${KNOWN_STATUSES.join('/')})` });
|
|
return;
|
|
}
|
|
const priorLine = seen.get(email);
|
|
if (priorLine !== undefined) {
|
|
duplicatesInFile.push(line);
|
|
return;
|
|
}
|
|
seen.set(email, line);
|
|
valid.push({ name, email, company, status });
|
|
});
|
|
|
|
return { total: rows.length, valid, errors, duplicatesInFile };
|
|
}
|
|
|
|
export function toCsv(rows: Record<string, unknown>[], columns: string[]): string {
|
|
const escape = (v: unknown) => {
|
|
const s = v === null || v === undefined ? '' : String(v);
|
|
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
|
|
};
|
|
return [columns.join(','), ...rows.map((r) => columns.map((c) => escape(r[c])).join(','))].join('\n');
|
|
}
|