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"; // Diese Spalten bilden den festen Aufbau der Kontaktanfragen-CSV. const defaultColumns = [ "createdAt", "name", "email", "subject", "message", "consent", ]; // Schützt CSV-Zellen vor Tabellenformeln, wenn die Datei geöffnet wird. function protect(value) { const text = String(value ?? ""); return /^(?:'|[\t\r\n]|\s*[=+@-])/u.test(text) ? `'${text}` : text; } // Entfernt beim Einlesen den Schutzpräfix und stellt den Eingabewert wieder her. function restore(value) { return value.startsWith("'") ? value.slice(1) : value; } // Erstellt den CSV-Speicher und hält Schreibvorgänge dieser Instanz in einer Warteschlange. export function createContactFormStorage(filePath, columns = defaultColumns) { let queue = Promise.resolve(); // Liest alle Anfragen ein und prüft, ob die Datei den erwarteten CSV-Kopf besitzt. async function 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 Kontaktdatei"); return parse(content, { bom: true, columns(header) { if ( header.join(",") !== columns.join(",") && header.join(",") !== columns .filter((column) => column !== "consent") .join(",") ) { throw new Error("Unbekanntes Kontakt-CSV-Format"); } return header; }, skip_empty_lines: true, }).map((row) => Object.fromEntries( Object.entries(row).map(([key, value]) => [ key, key === "consent" ? value === "true" : restore(value), ]), ), ); } // Schreibt die vollständige CSV in eine temporäre Datei und ersetzt danach atomar das Original. async function 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, protect(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(); } await rename(temporary, filePath); } finally { await rm(temporary, { force: true }); } } return { readAll, // Hängt eine Anfrage nacheinander an, damit parallele Absendevorgänge keine Zeilen verlieren. append(record) { const operation = queue.then(async () => { const records = await readAll(); records.push(record); await writeAll(records); }); queue = operation.catch(() => {}); return operation; }, }; }