Files
alpenwerk-hr/app/api/export/employees/route.ts
Maximilian Stubhan b87c8ad64c
Some checks failed
CI / Lint, Typen, Tests, Build (push) Failing after 5m40s
CI / Migrationen auf leerer Datenbank (push) Has been cancelled
Remove Supabase
The database moved to a container of our own; the platform is gone.
This takes out what was left of it — and, where the leftovers were load
bearing, moves rather than deletes.

Moved, not deleted:

  supabase/migrations/  -> db/migrations/      the schema's source of truth
  supabase/build-org.ts -> scripts/build-org.ts
  lib/supabase/types.ts -> lib/types.ts        52 import sites repointed

The bookkeeping needed care. It lived in `supabase_migrations.schema_migrations`,
and simply renaming the schema would have left the runner facing an empty
table: it would have called all 67 migrations pending and replayed them
against a database that is long since current. So the runner now creates
`migrationen.schema_migrations` and, once, copies the old rows across —
guarded so a second run does nothing and a fresh database skips it entirely.
Only then does migration 20260907100000 drop the old schema.

Deleted: the CLI config, the seed, the historical schema/function dumps
(nothing read them), scripts/umzug-von-supabase.sh (the move is done), and
both Supabase packages plus the CLI. Nothing in the application imported
them — the build now succeeds with no environment variables at all, which
is the proof.

Integration tests: six of them signed in through Supabase Auth and asserted
against the anon key and the service role. That model is gone, so the tests
were not portable — they are deleted. session-context and
employee-status-filter already ran on pg and are untouched; om-reporting is
ported to a direct connection because it guards a real risk (the reporting
line rule exists twice, once in SQL and once in TypeScript).

CI: the integration job started a Supabase stack. It now runs a postgres
service, applies deploy/db-init and every migration to an empty database —
that was the valuable part, and it still holds — then checks that a second
run is a no-op, which is what proves the bookkeeping works.

Docs: security-review.md audited a service-role key, a cookie adapter and
auth.users, none of which exist. Restating findings about removed components
would suggest today's system had been reviewed; it has not. It now records
what was removed and says a fresh review is due. data-model.md was already
marked obsolete and described the pre-OM schema; azure-migration.md was a
plan for a route not taken. Both deleted.

Verified: npm ci, typecheck, lint, 445 tests, build — all clean without the
packages. Integration tests skip cleanly with no database. Migration SQL and
the runner are reviewed but NOT executed: no Docker here, and the old
instance no longer resolves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 10:43:22 +02:00

181 lines
9.5 KiB
TypeScript

import { NextResponse, type NextRequest } from "next/server";
import { statusLabel } from "@/lib/absence";
import { exportFilename, exportResponseHeaders, toCsv, toXlsx, type ExportColumn } from "@/lib/export";
import { fmtName, todayIso } from "@/lib/format";
import { subtreeOf } from "@/lib/org";
import { loadPlacements, loadReportingLineMap } from "@/lib/placement";
import { LEERE_CRITERIA, parseCriteria, passtImSpeicher } from "@/lib/report-criteria";
import { deriveStatusAsOf, parseIsoDateParam, parseStatuses, type OrgLookups } from "@/lib/reports";
import { applyCriteria, loadDependentsCounts, loadOrgLookups, type ReportFilters } from "@/lib/reports-data";
import { requireHrUser } from "@/lib/auth/require-hr";
import { withUser } from "@/lib/db";
import type { Database, Weekday } from "@/lib/types";
// Die Rohzeile plus die Einordnung, die nicht mehr auf ihr steht: sie kommt
// über die Planstelle und die abgeleitete Berichtslinie.
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"] & {
org_unit_id: string | null;
position_number: string | null;
is_chief: boolean;
manager_id: string | null;
};
// Full raw data dump — every column on `employees`, not just the fields a
// pivot report groups by. Respects the same division/location/status/
// employment filters as the Berichte page. With `asOf` (Stichtag), status
// filtering happens against the *derived* status as of that date rather
// than the live `status` column — see deriveStatusAsOf.
export async function GET(request: NextRequest) {
const gate = await requireHrUser();
if ("denied" in gate) return gate.denied;
const params = request.nextUrl.searchParams;
const format = params.get("format") === "xlsx" ? "xlsx" : "csv";
const asOf = parseIsoDateParam(params.get("asOf"));
const filters: ReportFilters = {
division: params.get("division") ?? undefined,
location: params.get("location") ?? undefined,
status: params.get("status") ?? undefined,
criteria: parseCriteria((k) => params.get(k)),
};
const statuses = parseStatuses(filters.status);
const stichtag = asOf ?? todayIso();
const { employees, lookups, orgMaps, allEmployees, dependentsCounts, placements, lines } = await withUser(
gate.userId,
async (tx) => {
function employeeQuery() {
let q = tx.selectFrom("employees").selectAll().orderBy("last_name").orderBy("id");
if (filters.location) q = q.where("location_id", "=", filters.location);
if (!asOf) q = q.where("status", "in", statuses);
// Dieselben Bedingungen wie im Bericht daneben — sonst stimmt die
// Zahl auf dem Bildschirm nicht mit der Zeilenzahl im Export überein.
return applyCriteria(q, filters.criteria ?? LEERE_CRITERIA);
}
const [employees, lookupResult, allEmployees, dependentsCounts, placements, lines] = await Promise.all([
employeeQuery().execute(),
loadOrgLookups(tx),
tx.selectFrom("employees").select(["id", "first_name", "last_name"]).orderBy("id").execute(),
loadDependentsCounts(tx),
loadPlacements(tx, { asOf: stichtag }),
loadReportingLineMap(tx, stichtag),
]);
return {
employees,
lookups: lookupResult.lookups,
orgMaps: lookupResult.orgMaps,
allEmployees,
dependentsCounts,
placements,
lines,
};
}
);
const managerName = new Map(allEmployees.map((e) => [e.id, fmtName(e.first_name, e.last_name)]));
// Der Einheitenfilter meint den ganzen Teilbaum — sonst enthielte ein
// Export für "Produktion" nur die Bereichsleitung.
const allowedUnits = filters.division ? new Set(subtreeOf(orgMaps, filters.division)) : null;
const criteria = filters.criteria ?? LEERE_CRITERIA;
const enriched: EmployeeRow[] = employees.flatMap((e) => {
const placement = placements.get(e.id);
const orgUnitId = placement?.current ? placement.orgUnitId : null;
if (allowedUnits && (!orgUnitId || !allowedUnits.has(orgUnitId))) return [];
if (!passtImSpeicher({ work_days: e.work_days, dependentsCount: dependentsCounts.get(e.id) ?? 0 }, criteria)) return [];
return [{
...e,
org_unit_id: orgUnitId,
position_number: placement?.positionNumber ?? null,
is_chief: placement?.isChief ?? false,
manager_id: lines.get(e.id)?.acting_manager_id ?? null,
}];
});
const rows = asOf ? enriched.filter((e) => statuses.includes(deriveStatusAsOf(e, asOf))) : enriched;
const columns = employeeExportColumns(lookups, managerName, dependentsCounts, asOf);
const filename = exportFilename("mitarbeiter-export", format);
const body = format === "xlsx" ? await toXlsx(rows, columns, "Mitarbeiter") : toCsv(rows, columns);
// TS 5.9's Uint8Array<ArrayBufferLike> vs DOM's BlobPart/ArrayBuffer<> generic
// mismatch (microsoft/TypeScript#59417) — a real Uint8Array works fine here.
return new NextResponse(new Blob([body as BlobPart]), { headers: exportResponseHeaders(filename, format) });
}
const WEEKDAY_ORDER: Weekday[] = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
function employeeExportColumns(
lookups: OrgLookups,
managerName: Map<string, string>,
dependentsCounts: Map<string, number>,
asOf?: string
): ExportColumn<EmployeeRow>[] {
const columns: ExportColumn<EmployeeRow>[] = [
{ header: "Pers.-Nr.", get: (e) => e.personnel_number },
{ header: "Vorname", get: (e) => e.first_name },
{ header: "Nachname", get: (e) => e.last_name },
{ header: "Geschlecht", get: (e) => (e.gender === "m" ? "männlich" : "weiblich") },
{ header: "Geburtsdatum", get: (e) => e.birth_date, kind: "date" },
{ header: "SV-Nummer", get: (e) => e.sv_nummer },
{ header: "Staatsbürgerschaft", get: (e) => e.nationality },
{ header: "Aufenthaltstitel", get: (e) => e.hat_aufenthaltstitel },
{ header: "Aufenthaltstitel bis", get: (e) => e.aufenthaltstitel_bis, kind: "date" },
{ header: "Adresse", get: (e) => e.address },
{ header: "Postleitzahl", get: (e) => e.postal_code },
{ header: "Ort", get: (e) => e.city },
{ header: "Wohnsitzland", get: (e) => e.address_country },
{ header: "Private E-Mail", get: (e) => e.email },
{ header: "Private Telefonnummer", get: (e) => e.phone },
{ header: "Bereich", get: (e) => (e.org_unit_id ? (lookups.divisionName.get(e.org_unit_id) ?? "") : "") },
{ header: "Abteilung", get: (e) => (e.org_unit_id ? (lookups.departmentName.get(e.org_unit_id) ?? "") : "") },
{ header: "Team", get: (e) => (e.org_unit_id ? (lookups.teamName.get(e.org_unit_id) ?? "") : "") },
{ header: "Standort", get: (e) => lookups.locationName.get(e.location_id) ?? "" },
{ header: "Position", get: (e) => e.job_title },
{ header: "Vorgesetzte:r", get: (e) => (e.manager_id ? (managerName.get(e.manager_id) ?? "") : "") },
{ header: "Planstelle", get: (e) => e.position_number },
{ header: "Leitungsplanstelle", get: (e) => e.is_chief },
{ header: "Beschäftigungsausmaß", get: (e) => e.employment_type },
{ header: "Wochenstunden", get: (e) => e.weekly_hours },
// work_days is stored in click order (see RoleEmploymentFields), not
// guaranteed chronological — re-sort Mo→So for the export.
{ header: "Arbeitstage", get: (e) => [...e.work_days].sort((a, b) => WEEKDAY_ORDER.indexOf(a as Weekday) - WEEKDAY_ORDER.indexOf(b as Weekday)).join(", ") },
{ header: "Vertragsart", get: (e) => e.contract_type },
{ header: "Befristet bis", get: (e) => e.contract_end_date, kind: "date" },
{ header: "Angestellte:r / Arbeiter:in", get: (e) => e.worker_type },
{ header: "Kollektivvertrag", get: (e) => e.collective_agreement },
{ header: "Betriebsrat", get: (e) => e.is_betriebsrat },
{ header: "Dienstwagen", get: (e) => e.has_dienstwagen },
{ header: "Antriebsart", get: (e) => e.dienstwagen_art ?? "" },
{ header: "Besonderer Kündigungsschutz", get: (e) => e.has_kuendigungsschutz },
{ header: "Kündigungsschutz bis", get: (e) => e.kuendigungsschutz_bis, kind: "date" },
{ header: "Teilzeitvariante", get: (e) => e.teilzeit_art ?? "" },
{ header: "Teilzeit bis", get: (e) => e.teilzeit_bis, kind: "date" },
{ header: "Notfallkontakt", get: (e) => e.emergency_contact_name ?? "" },
{ header: "Notfallkontakt Telefon", get: (e) => e.emergency_contact_phone ?? "" },
{ header: "Notfallkontakt Verhältnis", get: (e) => e.emergency_contact_relation ?? "" },
{ header: "Laterale Führung", get: (e) => e.is_laterale_fuehrung },
{ header: "C-Level", get: (e) => e.is_c_level },
{ header: "Paygrade", get: (e) => e.paygrade },
{ header: "Herkunft", get: (e) => e.source },
// The export shows the display name, not the raw enum value — a payroll
// hand-off saying "Karenz" for what the app calls Langzeitabwesenheit
// would just cause questions.
{ header: "Status", get: (e) => statusLabel(e.status) },
{ header: "Art der Langzeitabwesenheit", get: (e) => e.absence_type },
{ header: "Eintrittsdatum", get: (e) => e.entry_date, kind: "date" },
{ header: "Austrittsdatum", get: (e) => e.exit_date, kind: "date" },
{ header: "Austrittsgrund", get: (e) => e.exit_reason },
{ header: "Abwesenheit ab", get: (e) => e.karenz_start_date, kind: "date" },
{ header: "Rückkehr geplant", get: (e) => e.karenz_return_date, kind: "date" },
{ header: "Anzahl Angehörige", get: (e) => dependentsCounts.get(e.id) ?? 0 },
];
if (asOf) {
const statusIndex = columns.findIndex((c) => c.header === "Status");
columns.splice(statusIndex + 1, 0, { header: `Status zum Stichtag (${asOf})`, get: (e) => deriveStatusAsOf(e, asOf) });
}
return columns;
}