Talk to PostgreSQL directly, and let the pooled connection forget

Zweiter Schritt weg von Supabase. Sämtliche 49 Lesezugriffe und alle
Mutationen laufen jetzt über lib/db statt über die REST-Schicht: Kysely auf
einem pg-Pool, jede Abfrage in einer Transaktion, in der zuerst
app.user_id gesetzt wird. Die Anmeldung hängt noch an GoTrue — sie liefert
die Kennung, die in withUser() geht. Damit war der Umbau in zwei Hälften
teilbar und die Anwendung durchgehend lauffähig.

Was dabei ersatzlos verschwindet:

  - fetchAllRows. Es gab die Funktion nur, weil PostgREST jede Antwort bei
    1000 Zeilen still abschneidet und ein Bericht dann leise falsch war.
    Am direkten Zugang ist eine Abfrage eine Abfrage.
  - sanitizeIlikeTerm samt Test. Sie entschärfte Zeichen, die in der
    Filtersyntax strukturelle Bedeutung hatten; jetzt wird der Suchbegriff
    als Parameter gebunden und ein Komma ist ein Komma. Die Lücke ist nicht
    abgesichert, sondern weg.
  - lib/supabase/admin.ts. Der Dienstschlüssel, der RLS aushebelte, hatte
    genau einen Aufrufer — den nächtlichen Lauf. Der benutzt jetzt dieselbe
    Rolle ohne BYPASSRLS und ruft eine SECURITY-DEFINER-Funktion auf, die
    selbst prüft, was sie tut. Es gibt keinen privilegierten Zugang mehr.

Nebenbei besser geworden, weil der direkte Zugang es erlaubt:

  - Eine Seite ist eine Transaktion. Das Layout etwa liest Profil,
    Planstellen, Standorte, Entwürfe und Notizen auf einem einheitlichen
    Lesestand statt in fünf unabhängigen Anfragen.
  - Der Bereichsfilter der Mitarbeiterliste ist ein EXISTS statt einer
    eingebetteten Ressource mit !inner — eine Person mit mehreren
    Zuordnungen über die Zeit erschien dort mehrfach.
  - Seitenweise Listen sortieren zusätzlich nach id. Bei gleichem Nachnamen
    oder gleichem Zeitstempel war die Reihenfolge vorher unbestimmt, und
    dieselbe Zeile konnte auf zwei Seiten erscheinen oder auf keiner.
  - Angehörige werden in der Datenbank gezählt statt alle Zeilen zu holen.
  - Namen an Ereigniszeilen kommen aus einem Join statt aus einem
    Nachschlag, der ausserhalb der Transaktion lag.

Der Statusfilter ist mitgezogen: dieselbe Regel wie deriveStatusAsOf,
Klausel für Klausel, jetzt als Kysely-Ausdruck. Der Integrationstest, der
beide über den gesamten Bestand vergleicht, läuft weiter — mit eigener
Verbindung, denn geprüft wird die Bedingung, nicht die Berechtigung.

Zwei Fehler auf dem Weg, beide vom Typprüfer gefangen: apply_due_pending_
changes() nimmt kein Argument, wurde von callFunction aber mit jsonb
aufgerufen — Postgres hätte keine passende Signatur gefunden. Und der
Sicherheitstest lädt jetzt Module mit `import "server-only"`, was ausserhalb
der Server-Übersetzung wirft.

Typecheck, Lint, Build und 180 Tests sind grün. Ungeprüft bleibt der Lauf
gegen eine echte Datenbank — dafür fehlt eine DATABASE_URL.
This commit is contained in:
2026-07-31 08:45:26 +02:00
parent a66263a96e
commit b3a0af2b8f
31 changed files with 1086 additions and 803 deletions

26
lib/auth/require-hr.ts Normal file
View File

@@ -0,0 +1,26 @@
import "server-only";
import { NextResponse } from "next/server";
import { withUser } from "@/lib/db";
import { currentUserId } from "./session";
// Route Handlers under /api/export/* are outside the App Router layout tree,
// so app/(app)/layout.tsx's HR gate never runs for them — each one has to
// re-establish that the caller is an active HR user itself. RLS is still the
// real boundary (an unauthorized session simply reads nothing); this exists
// so those routes answer 401/403 instead of handing back an empty workbook.
export type HrGate = { denied: NextResponse } | { userId: string };
export async function requireHrUser(): Promise<HrGate> {
const userId = await currentUserId();
if (!userId) return { denied: NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 }) };
const profile = await withUser(userId, (tx) =>
tx.selectFrom("profiles").select(["role", "is_active"]).where("id", "=", userId).executeTakeFirst()
);
if (profile?.role !== "hr" || profile.is_active !== true) {
return { denied: NextResponse.json({ error: "Nicht berechtigt." }, { status: 403 }) };
}
return { userId };
}

34
lib/auth/session.ts Normal file
View File

@@ -0,0 +1,34 @@
import "server-only";
import { createClient } from "@/lib/supabase/server";
// Der einzige Ort, an dem die Kennung der angemeldeten Person herkommt.
//
// Heute liefert sie GoTrue, morgen Auth.js mit Entra ID. Weil alles andere
// nur noch `currentUserId()` aufruft und den Wert an withUser() weiterreicht,
// ist der Wechsel des Anmeldeverfahrens eine Änderung an dieser Datei — nicht
// an fünfzig Aufrufstellen.
//
// Dass das aufgeht, liegt an einer Eigenschaft des Übergangs: profiles.id ist
// heute die auth.users.id. Die Kennung, die hier herauskommt, passt also
// bereits auf das, was app_current_user_id() in der Datenbank erwartet.
export async function currentUserId(): Promise<string | null> {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
return user?.id ?? null;
}
/**
* Wie currentUserId(), bricht aber ab, statt null zu liefern.
*
* Für Stellen, die ohne angemeldete Person keinen Sinn ergeben. Die
* Absicherung hängt trotzdem nicht daran: ohne Kontext geben die
* RLS-Policies nichts zurück, unabhängig davon, was der Anwendungscode tut.
*/
export async function requireUserId(): Promise<string> {
const id = await currentUserId();
if (!id) throw new Error("Nicht angemeldet.");
return id;
}

56
lib/db/rpc.ts Normal file
View File

@@ -0,0 +1,56 @@
import "server-only";
import { sql, withUser, type Tx } from "./index";
import type { Database } from "@/lib/supabase/types";
// Aufruf einer Datenbankfunktion.
//
// Die Geschäftslogik liegt in PL/pgSQL — Eintritt, Versetzung, Austritt und
// die übrigen zehn Mutationen. Daran ändert der Wechsel des Zugriffswegs
// nichts: es fällt nur die API-Schicht dazwischen weg. Aufgerufen wird die
// Funktion jetzt unmittelbar, innerhalb der Transaktion, in der auch der
// Sitzungskontext gilt — ohne den würde require_hr_admin() darin abweisen.
export type MutationFn = keyof Database["public"]["Functions"];
/**
* Ruft `fn(payload)` innerhalb der laufenden Transaktion auf.
*
* `payload` weglassen für die Funktionen ohne Argument —
* apply_due_pending_changes() ist die einzige. Mit einem jsonb-Argument
* aufgerufen fände Postgres keine passende Signatur.
*/
export async function callFunction(tx: Tx, fn: MutationFn, payload?: Record<string, unknown>): Promise<unknown> {
// Der Funktionsname stammt aus einer geschlossenen Aufzählung, nie aus
// einer Eingabe — sonst wäre die Verkettung hier eine Einladung.
const name = sql.raw(`"${fn}"`);
const query =
payload === undefined
? sql<{ result: unknown }>`select ${name}() as result`
: sql<{ result: unknown }>`select ${name}(${sql.val(JSON.stringify(payload))}::jsonb) as result`;
const result = await query.execute(tx);
return result.rows[0]?.result;
}
export type ActionResult = { success: boolean; error?: string };
/**
* Eine Mutation im Namen der angemeldeten Person, mit der üblichen
* Fehlerbehandlung für Server Actions.
*
* Die Prüfung der Berechtigung passiert in der Funktion selbst
* (require_hr_admin) und unabhängig davon in den RLS-Policies — nicht hier.
*/
export async function runMutation(
userId: string | null,
fn: MutationFn,
payload: Record<string, unknown>
): Promise<ActionResult> {
try {
await withUser(userId, (tx) => callFunction(tx, fn, payload));
return { success: true };
} catch (err) {
// Die Meldungen der Funktionen sind für die Oberfläche geschrieben
// („Diese Planstelle ist bereits besetzt.") und werden durchgereicht.
return { success: false, error: err instanceof Error ? err.message : "Unbekannter Fehler." };
}
}

View File

@@ -1,3 +1,5 @@
import type { Expression, ExpressionBuilder, SqlBool } from "kysely";
import type { Schema } from "./db/schema";
import type { EmploymentStatus } from "./supabase/types";
// The SQL counterpart of deriveStatusAsOf() in lib/reports.ts.
@@ -19,58 +21,59 @@ import type { EmploymentStatus } from "./supabase/types";
// tests/integration/employee-status-filter.test.ts asserts the two agree
// against a real database, which is the only place that can prove it.
type Filterable = {
gt: (column: string, value: string) => Filterable;
lte: (column: string, value: string) => Filterable;
gte: (column: string, value: string) => Filterable;
or: (filters: string) => Filterable;
is: (column: string, value: null) => Filterable;
not: (column: string, operator: string, value: null) => Filterable;
};
type Eb = ExpressionBuilder<Schema, "employees">;
/** True once the person has started and has not left yet. */
function employed<Q extends Filterable>(query: Q, asOf: string): Q {
return query.lte("entry_date", asOf).or(`exit_date.is.null,exit_date.gt.${asOf}`) as Q;
function employed(eb: Eb, asOf: string): Expression<SqlBool> {
return eb.and([eb("entry_date", "<=", asOf), eb.or([eb("exit_date", "is", null), eb("exit_date", ">", asOf)])]);
}
/**
* Narrows a PostgREST query to the employees whose *derived* status on
* `asOf` is one of `statuses`. Only the combinations the UI offers are
* supported; anything else is left unfiltered rather than silently applying
* a wrong one.
* Die Bedingung für die Menge, deren *abgeleiteter* Status am Stichtag einer
* der genannten ist — oder null, wenn nicht eingeschränkt werden soll.
*
* Nur die Kombinationen, die die Oberfläche anbietet, sind abgedeckt. Für
* alles andere kommt null zurück: lieber nicht filtern als falsch filtern.
*/
export function applyDerivedStatusFilter<Q extends Filterable>(query: Q, statuses: EmploymentStatus[], asOf: string): Q {
export function derivedStatusFilter(eb: Eb, statuses: EmploymentStatus[], asOf: string): Expression<SqlBool> | null {
const wanted = new Set(statuses);
if (wanted.size === 0) return query;
if (wanted.size === 0) return null;
// A single non-employed status is a straight date comparison.
if (wanted.size === 1 && wanted.has("Geplant")) return query.gt("entry_date", asOf) as Q;
if (wanted.size === 1 && wanted.has("Ausgetreten")) return query.not("exit_date", "is", null).lte("exit_date", asOf) as Q;
if (wanted.size === 1 && wanted.has("Geplant")) return eb("entry_date", ">", asOf);
if (wanted.size === 1 && wanted.has("Ausgetreten")) {
return eb.and([eb("exit_date", "is not", null), eb("exit_date", "<=", asOf)]);
}
const wantsAktiv = wanted.has("Aktiv");
const wantsKarenz = wanted.has("Karenz");
if (wantsAktiv && wantsKarenz && wanted.size === 2) {
// Everyone employed today, whether or not they are on leave.
return employed(query, asOf);
}
// Everyone employed today, whether or not they are on leave.
if (wantsAktiv && wantsKarenz && wanted.size === 2) return employed(eb, asOf);
if (wantsKarenz && !wantsAktiv && wanted.size === 1) {
return employed(query, asOf)
.not("karenz_start_date", "is", null)
.lte("karenz_start_date", asOf)
.or(`karenz_return_date.is.null,karenz_return_date.gt.${asOf}`) as Q;
return eb.and([
employed(eb, asOf),
eb("karenz_start_date", "is not", null),
eb("karenz_start_date", "<=", asOf),
eb.or([eb("karenz_return_date", "is", null), eb("karenz_return_date", ">", asOf)]),
]);
}
if (wantsAktiv && !wantsKarenz && wanted.size === 1) {
// Employed but *not* inside a karenz window: either no start date, a
// start still ahead, or a return that has already happened.
return employed(query, asOf).or(
`karenz_start_date.is.null,karenz_start_date.gt.${asOf},karenz_return_date.lte.${asOf}`
) as Q;
return eb.and([
employed(eb, asOf),
eb.or([
eb("karenz_start_date", "is", null),
eb("karenz_start_date", ">", asOf),
eb("karenz_return_date", "<=", asOf),
]),
]);
}
// Mixed selections spanning employed and non-employed states have no UI
// path today; filtering on a guess would be worse than not filtering.
return query;
return null;
}

View File

@@ -1,26 +1,32 @@
import type { SupabaseClient } from "@supabase/supabase-js";
import { fetchAllRows } from "./supabase/query";
import type { Tx } from "./db";
import type { Database } from "./supabase/types";
export type OpenNote = Database["public"]["Tables"]["employee_notes"]["Row"] & {
employeeName: string;
};
// "Meine Notizen" (Topbar-Glocke): das geteilte, mitarbeiterübergreifende
// Postfach aller noch nicht erledigten HR-Notizen — unabhängig davon wer
// sie verfasst hat oder zu wem sie gehören (mit Nutzer abgestimmt). Zwei
// einfache Queries, in JS gemerged — gleiches Muster wie loadEventHistory
// in lib/reports-data.ts, da der handgeschriebene Database-Typ keine
// relationalen Embeddings für eine einzelne verschachtelte Query kennt.
export async function loadOpenNotes(supabase: SupabaseClient<Database>): Promise<OpenNote[]> {
const [{ data: notes }, employees] = await Promise.all([
supabase.from("employee_notes").select("*").eq("done", false).order("created_at", { ascending: false }),
fetchAllRows(() => supabase.from("employees").select("id, first_name, last_name").order("id")),
]);
// Meine Notizen" (Topbar-Glocke): das geteilte, mitarbeiterübergreifende
// Postfach aller noch nicht erledigten HR-Notizen — unabhängig davon, wer sie
// verfasst hat oder zu wem sie gehören (mit Nutzer abgestimmt).
//
// Früher zwei Abfragen, in JavaScript zusammengeführt, weil die API-Schicht
// für eine einzelne verschachtelte Abfrage keine Verknüpfung anbot. Am
// direkten Zugang ist es schlicht ein Join.
export async function loadOpenNotes(tx: Tx): Promise<OpenNote[]> {
const rows = await tx
.selectFrom("employee_notes as n")
.leftJoin("employees as e", "e.id", "n.employee_id")
.selectAll("n")
.select(["e.first_name", "e.last_name"])
.where("n.done", "=", false)
.orderBy("n.created_at", "desc")
.execute();
const employeeById = new Map(employees.map((e) => [e.id, e]));
return (notes ?? []).map((n) => {
const emp = employeeById.get(n.employee_id);
return { ...n, employeeName: emp ? `${emp.first_name} ${emp.last_name}` : "Unbekannt" };
return rows.map((row) => {
const { first_name, last_name, ...note } = row;
return {
...(note as Database["public"]["Tables"]["employee_notes"]["Row"]),
employeeName: first_name && last_name ? `${first_name} ${last_name}` : "Unbekannt",
};
});
}

View File

@@ -1,4 +1,4 @@
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Tx } from "./db";
import type { Database } from "./supabase/types";
// Die Organisation ist ein Baum, keine drei Tabellen mehr. Alles, was früher
@@ -31,13 +31,17 @@ export type OrgMaps = {
// Die Referenzdaten sind winzig (60 Einheiten, 5 Standorte) — sie werden
// ganz geladen und im Speicher verknüpft, statt je Zeile nachzuschlagen.
export async function loadOrgMaps(supabase: SupabaseClient<Database>): Promise<OrgMaps> {
const [{ data: units }, { data: locations }] = await Promise.all([
supabase.from("org_units").select("id, org_number, name, parent_id, unit_type").order("org_number"),
supabase.from("locations").select("*").order("name"),
export async function loadOrgMaps(tx: Tx): Promise<OrgMaps> {
const [units, locations] = await Promise.all([
tx
.selectFrom("org_units")
.select(["id", "org_number", "name", "parent_id", "unit_type"])
.orderBy("org_number")
.execute(),
tx.selectFrom("locations").selectAll().orderBy("name").execute(),
]);
return buildOrgMaps((units ?? []) as OrgUnit[], locations ?? []);
return buildOrgMaps(units as OrgUnit[], locations as Location[]);
}
/** Der reine Teil: aus den Zeilen den Baum bauen, ohne Datenbank. */

View File

@@ -1,9 +1,7 @@
import type { SupabaseClient } from "@supabase/supabase-js";
import type { OrgEmployee, OrgVacancy } from "@/components/orgchart/types";
import type { Tx } from "./db";
import { todayIso } from "./format";
import { resolveReportingLines, type OmHolder, type OmUnit } from "./om-reporting";
import { fetchAllRows } from "./supabase/query";
import type { Database } from "./supabase/types";
// Das Organigramm, wie es an einem Stichtag stand oder stehen wird.
//
@@ -56,55 +54,76 @@ type AssignmentRow = { employee_id: string; position_id: string };
type PendingRow = { employee_id: string; effective_date: string; payload: Record<string, unknown> };
export async function loadOrgAsOf(supabase: SupabaseClient<Database>, asOf: string): Promise<OrgAsOfResult> {
export async function loadOrgAsOf(tx: Tx, asOf: string): Promise<OrgAsOfResult> {
const today = todayIso();
// Ohne die 1000-Zeilen-Grenze der API-Schicht fällt das seitenweise
// Nachladen weg: sechs Abfragen, jede vollständig.
const [units, positions, assignments, employees, pending, earliest] = await Promise.all([
fetchAllRows(() => supabase.from("org_units").select("id, parent_id").order("id")),
fetchAllRows(() =>
supabase
.from("om_positions")
.select("id, position_number, org_unit_id, is_chief, jobs!inner(title)")
.lte("valid_from", asOf)
.or(`valid_to.is.null,valid_to.gt.${asOf}`)
.order("id")
),
fetchAllRows(() =>
supabase
.from("position_assignments")
.select("employee_id, position_id")
.lte("valid_from", asOf)
.or(`valid_to.is.null,valid_to.gt.${asOf}`)
.order("employee_id")
),
fetchAllRows(() =>
supabase
.from("employees")
.select("id, personnel_number, first_name, last_name, job_title, karenz_start_date, karenz_return_date, absence_type")
.order("id")
),
tx.selectFrom("org_units").select(["id", "parent_id"]).orderBy("id").execute(),
tx
.selectFrom("om_positions as p")
.innerJoin("jobs as j", "j.id", "p.job_id")
.select(["p.id", "p.position_number", "p.org_unit_id", "p.is_chief", "j.title"])
.where("p.valid_from", "<=", asOf)
.where((eb) => eb.or([eb("p.valid_to", "is", null), eb("p.valid_to", ">", asOf)]))
.orderBy("p.id")
.execute(),
tx
.selectFrom("position_assignments")
.select(["employee_id", "position_id"])
.where("valid_from", "<=", asOf)
.where((eb) => eb.or([eb("valid_to", "is", null), eb("valid_to", ">", asOf)]))
.orderBy("employee_id")
.execute(),
tx
.selectFrom("employees")
.select([
"id",
"personnel_number",
"first_name",
"last_name",
"job_title",
"karenz_start_date",
"karenz_return_date",
"absence_type",
])
.orderBy("id")
.execute(),
asOf > today
? fetchAllRows(() =>
supabase
.from("pending_org_changes")
.select("employee_id, effective_date, payload")
.eq("status", "pending")
.lte("effective_date", asOf)
.in("change_type", [...PLACEMENT_CHANGES])
.order("effective_date")
)
? tx
.selectFrom("pending_org_changes")
.select(["employee_id", "effective_date", "payload"])
.where("status", "=", "pending")
.where("effective_date", "<=", asOf)
.where("change_type", "in", [...PLACEMENT_CHANGES])
.orderBy("effective_date")
.execute()
: Promise.resolve([]),
supabase.from("position_assignments").select("valid_from").order("valid_from").limit(1).maybeSingle(),
tx.selectFrom("position_assignments").select("valid_from").orderBy("valid_from").limit(1).executeTakeFirst(),
]);
return resolveOrgSnapshot({
asOf,
units: units.map((u) => ({ id: u.id, parentId: u.parent_id })),
positions: positions as unknown as PositionRow[],
// Der Join liefert den Jobtitel flach; die reine Funktion erwartet ihn
// verschachtelt, weil sie so auch aus einem Testbestand gefüttert wird.
positions: positions.map((p) => ({
id: p.id,
position_number: p.position_number,
org_unit_id: p.org_unit_id,
is_chief: p.is_chief,
jobs: { title: p.title },
})),
assignments: assignments as AssignmentRow[],
employees: employees as EmployeeRow[],
pending: pending as PendingRow[],
historyStartsAt: earliest.data?.valid_from ?? null,
historyStartsAt: earliest?.valid_from ?? null,
});
}

View File

@@ -1,6 +1,4 @@
import type { SupabaseClient } from "@supabase/supabase-js";
import { fetchAllRows } from "./supabase/query";
import type { Database } from "./supabase/types";
import { sql, type Tx } from "./db";
// Wo jemand in der Organisation steht, steht nicht mehr auf der Person. Es
// ergibt sich aus der Planstelle, die sie zum Stichtag innehat:
@@ -24,30 +22,25 @@ export type Placement = {
current: boolean;
};
const SELECT =
"employee_id, valid_from, valid_to, om_positions!inner(id, position_number, org_unit_id, is_chief, jobs!inner(title))";
type Row = {
employee_id: string;
valid_from: string;
valid_to: string | null;
om_positions: {
id: string;
position_number: string;
org_unit_id: string;
is_chief: boolean;
jobs: { title: string };
};
position_id: string;
position_number: string;
org_unit_id: string;
is_chief: boolean;
job_title: string;
};
function toPlacement(row: Row, asOf: string): Placement {
return {
employeeId: row.employee_id,
positionId: row.om_positions.id,
positionNumber: row.om_positions.position_number,
orgUnitId: row.om_positions.org_unit_id,
isChief: row.om_positions.is_chief,
jobTitle: row.om_positions.jobs.title,
positionId: row.position_id,
positionNumber: row.position_number,
orgUnitId: row.org_unit_id,
isChief: row.is_chief,
jobTitle: row.job_title,
validFrom: row.valid_from,
validTo: row.valid_to,
current: row.valid_from <= asOf && (row.valid_to === null || row.valid_to > asOf),
@@ -76,17 +69,33 @@ export function pickPlacements(rows: Row[], asOf: string): Map<string, Placement
}
export async function loadPlacements(
supabase: SupabaseClient<Database>,
tx: Tx,
{ asOf, employeeIds }: { asOf: string; employeeIds?: string[] }
): Promise<Map<string, Placement>> {
if (employeeIds?.length === 0) return new Map();
const rows = await fetchAllRows(() => {
const q = supabase.from("position_assignments").select(SELECT).order("employee_id");
return employeeIds ? q.in("employee_id", employeeIds) : q;
});
// Ein Join statt einer eingebetteten Ressource. Und ohne die
// 1000-Zeilen-Grenze von PostgREST fällt das seitenweise Nachladen weg,
// das es dafür brauchte.
let q = tx
.selectFrom("position_assignments as pa")
.innerJoin("om_positions as p", "p.id", "pa.position_id")
.innerJoin("jobs as j", "j.id", "p.job_id")
.select([
"pa.employee_id",
"pa.valid_from",
"pa.valid_to",
"p.id as position_id",
"p.position_number",
"p.org_unit_id",
"p.is_chief",
"j.title as job_title",
])
.orderBy("pa.employee_id");
return pickPlacements(rows as unknown as Row[], asOf);
if (employeeIds) q = q.where("pa.employee_id", "in", employeeIds);
return pickPlacements((await q.execute()) as Row[], asOf);
}
// ── Abgeleitete Berichtslinie ──────────────────────────────────────
@@ -105,11 +114,30 @@ export type ReportingLine = {
acting_manager_id: string | null;
};
/**
* `filter` schränkt die Funktion selbst ein, nicht das Ergebnis im Speicher —
* bei der Detailseite wandern damit neun Zeilen über die Leitung statt
* achthundert.
*/
export async function loadReportingLines(
supabase: SupabaseClient<Database>,
asOf: string
): Promise<Map<string, ReportingLine>> {
const { data, error } = await supabase.rpc("om_reporting_lines", { p_as_of: asOf });
if (error) throw new Error(`Berichtslinie konnte nicht geladen werden: ${error.message}`);
return new Map(((data ?? []) as ReportingLine[]).map((l) => [l.employee_id, l]));
tx: Tx,
asOf: string,
filter?: { employeeId?: string; actingManagerId?: string }
): Promise<ReportingLine[]> {
const conditions = [sql`true`];
if (filter?.employeeId) conditions.push(sql`employee_id = ${filter.employeeId}::uuid`);
if (filter?.actingManagerId) conditions.push(sql`acting_manager_id = ${filter.actingManagerId}::uuid`);
const result = await sql<ReportingLine>`
select * from om_reporting_lines(${asOf}::date)
where ${sql.join(conditions, sql` and `)}
`.execute(tx);
return result.rows;
}
/** Wie loadReportingLines, aber als Karte über die Personen-Kennung. */
export async function loadReportingLineMap(tx: Tx, asOf: string): Promise<Map<string, ReportingLine>> {
const lines = await loadReportingLines(tx, asOf);
return new Map(lines.map((l) => [l.employee_id, l]));
}

View File

@@ -1,8 +1,6 @@
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Tx } from "./db";
import { todayIso } from "./format";
import { breadcrumbLabel, loadOrgMaps, type OrgMaps } from "./org";
import { fetchAllRows } from "./supabase/query";
import type { Database } from "./supabase/types";
// Eine offene Stelle ist keine eigene Sache mehr. Sie ist eine Planstelle
// ohne laufende Besetzung — Vakanz ist eine Eigenschaft der Planstelle, kein
@@ -23,16 +21,6 @@ export type OpenPositionResolved = {
vacantSince: string;
};
type PositionRow = {
id: string;
position_number: string;
org_unit_id: string;
is_chief: boolean;
valid_from: string;
jobs: { title: string };
position_assignments: { employee_id: string; valid_from: string; valid_to: string | null }[];
};
/**
* Wer eine unbesetzte Planstelle führen würde: die Leitung der eigenen
* Einheit, für eine Leitungsplanstelle die der übergeordneten — dieselbe
@@ -43,70 +31,89 @@ function managerUnitFor(maps: OrgMaps, orgUnitId: string, isChief: boolean): str
return maps.units.get(orgUnitId)?.parent_id ?? null;
}
export async function loadOpenPositions(supabase: SupabaseClient<Database>): Promise<OpenPositionResolved[]> {
export async function loadOpenPositions(tx: Tx): Promise<OpenPositionResolved[]> {
const asOf = todayIso();
const [orgMaps, positions] = await Promise.all([
loadOrgMaps(supabase),
fetchAllRows(() =>
supabase
.from("om_positions")
.select(
"id, position_number, org_unit_id, is_chief, valid_from, jobs!inner(title), position_assignments(employee_id, valid_from, valid_to)"
const [orgMaps, open] = await Promise.all([
loadOrgMaps(tx),
// Unbesetzt heisst: keine am Stichtag laufende Zuordnung. Als NOT EXISTS
// in der Datenbank statt als Filter über alle Planstellen im Speicher.
tx
.selectFrom("om_positions as p")
.innerJoin("jobs as j", "j.id", "p.job_id")
.select(["p.id", "p.position_number", "p.org_unit_id", "p.is_chief", "p.valid_from", "j.title"])
.where("p.valid_from", "<=", asOf)
.where((eb) => eb.or([eb("p.valid_to", "is", null), eb("p.valid_to", ">", asOf)]))
.where((eb) =>
eb.not(
eb.exists(
eb
.selectFrom("position_assignments as a")
.select("a.id")
.whereRef("a.position_id", "=", "p.id")
.where("a.valid_from", "<=", asOf)
.where((e2) => e2.or([e2("a.valid_to", "is", null), e2("a.valid_to", ">", asOf)]))
)
)
.lte("valid_from", asOf)
.or(`valid_to.is.null,valid_to.gt.${asOf}`)
.order("position_number")
),
)
.orderBy("p.position_number")
.execute(),
]);
const open = (positions as unknown as PositionRow[]).filter(
(p) => !p.position_assignments.some((a) => a.valid_from <= asOf && (a.valid_to === null || a.valid_to > asOf))
);
if (open.length === 0) return [];
// Die Leitung der zuständigen Einheit — genau die Planstellen, die als
// Leitung markiert und laufend besetzt sind.
const chiefUnitIds = Array.from(
new Set(open.map((p) => managerUnitFor(orgMaps, p.org_unit_id, p.is_chief)).filter((id): id is string => Boolean(id)))
);
const chiefs = chiefUnitIds.length
? ((await fetchAllRows(() =>
supabase
.from("om_positions")
.select("org_unit_id, position_assignments!inner(employees!inner(first_name, last_name), valid_to)")
.eq("is_chief", true)
.in("org_unit_id", chiefUnitIds)
.is("position_assignments.valid_to", null)
)) as unknown as {
org_unit_id: string;
position_assignments: { employees: { first_name: string; last_name: string } }[];
}[])
: [];
const positionIds = open.map((p) => p.id);
const chiefNameByUnit = new Map(
chiefs.flatMap((c) => {
const holder = c.position_assignments[0]?.employees;
return holder ? [[c.org_unit_id, `${holder.first_name} ${holder.last_name}`] as const] : [];
})
);
// Zwei Nachschläge: seit wann die Stelle leer steht, und wer sie führen
// würde.
const [ended, chiefs] = await Promise.all([
tx
.selectFrom("position_assignments")
.select(["position_id", "valid_to"])
.where("position_id", "in", positionIds)
.where("valid_to", "is not", null)
.execute(),
(async () => {
const chiefUnitIds = Array.from(
new Set(
open
.map((p) => managerUnitFor(orgMaps, p.org_unit_id, p.is_chief))
.filter((id): id is string => Boolean(id))
)
);
if (chiefUnitIds.length === 0) return [];
return tx
.selectFrom("om_positions as p")
.innerJoin("position_assignments as a", "a.position_id", "p.id")
.innerJoin("employees as e", "e.id", "a.employee_id")
.select(["p.org_unit_id", "e.first_name", "e.last_name"])
.where("p.is_chief", "=", true)
.where("p.valid_to", "is", null)
.where("a.valid_to", "is", null)
.where("p.org_unit_id", "in", chiefUnitIds)
.execute();
})(),
]);
const lastEndByPosition = new Map<string, string>();
for (const e of ended) {
const prev = lastEndByPosition.get(e.position_id);
if (e.valid_to && (!prev || e.valid_to > prev)) lastEndByPosition.set(e.position_id, e.valid_to);
}
const chiefNameByUnit = new Map(chiefs.map((c) => [c.org_unit_id, `${c.first_name} ${c.last_name}`]));
return open.map((p) => {
const ended = p.position_assignments
.map((a) => a.valid_to)
.filter((d): d is string => d !== null)
.sort();
const managerUnit = managerUnitFor(orgMaps, p.org_unit_id, p.is_chief);
return {
id: p.id,
position_number: p.position_number,
title: p.jobs.title,
title: p.title,
org_unit_id: p.org_unit_id,
is_chief: p.is_chief,
valid_from: p.valid_from,
managerName: managerUnit ? (chiefNameByUnit.get(managerUnit) ?? null) : null,
orgLabel: breadcrumbLabel(orgMaps, p.org_unit_id),
vacantSince: ended.at(-1) ?? p.valid_from,
vacantSince: lastEndByPosition.get(p.id) ?? p.valid_from,
};
});
}

View File

@@ -1,9 +1,8 @@
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Tx } from "./db";
import { ancestorsOf, loadOrgMaps, subtreeOf, type OrgMaps } from "./org";
import { loadPlacements } from "./placement";
import { deriveStatusAsOf, EVENT_DATE_OPEN, parseStatuses, todayIso, type OrgLookups, type ReportEmployee, type ReportEvent } from "./reports";
import { fetchAllRows } from "./supabase/query";
import type { Database, EmploymentType, HistoryEventType } from "./supabase/types";
import type { 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
@@ -40,13 +39,13 @@ export function lookupsFromOrgMaps(orgMaps: OrgMaps, locations: { id: string; na
return { divisionName, departmentName, teamName, locationName: new Map(locations.map((l) => [l.id, l.name])) };
}
export async function loadOrgLookups(supabase: SupabaseClient<Database>): Promise<{
export async function loadOrgLookups(tx: Tx): Promise<{
lookups: OrgLookups;
orgMaps: OrgMaps;
divisions: { id: string; name: string }[];
locations: { id: string; name: string }[];
}> {
const orgMaps = await loadOrgMaps(supabase);
const orgMaps = await loadOrgMaps(tx);
const locations = orgMaps.locationList.map((l) => ({ id: l.id, name: l.name }));
return {
@@ -60,37 +59,61 @@ export async function loadOrgLookups(supabase: SupabaseClient<Database>): Promis
};
}
const SNAPSHOT_EMPLOYEE_COLUMNS =
"id, first_name, last_name, job_title, location_id, employment_type, contract_type, entry_date, exit_date, weekly_hours, source, paygrade, birth_date, gender, karenz_start_date, karenz_return_date, worker_type, collective_agreement, work_days, is_betriebsrat, has_dienstwagen, is_laterale_fuehrung, is_c_level";
const SNAPSHOT_EMPLOYEE_COLUMNS = [
"id",
"first_name",
"last_name",
"job_title",
"location_id",
"employment_type",
"contract_type",
"entry_date",
"exit_date",
"weekly_hours",
"source",
"paygrade",
"birth_date",
"gender",
"karenz_start_date",
"karenz_return_date",
"worker_type",
"collective_agreement",
"work_days",
"is_betriebsrat",
"has_dienstwagen",
"is_laterale_fuehrung",
"is_c_level",
] as const;
// employee_id -> number of employee_dependents rows. Selects only the FK
// column (no dependent PII needed) since only per-employee counts feed the
// has_dependents/avg_dependents report dimensions; counted client-side
// since the Supabase JS client has no `count(*) group by employee_id`
// shorthand. Shared by the Bestand pivot and the full employees export.
export async function loadDependentsCounts(supabase: SupabaseClient<Database>): Promise<Map<string, number>> {
const rows = await fetchAllRows(() => supabase.from("employee_dependents").select("employee_id").order("employee_id"));
const counts = new Map<string, number>();
for (const d of rows) counts.set(d.employee_id, (counts.get(d.employee_id) ?? 0) + 1);
return counts;
// Anzahl der Angehörigen je Person. Nur der Fremdschlüssel wird gelesen —
// für die Berichtsdimensionen zählt die Anzahl, nicht wer es ist.
export async function loadDependentsCounts(tx: Tx): Promise<Map<string, number>> {
// Am direkten Zugang zählt die Datenbank, statt dass die Anwendung alle
// Zeilen holt und sie selbst durchgeht.
const rows = await tx
.selectFrom("employee_dependents")
.select(({ fn }) => ["employee_id", fn.countAll<string>().as("anzahl")])
.groupBy("employee_id")
.execute();
return new Map(rows.map((r) => [r.employee_id, Number(r.anzahl)]));
}
// Bestand zum Stichtag: Status *und* Einordnung werden auf `asOf` aufgelöst.
export async function loadSnapshotEmployees(supabase: SupabaseClient<Database>, filters: SnapshotFilters): Promise<ReportEmployee[]> {
export async function loadSnapshotEmployees(tx: Tx, filters: SnapshotFilters): Promise<ReportEmployee[]> {
const asOf = filters.asOf || todayIso();
function snapshotQuery() {
let query = supabase.from("employees").select(SNAPSHOT_EMPLOYEE_COLUMNS).order("id");
if (filters.location) query = query.eq("location_id", filters.location);
if (filters.employment) query = query.eq("employment_type", filters.employment as EmploymentType);
return query;
let q = tx.selectFrom("employees").select([...SNAPSHOT_EMPLOYEE_COLUMNS]).orderBy("id");
if (filters.location) q = q.where("location_id", "=", filters.location);
if (filters.employment) q = q.where("employment_type", "=", filters.employment as EmploymentType);
return q;
}
const [data, dependentsCounts, placements, orgMaps] = await Promise.all([
fetchAllRows(snapshotQuery),
loadDependentsCounts(supabase),
loadPlacements(supabase, { asOf }),
filters.division ? loadOrgMaps(supabase) : Promise.resolve(null),
snapshotQuery().execute(),
loadDependentsCounts(tx),
loadPlacements(tx, { asOf }),
filters.division ? loadOrgMaps(tx) : Promise.resolve(null),
]);
// Der Bereichsfilter meint den ganzen Teilbaum: „Produktion" schliesst
@@ -146,40 +169,38 @@ export async function loadSnapshotEmployees(supabase: SupabaseClient<Database>,
// 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[]> {
export async function loadEventHistory(tx: Tx, 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`;
function historyQuery() {
let query = supabase.from("employee_history").select("employee_id, event_date, event_type, description").order("id");
if (from) query = query.gte("event_date", from);
if (to) query = query.lte("event_date", to);
if (filters.eventType) query = query.eq("event_type", filters.eventType);
return query;
let q = tx
.selectFrom("employee_history")
.select(["employee_id", "event_date", "event_type", "description"])
.orderBy("id");
if (from) q = q.where("event_date", ">=", from);
if (to) q = q.where("event_date", "<=", to);
if (filters.eventType) q = q.where("event_type", "=", filters.eventType);
return q;
}
const [history, employees, assignments, orgMaps] = await Promise.all([
fetchAllRows(historyQuery),
fetchAllRows(() => supabase.from("employees").select("id, first_name, last_name, job_title, location_id").order("id")),
fetchAllRows(() =>
supabase
.from("position_assignments")
.select("employee_id, valid_from, valid_to, om_positions!inner(org_unit_id)")
.order("employee_id")
),
filters.division ? loadOrgMaps(supabase) : Promise.resolve(null),
historyQuery().execute(),
tx.selectFrom("employees").select(["id", "first_name", "last_name", "job_title", "location_id"]).orderBy("id").execute(),
tx
.selectFrom("position_assignments as a")
.innerJoin("om_positions as p", "p.id", "a.position_id")
.select(["a.employee_id", "a.valid_from", "a.valid_to", "p.org_unit_id"])
.orderBy("a.employee_id")
.execute(),
filters.division ? loadOrgMaps(tx) : Promise.resolve(null),
]);
const spans = new Map<string, { from: string; to: string | null; unitId: string }[]>();
for (const a of assignments as unknown as {
employee_id: string;
valid_from: string;
valid_to: string | null;
om_positions: { org_unit_id: string };
}[]) {
for (const a of assignments) {
const list = spans.get(a.employee_id) ?? [];
list.push({ from: a.valid_from, to: a.valid_to, unitId: a.om_positions.org_unit_id });
list.push({ from: a.valid_from, to: a.valid_to, unitId: a.org_unit_id });
spans.set(a.employee_id, list);
}
@@ -193,7 +214,7 @@ export async function loadEventHistory(supabase: SupabaseClient<Database>, filte
if (filters.location && emp.location_id !== filters.location) continue;
const unitId =
spans.get(h.employee_id)?.find((s) => s.from <= h.event_date && (s.to === null || s.to > h.event_date))?.unitId ?? null;
spans.get(h.employee_id)?.find((s2) => s2.from <= h.event_date && (s2.to === null || s2.to > h.event_date))?.unitId ?? null;
if (allowedUnits && (!unitId || !allowedUnits.has(unitId))) continue;
events.push({

View File

@@ -1,23 +0,0 @@
import "server-only";
import { createClient as createSupabaseClient } from "@supabase/supabase-js";
import type { Database } from "./types";
// Service-role client: bypasses RLS entirely. Server-only — never import this
// from a Client Component or anything bundled for the browser. The
// "server-only" import makes an accidental client-side import a build error
// instead of a runtime one.
export function createAdminClient() {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!supabaseUrl) {
throw new Error("Missing NEXT_PUBLIC_SUPABASE_URL");
}
if (!serviceRoleKey) {
throw new Error("Missing SUPABASE_SERVICE_ROLE_KEY");
}
return createSupabaseClient<Database>(supabaseUrl, serviceRoleKey, {
auth: { autoRefreshToken: false, persistSession: false },
});
}

View File

@@ -1,21 +0,0 @@
import type { SupabaseClient } from "@supabase/supabase-js";
import { NextResponse } from "next/server";
import type { Database } from "./types";
// Route Handlers under /api/export/* are outside the App Router layout tree,
// so app/(app)/layout.tsx's HR gate never runs for them — each one has to
// re-establish that the caller is an active HR user itself. RLS is still the
// real boundary (an unauthorized session simply reads nothing); this exists
// so those routes answer 401/403 instead of handing back an empty workbook.
export async function requireHrUser(supabase: SupabaseClient<Database>): Promise<NextResponse | null> {
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
const { data: profile } = await supabase.from("profiles").select("role, is_active").eq("id", user.id).maybeSingle();
if (profile?.role !== "hr" || profile.is_active !== true) {
return NextResponse.json({ error: "Nicht berechtigt." }, { status: 403 });
}
return null;
}

View File

@@ -1,33 +0,0 @@
// PostgREST's .or() filter syntax treats "," "(" and ")" as structural
// delimiters between conditions. A raw user-supplied search term containing
// them (e.g. from a search box or ?q= param) can break out of the intended
// column conditions and append arbitrary extra filters to the query. Strip
// them before interpolating — harmless for real name/title searches, which
// never legitimately contain them.
export function sanitizeIlikeTerm(term: string): string {
return term.replace(/[,()]/g, "");
}
// PostgREST caps every response at db.max_rows (1000, see
// supabase/config.toml) and does so *silently* — a query over ~800 employees
// or the employee_history log just stops returning rows, and a report or
// export built from it is quietly wrong rather than failing. Anything that
// aggregates a whole table has to page explicitly; anything that renders a
// bounded list (an employee page, the audit log) uses .range() directly and
// does not need this.
const PAGE_SIZE = 1000;
type PagedQuery<Row> = {
range: (from: number, to: number) => PromiseLike<{ data: Row[] | null; error: unknown }>;
};
export async function fetchAllRows<Row>(buildQuery: () => PagedQuery<Row>): Promise<Row[]> {
const rows: Row[] = [];
for (let page = 0; ; page++) {
const { data, error } = await buildQuery().range(page * PAGE_SIZE, (page + 1) * PAGE_SIZE - 1);
if (error || !data) break;
rows.push(...data);
if (data.length < PAGE_SIZE) break;
}
return rows;
}