trihub-ernaehrung-backseatDevs/src/server/services/contact-form-storage.js

107 lines
3.5 KiB
JavaScript

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;
},
};
}