trihub-ernaehrung-backseatDevs/src/server/services/csv-storage.js

112 lines
4.1 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";
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 });
}
},
};
}