templates2.Admin.Next/lib/csv.ts
w4c-templates c64f7ffa9e
Some checks failed
CI / quality (push) Has been cancelled
chore(template): publish Admin.Next
2026-10-11 23:25:34 +03:00

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');
}