Reports/Export builder (CSV/XLSX), plus a security fix pass

Adds the Berichte export pipeline (/api/export/{report,events,employees})
with shared CSV/XLSX writers in lib/export.ts and lib/reports-data.ts.

Security pass alongside it: sanitize .or() search terms against PostgREST
filter injection, sanitize spreadsheet cells against CSV/Excel formula
injection, stop leaking raw DB error messages to clients, harden the
service-role client with server-only, add baseline security headers, and
bump the vulnerable nested postcss via an override.
This commit is contained in:
2026-07-15 20:34:27 +02:00
parent 901c5c426e
commit f96773da0f
21 changed files with 2323 additions and 279 deletions

View File

@@ -1,12 +1,8 @@
export type Measure =
| "headcount"
| "fte"
| "hires"
| "exits"
| "parttime_rate"
| "avg_age"
| "avg_tenure"
| "female_share";
import type { EmploymentStatus, HistoryEventType } from "./supabase/types";
// ── Bestand (point-in-time snapshot) ──────────────────────────────
export type Measure = "headcount" | "fte" | "parttime_rate" | "avg_age" | "avg_tenure" | "female_share";
export type GroupDimension =
| "division"
@@ -23,8 +19,6 @@ export type GroupDimension =
export const MEASURE_LABELS: Record<Measure, string> = {
headcount: "Headcount",
fte: "FTE",
hires: "Eintritte",
exits: "Austritte",
parttime_rate: "Teilzeitquote",
avg_age: "Ø Alter",
avg_tenure: "Ø Zugehörigkeit",
@@ -45,7 +39,22 @@ export const GROUP_LABELS: Record<GroupDimension, string> = {
};
export const AVERAGE_MEASURES: Measure[] = ["parttime_rate", "avg_age", "avg_tenure", "female_share"];
export const DATE_SCOPED_MEASURES: Measure[] = ["hires", "exits"];
const SUM_MEASURES: Measure[] = ["headcount", "fte"];
export const STATUS_OPTIONS: EmploymentStatus[] = ["Aktiv", "Karenz", "Geplant", "Ausgetreten"];
export const DEFAULT_STATUSES: EmploymentStatus[] = ["Aktiv", "Karenz"];
// `status` filters travel through the URL/exports as a comma-joined list
// (e.g. "Aktiv,Karenz"); this is the one place that turns that string back
// into a validated status set, defaulting to Aktiv+Karenz when unset — used
// by both the Bestand pivot and the full data export so they can never
// silently disagree on which statuses "no filter" means.
export function parseStatuses(status: string | undefined): EmploymentStatus[] {
if (!status) return DEFAULT_STATUSES;
const requested = status.split(",");
const valid = STATUS_OPTIONS.filter((s) => requested.includes(s));
return valid.length > 0 ? valid : DEFAULT_STATUSES;
}
export type ReportEmployee = {
id: string;
@@ -74,17 +83,38 @@ export type OrgLookups = {
locationName: Map<string, string>;
};
function ageFromBirthDate(birthDate: string): number {
export function todayIso(): string {
return new Date().toISOString().slice(0, 10);
}
// Reconstructs status as of any date from the columns that actually carry a
// timeline (entry/exit/Karenz), rather than trusting `employees.status`,
// which only ever reflects *today*. Division/team/location still reflect the
// employee's *current* assignment — the schema has no history of org-unit
// changes over time, only free-text employee_history descriptions — so a
// stichtag report groups by today's org placement, not the placement as of
// that date. Documented in the UI rather than silently wrong.
export function deriveStatusAsOf(
e: { entry_date: string; exit_date: string | null; karenz_start_date: string | null; karenz_return_date: string | null },
asOf: string
): EmploymentStatus {
if (e.entry_date > asOf) return "Geplant";
if (e.exit_date && e.exit_date <= asOf) return "Ausgetreten";
if (e.karenz_start_date && e.karenz_start_date <= asOf && (!e.karenz_return_date || asOf < e.karenz_return_date)) return "Karenz";
return "Aktiv";
}
function ageAsOf(birthDate: string, asOf: string): number {
const d = new Date(birthDate);
const today = new Date();
let age = today.getFullYear() - d.getFullYear();
if (today.getMonth() < d.getMonth() || (today.getMonth() === d.getMonth() && today.getDate() < d.getDate())) age -= 1;
const ref = new Date(asOf);
let age = ref.getFullYear() - d.getFullYear();
if (ref.getMonth() < d.getMonth() || (ref.getMonth() === d.getMonth() && ref.getDate() < d.getDate())) age -= 1;
return age;
}
function tenureYears(entryDate: string, exitDate: string | null): number {
function tenureYearsAsOf(entryDate: string, exitDate: string | null, asOf: string): number {
const start = new Date(entryDate);
const end = exitDate ? new Date(exitDate) : new Date();
const end = exitDate && exitDate <= asOf ? new Date(exitDate) : new Date(asOf);
return Math.max(0, (end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24 * 365.25));
}
@@ -115,21 +145,19 @@ export function groupKeyFor(e: ReportEmployee, dim: GroupDimension, lookups: Org
}
}
export function measureValue(rows: ReportEmployee[], measure: Measure): number {
export function measureValue(rows: ReportEmployee[], measure: Measure, asOf: string = todayIso()): number {
if (rows.length === 0) return 0;
switch (measure) {
case "headcount":
case "hires":
case "exits":
return rows.length;
case "fte":
return rows.reduce((s, e) => s + e.weekly_hours / 38.5, 0);
case "parttime_rate":
return (rows.filter((e) => e.employment_type === "Teilzeit").length / rows.length) * 100;
case "avg_age":
return rows.reduce((s, e) => s + ageFromBirthDate(e.birth_date), 0) / rows.length;
return rows.reduce((s, e) => s + ageAsOf(e.birth_date, asOf), 0) / rows.length;
case "avg_tenure":
return rows.reduce((s, e) => s + tenureYears(e.entry_date, e.exit_date), 0) / rows.length;
return rows.reduce((s, e) => s + tenureYearsAsOf(e.entry_date, e.exit_date, asOf), 0) / rows.length;
case "female_share":
return (rows.filter((e) => e.gender === "w").length / rows.length) * 100;
default:
@@ -146,7 +174,8 @@ export function aggregateReport(
measure: Measure,
group: GroupDimension,
split: GroupDimension | null,
lookups: OrgLookups
lookups: OrgLookups,
asOf: string = todayIso()
): ReportRow[] {
const byGroup = new Map<string, ReportEmployee[]>();
for (const e of employees) {
@@ -157,7 +186,7 @@ export function aggregateReport(
const rows: ReportRow[] = [];
for (const [key, rowsForGroup] of byGroup) {
const value = measureValue(rowsForGroup, measure);
const value = measureValue(rowsForGroup, measure, asOf);
const people: ReportPerson[] = rowsForGroup.map((e) => ({
id: e.id,
name: `${e.first_name} ${e.last_name}`,
@@ -175,7 +204,7 @@ export function aggregateReport(
}
row.split = Array.from(bySplit.entries()).map(([sKey, sRows]) => ({
key: sKey,
value: measureValue(sRows, measure),
value: measureValue(sRows, measure, asOf),
count: sRows.length,
}));
}
@@ -184,11 +213,137 @@ export function aggregateReport(
return rows.sort((a, b) => b.value - a.value);
}
export function sumValues(rows: { value: number }[]): number {
return rows.reduce((s, r) => s + r.value, 0);
}
// headcount/fte sum across groups; averages/ratios are weighted by each
// group's underlying record count for a sensible overall figure.
export function totalForRows(rows: { value: number; count: number }[], measure: Measure): number {
if (SUM_MEASURES.includes(measure)) return sumValues(rows);
const totalCount = rows.reduce((s, r) => s + r.count, 0);
if (totalCount === 0) return 0;
return rows.reduce((s, r) => s + r.value * r.count, 0) / totalCount;
}
export const REPORT_PRESETS: { name: string; measure: Measure; group: GroupDimension; split?: GroupDimension }[] = [
{ name: "Headcount nach Bereich", measure: "headcount", group: "division" },
{ name: "Frauenanteil nach Bereich", measure: "female_share", group: "division" },
{ name: "Headcount nach Paygrade", measure: "headcount", group: "paygrade" },
{ name: "Teilzeitquote nach Standort", measure: "parttime_rate", group: "location" },
{ name: "Eintritte nach Bereich", measure: "hires", group: "division" },
{ name: "Austritte nach Abteilung", measure: "exits", group: "department" },
];
// ── Ereignisse (events over a period) ─────────────────────────────
// Backed by employee_history, the append-only log — unlike Bestand, this
// covers every event type (not just Eintritt/Austritt), survives an
// employee's entry_date being overwritten by a later rehire, and each event
// keeps its own date/description regardless of the employee's current state.
// Sentinel for "von"/"bis" — distinct from "" (unset, falls back to the
// current-year default) or a real date. Written to the URL/exports as the
// literal string "open".
export const EVENT_DATE_OPEN = "open";
export type EventGroupDimension = "event_type" | "division" | "department" | "team" | "location" | "event_year";
export const EVENT_GROUP_LABELS: Record<EventGroupDimension, string> = {
event_type: "Ereignistyp",
division: "Bereich",
department: "Abteilung",
team: "Team",
location: "Standort",
event_year: "Jahr",
};
export const EVENT_TYPE_LABELS: Record<HistoryEventType, string> = {
Eintritt: "Eintritt",
Beförderung: "Beförderung",
Versetzung: "Versetzung",
Karenz: "Karenz",
Vertragsänderung: "Vertragsänderung",
Stammdatenänderung: "Stammdatenänderung",
Austritt: "Austritt",
Wiedereintritt: "Wiedereintritt",
Reorganisation: "Reorganisation",
Gehaltsanpassung: "Gehaltsanpassung",
Rückkehr: "Rückkehr (Karenz)",
};
export type ReportEvent = {
employee_id: string;
first_name: string;
last_name: string;
job_title: string;
division_id: string;
team_id: string | null;
location_id: string;
event_date: string;
event_type: HistoryEventType;
description: string;
};
function eventGroupKeyFor(e: ReportEvent, dim: EventGroupDimension, lookups: OrgLookups): string {
switch (dim) {
case "event_type":
return EVENT_TYPE_LABELS[e.event_type] ?? e.event_type;
case "division":
return lookups.divisionName.get(e.division_id) ?? "Unbekannt";
case "department":
return e.team_id ? (lookups.departmentNameByTeam.get(e.team_id) ?? "Unbekannt") : "";
case "team":
return e.team_id ? (lookups.teamName.get(e.team_id) ?? "Unbekannt") : "";
case "location":
return lookups.locationName.get(e.location_id) ?? "Unbekannt";
case "event_year":
return String(new Date(e.event_date).getFullYear());
default:
return "Unbekannt";
}
}
export function aggregateEvents(
events: ReportEvent[],
group: EventGroupDimension,
split: EventGroupDimension | null,
lookups: OrgLookups
): ReportRow[] {
const byGroup = new Map<string, ReportEvent[]>();
for (const e of events) {
const key = eventGroupKeyFor(e, group, lookups);
if (!byGroup.has(key)) byGroup.set(key, []);
byGroup.get(key)!.push(e);
}
const rows: ReportRow[] = [];
for (const [key, rowsForGroup] of byGroup) {
// Repurposes ReportPerson for events: title -> event description,
// entry_date -> event_date. Keeps the existing drill-down UI/export
// code working unchanged for both report modes.
const people: ReportPerson[] = rowsForGroup.map((e) => ({
id: e.employee_id,
name: `${e.first_name} ${e.last_name}`,
title: e.description,
team: e.team_id ? (lookups.teamName.get(e.team_id) ?? "") : "",
entry_date: e.event_date,
}));
const row: ReportRow = { key, value: rowsForGroup.length, count: rowsForGroup.length, people };
if (split) {
const bySplit = new Map<string, ReportEvent[]>();
for (const e of rowsForGroup) {
const sKey = eventGroupKeyFor(e, split, lookups);
if (!bySplit.has(sKey)) bySplit.set(sKey, []);
bySplit.get(sKey)!.push(e);
}
row.split = Array.from(bySplit.entries()).map(([sKey, sRows]) => ({ key: sKey, value: sRows.length, count: sRows.length }));
}
rows.push(row);
}
return rows.sort((a, b) => b.value - a.value);
}
export const EVENT_REPORT_PRESETS: { name: string; group: EventGroupDimension; split?: EventGroupDimension; eventType?: HistoryEventType }[] = [
{ name: "Ereignisse nach Typ", group: "event_type" },
{ name: "Eintritte nach Bereich", group: "division", eventType: "Eintritt" },
{ name: "Austritte nach Abteilung", group: "department", eventType: "Austritt" },
{ name: "Beförderungen nach Bereich", group: "division", eventType: "Beförderung" },
];