Files
alpenwerk-hr/lib/export.ts
Maximilian Stubhan 79f0e19bf8 Org assignment history, mobile support, and a correctness pass
Data model
- employee_assignments records org placement over time (valid_from/valid_to),
  written by a trigger on `employees` rather than inside each RPC: ~70
  `update employees` statements spread over fifteen migrations mean per-call
  bookkeeping would miss paths today and again with every future RPC. A
  partial unique index enforces the one-open-interval invariant the trigger
  relies on when closing the current row.
- The Organigramm gains a Stichtag (default today). Membership comes from
  entry/exit/karenz, past placement from the new history, future placement
  projected from pending_org_changes. Placements predating the migration are
  backfilled with today's values and flagged as such in the UI, since
  employee_history only ever stored free text and cannot be reconstructed.

Correctness
- Reports and exports silently truncated at PostgREST's 1000-row cap
  (db.max_rows); employee_history is already past it at ~800 staff. Every
  whole-table read now pages explicitly.
- XLSX date cells were a day early: ExcelJS converts a Date to an Excel
  serial straight off getTime(), so a Date built at local midnight lands on
  the previous day's serial in any positive-offset zone.
- Date handling is pinned to Europe/Vienna throughout, and date-only strings
  are formatted without a Date round-trip. The dashboard's YTD window was
  built by round-tripping a local Date through toISOString(), which shifted
  it a day early and dropped 31 December entirely.
- Export routes parsed measure/group/split/eventType with unchecked `as`
  casts, so an unknown value reached column headers as `undefined` and the
  Content-Disposition filename. Parsed against the label maps now, with the
  filename slugged as a backstop.
- toXlsx keyed columns by header text, silently dropping the second of any
  two columns sharing a name — split columns take their header from data.
- The org chart tree walks had no cycle guard; nothing in the schema forbids
  a manager_id cycle, and one would hang the tab rather than misreport.
- The login page reflected ?error= verbatim, letting anyone put arbitrary
  text on the real sign-in screen; messages are looked up by code now.
- React Flow needs elementsSelectable on, or it sets pointer-events:none on
  the whole node and the expand control stops responding.

UI
- Mobile: the shell was unusable below lg — a fixed 236px margin pushed
  content off-screen with no mobile navigation at all. The sidebar is now a
  drawer, dvh replaces vh, safe-area insets are honoured, inputs are 16px so
  iOS stops zooming on focus, and form grids stack.
- Org chart nodes redesigned: per-kind accent stripes and icons, vacant
  roles called out, expand control moved to the bottom edge carrying the
  child count.
- Pagination is windowed; it previously rendered one link per page (54 for
  the employee list, unbounded for the audit log).
- Positions page reduced to open positions with a single "Besetzen" action.
- The employee Organisation tab links into the org chart focused on that
  person, reusing the chart's existing search-match highlighting.

Also included, uncommitted until now
- Dependants, HR notes, academic titles, split address fields, position
  validity and role/employment fields, with their migrations and UI.
- Docker/compose deployment setup, data-model and security-review docs.
2026-07-24 23:38:10 +02:00

108 lines
4.7 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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): string {
if (value === null || value === undefined) return "";
const text = typeof value === "boolean" ? (value ? "Ja" : "Nein") : sanitizeForSpreadsheetCell(String(value));
return /[";\n\r]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
}
// Leading BOM + semicolon delimiter: Excel's German locale default, and what
// makes umlauts render correctly instead of mojibake on open.
export function toCsv<T>(rows: T[], columns: ExportColumn<T>[]): string {
const lines = [columns.map((c) => csvCell(c.header)).join(";")];
for (const row of rows) {
lines.push(columns.map((c) => csvCell(c.get(row))).join(";"));
}
return "" + 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}"`,
};
}