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

View File

@@ -1,18 +1,16 @@
"use server"; "use server";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { createClient } from "@/lib/supabase/server"; import { currentUserId } from "@/lib/auth/session";
import type { CollectiveAgreement, Database, NoteCategory, RelationshipType, Weekday, WorkerType } from "@/lib/supabase/types"; import { withUser } from "@/lib/db";
import { callFunction, runMutation, type ActionResult, type MutationFn } from "@/lib/db/rpc";
type ActionResult = { success: boolean; error?: string }; import type { CollectiveAgreement, NoteCategory, RelationshipType, Weekday, WorkerType } from "@/lib/supabase/types";
type MutationFn = keyof Database["public"]["Functions"];
async function callRpc(fn: MutationFn, payload: Record<string, unknown>, revalidate: string[]): Promise<ActionResult> { async function callRpc(fn: MutationFn, payload: Record<string, unknown>, revalidate: string[]): Promise<ActionResult> {
const supabase = await createClient(); const result = await runMutation(await currentUserId(), fn, payload);
const { error } = await supabase.rpc(fn, { payload }); if (!result.success) return result;
if (error) return { success: false, error: error.message };
for (const path of revalidate) revalidatePath(path); for (const path of revalidate) revalidatePath(path);
return { success: true }; return result;
} }
export async function hireEmployee(payload: { export async function hireEmployee(payload: {
@@ -43,13 +41,19 @@ export async function hireEmployee(payload: {
is_laterale_fuehrung?: boolean; is_laterale_fuehrung?: boolean;
is_c_level?: boolean; is_c_level?: boolean;
}): Promise<ActionResult & { employeeId?: string }> { }): Promise<ActionResult & { employeeId?: string }> {
const supabase = await createClient(); // Einzige Mutation, deren Rückgabewert gebraucht wird: die neue
const { data, error } = await supabase.rpc("hire_employee", { payload }); // Personen-Kennung, damit die Oberfläche direkt auf die Akte springen kann.
if (error) return { success: false, error: error.message }; try {
const employeeId = await withUser(await currentUserId(), (tx) =>
callFunction(tx, "hire_employee", payload as Record<string, unknown>)
);
revalidatePath("/employees"); revalidatePath("/employees");
revalidatePath("/"); revalidatePath("/");
revalidatePath("/positions"); revalidatePath("/positions");
return { success: true, employeeId: data as string }; return { success: true, employeeId: employeeId as string };
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : "Unbekannter Fehler." };
}
} }
export async function terminateEmployee(payload: { export async function terminateEmployee(payload: {

View File

@@ -1,45 +1,52 @@
"use server"; "use server";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { createClient } from "@/lib/supabase/server"; import { currentUserId } from "@/lib/auth/session";
import { withUser } from "@/lib/db";
type ActionResult = { success: boolean; error?: string }; import type { ActionResult } from "@/lib/db/rpc";
export async function saveHireDraft(payload: { export async function saveHireDraft(payload: {
id?: string; id?: string;
step: number; step: number;
data: Record<string, unknown>; data: Record<string, unknown>;
}): Promise<ActionResult & { id?: string }> { }): Promise<ActionResult & { id?: string }> {
const supabase = await createClient(); const userId = await currentUserId();
const { if (!userId) return { success: false, error: "Nicht angemeldet." };
data: { user },
} = await supabase.auth.getUser();
if (!user) return { success: false, error: "Nicht angemeldet." };
try {
const id = await withUser(userId, async (tx) => {
if (payload.id) { if (payload.id) {
const { error } = await supabase // Ob die Zeile der aufrufenden Person gehört, entscheidet die
.from("hire_drafts") // Policy hire_drafts_owner — nicht eine Prüfung hier.
.update({ step: payload.step, payload: payload.data, updated_at: new Date().toISOString() }) await tx
.eq("id", payload.id); .updateTable("hire_drafts")
if (error) return { success: false, error: error.message }; .set({ step: payload.step, payload: payload.data, updated_at: new Date().toISOString() })
revalidatePath("/"); .where("id", "=", payload.id)
return { success: true, id: payload.id }; .execute();
return payload.id;
} }
const { data, error } = await supabase const row = await tx
.from("hire_drafts") .insertInto("hire_drafts")
.insert({ created_by: user.id, step: payload.step, payload: payload.data }) .values({ created_by: userId, step: payload.step, payload: payload.data })
.select("id") .returning("id")
.single(); .executeTakeFirstOrThrow();
if (error) return { success: false, error: error.message }; return row.id;
});
revalidatePath("/"); revalidatePath("/");
return { success: true, id: data.id }; return { success: true, id };
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : "Unbekannter Fehler." };
}
} }
export async function deleteHireDraft(id: string): Promise<ActionResult> { export async function deleteHireDraft(id: string): Promise<ActionResult> {
const supabase = await createClient(); try {
const { error } = await supabase.from("hire_drafts").delete().eq("id", id); await withUser(await currentUserId(), (tx) => tx.deleteFrom("hire_drafts").where("id", "=", id).execute());
if (error) return { success: false, error: error.message };
revalidatePath("/"); revalidatePath("/");
return { success: true }; return { success: true };
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : "Unbekannter Fehler." };
}
} }

View File

@@ -1,9 +1,8 @@
"use server"; "use server";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { createClient } from "@/lib/supabase/server"; import { currentUserId } from "@/lib/auth/session";
import { runMutation, type ActionResult } from "@/lib/db/rpc";
type ActionResult = { success: boolean; error?: string };
const POSITION_PATHS = ["/positions", "/orgchart", "/"]; const POSITION_PATHS = ["/positions", "/orgchart", "/"];
@@ -12,11 +11,10 @@ async function callRpc(
payload: Record<string, unknown>, payload: Record<string, unknown>,
revalidate: string[] revalidate: string[]
): Promise<ActionResult> { ): Promise<ActionResult> {
const supabase = await createClient(); const result = await runMutation(await currentUserId(), fn, payload);
const { error } = await supabase.rpc(fn, { payload }); if (!result.success) return result;
if (error) return { success: false, error: error.message };
for (const path of revalidate) revalidatePath(path); for (const path of revalidate) revalidatePath(path);
return { success: true }; return result;
} }
export async function createPosition(payload: { export async function createPosition(payload: {

View File

@@ -1,27 +1,31 @@
"use server"; "use server";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { createClient } from "@/lib/supabase/server"; import { currentUserId } from "@/lib/auth/session";
import { withUser } from "@/lib/db";
type ActionResult = { success: boolean; error?: string }; import type { ActionResult } from "@/lib/db/rpc";
export async function saveReport(payload: { name: string; config: Record<string, unknown> }): Promise<ActionResult> { export async function saveReport(payload: { name: string; config: Record<string, unknown> }): Promise<ActionResult> {
const supabase = await createClient(); const userId = await currentUserId();
const { if (!userId) return { success: false, error: "Nicht angemeldet." };
data: { user },
} = await supabase.auth.getUser();
if (!user) return { success: false, error: "Nicht angemeldet." };
const { error } = await supabase.from("saved_reports").insert({ created_by: user.id, name: payload.name, config: payload.config }); try {
if (error) return { success: false, error: error.message }; await withUser(userId, (tx) =>
tx.insertInto("saved_reports").values({ created_by: userId, name: payload.name, config: payload.config }).execute()
);
revalidatePath("/reports"); revalidatePath("/reports");
return { success: true }; return { success: true };
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : "Unbekannter Fehler." };
}
} }
export async function deleteReport(id: string): Promise<ActionResult> { export async function deleteReport(id: string): Promise<ActionResult> {
const supabase = await createClient(); try {
const { error } = await supabase.from("saved_reports").delete().eq("id", id); await withUser(await currentUserId(), (tx) => tx.deleteFrom("saved_reports").where("id", "=", id).execute());
if (error) return { success: false, error: error.message };
revalidatePath("/reports"); revalidatePath("/reports");
return { success: true }; return { success: true };
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : "Unbekannter Fehler." };
}
} }

View File

@@ -4,8 +4,8 @@ import { AuditFilters } from "@/components/audit/AuditFilters";
import { CARD_CLASS } from "@/components/ui/Card"; import { CARD_CLASS } from "@/components/ui/Card";
import { Pagination } from "@/components/ui/Pagination"; import { Pagination } from "@/components/ui/Pagination";
import { actionBadgeStyle } from "@/lib/colors"; import { actionBadgeStyle } from "@/lib/colors";
import { sanitizeIlikeTerm } from "@/lib/supabase/query"; import { currentUserId } from "@/lib/auth/session";
import { createClient } from "@/lib/supabase/server"; import { withUser } from "@/lib/db";
const PAGE_SIZE = 25; const PAGE_SIZE = 25;
@@ -34,33 +34,50 @@ const dateTimeFormatter = new Intl.DateTimeFormat("de-AT", {
export default async function AuditPage({ searchParams }: { searchParams: Promise<SearchParams> }) { export default async function AuditPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
const params = await searchParams; const params = await searchParams;
const supabase = await createClient();
const page = Math.max(1, Number(params.page ?? "1") || 1); const page = Math.max(1, Number(params.page ?? "1") || 1);
const from = (page - 1) * PAGE_SIZE;
const to = from + PAGE_SIZE - 1;
let query = supabase const { entries, count } = await withUser(await currentUserId(), async (tx) => {
.from("audit_log") const base = () => {
.select("id, occurred_at, actor_name, action, target_label, target_employee_id, details", { count: "exact" }) let q = tx.selectFrom("audit_log");
.order("occurred_at", { ascending: false }) if (params.action) q = q.where("action", "=", params.action);
.range(from, to);
if (params.action) query = query.eq("action", params.action);
if (params.q) { if (params.q) {
const q = sanitizeIlikeTerm(params.q.trim()); // Als Parameter gebunden statt in die Abfrage geschrieben: die
query = query.or(`target_label.ilike.%${q}%,details.ilike.%${q}%,actor_name.ilike.%${q}%`); // Zeichen, die in der alten Filtersyntax ausbrechen konnten, haben
// hier keine Bedeutung mehr.
const like = `%${params.q.trim()}%`;
q = q.where((eb) =>
eb.or([eb("target_label", "ilike", like), eb("details", "ilike", like), eb("actor_name", "ilike", like)])
);
} }
return q;
};
const { data: entries, count } = await query; const [entries, total] = await Promise.all([
const totalPages = Math.max(1, Math.ceil((count ?? 0) / PAGE_SIZE)); base()
.select(["id", "occurred_at", "actor_name", "action", "target_label", "target_employee_id", "details"])
// Nach id als zweitem Kriterium: bei gleichem Zeitstempel wäre die
// Reihenfolge sonst unbestimmt und ein Eintrag könnte auf zwei Seiten
// erscheinen oder auf keiner.
.orderBy("occurred_at", "desc")
.orderBy("id", "desc")
.limit(PAGE_SIZE)
.offset((page - 1) * PAGE_SIZE)
.execute(),
base()
.select(({ fn }) => fn.countAll<string>().as("anzahl"))
.executeTakeFirst(),
]);
return { entries, count: Number(total?.anzahl ?? 0) };
});
const totalPages = Math.max(1, Math.ceil(count / PAGE_SIZE));
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<Suspense> <Suspense>
<AuditFilters /> <AuditFilters />
</Suspense> </Suspense>
<p className="text-sm text-ink-muted">{count ?? 0} Einträge</p> <p className="text-sm text-ink-muted">{count} Einträge</p>
<div className={`overflow-x-auto ${CARD_CLASS}`}> <div className={`overflow-x-auto ${CARD_CLASS}`}>
<table className="w-full min-w-[800px] text-sm"> <table className="w-full min-w-[800px] text-sm">
@@ -74,7 +91,7 @@ export default async function AuditPage({ searchParams }: { searchParams: Promis
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{(entries ?? []).map((entry) => { {entries.map((entry) => {
return ( return (
<tr key={entry.id} className="border-b border-border-subtle transition-colors last:border-0 hover:bg-brand-50"> <tr key={entry.id} className="border-b border-border-subtle transition-colors last:border-0 hover:bg-brand-50">
<td className="whitespace-nowrap px-4 py-2.5 tabular-nums text-ink-body"> <td className="whitespace-nowrap px-4 py-2.5 tabular-nums text-ink-body">
@@ -102,7 +119,7 @@ export default async function AuditPage({ searchParams }: { searchParams: Promis
</tr> </tr>
); );
})} })}
{(entries ?? []).length === 0 && ( {entries.length === 0 && (
<tr> <tr>
<td colSpan={5} className="px-4 py-8 text-center text-sm text-ink-muted"> <td colSpan={5} className="px-4 py-8 text-center text-sm text-ink-muted">
Keine Einträge gefunden. Keine Einträge gefunden.

View File

@@ -1,52 +1,44 @@
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { EmployeeDetail } from "@/components/employees/EmployeeDetail"; import { EmployeeDetail } from "@/components/employees/EmployeeDetail";
import { currentUserId } from "@/lib/auth/session";
import { withUser } from "@/lib/db";
import { todayIso } from "@/lib/format"; import { todayIso } from "@/lib/format";
import { breadcrumbLabel, loadOrgMaps } from "@/lib/org"; import { breadcrumbLabel, loadOrgMaps } from "@/lib/org";
import { loadPlacements, type ReportingLine } from "@/lib/placement"; import { loadPlacements, loadReportingLines } from "@/lib/placement";
import { loadOpenPositions } from "@/lib/positions"; import { loadOpenPositions } from "@/lib/positions";
import { createClient } from "@/lib/supabase/server";
type PageProps = { params: Promise<{ id: string }> }; type PageProps = { params: Promise<{ id: string }> };
export default async function EmployeeDetailPage({ params }: PageProps) { export default async function EmployeeDetailPage({ params }: PageProps) {
const { id } = await params; const { id } = await params;
const supabase = await createClient();
const today = todayIso(); const today = todayIso();
// Vorgesetzte und direkte Berichte stehen nirgends als Spalte — sie kommen const data = await withUser(await currentUserId(), async (tx) => {
// aus om_reporting_lines(). Beide Abfragen filtern *in* der Funktion, es // Vorgesetzte und direkte Berichte stehen nirgends als Spalte — sie
// wandern also neun Zeilen über die Leitung und nicht achthundert. // kommen aus om_reporting_lines(). Beide Abfragen schränken *in* der
const [ // Funktion ein, es wandern also neun Zeilen über die Leitung und nicht
{ data: employee }, // achthundert.
{ data: ownLine }, const [employee, ownLines, reports, history, dependents, notes, orgMaps, placements, openPositions] =
{ data: reportLines }, await Promise.all([
{ data: history }, tx.selectFrom("employees").selectAll().where("id", "=", id).executeTakeFirst(),
{ data: dependents }, loadReportingLines(tx, today, { employeeId: id }),
{ data: notes }, loadReportingLines(tx, today, { actingManagerId: id }),
orgMaps, tx
placements, .selectFrom("employee_history")
openPositions, .selectAll()
] = await Promise.all([ .where("employee_id", "=", id)
supabase.from("employees").select("*").eq("id", id).single(), .orderBy("event_date", "desc")
supabase.rpc("om_reporting_lines", { p_as_of: today }).eq("employee_id", id).maybeSingle(), .orderBy("created_at", "desc")
supabase.rpc("om_reporting_lines", { p_as_of: today }).eq("acting_manager_id", id), .execute(),
supabase tx.selectFrom("employee_dependents").selectAll().where("employee_id", "=", id).orderBy("created_at").execute(),
.from("employee_history") tx.selectFrom("employee_notes").selectAll().where("employee_id", "=", id).orderBy("created_at", "desc").execute(),
.select("*") loadOrgMaps(tx),
.eq("employee_id", id) loadPlacements(tx, { asOf: today, employeeIds: [id] }),
.order("event_date", { ascending: false }) loadOpenPositions(tx),
.order("created_at", { ascending: false }),
supabase.from("employee_dependents").select("*").eq("employee_id", id).order("created_at"),
supabase.from("employee_notes").select("*").eq("employee_id", id).order("created_at", { ascending: false }),
loadOrgMaps(supabase),
loadPlacements(supabase, { asOf: today, employeeIds: [id] }),
loadOpenPositions(supabase),
]); ]);
if (!employee) notFound(); if (!employee) return null;
const line = ownLines[0] ?? null;
const line = ownLine as ReportingLine | null;
const reports = (reportLines ?? []) as ReportingLine[];
// Namen für die beteiligten Personen in einem Zug: die Vertretung, die // Namen für die beteiligten Personen in einem Zug: die Vertretung, die
// formal zuständige Leitung und die direkten Berichte. // formal zuständige Leitung und die direkten Berichte.
@@ -57,12 +49,30 @@ export default async function EmployeeDetailPage({ params }: PageProps) {
) )
) )
); );
const { data: relatedRows } = relatedIds.length const relatedRows = relatedIds.length
? await supabase.from("employees").select("id, first_name, last_name, job_title, status").in("id", relatedIds) ? await tx
: { data: [] }; .selectFrom("employees")
const byId = new Map((relatedRows ?? []).map((e) => [e.id, e])); .select(["id", "first_name", "last_name", "job_title", "status"])
.where("id", "in", relatedIds)
.execute()
: [];
const placement = placements.get(id) ?? null; return {
employee,
line,
reports,
history,
dependents,
notes,
orgMaps,
placement: placements.get(id) ?? null,
openPositions,
byId: new Map(relatedRows.map((e) => [e.id, e])),
};
});
if (!data) notFound();
const { employee, line, reports, history, dependents, notes, orgMaps, placement, openPositions, byId } = data;
return ( return (
<EmployeeDetail <EmployeeDetail

View File

@@ -5,30 +5,16 @@ import { Avatar } from "@/components/ui/Avatar";
import { CARD_CLASS } from "@/components/ui/Card"; import { CARD_CLASS } from "@/components/ui/Card";
import { Pagination } from "@/components/ui/Pagination"; import { Pagination } from "@/components/ui/Pagination";
import { StatusChip } from "@/components/ui/StatusChip"; import { StatusChip } from "@/components/ui/StatusChip";
import { applyDerivedStatusFilter } from "@/lib/employee-status-filter"; import { currentUserId } from "@/lib/auth/session";
import { withUser } from "@/lib/db";
import { derivedStatusFilter } from "@/lib/employee-status-filter";
import { fmtDate, todayIso } from "@/lib/format"; import { fmtDate, todayIso } from "@/lib/format";
import { loadPlacements } from "@/lib/placement";
import { breadcrumbLabel, divisionOf, loadOrgMaps, subtreeOf, unitOf } from "@/lib/org"; import { breadcrumbLabel, divisionOf, loadOrgMaps, subtreeOf, unitOf } from "@/lib/org";
import { sanitizeIlikeTerm } from "@/lib/supabase/query"; import { loadPlacements } from "@/lib/placement";
import { createClient } from "@/lib/supabase/server";
import type { EmploymentStatus } from "@/lib/supabase/types"; import type { EmploymentStatus } from "@/lib/supabase/types";
const PAGE_SIZE = 15; const PAGE_SIZE = 15;
const COLUMNS =
"id, first_name, last_name, personnel_number, job_title, location_id, entry_date, employment_type, weekly_hours, status, absence_type";
// Was applyFilters vom Query-Builder braucht — mehr nicht.
type Narrowable = {
eq: (column: string, value: string | number) => Narrowable;
or: (filters: string) => Narrowable;
gt: (column: string, value: string) => Narrowable;
lte: (column: string, value: string) => Narrowable;
gte: (column: string, value: string) => Narrowable;
is: (column: string, value: null) => Narrowable;
not: (column: string, operator: string, value: null) => Narrowable;
};
type SearchParams = { q?: string; division?: string; status?: string; location?: string; page?: string }; type SearchParams = { q?: string; division?: string; status?: string; location?: string; page?: string };
type EmployeesPageProps = { type EmployeesPageProps = {
@@ -47,25 +33,9 @@ function pageHref(params: SearchParams, page: number): string {
export default async function EmployeesPage({ searchParams }: EmployeesPageProps) { export default async function EmployeesPage({ searchParams }: EmployeesPageProps) {
const params = await searchParams; const params = await searchParams;
const supabase = await createClient();
const page = Math.max(1, Number(params.page ?? "1") || 1); const page = Math.max(1, Number(params.page ?? "1") || 1);
const from = (page - 1) * PAGE_SIZE;
const to = from + PAGE_SIZE - 1;
const today = todayIso(); const today = todayIso();
// Die Referenzdaten kommen zuerst, weil der Bereichsfilter den Teilbaum
// braucht: „Produktion" meint die Abteilungen und Teams darunter, nicht die
// Einheit selbst — dort sitzt nur die Bereichsleitung.
const orgMaps = await loadOrgMaps(supabase);
// Nach Organisationseinheit gefiltert wird über die laufende Besetzung.
// `!inner` macht aus der Einbettung einen echten Join, sodass die Bedingung
// die Person aus dem Ergebnis nimmt statt bloss ihre eingebettete Liste zu
// leeren. Die Einbettung ändert die Form der Zeile, deshalb steht sie im
// Select und nicht in einem nachträglichen Filter.
const unitFilter = params.division && orgMaps.units.has(params.division) ? params.division : null;
// Comma-separated, so a dashboard tile can link here with the same // Comma-separated, so a dashboard tile can link here with the same
// status set it counted rather than a narrower one. // status set it counted rather than a narrower one.
const statuses = (params.status ?? "") const statuses = (params.status ?? "")
@@ -73,47 +43,100 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps
.map((s) => s.trim()) .map((s) => s.trim())
.filter((s): s is EmploymentStatus => (["Aktiv", "Karenz", "Geplant", "Ausgetreten"] as const).includes(s as EmploymentStatus)); .filter((s): s is EmploymentStatus => (["Aktiv", "Karenz", "Geplant", "Ausgetreten"] as const).includes(s as EmploymentStatus));
// Strukturell typisiert und generisch über den Builder, damit die beiden const { orgMaps, employees, count, placements } = await withUser(await currentUserId(), async (tx) => {
// Select-Formen unten ihre Zeilenform behalten. Ein bedingt // Die Referenzdaten zuerst: der Bereichsfilter braucht den Teilbaum.
// zusammengesetzter Select-String wird zu einer Union zweier Literale, die // „Produktion" meint die Abteilungen und Teams darunter — in der Einheit
// der Typparser von postgrest-js nicht mehr auflösen kann — daher zwei // selbst sitzt nur die Bereichsleitung.
// getrennte Abfragen mit einer gemeinsamen Filterkette. const orgMaps = await loadOrgMaps(tx);
function applyFilters<Q extends Narrowable>(query: Q): Q { const unitFilter = params.division && orgMaps.units.has(params.division) ? params.division : null;
let q = query;
if (params.q) { // Eine Filterkette, zwei Abfragen: eine für die Seite, eine für die
const term = params.q.trim(); // Gesamtzahl. Am direkten Zugang teilen sie sich denselben Aufbau —
if (/^\d+$/.test(term)) q = q.eq("personnel_number", Number(term)) as Q; // vorher brauchte es zwei getrennte Select-Formen, weil der Typparser der
else { // API-Schicht einen bedingt zusammengesetzten Select-String nicht
const safe = sanitizeIlikeTerm(term); // auflösen konnte.
q = q.or(`first_name.ilike.%${safe}%,last_name.ilike.%${safe}%,job_title.ilike.%${safe}%`) as Q; const base = () => {
} let q = tx.selectFrom("employees");
}
// Derived from the dates, not read off employees.status — see if (unitFilter) {
// lib/employee-status-filter.ts for why the two can disagree. // Nach Organisationseinheit gefiltert wird über die *laufende*
q = applyDerivedStatusFilter(q, statuses, today); // Besetzung. Als EXISTS, damit eine Person nicht mehrfach erscheint,
if (params.location) q = q.eq("location_id", params.location) as Q; // wenn sie über die Zeit mehrere Zuordnungen hatte.
return q; const units = subtreeOf(orgMaps, unitFilter);
q = q.where((eb) =>
eb.exists(
eb
.selectFrom("position_assignments as a")
.innerJoin("om_positions as p", "p.id", "a.position_id")
.select("a.id")
.whereRef("a.employee_id", "=", "employees.id")
.where("a.valid_to", "is", null)
.where("p.org_unit_id", "in", units)
)
);
} }
const { data: employeesData, count } = unitFilter if (params.q) {
? await applyFilters( const term = params.q.trim();
supabase if (/^d+$/.test(term)) {
.from("employees") q = q.where("personnel_number", "=", Number(term));
.select(`${COLUMNS}, position_assignments!inner(valid_to, om_positions!inner(org_unit_id))`, { count: "exact" }) } else {
.order("last_name", { ascending: true }) // Als Parameter gebunden statt in die Abfrage geschrieben: die
.range(from, to) // Zeichen, die in der alten Filtersyntax ausbrechen konnten, sind
.is("position_assignments.valid_to", null) // hier bedeutungslos.
.in("position_assignments.om_positions.org_unit_id", subtreeOf(orgMaps, unitFilter)) const like = `%${term}%`;
) q = q.where((eb) =>
: await applyFilters( eb.or([eb("first_name", "ilike", like), eb("last_name", "ilike", like), eb("job_title", "ilike", like)])
supabase.from("employees").select(COLUMNS, { count: "exact" }).order("last_name", { ascending: true }).range(from, to)
); );
const employees = employeesData ?? []; }
const totalPages = Math.max(1, Math.ceil((count ?? 0) / PAGE_SIZE)); }
// Derived from the dates, not read off employees.status — see
// lib/employee-status-filter.ts for why the two can disagree.
if (statuses.length > 0) {
q = q.where((eb) => derivedStatusFilter(eb, statuses, today) ?? eb.val(true));
}
if (params.location) q = q.where("location_id", "=", params.location);
return q;
};
const [rows, total] = await Promise.all([
base()
.select([
"id",
"first_name",
"last_name",
"personnel_number",
"job_title",
"location_id",
"entry_date",
"employment_type",
"weekly_hours",
"status",
"absence_type",
])
// Nach id als zweitem Kriterium: bei gleichem Nachnamen wäre die
// Reihenfolge sonst unbestimmt, und dieselbe Person könnte auf zwei
// Seiten erscheinen oder auf keiner.
.orderBy("last_name")
.orderBy("id")
.limit(PAGE_SIZE)
.offset((page - 1) * PAGE_SIZE)
.execute(),
base()
.select(({ fn }) => fn.countAll<string>().as("anzahl"))
.executeTakeFirst(),
]);
// Die Einordnung kommt über die Planstelle — nur für die 15 Zeilen dieser // Die Einordnung kommt über die Planstelle — nur für die 15 Zeilen dieser
// Seite, nicht für den ganzen Bestand. // Seite, nicht für den ganzen Bestand.
const placements = await loadPlacements(supabase, { asOf: today, employeeIds: employees.map((e) => e.id) }); const placements = await loadPlacements(tx, { asOf: today, employeeIds: rows.map((e) => e.id) });
return { orgMaps, employees: rows, count: Number(total?.anzahl ?? 0), placements };
});
const totalPages = Math.max(1, Math.ceil(count / PAGE_SIZE));
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">

View File

@@ -2,36 +2,51 @@ import { redirect } from "next/navigation";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { HireWizardProvider } from "@/components/hire/HireWizardContext"; import { HireWizardProvider } from "@/components/hire/HireWizardContext";
import { AppShell } from "@/components/shell/AppShell"; import { AppShell } from "@/components/shell/AppShell";
import { currentUserId } from "@/lib/auth/session";
import { withUser } from "@/lib/db";
import { loadOpenNotes } from "@/lib/notes"; import { loadOpenNotes } from "@/lib/notes";
import { loadOpenPositions } from "@/lib/positions"; import { loadOpenPositions } from "@/lib/positions";
import { createClient } from "@/lib/supabase/server";
export default async function AppLayout({ children }: { children: ReactNode }) { export default async function AppLayout({ children }: { children: ReactNode }) {
const supabase = await createClient(); const userId = await currentUserId();
const { if (!userId) redirect("/login");
data: { user },
} = await supabase.auth.getUser();
if (!user) redirect("/login");
// Alles in *einer* Transaktion, weil nur dort der Sitzungskontext gilt —
// und damit nebenbei auf einem einheitlichen Lesestand.
const data = await withUser(userId, async (tx) => {
// Defense in depth: proxy.ts already redirects any non-active-HR session // Defense in depth: proxy.ts already redirects any non-active-HR session
// away before this layout ever renders. Re-checking here means a gap in // away before this layout ever renders. Re-checking here means a gap in
// the proxy matcher (or a future route added outside it) still fails // the proxy matcher (or a future route added outside it) still fails
// closed instead of silently granting access — see docs/security.md. // closed instead of silently granting access — see docs/security.md.
const { data: profile } = await supabase.from("profiles").select("full_name, email, role, is_active").eq("id", user.id).maybeSingle(); const profile = await tx
if (profile?.role !== "hr" || profile?.is_active !== true) redirect("/login"); .selectFrom("profiles")
.select(["full_name", "email", "role", "is_active"])
.where("id", "=", userId)
.executeTakeFirst();
if (profile?.role !== "hr" || profile?.is_active !== true) return null;
const userLabel = profile.full_name || profile.email || user.email || ""; const [openPositions, locations, drafts, openNotes] = await Promise.all([
loadOpenPositions(tx),
const [openPositions, locationsRes, draftsRes, openNotes] = await Promise.all([ tx.selectFrom("locations").select(["id", "name", "country"]).orderBy("name").execute(),
loadOpenPositions(supabase), tx
supabase.from("locations").select("id, name, country").order("name"), .selectFrom("hire_drafts")
supabase.from("hire_drafts").select("id, step, payload, updated_at").eq("created_by", user.id).order("updated_at", { ascending: false }), .select(["id", "step", "payload", "updated_at"])
loadOpenNotes(supabase), .where("created_by", "=", userId)
.orderBy("updated_at", "desc")
.execute(),
loadOpenNotes(tx),
]); ]);
return { profile, openPositions, locations, drafts, openNotes };
});
if (!data) redirect("/login");
const userLabel = data.profile.full_name || data.profile.email || "";
return ( return (
<HireWizardProvider openPositions={openPositions} locations={locationsRes.data ?? []} drafts={draftsRes.data ?? []}> <HireWizardProvider openPositions={data.openPositions} locations={data.locations} drafts={data.drafts}>
<AppShell userLabel={userLabel} openNotes={openNotes}> <AppShell userLabel={userLabel} openNotes={data.openNotes}>
{children} {children}
</AppShell> </AppShell>
</HireWizardProvider> </HireWizardProvider>

View File

@@ -4,7 +4,8 @@ import type { OrgUnitNode } from "@/components/orgchart/types";
import { todayIso } from "@/lib/format"; import { todayIso } from "@/lib/format";
import { loadOrgAsOf } from "@/lib/orgchart-data"; import { loadOrgAsOf } from "@/lib/orgchart-data";
import { parseIsoDateParam } from "@/lib/reports"; import { parseIsoDateParam } from "@/lib/reports";
import { createClient } from "@/lib/supabase/server"; import { currentUserId } from "@/lib/auth/session";
import { withUser } from "@/lib/db";
type SearchParams = { asOf?: string; focus?: string }; type SearchParams = { asOf?: string; focus?: string };
@@ -18,18 +19,19 @@ export default async function OrgChartPage({ searchParams }: { searchParams: Pro
// so a junk value can't reach the client as an arbitrary string. // so a junk value can't reach the client as an arbitrary string.
const focusId = params.focus && UUID.test(params.focus) ? params.focus : null; const focusId = params.focus && UUID.test(params.focus) ? params.focus : null;
const supabase = await createClient(); const { org, units } = await withUser(await currentUserId(), async (tx) => {
const [org, units] = await Promise.all([
const [org, { data: units }] = await Promise.all([ loadOrgAsOf(tx, asOf),
loadOrgAsOf(supabase, asOf), tx.selectFrom("org_units").select(["id", "org_number", "name", "parent_id", "unit_type"]).orderBy("org_number").execute(),
supabase.from("org_units").select("id, org_number, name, parent_id, unit_type").order("org_number"),
]); ]);
return { org, units };
});
return ( return (
<Suspense> <Suspense>
<OrgChartClient <OrgChartClient
employees={org.employees} employees={org.employees}
units={(units ?? []) as OrgUnitNode[]} units={units as OrgUnitNode[]}
vacancies={org.vacancies} vacancies={org.vacancies}
asOf={asOf} asOf={asOf}
today={today} today={today}

View File

@@ -8,8 +8,9 @@ import { divisionOf, loadOrgMaps } from "@/lib/org";
import { loadPlacements } from "@/lib/placement"; import { loadPlacements } from "@/lib/placement";
import { loadOpenPositions } from "@/lib/positions"; import { loadOpenPositions } from "@/lib/positions";
import { deriveStatusAsOf } from "@/lib/reports"; import { deriveStatusAsOf } from "@/lib/reports";
import { createClient } from "@/lib/supabase/server"; import { currentUserId } from "@/lib/auth/session";
import { fetchAllRows } from "@/lib/supabase/query"; import { withUser } from "@/lib/db";
import type { HistoryEventType } from "@/lib/supabase/types";
// Each KPI carries a colour already; the accent bar repeats it in a second // Each KPI carries a colour already; the accent bar repeats it in a second
// channel so the tiles are scannable as a row rather than six identical // channel so the tiles are scannable as a row rather than six identical
@@ -39,18 +40,6 @@ const DOT_STYLES: Record<string, string> = {
const KIND_LABEL = { hire: "Eintritt", exit: "Austritt", return: "Rückkehr aus Abwesenheit" } as const; const KIND_LABEL = { hire: "Eintritt", exit: "Austritt", return: "Rückkehr aus Abwesenheit" } as const;
export default async function DashboardPage() { export default async function DashboardPage() {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
const { data: drafts } = user
? await supabase
.from("hire_drafts")
.select("id, step, payload, updated_at")
.eq("created_by", user.id)
.order("updated_at", { ascending: false })
: { data: [] };
// Built as strings, not by round-tripping a local Date through // Built as strings, not by round-tripping a local Date through
// toISOString(): in any positive-offset zone new Date(year, 0, 1) is still // toISOString(): in any positive-offset zone new Date(year, 0, 1) is still
// the previous year in UTC, which shifted the whole YTD window a day early // the previous year in UTC, which shifted the whole YTD window a day early
@@ -61,6 +50,8 @@ export default async function DashboardPage() {
const yearEnd = `${year}-12-31`; const yearEnd = `${year}-12-31`;
const in60Iso = addDaysIso(today, 60); const in60Iso = addDaysIso(today, 60);
const userId = await currentUserId();
// Headcount, FTE, Karenz and the division bars all come from one full read // Headcount, FTE, Karenz and the division bars all come from one full read
// and the *derived* status, not from the `employees.status` column. // and the *derived* status, not from the `employees.status` column.
// //
@@ -69,71 +60,117 @@ export default async function DashboardPage() {
// planned hire whose start date has passed, or a Karenz that ended without // planned hire whose start date has passed, or a Karenz that ended without
// anyone recording the return, made the dashboard and the Berichte page // anyone recording the return, made the dashboard and the Berichte page
// disagree about the same headcount. Same derivation, same numbers. // disagree about the same headcount. Same derivation, same numbers.
// It also replaces four separate count queries with one. const {
const [ drafts,
staffRows, staffRows,
hiresYtdRes, hiresYtd,
exitsYtdRes, exitsYtd,
openPositions, openPositions,
orgMaps, orgMaps,
placements, placements,
upcomingHiresRes, upcomingHires,
upcomingExitsRes, upcomingExits,
upcomingReturnsRes, upcomingReturns,
historyRes, history,
} = await withUser(userId, async (tx) => {
const countIn = (types: readonly HistoryEventType[]) =>
tx
.selectFrom("employee_history")
.select(({ fn }) => fn.countAll<string>().as("anzahl"))
.where("event_type", "in", [...types])
.where("event_date", ">=", yearStart)
.where("event_date", "<=", yearEnd)
.executeTakeFirst();
const [
drafts,
staffRows,
hiresYtd,
exitsYtd,
openPositions,
orgMaps,
placements,
upcomingHires,
upcomingExits,
upcomingReturns,
history,
] = await Promise.all([ ] = await Promise.all([
fetchAllRows(() => userId
supabase ? tx
.from("employees") .selectFrom("hire_drafts")
.select("id, weekly_hours, entry_date, exit_date, karenz_start_date, karenz_return_date") .select(["id", "step", "payload", "updated_at"])
.order("id") .where("created_by", "=", userId)
), .orderBy("updated_at", "desc")
.execute()
: Promise.resolve([]),
tx
.selectFrom("employees")
.select(["id", "weekly_hours", "entry_date", "exit_date", "karenz_start_date", "karenz_return_date"])
.orderBy("id")
.execute(),
// Entries/exits count history events, which is what the linked report // Entries/exits count history events, which is what the linked report
// counts too. `entry_date` would also sweep up rehires, whose event is // counts too. `entry_date` would also sweep up rehires, whose event is
// logged as 'Wiedereintritt' — the tile and its destination then showed // logged as 'Wiedereintritt' — the tile and its destination then showed
// different numbers for the same year. // different numbers for the same year.
supabase countIn(["Eintritt", "Wiedereintritt"]),
.from("employee_history") countIn(["Austritt"]),
.select("id", { count: "exact", head: true })
.in("event_type", ["Eintritt", "Wiedereintritt"]) loadOpenPositions(tx),
.gte("event_date", yearStart) loadOrgMaps(tx),
.lte("event_date", yearEnd), loadPlacements(tx, { asOf: today }),
supabase
.from("employee_history") tx
.select("id", { count: "exact", head: true }) .selectFrom("employees")
.eq("event_type", "Austritt") .select(["id", "first_name", "last_name", "entry_date"])
.gte("event_date", yearStart) .where("status", "=", "Geplant")
.lte("event_date", yearEnd), .where("entry_date", ">=", today)
loadOpenPositions(supabase), .where("entry_date", "<=", in60Iso)
loadOrgMaps(supabase), .execute(),
loadPlacements(supabase, { asOf: today }),
supabase tx
.from("employees") .selectFrom("employees")
.select("id, first_name, last_name, entry_date") .select(["id", "first_name", "last_name", "exit_date"])
.eq("status", "Geplant") .where("exit_date", "is not", null)
.gte("entry_date", today) .where("exit_date", ">=", today)
.lte("entry_date", in60Iso), .where("exit_date", "<=", in60Iso)
supabase .execute(),
.from("employees")
.select("id, first_name, last_name, exit_date") tx
.not("exit_date", "is", null) .selectFrom("employees")
.gte("exit_date", today) .select(["id", "first_name", "last_name", "karenz_return_date"])
.lte("exit_date", in60Iso), .where("status", "=", "Karenz")
supabase .where("karenz_return_date", "is not", null)
.from("employees") .where("karenz_return_date", ">=", today)
.select("id, first_name, last_name, karenz_return_date") .where("karenz_return_date", "<=", in60Iso)
.eq("status", "Karenz") .execute(),
.not("karenz_return_date", "is", null)
.gte("karenz_return_date", today) tx
.lte("karenz_return_date", in60Iso), .selectFrom("employee_history as h")
supabase .leftJoin("employees as e", "e.id", "h.employee_id")
.from("employee_history") .select(["h.id", "h.employee_id", "h.event_date", "h.event_type", "h.description", "e.first_name", "e.last_name"])
.select("id, employee_id, event_date, event_type, description") .orderBy("h.event_date", "desc")
.order("event_date", { ascending: false }) .orderBy("h.created_at", "desc")
.order("created_at", { ascending: false }) .limit(10)
.limit(10), .execute(),
]); ]);
return {
drafts,
staffRows,
hiresYtd: Number(hiresYtd?.anzahl ?? 0),
exitsYtd: Number(exitsYtd?.anzahl ?? 0),
openPositions,
orgMaps,
placements,
upcomingHires,
upcomingExits,
upcomingReturns,
history,
};
});
// "Aktiv" means status Aktiv — somebody on Karenz is employed but not // "Aktiv" means status Aktiv — somebody on Karenz is employed but not
// active, and is counted by its own tile instead. FTE follows the same // active, and is counted by its own tile instead. FTE follows the same
// set: Karenz contributes no capacity, so including it would overstate // set: Karenz contributes no capacity, so including it would overstate
@@ -166,19 +203,19 @@ export default async function DashboardPage() {
type UpcomingItem = { id: string; label: string; date: string; kind: keyof typeof KIND_LABEL }; type UpcomingItem = { id: string; label: string; date: string; kind: keyof typeof KIND_LABEL };
const upcoming: UpcomingItem[] = [ const upcoming: UpcomingItem[] = [
...(upcomingHiresRes.data ?? []).map((e) => ({ ...(upcomingHires).map((e) => ({
id: e.id, id: e.id,
label: `${e.first_name} ${e.last_name}`, label: `${e.first_name} ${e.last_name}`,
date: e.entry_date, date: e.entry_date,
kind: "hire" as const, kind: "hire" as const,
})), })),
...(upcomingExitsRes.data ?? []).map((e) => ({ ...(upcomingExits).map((e) => ({
id: e.id, id: e.id,
label: `${e.first_name} ${e.last_name}`, label: `${e.first_name} ${e.last_name}`,
date: e.exit_date!, date: e.exit_date!,
kind: "exit" as const, kind: "exit" as const,
})), })),
...(upcomingReturnsRes.data ?? []).map((e) => ({ ...(upcomingReturns).map((e) => ({
id: e.id, id: e.id,
label: `${e.first_name} ${e.last_name}`, label: `${e.first_name} ${e.last_name}`,
date: e.karenz_return_date!, date: e.karenz_return_date!,
@@ -188,12 +225,6 @@ export default async function DashboardPage() {
.sort((a, b) => a.date.localeCompare(b.date)) .sort((a, b) => a.date.localeCompare(b.date))
.slice(0, 8); .slice(0, 8);
const historyEmployeeIds = Array.from(new Set((historyRes.data ?? []).map((h) => h.employee_id)));
const historyEmployeesRes = historyEmployeeIds.length
? await supabase.from("employees").select("id, first_name, last_name").in("id", historyEmployeeIds)
: { data: [] as { id: string; first_name: string; last_name: string }[] };
const employeeNameById = new Map((historyEmployeesRes.data ?? []).map((e) => [e.id, `${e.first_name} ${e.last_name}`]));
// Each tile links to the view that shows what it counts, with the filters // Each tile links to the view that shows what it counts, with the filters
// pre-applied. // pre-applied.
// //
@@ -214,13 +245,13 @@ export default async function DashboardPage() {
{ label: "FTE", value: fte.toFixed(1), tone: "default", href: "/reports?mode=snapshot&measure=fte&status=Aktiv" }, { label: "FTE", value: fte.toFixed(1), tone: "default", href: "/reports?mode=snapshot&measure=fte&status=Aktiv" },
{ {
label: "Eintritte (Jahr)", label: "Eintritte (Jahr)",
value: hiresYtdRes.count ?? 0, value: hiresYtd,
tone: "success", tone: "success",
href: `/reports?mode=events&eventType=Eintritt&from=${yearStart}&to=${yearEnd}`, href: `/reports?mode=events&eventType=Eintritt&from=${yearStart}&to=${yearEnd}`,
}, },
{ {
label: "Austritte (Jahr)", label: "Austritte (Jahr)",
value: exitsYtdRes.count ?? 0, value: exitsYtd,
tone: "danger", tone: "danger",
href: `/reports?mode=events&eventType=Austritt&from=${yearStart}&to=${yearEnd}`, href: `/reports?mode=events&eventType=Austritt&from=${yearStart}&to=${yearEnd}`,
}, },
@@ -300,14 +331,14 @@ export default async function DashboardPage() {
<Card> <Card>
<CardTitle className="mb-1">Letzte Aktivitäten</CardTitle> <CardTitle className="mb-1">Letzte Aktivitäten</CardTitle>
<ul className="flex flex-col divide-y divide-border-subtle"> <ul className="flex flex-col divide-y divide-border-subtle">
{(historyRes.data ?? []).map((h) => ( {(history).map((h) => (
<li key={h.id} className="flex gap-2.5 py-2.5"> <li key={h.id} className="flex gap-2.5 py-2.5">
{/* Dot aligned to the first line of text, not centred on the {/* Dot aligned to the first line of text, not centred on the
whole row, so it stays put as descriptions wrap. */} whole row, so it stays put as descriptions wrap. */}
<span className={`mt-1.5 h-2 w-2 shrink-0 rounded-full ${DOT_STYLES[h.event_type] ?? "bg-ink-muted"}`} aria-hidden /> <span className={`mt-1.5 h-2 w-2 shrink-0 rounded-full ${DOT_STYLES[h.event_type] ?? "bg-ink-muted"}`} aria-hidden />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1"> <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="text-sm font-semibold text-ink">{employeeNameById.get(h.employee_id) ?? "Unbekannt"}</span> <span className="text-sm font-semibold text-ink">{h.first_name && h.last_name ? `${h.first_name} ${h.last_name}` : "Unbekannt"}</span>
<span className={`rounded-full px-2 py-0.5 text-[11px] font-semibold ${actionBadgeStyle(h.event_type)}`}> <span className={`rounded-full px-2 py-0.5 text-[11px] font-semibold ${actionBadgeStyle(h.event_type)}`}>
{h.event_type} {h.event_type}
</span> </span>
@@ -316,7 +347,7 @@ export default async function DashboardPage() {
</div> </div>
</li> </li>
))} ))}
{(historyRes.data ?? []).length === 0 && <p className="py-2 text-sm text-ink-muted">Keine Aktivitäten vorhanden.</p>} {(history).length === 0 && <p className="py-2 text-sm text-ink-muted">Keine Aktivitäten vorhanden.</p>}
</ul> </ul>
</Card> </Card>
</div> </div>

View File

@@ -3,21 +3,23 @@ import { PositionsPageClient } from "@/components/positions/PositionsPageClient"
import { daysBetweenIso } from "@/lib/format"; import { daysBetweenIso } from "@/lib/format";
import { loadOrgMaps } from "@/lib/org"; import { loadOrgMaps } from "@/lib/org";
import { loadOpenPositions } from "@/lib/positions"; import { loadOpenPositions } from "@/lib/positions";
import { createClient } from "@/lib/supabase/server"; import { currentUserId } from "@/lib/auth/session";
import { withUser } from "@/lib/db";
export default async function PositionsPage() { export default async function PositionsPage() {
const supabase = await createClient(); const { openPositions, orgMaps, chiefRows } = await withUser(await currentUserId(), async (tx) => {
const [openPositions, orgMaps, chiefRows] = await Promise.all([
const [openPositions, orgMaps, { data: chiefRows }] = await Promise.all([ loadOpenPositions(tx),
loadOpenPositions(supabase), loadOrgMaps(tx),
loadOrgMaps(supabase),
// Wo es schon eine gültige Leitungsplanstelle gibt, lässt der // Wo es schon eine gültige Leitungsplanstelle gibt, lässt der
// Unique-Index keine zweite zu — das gehört in den Dialog, nicht in eine // Unique-Index keine zweite zu — das gehört in den Dialog, nicht in eine
// Fehlermeldung nach dem Absenden. // Fehlermeldung nach dem Absenden.
supabase.from("om_positions").select("org_unit_id").eq("is_chief", true).is("valid_to", null), tx.selectFrom("om_positions").select("org_unit_id").where("is_chief", "=", true).where("valid_to", "is", null).execute(),
]); ]);
return { openPositions, orgMaps, chiefRows };
});
const withChief = new Set((chiefRows ?? []).map((r) => r.org_unit_id)); const withChief = new Set(chiefRows.map((r) => r.org_unit_id));
const units: UnitOption[] = orgMaps.unitList.map((u) => ({ const units: UnitOption[] = orgMaps.unitList.map((u) => ({
id: u.id, id: u.id,
name: u.name, name: u.name,

View File

@@ -16,7 +16,8 @@ import {
totalForRows, totalForRows,
} from "@/lib/reports"; } from "@/lib/reports";
import { loadEventHistory, loadOrgLookups, loadSnapshotEmployees } from "@/lib/reports-data"; import { loadEventHistory, loadOrgLookups, loadSnapshotEmployees } from "@/lib/reports-data";
import { createClient } from "@/lib/supabase/server"; import { currentUserId } from "@/lib/auth/session";
import { withUser } from "@/lib/db";
type SearchParams = { type SearchParams = {
mode?: string; mode?: string;
@@ -35,7 +36,6 @@ type SearchParams = {
export default async function ReportsPage({ searchParams }: { searchParams: Promise<SearchParams> }) { export default async function ReportsPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
const params = await searchParams; const params = await searchParams;
const supabase = await createClient();
const mode = parseMode(params.mode); const mode = parseMode(params.mode);
// Both modes are parsed up front so the data load can start before // Both modes are parsed up front so the data load can start before
@@ -55,11 +55,17 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom
// in, so all three go out together. Against a hosted database a round trip // in, so all three go out together. Against a hosted database a round trip
// costs about as much as the query itself, which made this page's three // costs about as much as the query itself, which made this page's three
// sequential waves its dominant cost. // sequential waves its dominant cost.
const [{ lookups, divisions, locations }, { data: userRes }, events, employees] = await Promise.all([ const userId = await currentUserId();
loadOrgLookups(supabase),
supabase.auth.getUser(), // Alles in einer Transaktion — dort gilt der Sitzungskontext, und der
// Lesestand ist über alle Abfragen hinweg derselbe. Vorher waren es drei
// Wellen nacheinander, was gegen eine entfernte Datenbank der teuerste
// Teil dieser Seite war.
const { lookups, divisions, locations, events, employees, savedReports } = await withUser(userId, async (tx) => {
const [{ lookups, divisions, locations }, events, employees, savedReports] = await Promise.all([
loadOrgLookups(tx),
mode === "events" mode === "events"
? loadEventHistory(supabase, { ? loadEventHistory(tx, {
eventType: eventType ?? undefined, eventType: eventType ?? undefined,
division: params.division, division: params.division,
location: params.location, location: params.location,
@@ -68,7 +74,7 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom
}) })
: Promise.resolve([]), : Promise.resolve([]),
mode === "snapshot" mode === "snapshot"
? loadSnapshotEmployees(supabase, { ? loadSnapshotEmployees(tx, {
division: params.division, division: params.division,
location: params.location, location: params.location,
status: params.status, status: params.status,
@@ -76,13 +82,17 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom
asOf, asOf,
}) })
: Promise.resolve([]), : Promise.resolve([]),
userId
? tx
.selectFrom("saved_reports")
.select(["id", "name", "config"])
.where("created_by", "=", userId)
.orderBy("created_at", "desc")
.execute()
: Promise.resolve([]),
]); ]);
return { lookups, divisions, locations, events, employees, savedReports };
const user = userRes.user; });
// Still a wave of its own: it needs the user id the call above resolves.
const { data: savedReports } = user
? await supabase.from("saved_reports").select("id, name, config").eq("created_by", user.id).order("created_at", { ascending: false })
: { data: [] };
if (mode === "events") { if (mode === "events") {
const rows = aggregateEvents(events, eventGroup, eventSplit, lookups); const rows = aggregateEvents(events, eventGroup, eventSplit, lookups);
@@ -100,7 +110,7 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom
recordCount={events.length} recordCount={events.length}
divisions={divisions} divisions={divisions}
locations={locations} locations={locations}
savedReports={savedReports ?? []} savedReports={savedReports}
/> />
</Suspense> </Suspense>
); );
@@ -127,7 +137,7 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom
recordCount={employees.length} recordCount={employees.length}
divisions={divisions} divisions={divisions}
locations={locations} locations={locations}
savedReports={savedReports ?? []} savedReports={savedReports}
/> />
</Suspense> </Suspense>
); );

View File

@@ -1,5 +1,6 @@
import { NextResponse, type NextRequest } from "next/server"; import { NextResponse, type NextRequest } from "next/server";
import { createAdminClient } from "@/lib/supabase/admin"; import { asSystem } from "@/lib/db";
import { callFunction } from "@/lib/db/rpc";
// Applies effective-dated changes (Versetzung/Beförderung/Karenz/Reorg/Daten // Applies effective-dated changes (Versetzung/Beförderung/Karenz/Reorg/Daten
// ändern with a future "Wirksam ab" date) once their date has arrived — see // ändern with a future "Wirksam ab" date) once their date has arrived — see
@@ -13,13 +14,15 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: "Nicht autorisiert." }, { status: 401 }); return NextResponse.json({ error: "Nicht autorisiert." }, { status: 401 });
} }
const supabase = createAdminClient(); // Kein privilegierter Zugang mehr: derselbe Datenbankbenutzer ohne
const { data, error } = await supabase.rpc("apply_due_pending_changes"); // BYPASSRLS wie überall. apply_due_pending_changes ist SECURITY DEFINER
// und prüft selbst, was sie tut — der Dienstschlüssel, der RLS aushebelte,
if (error) { // ist damit entfallen.
console.error("apply_due_pending_changes failed:", error); try {
const applied = await asSystem((tx) => callFunction(tx, "apply_due_pending_changes"));
return NextResponse.json({ applied });
} catch (err) {
console.error("apply_due_pending_changes failed:", err);
return NextResponse.json({ error: "Interner Fehler." }, { status: 500 }); return NextResponse.json({ error: "Interner Fehler." }, { status: 500 });
} }
return NextResponse.json({ applied: data });
} }

View File

@@ -3,12 +3,11 @@ import { statusLabel } from "@/lib/absence";
import { exportFilename, exportResponseHeaders, toCsv, toXlsx, type ExportColumn } from "@/lib/export"; import { exportFilename, exportResponseHeaders, toCsv, toXlsx, type ExportColumn } from "@/lib/export";
import { todayIso } from "@/lib/format"; import { todayIso } from "@/lib/format";
import { subtreeOf } from "@/lib/org"; import { subtreeOf } from "@/lib/org";
import { loadPlacements, loadReportingLines } from "@/lib/placement"; import { loadPlacements, loadReportingLineMap } from "@/lib/placement";
import { deriveStatusAsOf, parseIsoDateParam, parseStatuses, type OrgLookups } from "@/lib/reports"; import { deriveStatusAsOf, parseIsoDateParam, parseStatuses, type OrgLookups } from "@/lib/reports";
import { loadDependentsCounts, loadOrgLookups, type ReportFilters } from "@/lib/reports-data"; import { loadDependentsCounts, loadOrgLookups, type ReportFilters } from "@/lib/reports-data";
import { requireHrUser } from "@/lib/supabase/auth"; import { requireHrUser } from "@/lib/auth/require-hr";
import { fetchAllRows } from "@/lib/supabase/query"; import { withUser } from "@/lib/db";
import { createClient } from "@/lib/supabase/server";
import type { Database, EmploymentType, Weekday } from "@/lib/supabase/types"; import type { Database, EmploymentType, Weekday } from "@/lib/supabase/types";
// Die Rohzeile plus die Einordnung, die nicht mehr auf ihr steht: sie kommt // Die Rohzeile plus die Einordnung, die nicht mehr auf ihr steht: sie kommt
@@ -26,9 +25,8 @@ type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"] & {
// filtering happens against the *derived* status as of that date rather // filtering happens against the *derived* status as of that date rather
// than the live `status` column — see deriveStatusAsOf. // than the live `status` column — see deriveStatusAsOf.
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const supabase = await createClient(); const gate = await requireHrUser();
const denied = await requireHrUser(supabase); if ("denied" in gate) return gate.denied;
if (denied) return denied;
const params = request.nextUrl.searchParams; const params = request.nextUrl.searchParams;
const format = params.get("format") === "xlsx" ? "xlsx" : "csv"; const format = params.get("format") === "xlsx" ? "xlsx" : "csv";
@@ -44,23 +42,38 @@ export async function GET(request: NextRequest) {
const stichtag = asOf ?? todayIso(); const stichtag = asOf ?? todayIso();
const { employees, lookups, orgMaps, allEmployees, dependentsCounts, placements, lines } = await withUser(
gate.userId,
async (tx) => {
function employeeQuery() { function employeeQuery() {
let query = supabase.from("employees").select("*").order("last_name").order("id"); let q = tx.selectFrom("employees").selectAll().orderBy("last_name").orderBy("id");
if (filters.location) query = query.eq("location_id", filters.location); if (filters.location) q = q.where("location_id", "=", filters.location);
if (filters.employment) query = query.eq("employment_type", filters.employment as EmploymentType); if (filters.employment) q = q.where("employment_type", "=", filters.employment as EmploymentType);
if (!asOf) query = query.in("status", statuses); if (!asOf) q = q.where("status", "in", statuses);
return query; return q;
} }
const [employees, { lookups, orgMaps }, allEmployees, dependentsCounts, placements, lines] = await Promise.all([ const [employees, lookupResult, allEmployees, dependentsCounts, placements, lines] = await Promise.all([
fetchAllRows(employeeQuery), employeeQuery().execute(),
loadOrgLookups(supabase), loadOrgLookups(tx),
fetchAllRows(() => supabase.from("employees").select("id, first_name, last_name").order("id")), tx.selectFrom("employees").select(["id", "first_name", "last_name"]).orderBy("id").execute(),
loadDependentsCounts(supabase), loadDependentsCounts(tx),
loadPlacements(supabase, { asOf: stichtag }), loadPlacements(tx, { asOf: stichtag }),
loadReportingLines(supabase, 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, `${e.first_name} ${e.last_name}`])); const managerName = new Map(allEmployees.map((e) => [e.id, `${e.first_name} ${e.last_name}`]));
// Der Einheitenfilter meint den ganzen Teilbaum — sonst enthielte ein // Der Einheitenfilter meint den ganzen Teilbaum — sonst enthielte ein
// Export für "Produktion" nur die Bereichsleitung. // Export für "Produktion" nur die Bereichsleitung.

View File

@@ -2,25 +2,25 @@ import { NextResponse, type NextRequest } from "next/server";
import { exportFilename, exportResponseHeaders, toCsv, toXlsx, type ExportColumn } from "@/lib/export"; import { exportFilename, exportResponseHeaders, toCsv, toXlsx, type ExportColumn } from "@/lib/export";
import { EVENT_TYPE_LABELS, parseEventDateParam, parseEventType, type OrgLookups, type ReportEvent } from "@/lib/reports"; import { EVENT_TYPE_LABELS, parseEventDateParam, parseEventType, type OrgLookups, type ReportEvent } from "@/lib/reports";
import { loadEventHistory, loadOrgLookups } from "@/lib/reports-data"; import { loadEventHistory, loadOrgLookups } from "@/lib/reports-data";
import { requireHrUser } from "@/lib/supabase/auth"; import { requireHrUser } from "@/lib/auth/require-hr";
import { createClient } from "@/lib/supabase/server"; import { withUser } from "@/lib/db";
// Full raw event-log dump — one row per employee_history entry in the // Full raw event-log dump — one row per employee_history entry in the
// selected period (default: current year), every event type unless one is // selected period (default: current year), every event type unless one is
// picked, org columns resolved from each affected employee's current // picked, org columns resolved from each affected employee's current
// placement (see loadEventHistory). // placement (see loadEventHistory).
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const supabase = await createClient(); const gate = await requireHrUser();
const denied = await requireHrUser(supabase); if ("denied" in gate) return gate.denied;
if (denied) return denied;
const params = request.nextUrl.searchParams; const params = request.nextUrl.searchParams;
const format = params.get("format") === "xlsx" ? "xlsx" : "csv"; const format = params.get("format") === "xlsx" ? "xlsx" : "csv";
const eventType = parseEventType(params.get("eventType")); const eventType = parseEventType(params.get("eventType"));
const { lookups, events } = await withUser(gate.userId, async (tx) => {
const [{ lookups }, events] = await Promise.all([ const [{ lookups }, events] = await Promise.all([
loadOrgLookups(supabase), loadOrgLookups(tx),
loadEventHistory(supabase, { loadEventHistory(tx, {
eventType: eventType ?? undefined, eventType: eventType ?? undefined,
division: params.get("division") ?? undefined, division: params.get("division") ?? undefined,
location: params.get("location") ?? undefined, location: params.get("location") ?? undefined,
@@ -28,6 +28,8 @@ export async function GET(request: NextRequest) {
to: parseEventDateParam(params.get("to")), to: parseEventDateParam(params.get("to")),
}), }),
]); ]);
return { lookups, events };
});
const columns = eventExportColumns(lookups); const columns = eventExportColumns(lookups);
const filename = exportFilename(`ereignisse-${eventType ?? "alle"}`, format); const filename = exportFilename(`ereignisse-${eventType ?? "alle"}`, format);

View File

@@ -24,23 +24,24 @@ import {
type ReportRow, type ReportRow,
} from "@/lib/reports"; } from "@/lib/reports";
import { loadEventHistory, loadOrgLookups, loadSnapshotEmployees } from "@/lib/reports-data"; import { loadEventHistory, loadOrgLookups, loadSnapshotEmployees } from "@/lib/reports-data";
import { requireHrUser } from "@/lib/supabase/auth"; import { requireHrUser } from "@/lib/auth/require-hr";
import { createClient } from "@/lib/supabase/server"; import { withUser } from "@/lib/db";
// Exports exactly the pivot table currently on screen (same mode/measure or // Exports exactly the pivot table currently on screen (same mode/measure or
// event-type/group/split/filters, read from the query string the client // event-type/group/split/filters, read from the query string the client
// already keeps in the URL) as a flat table — one row per group, one column // already keeps in the URL) as a flat table — one row per group, one column
// per split value if a split is active. // per split value if a split is active.
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const supabase = await createClient(); const gate = await requireHrUser();
const denied = await requireHrUser(supabase); if ("denied" in gate) return gate.denied;
if (denied) return denied;
const params = request.nextUrl.searchParams; const params = request.nextUrl.searchParams;
const format = params.get("format") === "xlsx" ? "xlsx" : "csv"; const format = params.get("format") === "xlsx" ? "xlsx" : "csv";
const mode = parseMode(params.get("mode")); const mode = parseMode(params.get("mode"));
const { lookups } = await loadOrgLookups(supabase); // Eine Transaktion für Nachschlagewerte und Daten: dort gilt der
// Sitzungskontext, und beide sehen denselben Lesestand.
const { rows, columns, filenameBase } = await withUser(gate.userId, async (tx) => {
const { lookups } = await loadOrgLookups(tx);
let rows: ReportRow[]; let rows: ReportRow[];
let columns: ExportColumn<ReportRow>[]; let columns: ExportColumn<ReportRow>[];
let filenameBase: string; let filenameBase: string;
@@ -49,7 +50,7 @@ export async function GET(request: NextRequest) {
const group = parseEventGroupDimension(params.get("group")); const group = parseEventGroupDimension(params.get("group"));
const split = parseEventSplitDimension(params.get("split")); const split = parseEventSplitDimension(params.get("split"));
const eventType = parseEventType(params.get("eventType")); const eventType = parseEventType(params.get("eventType"));
const events = await loadEventHistory(supabase, { const events = await loadEventHistory(tx, {
eventType: eventType ?? undefined, eventType: eventType ?? undefined,
division: params.get("division") ?? undefined, division: params.get("division") ?? undefined,
location: params.get("location") ?? undefined, location: params.get("location") ?? undefined,
@@ -64,7 +65,7 @@ export async function GET(request: NextRequest) {
const group = parseGroupDimension(params.get("group")); const group = parseGroupDimension(params.get("group"));
const split = parseSplitDimension(params.get("split")); const split = parseSplitDimension(params.get("split"));
const asOf = parseIsoDateParam(params.get("asOf")); const asOf = parseIsoDateParam(params.get("asOf"));
const employees = await loadSnapshotEmployees(supabase, { const employees = await loadSnapshotEmployees(tx, {
division: params.get("division") ?? undefined, division: params.get("division") ?? undefined,
location: params.get("location") ?? undefined, location: params.get("location") ?? undefined,
status: params.get("status") ?? undefined, status: params.get("status") ?? undefined,
@@ -76,7 +77,11 @@ export async function GET(request: NextRequest) {
filenameBase = `bericht-${measure}-${group}`; filenameBase = `bericht-${measure}-${group}`;
} }
return { rows, columns, filenameBase };
});
const filename = exportFilename(filenameBase, format); const filename = exportFilename(filenameBase, format);
const body = format === "xlsx" ? await toXlsx(rows, columns, "Bericht") : toCsv(rows, columns); const body = format === "xlsx" ? await toXlsx(rows, columns, "Bericht") : toCsv(rows, columns);
// TS 5.9's Uint8Array<ArrayBufferLike> vs DOM's BlobPart/ArrayBuffer<> generic // TS 5.9's Uint8Array<ArrayBufferLike> vs DOM's BlobPart/ArrayBuffer<> generic
// mismatch (microsoft/TypeScript#59417) — a real Uint8Array works fine here. // mismatch (microsoft/TypeScript#59417) — a real Uint8Array works fine here.

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"; import type { EmploymentStatus } from "./supabase/types";
// The SQL counterpart of deriveStatusAsOf() in lib/reports.ts. // 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 // tests/integration/employee-status-filter.test.ts asserts the two agree
// against a real database, which is the only place that can prove it. // against a real database, which is the only place that can prove it.
type Filterable = { type Eb = ExpressionBuilder<Schema, "employees">;
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;
};
/** True once the person has started and has not left yet. */ /** True once the person has started and has not left yet. */
function employed<Q extends Filterable>(query: Q, asOf: string): Q { function employed(eb: Eb, asOf: string): Expression<SqlBool> {
return query.lte("entry_date", asOf).or(`exit_date.is.null,exit_date.gt.${asOf}`) as Q; 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 * Die Bedingung für die Menge, deren *abgeleiteter* Status am Stichtag einer
* `asOf` is one of `statuses`. Only the combinations the UI offers are * der genannten ist — oder null, wenn nicht eingeschränkt werden soll.
* supported; anything else is left unfiltered rather than silently applying *
* a wrong one. * 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); 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. // 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("Geplant")) return eb("entry_date", ">", asOf);
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("Ausgetreten")) {
return eb.and([eb("exit_date", "is not", null), eb("exit_date", "<=", asOf)]);
}
const wantsAktiv = wanted.has("Aktiv"); const wantsAktiv = wanted.has("Aktiv");
const wantsKarenz = wanted.has("Karenz"); const wantsKarenz = wanted.has("Karenz");
if (wantsAktiv && wantsKarenz && wanted.size === 2) {
// Everyone employed today, whether or not they are on leave. // Everyone employed today, whether or not they are on leave.
return employed(query, asOf); if (wantsAktiv && wantsKarenz && wanted.size === 2) return employed(eb, asOf);
}
if (wantsKarenz && !wantsAktiv && wanted.size === 1) { if (wantsKarenz && !wantsAktiv && wanted.size === 1) {
return employed(query, asOf) return eb.and([
.not("karenz_start_date", "is", null) employed(eb, asOf),
.lte("karenz_start_date", asOf) eb("karenz_start_date", "is not", null),
.or(`karenz_return_date.is.null,karenz_return_date.gt.${asOf}`) as Q; eb("karenz_start_date", "<=", asOf),
eb.or([eb("karenz_return_date", "is", null), eb("karenz_return_date", ">", asOf)]),
]);
} }
if (wantsAktiv && !wantsKarenz && wanted.size === 1) { if (wantsAktiv && !wantsKarenz && wanted.size === 1) {
// Employed but *not* inside a karenz window: either no start date, a // Employed but *not* inside a karenz window: either no start date, a
// start still ahead, or a return that has already happened. // start still ahead, or a return that has already happened.
return employed(query, asOf).or( return eb.and([
`karenz_start_date.is.null,karenz_start_date.gt.${asOf},karenz_return_date.lte.${asOf}` employed(eb, asOf),
) as Q; 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 // Mixed selections spanning employed and non-employed states have no UI
// path today; filtering on a guess would be worse than not filtering. // 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 type { Tx } from "./db";
import { fetchAllRows } from "./supabase/query";
import type { Database } from "./supabase/types"; import type { Database } from "./supabase/types";
export type OpenNote = Database["public"]["Tables"]["employee_notes"]["Row"] & { export type OpenNote = Database["public"]["Tables"]["employee_notes"]["Row"] & {
employeeName: string; employeeName: string;
}; };
// "Meine Notizen" (Topbar-Glocke): das geteilte, mitarbeiterübergreifende // Meine Notizen" (Topbar-Glocke): das geteilte, mitarbeiterübergreifende
// Postfach aller noch nicht erledigten HR-Notizen — unabhängig davon wer // Postfach aller noch nicht erledigten HR-Notizen — unabhängig davon, wer sie
// sie verfasst hat oder zu wem sie gehören (mit Nutzer abgestimmt). Zwei // verfasst hat oder zu wem sie gehören (mit Nutzer abgestimmt).
// einfache Queries, in JS gemerged — gleiches Muster wie loadEventHistory //
// in lib/reports-data.ts, da der handgeschriebene Database-Typ keine // Früher zwei Abfragen, in JavaScript zusammengeführt, weil die API-Schicht
// relationalen Embeddings für eine einzelne verschachtelte Query kennt. // für eine einzelne verschachtelte Abfrage keine Verknüpfung anbot. Am
export async function loadOpenNotes(supabase: SupabaseClient<Database>): Promise<OpenNote[]> { // direkten Zugang ist es schlicht ein Join.
const [{ data: notes }, employees] = await Promise.all([ export async function loadOpenNotes(tx: Tx): Promise<OpenNote[]> {
supabase.from("employee_notes").select("*").eq("done", false).order("created_at", { ascending: false }), const rows = await tx
fetchAllRows(() => supabase.from("employees").select("id, first_name, last_name").order("id")), .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 rows.map((row) => {
return (notes ?? []).map((n) => { const { first_name, last_name, ...note } = row;
const emp = employeeById.get(n.employee_id); return {
return { ...n, employeeName: emp ? `${emp.first_name} ${emp.last_name}` : "Unbekannt" }; ...(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"; import type { Database } from "./supabase/types";
// Die Organisation ist ein Baum, keine drei Tabellen mehr. Alles, was früher // 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 // Die Referenzdaten sind winzig (60 Einheiten, 5 Standorte) — sie werden
// ganz geladen und im Speicher verknüpft, statt je Zeile nachzuschlagen. // ganz geladen und im Speicher verknüpft, statt je Zeile nachzuschlagen.
export async function loadOrgMaps(supabase: SupabaseClient<Database>): Promise<OrgMaps> { export async function loadOrgMaps(tx: Tx): Promise<OrgMaps> {
const [{ data: units }, { data: locations }] = await Promise.all([ const [units, locations] = await Promise.all([
supabase.from("org_units").select("id, org_number, name, parent_id, unit_type").order("org_number"), tx
supabase.from("locations").select("*").order("name"), .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. */ /** 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 { OrgEmployee, OrgVacancy } from "@/components/orgchart/types";
import type { Tx } from "./db";
import { todayIso } from "./format"; import { todayIso } from "./format";
import { resolveReportingLines, type OmHolder, type OmUnit } from "./om-reporting"; 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. // 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> }; 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(); 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([ const [units, positions, assignments, employees, pending, earliest] = await Promise.all([
fetchAllRows(() => supabase.from("org_units").select("id, parent_id").order("id")), tx.selectFrom("org_units").select(["id", "parent_id"]).orderBy("id").execute(),
fetchAllRows(() =>
supabase tx
.from("om_positions") .selectFrom("om_positions as p")
.select("id, position_number, org_unit_id, is_chief, jobs!inner(title)") .innerJoin("jobs as j", "j.id", "p.job_id")
.lte("valid_from", asOf) .select(["p.id", "p.position_number", "p.org_unit_id", "p.is_chief", "j.title"])
.or(`valid_to.is.null,valid_to.gt.${asOf}`) .where("p.valid_from", "<=", asOf)
.order("id") .where((eb) => eb.or([eb("p.valid_to", "is", null), eb("p.valid_to", ">", asOf)]))
), .orderBy("p.id")
fetchAllRows(() => .execute(),
supabase
.from("position_assignments") tx
.select("employee_id, position_id") .selectFrom("position_assignments")
.lte("valid_from", asOf) .select(["employee_id", "position_id"])
.or(`valid_to.is.null,valid_to.gt.${asOf}`) .where("valid_from", "<=", asOf)
.order("employee_id") .where((eb) => eb.or([eb("valid_to", "is", null), eb("valid_to", ">", asOf)]))
), .orderBy("employee_id")
fetchAllRows(() => .execute(),
supabase
.from("employees") tx
.select("id, personnel_number, first_name, last_name, job_title, karenz_start_date, karenz_return_date, absence_type") .selectFrom("employees")
.order("id") .select([
), "id",
"personnel_number",
"first_name",
"last_name",
"job_title",
"karenz_start_date",
"karenz_return_date",
"absence_type",
])
.orderBy("id")
.execute(),
asOf > today asOf > today
? fetchAllRows(() => ? tx
supabase .selectFrom("pending_org_changes")
.from("pending_org_changes") .select(["employee_id", "effective_date", "payload"])
.select("employee_id, effective_date, payload") .where("status", "=", "pending")
.eq("status", "pending") .where("effective_date", "<=", asOf)
.lte("effective_date", asOf) .where("change_type", "in", [...PLACEMENT_CHANGES])
.in("change_type", [...PLACEMENT_CHANGES]) .orderBy("effective_date")
.order("effective_date") .execute()
)
: Promise.resolve([]), : 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({ return resolveOrgSnapshot({
asOf, asOf,
units: units.map((u) => ({ id: u.id, parentId: u.parent_id })), 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[], assignments: assignments as AssignmentRow[],
employees: employees as EmployeeRow[], employees: employees as EmployeeRow[],
pending: pending as PendingRow[], 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 { sql, type Tx } from "./db";
import { fetchAllRows } from "./supabase/query";
import type { Database } from "./supabase/types";
// Wo jemand in der Organisation steht, steht nicht mehr auf der Person. Es // Wo jemand in der Organisation steht, steht nicht mehr auf der Person. Es
// ergibt sich aus der Planstelle, die sie zum Stichtag innehat: // ergibt sich aus der Planstelle, die sie zum Stichtag innehat:
@@ -24,30 +22,25 @@ export type Placement = {
current: boolean; 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 = { type Row = {
employee_id: string; employee_id: string;
valid_from: string; valid_from: string;
valid_to: string | null; valid_to: string | null;
om_positions: { position_id: string;
id: string;
position_number: string; position_number: string;
org_unit_id: string; org_unit_id: string;
is_chief: boolean; is_chief: boolean;
jobs: { title: string }; job_title: string;
};
}; };
function toPlacement(row: Row, asOf: string): Placement { function toPlacement(row: Row, asOf: string): Placement {
return { return {
employeeId: row.employee_id, employeeId: row.employee_id,
positionId: row.om_positions.id, positionId: row.position_id,
positionNumber: row.om_positions.position_number, positionNumber: row.position_number,
orgUnitId: row.om_positions.org_unit_id, orgUnitId: row.org_unit_id,
isChief: row.om_positions.is_chief, isChief: row.is_chief,
jobTitle: row.om_positions.jobs.title, jobTitle: row.job_title,
validFrom: row.valid_from, validFrom: row.valid_from,
validTo: row.valid_to, validTo: row.valid_to,
current: row.valid_from <= asOf && (row.valid_to === null || row.valid_to > asOf), 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( export async function loadPlacements(
supabase: SupabaseClient<Database>, tx: Tx,
{ asOf, employeeIds }: { asOf: string; employeeIds?: string[] } { asOf, employeeIds }: { asOf: string; employeeIds?: string[] }
): Promise<Map<string, Placement>> { ): Promise<Map<string, Placement>> {
if (employeeIds?.length === 0) return new Map(); if (employeeIds?.length === 0) return new Map();
const rows = await fetchAllRows(() => { // Ein Join statt einer eingebetteten Ressource. Und ohne die
const q = supabase.from("position_assignments").select(SELECT).order("employee_id"); // 1000-Zeilen-Grenze von PostgREST fällt das seitenweise Nachladen weg,
return employeeIds ? q.in("employee_id", employeeIds) : q; // 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 ────────────────────────────────────── // ── Abgeleitete Berichtslinie ──────────────────────────────────────
@@ -105,11 +114,30 @@ export type ReportingLine = {
acting_manager_id: string | null; 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( export async function loadReportingLines(
supabase: SupabaseClient<Database>, tx: Tx,
asOf: string asOf: string,
): Promise<Map<string, ReportingLine>> { filter?: { employeeId?: string; actingManagerId?: string }
const { data, error } = await supabase.rpc("om_reporting_lines", { p_as_of: asOf }); ): Promise<ReportingLine[]> {
if (error) throw new Error(`Berichtslinie konnte nicht geladen werden: ${error.message}`); const conditions = [sql`true`];
return new Map(((data ?? []) as ReportingLine[]).map((l) => [l.employee_id, l])); 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 { todayIso } from "./format";
import { breadcrumbLabel, loadOrgMaps, type OrgMaps } from "./org"; 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 // Eine offene Stelle ist keine eigene Sache mehr. Sie ist eine Planstelle
// ohne laufende Besetzung — Vakanz ist eine Eigenschaft der Planstelle, kein // ohne laufende Besetzung — Vakanz ist eine Eigenschaft der Planstelle, kein
@@ -23,16 +21,6 @@ export type OpenPositionResolved = {
vacantSince: string; 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 * Wer eine unbesetzte Planstelle führen würde: die Leitung der eigenen
* Einheit, für eine Leitungsplanstelle die der übergeordneten — dieselbe * 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; 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 asOf = todayIso();
const [orgMaps, positions] = await Promise.all([ const [orgMaps, open] = await Promise.all([
loadOrgMaps(supabase), loadOrgMaps(tx),
fetchAllRows(() => // Unbesetzt heisst: keine am Stichtag laufende Zuordnung. Als NOT EXISTS
supabase // in der Datenbank statt als Filter über alle Planstellen im Speicher.
.from("om_positions") tx
.select( .selectFrom("om_positions as p")
"id, position_number, org_unit_id, is_chief, valid_from, jobs!inner(title), position_assignments(employee_id, valid_from, valid_to)" .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 []; if (open.length === 0) return [];
// Die Leitung der zuständigen Einheit — genau die Planstellen, die als const positionIds = open.map((p) => p.id);
// 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 chiefNameByUnit = new Map( // Zwei Nachschläge: seit wann die Stelle leer steht, und wer sie führen
chiefs.flatMap((c) => { // würde.
const holder = c.position_assignments[0]?.employees; const [ended, chiefs] = await Promise.all([
return holder ? [[c.org_unit_id, `${holder.first_name} ${holder.last_name}`] as const] : []; 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) => { 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); const managerUnit = managerUnitFor(orgMaps, p.org_unit_id, p.is_chief);
return { return {
id: p.id, id: p.id,
position_number: p.position_number, position_number: p.position_number,
title: p.jobs.title, title: p.title,
org_unit_id: p.org_unit_id, org_unit_id: p.org_unit_id,
is_chief: p.is_chief, is_chief: p.is_chief,
valid_from: p.valid_from, valid_from: p.valid_from,
managerName: managerUnit ? (chiefNameByUnit.get(managerUnit) ?? null) : null, managerName: managerUnit ? (chiefNameByUnit.get(managerUnit) ?? null) : null,
orgLabel: breadcrumbLabel(orgMaps, p.org_unit_id), 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 { ancestorsOf, loadOrgMaps, subtreeOf, type OrgMaps } from "./org";
import { loadPlacements } from "./placement"; import { loadPlacements } from "./placement";
import { deriveStatusAsOf, EVENT_DATE_OPEN, parseStatuses, todayIso, type OrgLookups, type ReportEmployee, type ReportEvent } from "./reports"; import { deriveStatusAsOf, EVENT_DATE_OPEN, parseStatuses, todayIso, type OrgLookups, type ReportEmployee, type ReportEvent } from "./reports";
import { fetchAllRows } from "./supabase/query"; import type { EmploymentType, HistoryEventType } from "./supabase/types";
import type { Database, EmploymentType, HistoryEventType } from "./supabase/types";
// Shared by the Berichte page and /api/export/* so they can never drift on // 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 // 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])) }; 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; lookups: OrgLookups;
orgMaps: OrgMaps; orgMaps: OrgMaps;
divisions: { id: string; name: string }[]; divisions: { id: string; name: string }[];
locations: { 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 })); const locations = orgMaps.locationList.map((l) => ({ id: l.id, name: l.name }));
return { return {
@@ -60,37 +59,61 @@ export async function loadOrgLookups(supabase: SupabaseClient<Database>): Promis
}; };
} }
const SNAPSHOT_EMPLOYEE_COLUMNS = 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"; "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 // Anzahl der Angehörigen je Person. Nur der Fremdschlüssel wird gelesen —
// column (no dependent PII needed) since only per-employee counts feed the // für die Berichtsdimensionen zählt die Anzahl, nicht wer es ist.
// has_dependents/avg_dependents report dimensions; counted client-side export async function loadDependentsCounts(tx: Tx): Promise<Map<string, number>> {
// since the Supabase JS client has no `count(*) group by employee_id` // Am direkten Zugang zählt die Datenbank, statt dass die Anwendung alle
// shorthand. Shared by the Bestand pivot and the full employees export. // Zeilen holt und sie selbst durchgeht.
export async function loadDependentsCounts(supabase: SupabaseClient<Database>): Promise<Map<string, number>> { const rows = await tx
const rows = await fetchAllRows(() => supabase.from("employee_dependents").select("employee_id").order("employee_id")); .selectFrom("employee_dependents")
const counts = new Map<string, number>(); .select(({ fn }) => ["employee_id", fn.countAll<string>().as("anzahl")])
for (const d of rows) counts.set(d.employee_id, (counts.get(d.employee_id) ?? 0) + 1); .groupBy("employee_id")
return counts; .execute();
return new Map(rows.map((r) => [r.employee_id, Number(r.anzahl)]));
} }
// Bestand zum Stichtag: Status *und* Einordnung werden auf `asOf` aufgelöst. // 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(); const asOf = filters.asOf || todayIso();
function snapshotQuery() { function snapshotQuery() {
let query = supabase.from("employees").select(SNAPSHOT_EMPLOYEE_COLUMNS).order("id"); let q = tx.selectFrom("employees").select([...SNAPSHOT_EMPLOYEE_COLUMNS]).orderBy("id");
if (filters.location) query = query.eq("location_id", filters.location); if (filters.location) q = q.where("location_id", "=", filters.location);
if (filters.employment) query = query.eq("employment_type", filters.employment as EmploymentType); if (filters.employment) q = q.where("employment_type", "=", filters.employment as EmploymentType);
return query; return q;
} }
const [data, dependentsCounts, placements, orgMaps] = await Promise.all([ const [data, dependentsCounts, placements, orgMaps] = await Promise.all([
fetchAllRows(snapshotQuery), snapshotQuery().execute(),
loadDependentsCounts(supabase), loadDependentsCounts(tx),
loadPlacements(supabase, { asOf }), loadPlacements(tx, { asOf }),
filters.division ? loadOrgMaps(supabase) : Promise.resolve(null), filters.division ? loadOrgMaps(tx) : Promise.resolve(null),
]); ]);
// Der Bereichsfilter meint den ganzen Teilbaum: „Produktion" schliesst // 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 // from/to: "" (unset) falls back to the current calendar year; the literal
// sentinel EVENT_DATE_OPEN means that side of the interval is intentionally // sentinel EVENT_DATE_OPEN means that side of the interval is intentionally
// unbounded (e.g. "alle Ereignisse bis heute", no start date). // 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 currentYear = new Date().getFullYear();
const from = filters.from === EVENT_DATE_OPEN ? undefined : filters.from || `${currentYear}-01-01`; 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`; const to = filters.to === EVENT_DATE_OPEN ? undefined : filters.to || `${currentYear}-12-31`;
function historyQuery() { function historyQuery() {
let query = supabase.from("employee_history").select("employee_id, event_date, event_type, description").order("id"); let q = tx
if (from) query = query.gte("event_date", from); .selectFrom("employee_history")
if (to) query = query.lte("event_date", to); .select(["employee_id", "event_date", "event_type", "description"])
if (filters.eventType) query = query.eq("event_type", filters.eventType); .orderBy("id");
return query; 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([ const [history, employees, assignments, orgMaps] = await Promise.all([
fetchAllRows(historyQuery), historyQuery().execute(),
fetchAllRows(() => supabase.from("employees").select("id, first_name, last_name, job_title, location_id").order("id")), tx.selectFrom("employees").select(["id", "first_name", "last_name", "job_title", "location_id"]).orderBy("id").execute(),
fetchAllRows(() => tx
supabase .selectFrom("position_assignments as a")
.from("position_assignments") .innerJoin("om_positions as p", "p.id", "a.position_id")
.select("employee_id, valid_from, valid_to, om_positions!inner(org_unit_id)") .select(["a.employee_id", "a.valid_from", "a.valid_to", "p.org_unit_id"])
.order("employee_id") .orderBy("a.employee_id")
), .execute(),
filters.division ? loadOrgMaps(supabase) : Promise.resolve(null), filters.division ? loadOrgMaps(tx) : Promise.resolve(null),
]); ]);
const spans = new Map<string, { from: string; to: string | null; unitId: string }[]>(); const spans = new Map<string, { from: string; to: string | null; unitId: string }[]>();
for (const a of assignments as unknown as { for (const a of assignments) {
employee_id: string;
valid_from: string;
valid_to: string | null;
om_positions: { org_unit_id: string };
}[]) {
const list = spans.get(a.employee_id) ?? []; 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); 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; if (filters.location && emp.location_id !== filters.location) continue;
const unitId = 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; if (allowedUnits && (!unitId || !allowedUnits.has(unitId))) continue;
events.push({ 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;
}

View File

@@ -1,9 +1,11 @@
import { describe, expect, it } from "vitest"; import { Kysely, PostgresDialect } from "kysely";
import { applyDerivedStatusFilter } from "@/lib/employee-status-filter"; import { Pool } from "pg";
import { afterAll, describe, expect, it } from "vitest";
import { derivedStatusFilter } from "@/lib/employee-status-filter";
import type { Schema } from "@/lib/db/schema";
import { todayIso } from "@/lib/format"; import { todayIso } from "@/lib/format";
import { deriveStatusAsOf } from "@/lib/reports"; import { deriveStatusAsOf } from "@/lib/reports";
import type { EmploymentStatus } from "@/lib/supabase/types"; import type { EmploymentStatus } from "@/lib/supabase/types";
import { adminClient } from "./helpers";
// lib/employee-status-filter.ts is a SQL restatement of deriveStatusAsOf(): // lib/employee-status-filter.ts is a SQL restatement of deriveStatusAsOf():
// the employee list pages in the database and cannot derive status in JS, so // the employee list pages in the database and cannot derive status in JS, so
@@ -13,22 +15,36 @@ import { adminClient } from "./helpers";
// //
// Only a real database can settle it, so this runs both over the whole // Only a real database can settle it, so this runs both over the whole
// seeded roster and demands the same set of ids. // seeded roster and demands the same set of ids.
describe("derived status filter matches deriveStatusAsOf", () => { //
// Eigene Verbindung statt der Zugriffsschicht: geprüft wird die Bedingung,
// nicht die Berechtigung. Mit RLS dazwischen liefe der Test gegen eine
// gefilterte Teilmenge und bewiese nichts über die Regel.
const db = new Kysely<Schema>({
dialect: new PostgresDialect({ pool: new Pool({ connectionString: process.env.DATABASE_URL, max: 2 }) }),
});
describe.skipIf(!process.env.DATABASE_URL)("derived status filter matches deriveStatusAsOf", () => {
const asOf = todayIso(); const asOf = todayIso();
afterAll(async () => {
await db.destroy();
});
async function idsFromDatabase(statuses: EmploymentStatus[]): Promise<Set<string>> { async function idsFromDatabase(statuses: EmploymentStatus[]): Promise<Set<string>> {
const query = adminClient.from("employees").select("id"); const rows = await db
const { data, error } = await applyDerivedStatusFilter(query, statuses, asOf); .selectFrom("employees")
if (error) throw new Error(error.message); .select("id")
return new Set((data ?? []).map((r) => r.id)); .where((eb) => derivedStatusFilter(eb, statuses, asOf) ?? eb.val(true))
.execute();
return new Set(rows.map((r) => r.id));
} }
async function idsFromDerivation(statuses: EmploymentStatus[]): Promise<Set<string>> { async function idsFromDerivation(statuses: EmploymentStatus[]): Promise<Set<string>> {
const { data, error } = await adminClient const rows = await db
.from("employees") .selectFrom("employees")
.select("id, entry_date, exit_date, karenz_start_date, karenz_return_date"); .select(["id", "entry_date", "exit_date", "karenz_start_date", "karenz_return_date"])
if (error) throw new Error(error.message); .execute();
return new Set((data ?? []).filter((e) => statuses.includes(deriveStatusAsOf(e, asOf))).map((e) => e.id)); return new Set(rows.filter((e) => statuses.includes(deriveStatusAsOf(e, asOf))).map((e) => e.id));
} }
async function expectSameSet(statuses: EmploymentStatus[]) { async function expectSameSet(statuses: EmploymentStatus[]) {
@@ -68,12 +84,11 @@ describe("derived status filter matches deriveStatusAsOf", () => {
}); });
it("returns everyone when no status is selected", async () => { it("returns everyone when no status is selected", async () => {
const { count: total } = await adminClient.from("employees").select("id", { count: "exact", head: true }); const total = await db
const { count: filtered } = await applyDerivedStatusFilter( .selectFrom("employees")
adminClient.from("employees").select("id", { count: "exact", head: true }), .select(({ fn }) => fn.countAll<string>().as("anzahl"))
[], .executeTakeFirstOrThrow();
asOf const filtered = await idsFromDatabase([]);
); expect(filtered.size).toBe(Number(total.anzahl));
expect(filtered).toBe(total);
}); });
}); });

View File

@@ -9,14 +9,18 @@ import {
parseMeasure, parseMeasure,
parseSplitDimension, parseSplitDimension,
} from "@/lib/reports"; } from "@/lib/reports";
import { sanitizeIlikeTerm } from "@/lib/supabase/query";
// The route under test imports lib/supabase/admin.ts, which is guarded by // Die Cron-Route lädt lib/db, das über `import "server-only"` abgesichert
// `import "server-only"` — that throws when loaded outside Next's own // ist und beim Laden eine DATABASE_URL verlangt — beides gibt es unter
// server compilation (e.g. here, under plain Vitest/Node). Mock it out: // reinem Vitest nicht. Weggemockt: geprüft wird hier nur der
// these tests only exercise the auth guard, which returns before the real // Geheimnis-Abgleich, der zurückkehrt, bevor irgendeine Verbindung
// admin client is ever created. // entsteht.
vi.mock("@/lib/supabase/admin", () => ({ createAdminClient: vi.fn() })); // `import "server-only"` wirft ausserhalb der Server-Übersetzung von Next —
// also auch hier. Der Riegel ist im Betrieb richtig; für den Test wird das
// Modul zu einer leeren Hülle.
vi.mock("server-only", () => ({}));
vi.mock("@/lib/db", () => ({ asSystem: vi.fn(), withUser: vi.fn() }));
vi.mock("@/lib/db/rpc", () => ({ callFunction: vi.fn() }));
describe("sanitizeForSpreadsheetCell", () => { describe("sanitizeForSpreadsheetCell", () => {
it("prefixes values that would be read as a formula by Excel/Sheets", () => { it("prefixes values that would be read as a formula by Excel/Sheets", () => {
@@ -37,20 +41,11 @@ describe("sanitizeForSpreadsheetCell", () => {
}); });
}); });
describe("sanitizeIlikeTerm", () => { // Der Test zu sanitizeIlikeTerm ist entfallen, weil die Funktion es ist.
it("strips PostgREST or-filter delimiter characters", () => { // Sie entschärfte Zeichen, die in der Filtersyntax der alten API-Schicht
expect(sanitizeIlikeTerm("a,b")).toBe("ab"); // strukturelle Bedeutung hatten. Am direkten Datenbankzugang wird der
expect(sanitizeIlikeTerm("a(b)c")).toBe("abc"); // Suchbegriff als Parameter gebunden — ein Komma oder eine Klammer darin
// An attempt to close the current ilike condition and append another // ist schlicht ein Zeichen. Die Lücke ist nicht abgesichert, sondern weg.
// column filter is neutralized by removing the delimiters, not escaped
// into a differently-structured (but still injected) query.
expect(sanitizeIlikeTerm("x),sv_nummer.ilike.%")).toBe("xsv_nummer.ilike.%");
});
it("leaves a normal search term untouched", () => {
expect(sanitizeIlikeTerm("Gruber")).toBe("Gruber");
});
});
describe("report query-string parsing", () => { describe("report query-string parsing", () => {
it("falls back to a known dimension instead of passing an unknown one through", () => { it("falls back to a known dimension instead of passing an unknown one through", () => {
@@ -152,6 +147,6 @@ describe("protected export route without a session (/api/export/employees)", ()
const request = new NextRequest("http://localhost/api/export/employees"); const request = new NextRequest("http://localhost/api/export/employees");
const res = await GET(request); const res = await GET(request);
expect(res.status).toBe(401); expect(res.status).toBe(401);
vi.doUnmock("@/lib/supabase/server"); vi.doUnmock("@/lib/auth/session");
}); });
}); });