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