The 57 columns of the Cornerstone user import, in the given order and spelling, offered as CSV and Excel under the Honestly report. Same people and same filters as the full export. Two things about the file itself, both of which would have failed quietly. Cornerstone reads comma-separated, while toCsv writes the semicolons and the BOM that German Excel wants -- the BOM would have hidden inside the name of the first column, so "User ID" would have matched nothing in the mapping. toCsv now takes the form as an argument and keeps its old defaults; the Cornerstone form lives next to the columns so a test can hold both. Quoting follows the delimiter now, otherwise a comma in an address would split the row. Dates go out day-first, matching the import setting. An ISO date is read as a different, equally valid date and nobody notices. Gender maps to Cornerstone's own values; anything unexpected becomes "not specified" rather than empty, because an invalid value makes Cornerstone reject the whole row, not just the field. Status and Employment Status come from separate rules: somebody on Karenz has a working account and is not working, and filling both from one value gets one of the two wrong. Four fields carry visible placeholders because the leading systems do not supply their identifiers yet: Division ID, Doxis, Interflex, LGVplus. Home Phone and Personal Email stay empty on purpose -- what leaves the house is the business data. Not verified against a running Cornerstone import, and not seen in a browser; there is no database reachable here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
121 lines
5.3 KiB
TypeScript
121 lines
5.3 KiB
TypeScript
import ExcelJS from "exceljs";
|
||
import { todayIso } from "./format";
|
||
|
||
// Shared by every /api/export/* route: define columns once as { header, get },
|
||
// get both a semicolon CSV (Excel-DE friendly) and a real .xlsx workbook from
|
||
// the same data + column definitions. kind: "date" tells the xlsx writer to
|
||
// emit a real date cell (not a text string) for ISO ("YYYY-MM-DD") values.
|
||
export type ExportColumn<T> = {
|
||
header: string;
|
||
get: (row: T) => string | number | boolean | null;
|
||
kind?: "date";
|
||
};
|
||
|
||
// CSV/Excel formula injection (CWE-1236): a cell whose text begins with
|
||
// =, +, -, or @ is interpreted as a formula by Excel/Sheets/LibreOffice on
|
||
// open, not as literal text — dangerous when the source data (employee
|
||
// names, job titles, free-text notes, audit details, ...) can contain
|
||
// attacker- or user-supplied strings. Prefixing with a single quote is the
|
||
// standard mitigation (OWASP CSV Injection cheat sheet); it forces the cell
|
||
// to render as text at the cost of a visible leading ' for the rare
|
||
// legitimate value that starts with one of these characters.
|
||
export function sanitizeForSpreadsheetCell(text: string): string {
|
||
return /^[=+\-@]/.test(text) ? `'${text}` : text;
|
||
}
|
||
|
||
function csvCell(value: string | number | boolean | null, trenner: string): string {
|
||
if (value === null || value === undefined) return "";
|
||
const text = typeof value === "boolean" ? (value ? "Ja" : "Nein") : sanitizeForSpreadsheetCell(String(value));
|
||
// Das Trennzeichen gehört in die Prüfung: mit Komma als Trenner muss ein
|
||
// Komma im Wert maskiert werden, sonst zerfällt die Zeile in zwei Spalten.
|
||
return text.includes(trenner) || /["\n\r]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
|
||
}
|
||
|
||
/**
|
||
* Wie die Datei aussehen soll.
|
||
*
|
||
* Die Vorgaben sind für Excel in deutscher Einstellung gemacht: Semikolon als
|
||
* Trenner und ein BOM voran, damit Umlaute nicht als Buchstabensalat
|
||
* ankommen. Ein fremdes System will oft das Gegenteil — Cornerstone etwa
|
||
* erwartet Kommas, und ein BOM machte dort aus der ersten Spalte eine, die in
|
||
* keiner Zuordnung vorkommt, weil das unsichtbare Zeichen im Namen steckt.
|
||
*/
|
||
export type CsvForm = { trenner?: "," | ";"; bom?: boolean };
|
||
|
||
export function toCsv<T>(rows: T[], columns: ExportColumn<T>[], form: CsvForm = {}): string {
|
||
const trenner = form.trenner ?? ";";
|
||
const bom = form.bom ?? true;
|
||
const lines = [columns.map((c) => csvCell(c.header, trenner)).join(trenner)];
|
||
for (const row of rows) {
|
||
lines.push(columns.map((c) => csvCell(c.get(row), trenner)).join(trenner));
|
||
}
|
||
return (bom ? "" : "") + lines.join("\r\n");
|
||
}
|
||
|
||
// Anchored at UTC midnight, not local: ExcelJS converts a JS Date to an Excel
|
||
// serial straight off getTime() with no timezone adjustment, so a Date built
|
||
// at *local* midnight in a positive-offset zone (Vienna) lands on the previous
|
||
// day's serial and every date cell in the workbook renders one day early.
|
||
function parseIsoDate(value: string): Date | null {
|
||
const d = new Date(`${value}T00:00:00Z`);
|
||
return Number.isNaN(d.getTime()) ? null : d;
|
||
}
|
||
|
||
export async function toXlsx<T>(rows: T[], columns: ExportColumn<T>[], sheetName: string): Promise<Uint8Array> {
|
||
const workbook = new ExcelJS.Workbook();
|
||
const sheet = workbook.addWorksheet(sheetName.slice(0, 31));
|
||
|
||
// Keyed by position, not by header text: split columns take their header
|
||
// from the data (a team name, a weekday), so two columns can legitimately
|
||
// collide — and ExcelJS silently drops the second one when two share a key.
|
||
sheet.columns = columns.map((c, i) => ({
|
||
header: c.header,
|
||
key: String(i),
|
||
width: Math.min(40, Math.max(12, c.header.length + 4)),
|
||
style: c.kind === "date" ? { numFmt: "dd.mm.yyyy" } : undefined,
|
||
}));
|
||
sheet.getRow(1).font = { bold: true };
|
||
sheet.autoFilter = { from: { row: 1, column: 1 }, to: { row: 1, column: columns.length } };
|
||
sheet.views = [{ state: "frozen", ySplit: 1 }];
|
||
|
||
for (const row of rows) {
|
||
const record: Record<string, string | number | boolean | Date | null> = {};
|
||
for (const [i, c] of columns.entries()) {
|
||
const value = c.get(row);
|
||
record[String(i)] =
|
||
c.kind === "date" && typeof value === "string" && value
|
||
? (parseIsoDate(value) ?? value)
|
||
: typeof value === "string"
|
||
? sanitizeForSpreadsheetCell(value)
|
||
: value;
|
||
}
|
||
sheet.addRow(record);
|
||
}
|
||
|
||
const written = await workbook.xlsx.writeBuffer();
|
||
return new Uint8Array(written);
|
||
}
|
||
|
||
// The base carries values that originate in the query string (event type,
|
||
// measure, dimension) and ends up inside a Content-Disposition header, so it
|
||
// is reduced to a filename-safe slug here rather than trusted. Callers also
|
||
// validate those params; this is the backstop that makes header injection
|
||
// impossible regardless.
|
||
export function exportFilename(base: string, format: "csv" | "xlsx"): string {
|
||
const slug = base
|
||
.normalize("NFKD")
|
||
.replace(/[^a-zA-Z0-9._-]+/g, "-")
|
||
.replace(/^-+|-+$/g, "")
|
||
.slice(0, 80);
|
||
return `${slug || "export"}-${todayIso()}.${format}`;
|
||
}
|
||
|
||
export function exportResponseHeaders(filename: string, format: "csv" | "xlsx"): HeadersInit {
|
||
const contentType =
|
||
format === "xlsx" ? "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" : "text/csv; charset=utf-8";
|
||
return {
|
||
"Content-Type": contentType,
|
||
"Content-Disposition": `attachment; filename="${filename}"`,
|
||
};
|
||
}
|