import { mkdir, open, readFile, rename, rm } from "node:fs/promises"; import { dirname } from "node:path"; import { randomUUID } from "node:crypto"; import { parse } from "csv-parse/sync"; import { stringify } from "csv-stringify/sync"; const legacyColumns = [ "bookingId", "createdAt", "packageId", "packageName", "name", "email", "emailStatus", "idempotencyKey", "requestHash", ]; const orderColumns = [...legacyColumns, "bookedPackage"]; const customerColumns = [...orderColumns, "customer", "acceptedTerms"]; const consentColumns = [...customerColumns, "consent"]; const phoneColumns = [...customerColumns, "phone"]; export const columns = [...phoneColumns, "consent"]; const jsonColumns = ["bookedPackage", "customer", "acceptedTerms", "consent"]; // Apostroph-Präfix schützt Tabellenprogramme. Ein vorhandenes Apostroph wird // ebenfalls maskiert, damit Lesen/Schreiben die Originalwerte exakt erhält. function protectCell(value) { const text = String(value ?? ""); return /^(?:'|[\t\r\n]|\s*[=+@-])/u.test(text) ? `'${text}` : text; } function restoreCell(value) { return value.startsWith("'") ? value.slice(1) : value; } export function createCsvStorage(filePath) { return { async readAll() { let content; try { content = await readFile(filePath, "utf8"); } catch (error) { if (error.code === "ENOENT") return []; throw error; } if (!content.trim()) throw new Error("Leere Buchungsdatei"); return parse(content, { bom: true, columns(header) { if ( header.join(",") !== columns.join(",") && header.join(",") !== customerColumns.join(",") && header.join(",") !== phoneColumns.join(",") && header.join(",") !== consentColumns.join(",") && header.join(",") !== legacyColumns.join(",") && header.join(",") !== orderColumns.join(",") ) { throw new Error("Unbekanntes CSV-Format"); } return header; }, skip_empty_lines: true, }).map((row) => Object.fromEntries( Object.entries(row).map(([key, value]) => [ key, jsonColumns.includes(key) ? value ? JSON.parse(restoreCell(value)) : null : restoreCell(value), ]), ), ); }, async writeAll(records) { await mkdir(dirname(filePath), { recursive: true }); const temporary = `${filePath}.${randomUUID()}.tmp`; const content = stringify( records.map((row) => Object.fromEntries( columns.map((column) => [ column, protectCell( jsonColumns.includes(column) && row[column] != null ? JSON.stringify(row[column]) : row[column], ), ]), ), ), { header: true, columns, record_delimiter: "\r\n" }, ); try { const handle = await open(temporary, "wx", 0o600); try { await handle.writeFile(content, "utf8"); await handle.sync(); } finally { await handle.close(); } // Atomarer Austausch im selben Verzeichnis: keine halben Zeilen. await rename(temporary, filePath); } finally { await rm(temporary, { force: true }); } }, }; }