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

127
lib/reports-data.ts Normal file
View File

@@ -0,0 +1,127 @@
import type { SupabaseClient } from "@supabase/supabase-js";
import { deriveStatusAsOf, EVENT_DATE_OPEN, parseStatuses, todayIso, type OrgLookups, type ReportEmployee, type ReportEvent } from "./reports";
import type { Database, EmploymentType, HistoryEventType } from "./supabase/types";
// Shared by the Berichte page and /api/export/* so they can never drift on
// what "the current view" means — same filters, same stichtag/event-window
// rules.
export type ReportFilters = {
division?: string;
location?: string;
status?: string;
employment?: string;
};
export type SnapshotFilters = ReportFilters & { asOf?: string };
export type EventFilters = { eventType?: HistoryEventType; division?: string; location?: string; from?: string; to?: string };
export async function loadOrgLookups(supabase: SupabaseClient<Database>): Promise<{
lookups: OrgLookups;
divisions: { id: string; name: string }[];
locations: { id: string; name: string }[];
}> {
const [{ data: divisions }, { data: departments }, { data: teams }, { data: locations }] = await Promise.all([
supabase.from("divisions").select("id, name").order("name"),
supabase.from("departments").select("id, name"),
supabase.from("teams").select("id, name, department_id"),
supabase.from("locations").select("id, name").order("name"),
]);
const departmentNameById = new Map((departments ?? []).map((d) => [d.id, d.name]));
return {
lookups: {
divisionName: new Map((divisions ?? []).map((d) => [d.id, d.name])),
departmentNameByTeam: new Map((teams ?? []).map((t) => [t.id, departmentNameById.get(t.department_id) ?? "Unbekannt"])),
teamName: new Map((teams ?? []).map((t) => [t.id, t.name])),
locationName: new Map((locations ?? []).map((l) => [l.id, l.name])),
},
divisions: divisions ?? [],
locations: locations ?? [],
};
}
const SNAPSHOT_EMPLOYEE_COLUMNS =
"id, first_name, last_name, job_title, division_id, team_id, location_id, employment_type, contract_type, entry_date, exit_date, weekly_hours, source, paygrade, birth_date, gender, karenz_start_date, karenz_return_date";
// Bestand zum Stichtag: reconstructs each employee's status as of `asOf`
// (defaults to today) from entry/exit/Karenz dates — see deriveStatusAsOf.
// division/team/location still reflect the employee's *current* assignment.
export async function loadSnapshotEmployees(supabase: SupabaseClient<Database>, filters: SnapshotFilters): Promise<ReportEmployee[]> {
const asOf = filters.asOf || todayIso();
let query = supabase.from("employees").select(SNAPSHOT_EMPLOYEE_COLUMNS);
if (filters.division) query = query.eq("division_id", filters.division);
if (filters.location) query = query.eq("location_id", filters.location);
if (filters.employment) query = query.eq("employment_type", filters.employment as EmploymentType);
const { data } = await query;
const withDerivedStatus: ReportEmployee[] = (data ?? []).map((e) => ({
id: e.id,
first_name: e.first_name,
last_name: e.last_name,
job_title: e.job_title,
division_id: e.division_id,
team_id: e.team_id,
location_id: e.location_id,
status: deriveStatusAsOf(e, asOf),
employment_type: e.employment_type,
contract_type: e.contract_type,
entry_date: e.entry_date,
exit_date: e.exit_date,
weekly_hours: e.weekly_hours,
source: e.source,
paygrade: e.paygrade,
birth_date: e.birth_date,
gender: e.gender,
}));
const statuses = parseStatuses(filters.status);
return withDerivedStatus.filter((e) => statuses.includes(e.status as (typeof statuses)[number]));
}
// Ereignisse: employee_history has no division_id/team_id of its own, so
// this joins in the affected employee's *current* org placement (two plain
// queries, merged in JS — the hand-written Database type has no relational
// embedding metadata for a single nested-select query).
//
// from/to: "" (unset) falls back to the current calendar year; the literal
// sentinel EVENT_DATE_OPEN means that side of the interval is intentionally
// unbounded (e.g. "alle Ereignisse bis heute", no start date).
export async function loadEventHistory(supabase: SupabaseClient<Database>, filters: EventFilters): Promise<ReportEvent[]> {
const currentYear = new Date().getFullYear();
const from = filters.from === EVENT_DATE_OPEN ? undefined : filters.from || `${currentYear}-01-01`;
const to = filters.to === EVENT_DATE_OPEN ? undefined : filters.to || `${currentYear}-12-31`;
let historyQuery = supabase.from("employee_history").select("employee_id, event_date, event_type, description");
if (from) historyQuery = historyQuery.gte("event_date", from);
if (to) historyQuery = historyQuery.lte("event_date", to);
if (filters.eventType) historyQuery = historyQuery.eq("event_type", filters.eventType);
const [{ data: history }, { data: employees }] = await Promise.all([
historyQuery,
supabase.from("employees").select("id, first_name, last_name, job_title, division_id, team_id, location_id"),
]);
const employeeById = new Map((employees ?? []).map((e) => [e.id, e]));
const events: ReportEvent[] = [];
for (const h of history ?? []) {
const emp = employeeById.get(h.employee_id);
if (!emp) continue;
if (filters.division && emp.division_id !== filters.division) continue;
if (filters.location && emp.location_id !== filters.location) continue;
events.push({
employee_id: emp.id,
first_name: emp.first_name,
last_name: emp.last_name,
job_title: emp.job_title,
division_id: emp.division_id,
team_id: emp.team_id,
location_id: emp.location_id,
event_date: h.event_date,
event_type: h.event_type,
description: h.description,
});
}
return events;
}