diff --git a/actions/employees.ts b/actions/employees.ts index 987e98d..3034921 100644 --- a/actions/employees.ts +++ b/actions/employees.ts @@ -1,18 +1,16 @@ "use server"; import { revalidatePath } from "next/cache"; -import { createClient } from "@/lib/supabase/server"; -import type { CollectiveAgreement, Database, NoteCategory, RelationshipType, Weekday, WorkerType } from "@/lib/supabase/types"; - -type ActionResult = { success: boolean; error?: string }; -type MutationFn = keyof Database["public"]["Functions"]; +import { currentUserId } from "@/lib/auth/session"; +import { withUser } from "@/lib/db"; +import { callFunction, runMutation, type ActionResult, type MutationFn } from "@/lib/db/rpc"; +import type { CollectiveAgreement, NoteCategory, RelationshipType, Weekday, WorkerType } from "@/lib/supabase/types"; async function callRpc(fn: MutationFn, payload: Record, revalidate: string[]): Promise { - const supabase = await createClient(); - const { error } = await supabase.rpc(fn, { payload }); - if (error) return { success: false, error: error.message }; + const result = await runMutation(await currentUserId(), fn, payload); + if (!result.success) return result; for (const path of revalidate) revalidatePath(path); - return { success: true }; + return result; } export async function hireEmployee(payload: { @@ -43,13 +41,19 @@ export async function hireEmployee(payload: { is_laterale_fuehrung?: boolean; is_c_level?: boolean; }): Promise { - const supabase = await createClient(); - const { data, error } = await supabase.rpc("hire_employee", { payload }); - if (error) return { success: false, error: error.message }; - revalidatePath("/employees"); - revalidatePath("/"); - revalidatePath("/positions"); - return { success: true, employeeId: data as string }; + // Einzige Mutation, deren Rückgabewert gebraucht wird: die neue + // Personen-Kennung, damit die Oberfläche direkt auf die Akte springen kann. + try { + const employeeId = await withUser(await currentUserId(), (tx) => + callFunction(tx, "hire_employee", payload as Record) + ); + revalidatePath("/employees"); + revalidatePath("/"); + revalidatePath("/positions"); + 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: { diff --git a/actions/hireDrafts.ts b/actions/hireDrafts.ts index 66552de..a60a9c0 100644 --- a/actions/hireDrafts.ts +++ b/actions/hireDrafts.ts @@ -1,45 +1,52 @@ "use server"; import { revalidatePath } from "next/cache"; -import { createClient } from "@/lib/supabase/server"; - -type ActionResult = { success: boolean; error?: string }; +import { currentUserId } from "@/lib/auth/session"; +import { withUser } from "@/lib/db"; +import type { ActionResult } from "@/lib/db/rpc"; export async function saveHireDraft(payload: { id?: string; step: number; data: Record; }): Promise { - const supabase = await createClient(); - const { - data: { user }, - } = await supabase.auth.getUser(); - if (!user) return { success: false, error: "Nicht angemeldet." }; + const userId = await currentUserId(); + if (!userId) return { success: false, error: "Nicht angemeldet." }; + + try { + const id = await withUser(userId, async (tx) => { + if (payload.id) { + // Ob die Zeile der aufrufenden Person gehört, entscheidet die + // Policy hire_drafts_owner — nicht eine Prüfung hier. + await tx + .updateTable("hire_drafts") + .set({ step: payload.step, payload: payload.data, updated_at: new Date().toISOString() }) + .where("id", "=", payload.id) + .execute(); + return payload.id; + } + + const row = await tx + .insertInto("hire_drafts") + .values({ created_by: userId, step: payload.step, payload: payload.data }) + .returning("id") + .executeTakeFirstOrThrow(); + return row.id; + }); - if (payload.id) { - const { error } = await supabase - .from("hire_drafts") - .update({ step: payload.step, payload: payload.data, updated_at: new Date().toISOString() }) - .eq("id", payload.id); - if (error) return { success: false, error: error.message }; revalidatePath("/"); - return { success: true, id: payload.id }; + return { success: true, id }; + } catch (err) { + return { success: false, error: err instanceof Error ? err.message : "Unbekannter Fehler." }; } - - const { data, error } = await supabase - .from("hire_drafts") - .insert({ created_by: user.id, step: payload.step, payload: payload.data }) - .select("id") - .single(); - if (error) return { success: false, error: error.message }; - revalidatePath("/"); - return { success: true, id: data.id }; } export async function deleteHireDraft(id: string): Promise { - const supabase = await createClient(); - const { error } = await supabase.from("hire_drafts").delete().eq("id", id); - if (error) return { success: false, error: error.message }; - revalidatePath("/"); - return { success: true }; + try { + await withUser(await currentUserId(), (tx) => tx.deleteFrom("hire_drafts").where("id", "=", id).execute()); + revalidatePath("/"); + return { success: true }; + } catch (err) { + return { success: false, error: err instanceof Error ? err.message : "Unbekannter Fehler." }; + } } diff --git a/actions/positions.ts b/actions/positions.ts index 2e43393..35df7fb 100644 --- a/actions/positions.ts +++ b/actions/positions.ts @@ -1,9 +1,8 @@ "use server"; import { revalidatePath } from "next/cache"; -import { createClient } from "@/lib/supabase/server"; - -type ActionResult = { success: boolean; error?: string }; +import { currentUserId } from "@/lib/auth/session"; +import { runMutation, type ActionResult } from "@/lib/db/rpc"; const POSITION_PATHS = ["/positions", "/orgchart", "/"]; @@ -12,11 +11,10 @@ async function callRpc( payload: Record, revalidate: string[] ): Promise { - const supabase = await createClient(); - const { error } = await supabase.rpc(fn, { payload }); - if (error) return { success: false, error: error.message }; + const result = await runMutation(await currentUserId(), fn, payload); + if (!result.success) return result; for (const path of revalidate) revalidatePath(path); - return { success: true }; + return result; } export async function createPosition(payload: { diff --git a/actions/reports.ts b/actions/reports.ts index 0558ff1..b0d21e3 100644 --- a/actions/reports.ts +++ b/actions/reports.ts @@ -1,27 +1,31 @@ "use server"; import { revalidatePath } from "next/cache"; -import { createClient } from "@/lib/supabase/server"; - -type ActionResult = { success: boolean; error?: string }; +import { currentUserId } from "@/lib/auth/session"; +import { withUser } from "@/lib/db"; +import type { ActionResult } from "@/lib/db/rpc"; export async function saveReport(payload: { name: string; config: Record }): Promise { - const supabase = await createClient(); - const { - data: { user }, - } = await supabase.auth.getUser(); - if (!user) return { success: false, error: "Nicht angemeldet." }; + const userId = await currentUserId(); + if (!userId) return { success: false, error: "Nicht angemeldet." }; - const { error } = await supabase.from("saved_reports").insert({ created_by: user.id, name: payload.name, config: payload.config }); - if (error) return { success: false, error: error.message }; - revalidatePath("/reports"); - return { success: true }; + try { + await withUser(userId, (tx) => + tx.insertInto("saved_reports").values({ created_by: userId, name: payload.name, config: payload.config }).execute() + ); + revalidatePath("/reports"); + return { success: true }; + } catch (err) { + return { success: false, error: err instanceof Error ? err.message : "Unbekannter Fehler." }; + } } export async function deleteReport(id: string): Promise { - const supabase = await createClient(); - const { error } = await supabase.from("saved_reports").delete().eq("id", id); - if (error) return { success: false, error: error.message }; - revalidatePath("/reports"); - return { success: true }; + try { + await withUser(await currentUserId(), (tx) => tx.deleteFrom("saved_reports").where("id", "=", id).execute()); + revalidatePath("/reports"); + return { success: true }; + } catch (err) { + return { success: false, error: err instanceof Error ? err.message : "Unbekannter Fehler." }; + } } diff --git a/app/(app)/audit/page.tsx b/app/(app)/audit/page.tsx index 704f248..585dbeb 100644 --- a/app/(app)/audit/page.tsx +++ b/app/(app)/audit/page.tsx @@ -4,8 +4,8 @@ import { AuditFilters } from "@/components/audit/AuditFilters"; import { CARD_CLASS } from "@/components/ui/Card"; import { Pagination } from "@/components/ui/Pagination"; import { actionBadgeStyle } from "@/lib/colors"; -import { sanitizeIlikeTerm } from "@/lib/supabase/query"; -import { createClient } from "@/lib/supabase/server"; +import { currentUserId } from "@/lib/auth/session"; +import { withUser } from "@/lib/db"; const PAGE_SIZE = 25; @@ -34,33 +34,50 @@ const dateTimeFormatter = new Intl.DateTimeFormat("de-AT", { export default async function AuditPage({ searchParams }: { searchParams: Promise }) { const params = await searchParams; - const supabase = await createClient(); - 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 - .from("audit_log") - .select("id, occurred_at, actor_name, action, target_label, target_employee_id, details", { count: "exact" }) - .order("occurred_at", { ascending: false }) - .range(from, to); + const { entries, count } = await withUser(await currentUserId(), async (tx) => { + const base = () => { + let q = tx.selectFrom("audit_log"); + if (params.action) q = q.where("action", "=", params.action); + if (params.q) { + // Als Parameter gebunden statt in die Abfrage geschrieben: die + // 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; + }; - if (params.action) query = query.eq("action", params.action); - if (params.q) { - const q = sanitizeIlikeTerm(params.q.trim()); - query = query.or(`target_label.ilike.%${q}%,details.ilike.%${q}%,actor_name.ilike.%${q}%`); - } + const [entries, total] = await Promise.all([ + 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().as("anzahl")) + .executeTakeFirst(), + ]); + return { entries, count: Number(total?.anzahl ?? 0) }; + }); - const { data: entries, count } = await query; - const totalPages = Math.max(1, Math.ceil((count ?? 0) / PAGE_SIZE)); + const totalPages = Math.max(1, Math.ceil(count / PAGE_SIZE)); return (
-

{count ?? 0} Einträge

+

{count} Einträge

@@ -74,7 +91,7 @@ export default async function AuditPage({ searchParams }: { searchParams: Promis - {(entries ?? []).map((entry) => { + {entries.map((entry) => { return ( ); })} - {(entries ?? []).length === 0 && ( + {entries.length === 0 && (
@@ -102,7 +119,7 @@ export default async function AuditPage({ searchParams }: { searchParams: Promis
Keine Einträge gefunden. diff --git a/app/(app)/employees/[id]/page.tsx b/app/(app)/employees/[id]/page.tsx index 7003c7a..467d28f 100644 --- a/app/(app)/employees/[id]/page.tsx +++ b/app/(app)/employees/[id]/page.tsx @@ -1,68 +1,78 @@ import { notFound } from "next/navigation"; import { EmployeeDetail } from "@/components/employees/EmployeeDetail"; +import { currentUserId } from "@/lib/auth/session"; +import { withUser } from "@/lib/db"; import { todayIso } from "@/lib/format"; 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 { createClient } from "@/lib/supabase/server"; type PageProps = { params: Promise<{ id: string }> }; export default async function EmployeeDetailPage({ params }: PageProps) { const { id } = await params; - const supabase = await createClient(); const today = todayIso(); - // Vorgesetzte und direkte Berichte stehen nirgends als Spalte — sie kommen - // aus om_reporting_lines(). Beide Abfragen filtern *in* der Funktion, es - // wandern also neun Zeilen über die Leitung und nicht achthundert. - const [ - { data: employee }, - { data: ownLine }, - { data: reportLines }, - { data: history }, - { data: dependents }, - { data: notes }, - orgMaps, - placements, - openPositions, - ] = await Promise.all([ - supabase.from("employees").select("*").eq("id", id).single(), - supabase.rpc("om_reporting_lines", { p_as_of: today }).eq("employee_id", id).maybeSingle(), - supabase.rpc("om_reporting_lines", { p_as_of: today }).eq("acting_manager_id", id), - supabase - .from("employee_history") - .select("*") - .eq("employee_id", id) - .order("event_date", { ascending: false }) - .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), - ]); + const data = await withUser(await currentUserId(), async (tx) => { + // Vorgesetzte und direkte Berichte stehen nirgends als Spalte — sie + // kommen aus om_reporting_lines(). Beide Abfragen schränken *in* der + // Funktion ein, es wandern also neun Zeilen über die Leitung und nicht + // achthundert. + const [employee, ownLines, reports, history, dependents, notes, orgMaps, placements, openPositions] = + await Promise.all([ + tx.selectFrom("employees").selectAll().where("id", "=", id).executeTakeFirst(), + loadReportingLines(tx, today, { employeeId: id }), + loadReportingLines(tx, today, { actingManagerId: id }), + tx + .selectFrom("employee_history") + .selectAll() + .where("employee_id", "=", id) + .orderBy("event_date", "desc") + .orderBy("created_at", "desc") + .execute(), + tx.selectFrom("employee_dependents").selectAll().where("employee_id", "=", id).orderBy("created_at").execute(), + tx.selectFrom("employee_notes").selectAll().where("employee_id", "=", id).orderBy("created_at", "desc").execute(), + loadOrgMaps(tx), + loadPlacements(tx, { asOf: today, employeeIds: [id] }), + loadOpenPositions(tx), + ]); - 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 - // formal zuständige Leitung und die direkten Berichte. - const relatedIds = Array.from( - new Set( - [line?.acting_manager_id, line?.formal_manager_id, ...reports.map((r) => r.employee_id)].filter( - (x): x is string => Boolean(x) + // Namen für die beteiligten Personen in einem Zug: die Vertretung, die + // formal zuständige Leitung und die direkten Berichte. + const relatedIds = Array.from( + new Set( + [line?.acting_manager_id, line?.formal_manager_id, ...reports.map((r) => r.employee_id)].filter( + (x): x is string => Boolean(x) + ) ) - ) - ); - const { data: relatedRows } = relatedIds.length - ? await supabase.from("employees").select("id, first_name, last_name, job_title, status").in("id", relatedIds) - : { data: [] }; - const byId = new Map((relatedRows ?? []).map((e) => [e.id, e])); + ); + const relatedRows = relatedIds.length + ? await tx + .selectFrom("employees") + .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 ( 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 EmployeesPageProps = { @@ -47,25 +33,9 @@ function pageHref(params: SearchParams, page: number): string { export default async function EmployeesPage({ searchParams }: EmployeesPageProps) { const params = await searchParams; - const supabase = await createClient(); - 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(); - // 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 // status set it counted rather than a narrower one. const statuses = (params.status ?? "") @@ -73,47 +43,100 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps .map((s) => s.trim()) .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 - // Select-Formen unten ihre Zeilenform behalten. Ein bedingt - // zusammengesetzter Select-String wird zu einer Union zweier Literale, die - // der Typparser von postgrest-js nicht mehr auflösen kann — daher zwei - // getrennte Abfragen mit einer gemeinsamen Filterkette. - function applyFilters(query: Q): Q { - let q = query; - if (params.q) { - const term = params.q.trim(); - if (/^\d+$/.test(term)) q = q.eq("personnel_number", Number(term)) as Q; - else { - const safe = sanitizeIlikeTerm(term); - q = q.or(`first_name.ilike.%${safe}%,last_name.ilike.%${safe}%,job_title.ilike.%${safe}%`) as Q; + const { orgMaps, employees, count, placements } = await withUser(await currentUserId(), async (tx) => { + // Die Referenzdaten zuerst: der Bereichsfilter braucht den Teilbaum. + // „Produktion" meint die Abteilungen und Teams darunter — in der Einheit + // selbst sitzt nur die Bereichsleitung. + const orgMaps = await loadOrgMaps(tx); + const unitFilter = params.division && orgMaps.units.has(params.division) ? params.division : null; + + // Eine Filterkette, zwei Abfragen: eine für die Seite, eine für die + // Gesamtzahl. Am direkten Zugang teilen sie sich denselben Aufbau — + // vorher brauchte es zwei getrennte Select-Formen, weil der Typparser der + // API-Schicht einen bedingt zusammengesetzten Select-String nicht + // auflösen konnte. + const base = () => { + let q = tx.selectFrom("employees"); + + if (unitFilter) { + // Nach Organisationseinheit gefiltert wird über die *laufende* + // Besetzung. Als EXISTS, damit eine Person nicht mehrfach erscheint, + // wenn sie über die Zeit mehrere Zuordnungen hatte. + 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) + ) + ); } - } - // Derived from the dates, not read off employees.status — see - // lib/employee-status-filter.ts for why the two can disagree. - q = applyDerivedStatusFilter(q, statuses, today); - if (params.location) q = q.eq("location_id", params.location) as Q; - return q; - } - const { data: employeesData, count } = unitFilter - ? await applyFilters( - supabase - .from("employees") - .select(`${COLUMNS}, position_assignments!inner(valid_to, om_positions!inner(org_unit_id))`, { count: "exact" }) - .order("last_name", { ascending: true }) - .range(from, to) - .is("position_assignments.valid_to", null) - .in("position_assignments.om_positions.org_unit_id", subtreeOf(orgMaps, unitFilter)) - ) - : await applyFilters( - 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)); + if (params.q) { + const term = params.q.trim(); + if (/^d+$/.test(term)) { + q = q.where("personnel_number", "=", Number(term)); + } else { + // Als Parameter gebunden statt in die Abfrage geschrieben: die + // Zeichen, die in der alten Filtersyntax ausbrechen konnten, sind + // hier bedeutungslos. + const like = `%${term}%`; + q = q.where((eb) => + eb.or([eb("first_name", "ilike", like), eb("last_name", "ilike", like), eb("job_title", "ilike", like)]) + ); + } + } - // Die Einordnung kommt über die Planstelle — nur für die 15 Zeilen dieser - // Seite, nicht für den ganzen Bestand. - const placements = await loadPlacements(supabase, { asOf: today, employeeIds: employees.map((e) => e.id) }); + // 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().as("anzahl")) + .executeTakeFirst(), + ]); + + // Die Einordnung kommt über die Planstelle — nur für die 15 Zeilen dieser + // Seite, nicht für den ganzen Bestand. + 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 (
diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx index f189f25..f7c1275 100644 --- a/app/(app)/layout.tsx +++ b/app/(app)/layout.tsx @@ -2,36 +2,51 @@ import { redirect } from "next/navigation"; import type { ReactNode } from "react"; import { HireWizardProvider } from "@/components/hire/HireWizardContext"; import { AppShell } from "@/components/shell/AppShell"; +import { currentUserId } from "@/lib/auth/session"; +import { withUser } from "@/lib/db"; import { loadOpenNotes } from "@/lib/notes"; import { loadOpenPositions } from "@/lib/positions"; -import { createClient } from "@/lib/supabase/server"; export default async function AppLayout({ children }: { children: ReactNode }) { - const supabase = await createClient(); - const { - data: { user }, - } = await supabase.auth.getUser(); - if (!user) redirect("/login"); + const userId = await currentUserId(); + if (!userId) redirect("/login"); - // 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 - // the proxy matcher (or a future route added outside it) still fails - // 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(); - if (profile?.role !== "hr" || profile?.is_active !== true) 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 + // 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 + // closed instead of silently granting access — see docs/security.md. + const profile = await tx + .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), + tx.selectFrom("locations").select(["id", "name", "country"]).orderBy("name").execute(), + tx + .selectFrom("hire_drafts") + .select(["id", "step", "payload", "updated_at"]) + .where("created_by", "=", userId) + .orderBy("updated_at", "desc") + .execute(), + loadOpenNotes(tx), + ]); - const [openPositions, locationsRes, draftsRes, openNotes] = await Promise.all([ - loadOpenPositions(supabase), - supabase.from("locations").select("id, name, country").order("name"), - supabase.from("hire_drafts").select("id, step, payload, updated_at").eq("created_by", user.id).order("updated_at", { ascending: false }), - loadOpenNotes(supabase), - ]); + return { profile, openPositions, locations, drafts, openNotes }; + }); + + if (!data) redirect("/login"); + + const userLabel = data.profile.full_name || data.profile.email || ""; return ( - - + + {children} diff --git a/app/(app)/orgchart/page.tsx b/app/(app)/orgchart/page.tsx index 6f288de..aa2d7bb 100644 --- a/app/(app)/orgchart/page.tsx +++ b/app/(app)/orgchart/page.tsx @@ -4,7 +4,8 @@ import type { OrgUnitNode } from "@/components/orgchart/types"; import { todayIso } from "@/lib/format"; import { loadOrgAsOf } from "@/lib/orgchart-data"; 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 }; @@ -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. const focusId = params.focus && UUID.test(params.focus) ? params.focus : null; - const supabase = await createClient(); - - const [org, { data: units }] = await Promise.all([ - loadOrgAsOf(supabase, asOf), - supabase.from("org_units").select("id, org_number, name, parent_id, unit_type").order("org_number"), - ]); + const { org, units } = await withUser(await currentUserId(), async (tx) => { + const [org, units] = await Promise.all([ + loadOrgAsOf(tx, asOf), + tx.selectFrom("org_units").select(["id", "org_number", "name", "parent_id", "unit_type"]).orderBy("org_number").execute(), + ]); + return { org, units }; + }); return ( = { const KIND_LABEL = { hire: "Eintritt", exit: "Austritt", return: "Rückkehr aus Abwesenheit" } as const; 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 // 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 @@ -61,6 +50,8 @@ export default async function DashboardPage() { const yearEnd = `${year}-12-31`; const in60Iso = addDaysIso(today, 60); + const userId = await currentUserId(); + // Headcount, FTE, Karenz and the division bars all come from one full read // and the *derived* status, not from the `employees.status` column. // @@ -69,70 +60,116 @@ export default async function DashboardPage() { // planned hire whose start date has passed, or a Karenz that ended without // anyone recording the return, made the dashboard and the Berichte page // disagree about the same headcount. Same derivation, same numbers. - // It also replaces four separate count queries with one. - const [ + const { + drafts, staffRows, - hiresYtdRes, - exitsYtdRes, + hiresYtd, + exitsYtd, openPositions, orgMaps, placements, - upcomingHiresRes, - upcomingExitsRes, - upcomingReturnsRes, - historyRes, - ] = await Promise.all([ - fetchAllRows(() => - supabase - .from("employees") - .select("id, weekly_hours, entry_date, exit_date, karenz_start_date, karenz_return_date") - .order("id") - ), - // Entries/exits count history events, which is what the linked report - // counts too. `entry_date` would also sweep up rehires, whose event is - // logged as 'Wiedereintritt' — the tile and its destination then showed - // different numbers for the same year. - supabase - .from("employee_history") - .select("id", { count: "exact", head: true }) - .in("event_type", ["Eintritt", "Wiedereintritt"]) - .gte("event_date", yearStart) - .lte("event_date", yearEnd), - supabase - .from("employee_history") - .select("id", { count: "exact", head: true }) - .eq("event_type", "Austritt") - .gte("event_date", yearStart) - .lte("event_date", yearEnd), - loadOpenPositions(supabase), - loadOrgMaps(supabase), - loadPlacements(supabase, { asOf: today }), - supabase - .from("employees") - .select("id, first_name, last_name, entry_date") - .eq("status", "Geplant") - .gte("entry_date", today) - .lte("entry_date", in60Iso), - supabase - .from("employees") - .select("id, first_name, last_name, exit_date") - .not("exit_date", "is", null) - .gte("exit_date", today) - .lte("exit_date", in60Iso), - supabase - .from("employees") - .select("id, first_name, last_name, karenz_return_date") - .eq("status", "Karenz") - .not("karenz_return_date", "is", null) - .gte("karenz_return_date", today) - .lte("karenz_return_date", in60Iso), - supabase - .from("employee_history") - .select("id, employee_id, event_date, event_type, description") - .order("event_date", { ascending: false }) - .order("created_at", { ascending: false }) - .limit(10), - ]); + upcomingHires, + upcomingExits, + upcomingReturns, + history, + } = await withUser(userId, async (tx) => { + const countIn = (types: readonly HistoryEventType[]) => + tx + .selectFrom("employee_history") + .select(({ fn }) => fn.countAll().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([ + userId + ? tx + .selectFrom("hire_drafts") + .select(["id", "step", "payload", "updated_at"]) + .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 + // counts too. `entry_date` would also sweep up rehires, whose event is + // logged as 'Wiedereintritt' — the tile and its destination then showed + // different numbers for the same year. + countIn(["Eintritt", "Wiedereintritt"]), + countIn(["Austritt"]), + + loadOpenPositions(tx), + loadOrgMaps(tx), + loadPlacements(tx, { asOf: today }), + + tx + .selectFrom("employees") + .select(["id", "first_name", "last_name", "entry_date"]) + .where("status", "=", "Geplant") + .where("entry_date", ">=", today) + .where("entry_date", "<=", in60Iso) + .execute(), + + tx + .selectFrom("employees") + .select(["id", "first_name", "last_name", "exit_date"]) + .where("exit_date", "is not", null) + .where("exit_date", ">=", today) + .where("exit_date", "<=", in60Iso) + .execute(), + + tx + .selectFrom("employees") + .select(["id", "first_name", "last_name", "karenz_return_date"]) + .where("status", "=", "Karenz") + .where("karenz_return_date", "is not", null) + .where("karenz_return_date", ">=", today) + .where("karenz_return_date", "<=", in60Iso) + .execute(), + + tx + .selectFrom("employee_history as h") + .leftJoin("employees as e", "e.id", "h.employee_id") + .select(["h.id", "h.employee_id", "h.event_date", "h.event_type", "h.description", "e.first_name", "e.last_name"]) + .orderBy("h.event_date", "desc") + .orderBy("h.created_at", "desc") + .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 // active, and is counted by its own tile instead. FTE follows the same @@ -166,19 +203,19 @@ export default async function DashboardPage() { type UpcomingItem = { id: string; label: string; date: string; kind: keyof typeof KIND_LABEL }; const upcoming: UpcomingItem[] = [ - ...(upcomingHiresRes.data ?? []).map((e) => ({ + ...(upcomingHires).map((e) => ({ id: e.id, label: `${e.first_name} ${e.last_name}`, date: e.entry_date, kind: "hire" as const, })), - ...(upcomingExitsRes.data ?? []).map((e) => ({ + ...(upcomingExits).map((e) => ({ id: e.id, label: `${e.first_name} ${e.last_name}`, date: e.exit_date!, kind: "exit" as const, })), - ...(upcomingReturnsRes.data ?? []).map((e) => ({ + ...(upcomingReturns).map((e) => ({ id: e.id, label: `${e.first_name} ${e.last_name}`, date: e.karenz_return_date!, @@ -188,12 +225,6 @@ export default async function DashboardPage() { .sort((a, b) => a.date.localeCompare(b.date)) .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 // 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: "Eintritte (Jahr)", - value: hiresYtdRes.count ?? 0, + value: hiresYtd, tone: "success", href: `/reports?mode=events&eventType=Eintritt&from=${yearStart}&to=${yearEnd}`, }, { label: "Austritte (Jahr)", - value: exitsYtdRes.count ?? 0, + value: exitsYtd, tone: "danger", href: `/reports?mode=events&eventType=Austritt&from=${yearStart}&to=${yearEnd}`, }, @@ -300,14 +331,14 @@ export default async function DashboardPage() { Letzte Aktivitäten
    - {(historyRes.data ?? []).map((h) => ( + {(history).map((h) => (
  • {/* Dot aligned to the first line of text, not centred on the whole row, so it stays put as descriptions wrap. */}
    - {employeeNameById.get(h.employee_id) ?? "Unbekannt"} + {h.first_name && h.last_name ? `${h.first_name} ${h.last_name}` : "Unbekannt"} {h.event_type} @@ -316,7 +347,7 @@ export default async function DashboardPage() {
  • ))} - {(historyRes.data ?? []).length === 0 &&

    Keine Aktivitäten vorhanden.

    } + {(history).length === 0 &&

    Keine Aktivitäten vorhanden.

    }
diff --git a/app/(app)/positions/page.tsx b/app/(app)/positions/page.tsx index c942a4a..12bb401 100644 --- a/app/(app)/positions/page.tsx +++ b/app/(app)/positions/page.tsx @@ -3,21 +3,23 @@ import { PositionsPageClient } from "@/components/positions/PositionsPageClient" import { daysBetweenIso } from "@/lib/format"; import { loadOrgMaps } from "@/lib/org"; 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() { - const supabase = await createClient(); + const { openPositions, orgMaps, chiefRows } = await withUser(await currentUserId(), async (tx) => { + const [openPositions, orgMaps, chiefRows] = await Promise.all([ + loadOpenPositions(tx), + loadOrgMaps(tx), + // 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 + // Fehlermeldung nach dem Absenden. + tx.selectFrom("om_positions").select("org_unit_id").where("is_chief", "=", true).where("valid_to", "is", null).execute(), + ]); + return { openPositions, orgMaps, chiefRows }; + }); - const [openPositions, orgMaps, { data: chiefRows }] = await Promise.all([ - loadOpenPositions(supabase), - loadOrgMaps(supabase), - // 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 - // Fehlermeldung nach dem Absenden. - supabase.from("om_positions").select("org_unit_id").eq("is_chief", true).is("valid_to", null), - ]); - - 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) => ({ id: u.id, name: u.name, diff --git a/app/(app)/reports/page.tsx b/app/(app)/reports/page.tsx index d30ef89..5cc1949 100644 --- a/app/(app)/reports/page.tsx +++ b/app/(app)/reports/page.tsx @@ -16,7 +16,8 @@ import { totalForRows, } from "@/lib/reports"; 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 = { mode?: string; @@ -35,7 +36,6 @@ type SearchParams = { export default async function ReportsPage({ searchParams }: { searchParams: Promise }) { const params = await searchParams; - const supabase = await createClient(); const mode = parseMode(params.mode); // Both modes are parsed up front so the data load can start before @@ -55,34 +55,44 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom // 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 // sequential waves its dominant cost. - const [{ lookups, divisions, locations }, { data: userRes }, events, employees] = await Promise.all([ - loadOrgLookups(supabase), - supabase.auth.getUser(), - mode === "events" - ? loadEventHistory(supabase, { - eventType: eventType ?? undefined, - division: params.division, - location: params.location, - from, - to, - }) - : Promise.resolve([]), - mode === "snapshot" - ? loadSnapshotEmployees(supabase, { - division: params.division, - location: params.location, - status: params.status, - employment: params.employment, - asOf, - }) - : Promise.resolve([]), - ]); + const userId = await currentUserId(); - 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: [] }; + // 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" + ? loadEventHistory(tx, { + eventType: eventType ?? undefined, + division: params.division, + location: params.location, + from, + to, + }) + : Promise.resolve([]), + mode === "snapshot" + ? loadSnapshotEmployees(tx, { + division: params.division, + location: params.location, + status: params.status, + employment: params.employment, + asOf, + }) + : 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 }; + }); if (mode === "events") { const rows = aggregateEvents(events, eventGroup, eventSplit, lookups); @@ -100,7 +110,7 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom recordCount={events.length} divisions={divisions} locations={locations} - savedReports={savedReports ?? []} + savedReports={savedReports} /> ); @@ -127,7 +137,7 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom recordCount={employees.length} divisions={divisions} locations={locations} - savedReports={savedReports ?? []} + savedReports={savedReports} /> ); diff --git a/app/api/cron/apply-pending-changes/route.ts b/app/api/cron/apply-pending-changes/route.ts index 2c32824..e3bc0fd 100644 --- a/app/api/cron/apply-pending-changes/route.ts +++ b/app/api/cron/apply-pending-changes/route.ts @@ -1,5 +1,6 @@ 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 // ä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 }); } - const supabase = createAdminClient(); - const { data, error } = await supabase.rpc("apply_due_pending_changes"); - - if (error) { - console.error("apply_due_pending_changes failed:", error); + // Kein privilegierter Zugang mehr: derselbe Datenbankbenutzer ohne + // BYPASSRLS wie überall. apply_due_pending_changes ist SECURITY DEFINER + // und prüft selbst, was sie tut — der Dienstschlüssel, der RLS aushebelte, + // ist damit entfallen. + 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({ applied: data }); } diff --git a/app/api/export/employees/route.ts b/app/api/export/employees/route.ts index 283f7eb..d295868 100644 --- a/app/api/export/employees/route.ts +++ b/app/api/export/employees/route.ts @@ -3,12 +3,11 @@ import { statusLabel } from "@/lib/absence"; import { exportFilename, exportResponseHeaders, toCsv, toXlsx, type ExportColumn } from "@/lib/export"; import { todayIso } from "@/lib/format"; 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 { loadDependentsCounts, loadOrgLookups, type ReportFilters } from "@/lib/reports-data"; -import { requireHrUser } from "@/lib/supabase/auth"; -import { fetchAllRows } from "@/lib/supabase/query"; -import { createClient } from "@/lib/supabase/server"; +import { requireHrUser } from "@/lib/auth/require-hr"; +import { withUser } from "@/lib/db"; import type { Database, EmploymentType, Weekday } from "@/lib/supabase/types"; // 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 // than the live `status` column — see deriveStatusAsOf. export async function GET(request: NextRequest) { - const supabase = await createClient(); - const denied = await requireHrUser(supabase); - if (denied) return denied; + const gate = await requireHrUser(); + if ("denied" in gate) return gate.denied; const params = request.nextUrl.searchParams; const format = params.get("format") === "xlsx" ? "xlsx" : "csv"; @@ -44,22 +42,37 @@ export async function GET(request: NextRequest) { const stichtag = asOf ?? todayIso(); - function employeeQuery() { - let query = supabase.from("employees").select("*").order("last_name").order("id"); - if (filters.location) query = query.eq("location_id", filters.location); - if (filters.employment) query = query.eq("employment_type", filters.employment as EmploymentType); - if (!asOf) query = query.in("status", statuses); - return query; - } + const { employees, lookups, orgMaps, allEmployees, dependentsCounts, placements, lines } = await withUser( + gate.userId, + async (tx) => { + function employeeQuery() { + let q = tx.selectFrom("employees").selectAll().orderBy("last_name").orderBy("id"); + if (filters.location) q = q.where("location_id", "=", filters.location); + if (filters.employment) q = q.where("employment_type", "=", filters.employment as EmploymentType); + if (!asOf) q = q.where("status", "in", statuses); + return q; + } - const [employees, { lookups, orgMaps }, allEmployees, dependentsCounts, placements, lines] = await Promise.all([ - fetchAllRows(employeeQuery), - loadOrgLookups(supabase), - fetchAllRows(() => supabase.from("employees").select("id, first_name, last_name").order("id")), - loadDependentsCounts(supabase), - loadPlacements(supabase, { asOf: stichtag }), - loadReportingLines(supabase, stichtag), - ]); + const [employees, lookupResult, allEmployees, dependentsCounts, placements, lines] = await Promise.all([ + employeeQuery().execute(), + loadOrgLookups(tx), + tx.selectFrom("employees").select(["id", "first_name", "last_name"]).orderBy("id").execute(), + loadDependentsCounts(tx), + loadPlacements(tx, { asOf: stichtag }), + loadReportingLineMap(tx, stichtag), + ]); + + return { + employees, + lookups: lookupResult.lookups, + orgMaps: lookupResult.orgMaps, + allEmployees, + dependentsCounts, + placements, + lines, + }; + } + ); const managerName = new Map(allEmployees.map((e) => [e.id, `${e.first_name} ${e.last_name}`])); // Der Einheitenfilter meint den ganzen Teilbaum — sonst enthielte ein diff --git a/app/api/export/events/route.ts b/app/api/export/events/route.ts index 8a03382..2459670 100644 --- a/app/api/export/events/route.ts +++ b/app/api/export/events/route.ts @@ -2,32 +2,34 @@ import { NextResponse, type NextRequest } from "next/server"; import { exportFilename, exportResponseHeaders, toCsv, toXlsx, type ExportColumn } from "@/lib/export"; import { EVENT_TYPE_LABELS, parseEventDateParam, parseEventType, type OrgLookups, type ReportEvent } from "@/lib/reports"; import { loadEventHistory, loadOrgLookups } from "@/lib/reports-data"; -import { requireHrUser } from "@/lib/supabase/auth"; -import { createClient } from "@/lib/supabase/server"; +import { requireHrUser } from "@/lib/auth/require-hr"; +import { withUser } from "@/lib/db"; // Full raw event-log dump — one row per employee_history entry in the // selected period (default: current year), every event type unless one is // picked, org columns resolved from each affected employee's current // placement (see loadEventHistory). export async function GET(request: NextRequest) { - const supabase = await createClient(); - const denied = await requireHrUser(supabase); - if (denied) return denied; + const gate = await requireHrUser(); + if ("denied" in gate) return gate.denied; const params = request.nextUrl.searchParams; const format = params.get("format") === "xlsx" ? "xlsx" : "csv"; const eventType = parseEventType(params.get("eventType")); - const [{ lookups }, events] = await Promise.all([ - loadOrgLookups(supabase), - loadEventHistory(supabase, { - eventType: eventType ?? undefined, - division: params.get("division") ?? undefined, - location: params.get("location") ?? undefined, - from: parseEventDateParam(params.get("from")), - to: parseEventDateParam(params.get("to")), - }), - ]); + const { lookups, events } = await withUser(gate.userId, async (tx) => { + const [{ lookups }, events] = await Promise.all([ + loadOrgLookups(tx), + loadEventHistory(tx, { + eventType: eventType ?? undefined, + division: params.get("division") ?? undefined, + location: params.get("location") ?? undefined, + from: parseEventDateParam(params.get("from")), + to: parseEventDateParam(params.get("to")), + }), + ]); + return { lookups, events }; + }); const columns = eventExportColumns(lookups); const filename = exportFilename(`ereignisse-${eventType ?? "alle"}`, format); diff --git a/app/api/export/report/route.ts b/app/api/export/report/route.ts index 5e306a6..233e5b0 100644 --- a/app/api/export/report/route.ts +++ b/app/api/export/report/route.ts @@ -24,59 +24,64 @@ import { type ReportRow, } from "@/lib/reports"; import { loadEventHistory, loadOrgLookups, loadSnapshotEmployees } from "@/lib/reports-data"; -import { requireHrUser } from "@/lib/supabase/auth"; -import { createClient } from "@/lib/supabase/server"; +import { requireHrUser } from "@/lib/auth/require-hr"; +import { withUser } from "@/lib/db"; // Exports exactly the pivot table currently on screen (same mode/measure or // 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 // per split value if a split is active. export async function GET(request: NextRequest) { - const supabase = await createClient(); - const denied = await requireHrUser(supabase); - if (denied) return denied; + const gate = await requireHrUser(); + if ("denied" in gate) return gate.denied; const params = request.nextUrl.searchParams; const format = params.get("format") === "xlsx" ? "xlsx" : "csv"; const 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 columns: ExportColumn[]; + let filenameBase: string; - let rows: ReportRow[]; - let columns: ExportColumn[]; - let filenameBase: string; + if (mode === "events") { + const group = parseEventGroupDimension(params.get("group")); + const split = parseEventSplitDimension(params.get("split")); + const eventType = parseEventType(params.get("eventType")); + const events = await loadEventHistory(tx, { + eventType: eventType ?? undefined, + division: params.get("division") ?? undefined, + location: params.get("location") ?? undefined, + from: parseEventDateParam(params.get("from")), + to: parseEventDateParam(params.get("to")), + }); + rows = aggregateEvents(events, group, split, lookups); + columns = eventReportColumns(rows, group, split, sumValues(rows)); + filenameBase = `ereignisse-${eventType ?? "alle"}-${group}`; + } else { + const measure = parseMeasure(params.get("measure")); + const group = parseGroupDimension(params.get("group")); + const split = parseSplitDimension(params.get("split")); + const asOf = parseIsoDateParam(params.get("asOf")); + const employees = await loadSnapshotEmployees(tx, { + division: params.get("division") ?? undefined, + location: params.get("location") ?? undefined, + status: params.get("status") ?? undefined, + employment: params.get("employment") ?? undefined, + asOf, + }); + rows = aggregateReport(employees, measure, group, split, lookups, asOf); + columns = snapshotReportColumns(rows, measure, group, split, totalForRows(rows, measure)); + filenameBase = `bericht-${measure}-${group}`; + } - if (mode === "events") { - const group = parseEventGroupDimension(params.get("group")); - const split = parseEventSplitDimension(params.get("split")); - const eventType = parseEventType(params.get("eventType")); - const events = await loadEventHistory(supabase, { - eventType: eventType ?? undefined, - division: params.get("division") ?? undefined, - location: params.get("location") ?? undefined, - from: parseEventDateParam(params.get("from")), - to: parseEventDateParam(params.get("to")), - }); - rows = aggregateEvents(events, group, split, lookups); - columns = eventReportColumns(rows, group, split, sumValues(rows)); - filenameBase = `ereignisse-${eventType ?? "alle"}-${group}`; - } else { - const measure = parseMeasure(params.get("measure")); - const group = parseGroupDimension(params.get("group")); - const split = parseSplitDimension(params.get("split")); - const asOf = parseIsoDateParam(params.get("asOf")); - const employees = await loadSnapshotEmployees(supabase, { - division: params.get("division") ?? undefined, - location: params.get("location") ?? undefined, - status: params.get("status") ?? undefined, - employment: params.get("employment") ?? undefined, - asOf, - }); - rows = aggregateReport(employees, measure, group, split, lookups, asOf); - columns = snapshotReportColumns(rows, measure, group, split, totalForRows(rows, measure)); - filenameBase = `bericht-${measure}-${group}`; - } + return { rows, columns, filenameBase }; + }); const filename = exportFilename(filenameBase, format); + const body = format === "xlsx" ? await toXlsx(rows, columns, "Bericht") : toCsv(rows, columns); // TS 5.9's Uint8Array vs DOM's BlobPart/ArrayBuffer<> generic // mismatch (microsoft/TypeScript#59417) — a real Uint8Array works fine here. diff --git a/lib/auth/require-hr.ts b/lib/auth/require-hr.ts new file mode 100644 index 0000000..2497e11 --- /dev/null +++ b/lib/auth/require-hr.ts @@ -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 { + 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 }; +} diff --git a/lib/auth/session.ts b/lib/auth/session.ts new file mode 100644 index 0000000..6c348bb --- /dev/null +++ b/lib/auth/session.ts @@ -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 { + 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 { + const id = await currentUserId(); + if (!id) throw new Error("Nicht angemeldet."); + return id; +} diff --git a/lib/db/rpc.ts b/lib/db/rpc.ts new file mode 100644 index 0000000..fade4bb --- /dev/null +++ b/lib/db/rpc.ts @@ -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): Promise { + // 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 +): Promise { + 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." }; + } +} diff --git a/lib/employee-status-filter.ts b/lib/employee-status-filter.ts index cdc811b..5dd722f 100644 --- a/lib/employee-status-filter.ts +++ b/lib/employee-status-filter.ts @@ -1,3 +1,5 @@ +import type { Expression, ExpressionBuilder, SqlBool } from "kysely"; +import type { Schema } from "./db/schema"; import type { EmploymentStatus } from "./supabase/types"; // The SQL counterpart of deriveStatusAsOf() in lib/reports.ts. @@ -19,58 +21,59 @@ import type { EmploymentStatus } from "./supabase/types"; // tests/integration/employee-status-filter.test.ts asserts the two agree // against a real database, which is the only place that can prove it. -type Filterable = { - gt: (column: string, value: string) => Filterable; - lte: (column: string, value: string) => Filterable; - gte: (column: string, value: string) => Filterable; - or: (filters: string) => Filterable; - is: (column: string, value: null) => Filterable; - not: (column: string, operator: string, value: null) => Filterable; -}; +type Eb = ExpressionBuilder; /** True once the person has started and has not left yet. */ -function employed(query: Q, asOf: string): Q { - return query.lte("entry_date", asOf).or(`exit_date.is.null,exit_date.gt.${asOf}`) as Q; +function employed(eb: Eb, asOf: string): Expression { + return eb.and([eb("entry_date", "<=", asOf), eb.or([eb("exit_date", "is", null), eb("exit_date", ">", asOf)])]); } /** - * Narrows a PostgREST query to the employees whose *derived* status on - * `asOf` is one of `statuses`. Only the combinations the UI offers are - * supported; anything else is left unfiltered rather than silently applying - * a wrong one. + * Die Bedingung für die Menge, deren *abgeleiteter* Status am Stichtag einer + * der genannten ist — oder null, wenn nicht eingeschränkt werden soll. + * + * Nur die Kombinationen, die die Oberfläche anbietet, sind abgedeckt. Für + * alles andere kommt null zurück: lieber nicht filtern als falsch filtern. */ -export function applyDerivedStatusFilter(query: Q, statuses: EmploymentStatus[], asOf: string): Q { +export function derivedStatusFilter(eb: Eb, statuses: EmploymentStatus[], asOf: string): Expression | null { const wanted = new Set(statuses); - if (wanted.size === 0) return query; + if (wanted.size === 0) return null; // A single non-employed status is a straight date comparison. - if (wanted.size === 1 && wanted.has("Geplant")) return query.gt("entry_date", asOf) as Q; - if (wanted.size === 1 && wanted.has("Ausgetreten")) return query.not("exit_date", "is", null).lte("exit_date", asOf) as Q; + if (wanted.size === 1 && wanted.has("Geplant")) return eb("entry_date", ">", asOf); + if (wanted.size === 1 && wanted.has("Ausgetreten")) { + return eb.and([eb("exit_date", "is not", null), eb("exit_date", "<=", asOf)]); + } const wantsAktiv = wanted.has("Aktiv"); const wantsKarenz = wanted.has("Karenz"); - if (wantsAktiv && wantsKarenz && wanted.size === 2) { - // Everyone employed today, whether or not they are on leave. - return employed(query, asOf); - } + // Everyone employed today, whether or not they are on leave. + if (wantsAktiv && wantsKarenz && wanted.size === 2) return employed(eb, asOf); if (wantsKarenz && !wantsAktiv && wanted.size === 1) { - return employed(query, asOf) - .not("karenz_start_date", "is", null) - .lte("karenz_start_date", asOf) - .or(`karenz_return_date.is.null,karenz_return_date.gt.${asOf}`) as Q; + return eb.and([ + employed(eb, asOf), + eb("karenz_start_date", "is not", null), + eb("karenz_start_date", "<=", asOf), + eb.or([eb("karenz_return_date", "is", null), eb("karenz_return_date", ">", asOf)]), + ]); } if (wantsAktiv && !wantsKarenz && wanted.size === 1) { // Employed but *not* inside a karenz window: either no start date, a // start still ahead, or a return that has already happened. - return employed(query, asOf).or( - `karenz_start_date.is.null,karenz_start_date.gt.${asOf},karenz_return_date.lte.${asOf}` - ) as Q; + return eb.and([ + employed(eb, asOf), + eb.or([ + eb("karenz_start_date", "is", null), + eb("karenz_start_date", ">", asOf), + eb("karenz_return_date", "<=", asOf), + ]), + ]); } // Mixed selections spanning employed and non-employed states have no UI // path today; filtering on a guess would be worse than not filtering. - return query; + return null; } diff --git a/lib/notes.ts b/lib/notes.ts index 72aaebf..794124a 100644 --- a/lib/notes.ts +++ b/lib/notes.ts @@ -1,26 +1,32 @@ -import type { SupabaseClient } from "@supabase/supabase-js"; -import { fetchAllRows } from "./supabase/query"; +import type { Tx } from "./db"; import type { Database } from "./supabase/types"; export type OpenNote = Database["public"]["Tables"]["employee_notes"]["Row"] & { employeeName: string; }; -// "Meine Notizen" (Topbar-Glocke): das geteilte, mitarbeiterübergreifende -// Postfach aller noch nicht erledigten HR-Notizen — unabhängig davon wer -// sie verfasst hat oder zu wem sie gehören (mit Nutzer abgestimmt). Zwei -// einfache Queries, in JS gemerged — gleiches Muster wie loadEventHistory -// in lib/reports-data.ts, da der handgeschriebene Database-Typ keine -// relationalen Embeddings für eine einzelne verschachtelte Query kennt. -export async function loadOpenNotes(supabase: SupabaseClient): Promise { - const [{ data: notes }, employees] = await Promise.all([ - supabase.from("employee_notes").select("*").eq("done", false).order("created_at", { ascending: false }), - fetchAllRows(() => supabase.from("employees").select("id, first_name, last_name").order("id")), - ]); +// „Meine Notizen" (Topbar-Glocke): das geteilte, mitarbeiterübergreifende +// Postfach aller noch nicht erledigten HR-Notizen — unabhängig davon, wer sie +// verfasst hat oder zu wem sie gehören (mit Nutzer abgestimmt). +// +// Früher zwei Abfragen, in JavaScript zusammengeführt, weil die API-Schicht +// für eine einzelne verschachtelte Abfrage keine Verknüpfung anbot. Am +// direkten Zugang ist es schlicht ein Join. +export async function loadOpenNotes(tx: Tx): Promise { + const rows = await tx + .selectFrom("employee_notes as n") + .leftJoin("employees as e", "e.id", "n.employee_id") + .selectAll("n") + .select(["e.first_name", "e.last_name"]) + .where("n.done", "=", false) + .orderBy("n.created_at", "desc") + .execute(); - const employeeById = new Map(employees.map((e) => [e.id, e])); - return (notes ?? []).map((n) => { - const emp = employeeById.get(n.employee_id); - return { ...n, employeeName: emp ? `${emp.first_name} ${emp.last_name}` : "Unbekannt" }; + return rows.map((row) => { + const { first_name, last_name, ...note } = row; + return { + ...(note as Database["public"]["Tables"]["employee_notes"]["Row"]), + employeeName: first_name && last_name ? `${first_name} ${last_name}` : "Unbekannt", + }; }); } diff --git a/lib/org.ts b/lib/org.ts index f5675a8..9296189 100644 --- a/lib/org.ts +++ b/lib/org.ts @@ -1,4 +1,4 @@ -import type { SupabaseClient } from "@supabase/supabase-js"; +import type { Tx } from "./db"; import type { Database } from "./supabase/types"; // Die Organisation ist ein Baum, keine drei Tabellen mehr. Alles, was früher @@ -31,13 +31,17 @@ export type OrgMaps = { // Die Referenzdaten sind winzig (60 Einheiten, 5 Standorte) — sie werden // ganz geladen und im Speicher verknüpft, statt je Zeile nachzuschlagen. -export async function loadOrgMaps(supabase: SupabaseClient): Promise { - const [{ data: units }, { data: locations }] = await Promise.all([ - supabase.from("org_units").select("id, org_number, name, parent_id, unit_type").order("org_number"), - supabase.from("locations").select("*").order("name"), +export async function loadOrgMaps(tx: Tx): Promise { + const [units, locations] = await Promise.all([ + tx + .selectFrom("org_units") + .select(["id", "org_number", "name", "parent_id", "unit_type"]) + .orderBy("org_number") + .execute(), + tx.selectFrom("locations").selectAll().orderBy("name").execute(), ]); - return buildOrgMaps((units ?? []) as OrgUnit[], locations ?? []); + return buildOrgMaps(units as OrgUnit[], locations as Location[]); } /** Der reine Teil: aus den Zeilen den Baum bauen, ohne Datenbank. */ diff --git a/lib/orgchart-data.ts b/lib/orgchart-data.ts index 480efde..eed1adc 100644 --- a/lib/orgchart-data.ts +++ b/lib/orgchart-data.ts @@ -1,9 +1,7 @@ -import type { SupabaseClient } from "@supabase/supabase-js"; import type { OrgEmployee, OrgVacancy } from "@/components/orgchart/types"; +import type { Tx } from "./db"; import { todayIso } from "./format"; import { resolveReportingLines, type OmHolder, type OmUnit } from "./om-reporting"; -import { fetchAllRows } from "./supabase/query"; -import type { Database } from "./supabase/types"; // Das Organigramm, wie es an einem Stichtag stand oder stehen wird. // @@ -56,55 +54,76 @@ type AssignmentRow = { employee_id: string; position_id: string }; type PendingRow = { employee_id: string; effective_date: string; payload: Record }; -export async function loadOrgAsOf(supabase: SupabaseClient, asOf: string): Promise { +export async function loadOrgAsOf(tx: Tx, asOf: string): Promise { const today = todayIso(); + // Ohne die 1000-Zeilen-Grenze der API-Schicht fällt das seitenweise + // Nachladen weg: sechs Abfragen, jede vollständig. const [units, positions, assignments, employees, pending, earliest] = await Promise.all([ - fetchAllRows(() => supabase.from("org_units").select("id, parent_id").order("id")), - fetchAllRows(() => - supabase - .from("om_positions") - .select("id, position_number, org_unit_id, is_chief, jobs!inner(title)") - .lte("valid_from", asOf) - .or(`valid_to.is.null,valid_to.gt.${asOf}`) - .order("id") - ), - fetchAllRows(() => - supabase - .from("position_assignments") - .select("employee_id, position_id") - .lte("valid_from", asOf) - .or(`valid_to.is.null,valid_to.gt.${asOf}`) - .order("employee_id") - ), - fetchAllRows(() => - supabase - .from("employees") - .select("id, personnel_number, first_name, last_name, job_title, karenz_start_date, karenz_return_date, absence_type") - .order("id") - ), + tx.selectFrom("org_units").select(["id", "parent_id"]).orderBy("id").execute(), + + tx + .selectFrom("om_positions as p") + .innerJoin("jobs as j", "j.id", "p.job_id") + .select(["p.id", "p.position_number", "p.org_unit_id", "p.is_chief", "j.title"]) + .where("p.valid_from", "<=", asOf) + .where((eb) => eb.or([eb("p.valid_to", "is", null), eb("p.valid_to", ">", asOf)])) + .orderBy("p.id") + .execute(), + + tx + .selectFrom("position_assignments") + .select(["employee_id", "position_id"]) + .where("valid_from", "<=", asOf) + .where((eb) => eb.or([eb("valid_to", "is", null), eb("valid_to", ">", asOf)])) + .orderBy("employee_id") + .execute(), + + tx + .selectFrom("employees") + .select([ + "id", + "personnel_number", + "first_name", + "last_name", + "job_title", + "karenz_start_date", + "karenz_return_date", + "absence_type", + ]) + .orderBy("id") + .execute(), + asOf > today - ? fetchAllRows(() => - supabase - .from("pending_org_changes") - .select("employee_id, effective_date, payload") - .eq("status", "pending") - .lte("effective_date", asOf) - .in("change_type", [...PLACEMENT_CHANGES]) - .order("effective_date") - ) + ? tx + .selectFrom("pending_org_changes") + .select(["employee_id", "effective_date", "payload"]) + .where("status", "=", "pending") + .where("effective_date", "<=", asOf) + .where("change_type", "in", [...PLACEMENT_CHANGES]) + .orderBy("effective_date") + .execute() : Promise.resolve([]), - supabase.from("position_assignments").select("valid_from").order("valid_from").limit(1).maybeSingle(), + + tx.selectFrom("position_assignments").select("valid_from").orderBy("valid_from").limit(1).executeTakeFirst(), ]); return resolveOrgSnapshot({ asOf, units: units.map((u) => ({ id: u.id, parentId: u.parent_id })), - positions: positions as unknown as PositionRow[], + // Der Join liefert den Jobtitel flach; die reine Funktion erwartet ihn + // verschachtelt, weil sie so auch aus einem Testbestand gefüttert wird. + positions: positions.map((p) => ({ + id: p.id, + position_number: p.position_number, + org_unit_id: p.org_unit_id, + is_chief: p.is_chief, + jobs: { title: p.title }, + })), assignments: assignments as AssignmentRow[], employees: employees as EmployeeRow[], pending: pending as PendingRow[], - historyStartsAt: earliest.data?.valid_from ?? null, + historyStartsAt: earliest?.valid_from ?? null, }); } diff --git a/lib/placement.ts b/lib/placement.ts index 4dd7672..4534e6f 100644 --- a/lib/placement.ts +++ b/lib/placement.ts @@ -1,6 +1,4 @@ -import type { SupabaseClient } from "@supabase/supabase-js"; -import { fetchAllRows } from "./supabase/query"; -import type { Database } from "./supabase/types"; +import { sql, type Tx } from "./db"; // Wo jemand in der Organisation steht, steht nicht mehr auf der Person. Es // ergibt sich aus der Planstelle, die sie zum Stichtag innehat: @@ -24,30 +22,25 @@ export type Placement = { current: boolean; }; -const SELECT = - "employee_id, valid_from, valid_to, om_positions!inner(id, position_number, org_unit_id, is_chief, jobs!inner(title))"; - type Row = { employee_id: string; valid_from: string; valid_to: string | null; - om_positions: { - id: string; - position_number: string; - org_unit_id: string; - is_chief: boolean; - jobs: { title: string }; - }; + position_id: string; + position_number: string; + org_unit_id: string; + is_chief: boolean; + job_title: string; }; function toPlacement(row: Row, asOf: string): Placement { return { employeeId: row.employee_id, - positionId: row.om_positions.id, - positionNumber: row.om_positions.position_number, - orgUnitId: row.om_positions.org_unit_id, - isChief: row.om_positions.is_chief, - jobTitle: row.om_positions.jobs.title, + positionId: row.position_id, + positionNumber: row.position_number, + orgUnitId: row.org_unit_id, + isChief: row.is_chief, + jobTitle: row.job_title, validFrom: row.valid_from, validTo: row.valid_to, current: row.valid_from <= asOf && (row.valid_to === null || row.valid_to > asOf), @@ -76,17 +69,33 @@ export function pickPlacements(rows: Row[], asOf: string): Map, + tx: Tx, { asOf, employeeIds }: { asOf: string; employeeIds?: string[] } ): Promise> { if (employeeIds?.length === 0) return new Map(); - const rows = await fetchAllRows(() => { - const q = supabase.from("position_assignments").select(SELECT).order("employee_id"); - return employeeIds ? q.in("employee_id", employeeIds) : q; - }); + // Ein Join statt einer eingebetteten Ressource. Und ohne die + // 1000-Zeilen-Grenze von PostgREST fällt das seitenweise Nachladen weg, + // das es dafür brauchte. + let q = tx + .selectFrom("position_assignments as pa") + .innerJoin("om_positions as p", "p.id", "pa.position_id") + .innerJoin("jobs as j", "j.id", "p.job_id") + .select([ + "pa.employee_id", + "pa.valid_from", + "pa.valid_to", + "p.id as position_id", + "p.position_number", + "p.org_unit_id", + "p.is_chief", + "j.title as job_title", + ]) + .orderBy("pa.employee_id"); - return pickPlacements(rows as unknown as Row[], asOf); + if (employeeIds) q = q.where("pa.employee_id", "in", employeeIds); + + return pickPlacements((await q.execute()) as Row[], asOf); } // ── Abgeleitete Berichtslinie ────────────────────────────────────── @@ -105,11 +114,30 @@ export type ReportingLine = { acting_manager_id: string | null; }; +/** + * `filter` schränkt die Funktion selbst ein, nicht das Ergebnis im Speicher — + * bei der Detailseite wandern damit neun Zeilen über die Leitung statt + * achthundert. + */ export async function loadReportingLines( - supabase: SupabaseClient, - asOf: string -): Promise> { - const { data, error } = await supabase.rpc("om_reporting_lines", { p_as_of: asOf }); - if (error) throw new Error(`Berichtslinie konnte nicht geladen werden: ${error.message}`); - return new Map(((data ?? []) as ReportingLine[]).map((l) => [l.employee_id, l])); + tx: Tx, + asOf: string, + filter?: { employeeId?: string; actingManagerId?: string } +): Promise { + const conditions = [sql`true`]; + if (filter?.employeeId) conditions.push(sql`employee_id = ${filter.employeeId}::uuid`); + if (filter?.actingManagerId) conditions.push(sql`acting_manager_id = ${filter.actingManagerId}::uuid`); + + const result = await sql` + 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> { + const lines = await loadReportingLines(tx, asOf); + return new Map(lines.map((l) => [l.employee_id, l])); } diff --git a/lib/positions.ts b/lib/positions.ts index 064097a..7ec90b4 100644 --- a/lib/positions.ts +++ b/lib/positions.ts @@ -1,8 +1,6 @@ -import type { SupabaseClient } from "@supabase/supabase-js"; +import type { Tx } from "./db"; import { todayIso } from "./format"; import { breadcrumbLabel, loadOrgMaps, type OrgMaps } from "./org"; -import { fetchAllRows } from "./supabase/query"; -import type { Database } from "./supabase/types"; // Eine offene Stelle ist keine eigene Sache mehr. Sie ist eine Planstelle // ohne laufende Besetzung — Vakanz ist eine Eigenschaft der Planstelle, kein @@ -23,16 +21,6 @@ export type OpenPositionResolved = { vacantSince: string; }; -type PositionRow = { - id: string; - position_number: string; - org_unit_id: string; - is_chief: boolean; - valid_from: string; - jobs: { title: string }; - position_assignments: { employee_id: string; valid_from: string; valid_to: string | null }[]; -}; - /** * Wer eine unbesetzte Planstelle führen würde: die Leitung der eigenen * Einheit, für eine Leitungsplanstelle die der übergeordneten — dieselbe @@ -43,70 +31,89 @@ function managerUnitFor(maps: OrgMaps, orgUnitId: string, isChief: boolean): str return maps.units.get(orgUnitId)?.parent_id ?? null; } -export async function loadOpenPositions(supabase: SupabaseClient): Promise { +export async function loadOpenPositions(tx: Tx): Promise { const asOf = todayIso(); - const [orgMaps, positions] = await Promise.all([ - loadOrgMaps(supabase), - fetchAllRows(() => - supabase - .from("om_positions") - .select( - "id, position_number, org_unit_id, is_chief, valid_from, jobs!inner(title), position_assignments(employee_id, valid_from, valid_to)" + const [orgMaps, open] = await Promise.all([ + loadOrgMaps(tx), + // Unbesetzt heisst: keine am Stichtag laufende Zuordnung. Als NOT EXISTS + // in der Datenbank statt als Filter über alle Planstellen im Speicher. + tx + .selectFrom("om_positions as p") + .innerJoin("jobs as j", "j.id", "p.job_id") + .select(["p.id", "p.position_number", "p.org_unit_id", "p.is_chief", "p.valid_from", "j.title"]) + .where("p.valid_from", "<=", asOf) + .where((eb) => eb.or([eb("p.valid_to", "is", null), eb("p.valid_to", ">", asOf)])) + .where((eb) => + eb.not( + eb.exists( + eb + .selectFrom("position_assignments as a") + .select("a.id") + .whereRef("a.position_id", "=", "p.id") + .where("a.valid_from", "<=", asOf) + .where((e2) => e2.or([e2("a.valid_to", "is", null), e2("a.valid_to", ">", asOf)])) + ) ) - .lte("valid_from", asOf) - .or(`valid_to.is.null,valid_to.gt.${asOf}`) - .order("position_number") - ), + ) + .orderBy("p.position_number") + .execute(), ]); - const open = (positions as unknown as PositionRow[]).filter( - (p) => !p.position_assignments.some((a) => a.valid_from <= asOf && (a.valid_to === null || a.valid_to > asOf)) - ); if (open.length === 0) return []; - // Die Leitung der zuständigen Einheit — genau die Planstellen, die als - // Leitung markiert und laufend besetzt sind. - const chiefUnitIds = Array.from( - new Set(open.map((p) => managerUnitFor(orgMaps, p.org_unit_id, p.is_chief)).filter((id): id is string => Boolean(id))) - ); - const chiefs = chiefUnitIds.length - ? ((await fetchAllRows(() => - supabase - .from("om_positions") - .select("org_unit_id, position_assignments!inner(employees!inner(first_name, last_name), valid_to)") - .eq("is_chief", true) - .in("org_unit_id", chiefUnitIds) - .is("position_assignments.valid_to", null) - )) as unknown as { - org_unit_id: string; - position_assignments: { employees: { first_name: string; last_name: string } }[]; - }[]) - : []; + const positionIds = open.map((p) => p.id); - const chiefNameByUnit = new Map( - chiefs.flatMap((c) => { - const holder = c.position_assignments[0]?.employees; - return holder ? [[c.org_unit_id, `${holder.first_name} ${holder.last_name}`] as const] : []; - }) - ); + // Zwei Nachschläge: seit wann die Stelle leer steht, und wer sie führen + // würde. + const [ended, chiefs] = await Promise.all([ + tx + .selectFrom("position_assignments") + .select(["position_id", "valid_to"]) + .where("position_id", "in", positionIds) + .where("valid_to", "is not", null) + .execute(), + (async () => { + const chiefUnitIds = Array.from( + new Set( + open + .map((p) => managerUnitFor(orgMaps, p.org_unit_id, p.is_chief)) + .filter((id): id is string => Boolean(id)) + ) + ); + if (chiefUnitIds.length === 0) return []; + return tx + .selectFrom("om_positions as p") + .innerJoin("position_assignments as a", "a.position_id", "p.id") + .innerJoin("employees as e", "e.id", "a.employee_id") + .select(["p.org_unit_id", "e.first_name", "e.last_name"]) + .where("p.is_chief", "=", true) + .where("p.valid_to", "is", null) + .where("a.valid_to", "is", null) + .where("p.org_unit_id", "in", chiefUnitIds) + .execute(); + })(), + ]); + + const lastEndByPosition = new Map(); + for (const e of ended) { + const prev = lastEndByPosition.get(e.position_id); + if (e.valid_to && (!prev || e.valid_to > prev)) lastEndByPosition.set(e.position_id, e.valid_to); + } + const chiefNameByUnit = new Map(chiefs.map((c) => [c.org_unit_id, `${c.first_name} ${c.last_name}`])); return open.map((p) => { - const ended = p.position_assignments - .map((a) => a.valid_to) - .filter((d): d is string => d !== null) - .sort(); const managerUnit = managerUnitFor(orgMaps, p.org_unit_id, p.is_chief); return { id: p.id, position_number: p.position_number, - title: p.jobs.title, + title: p.title, org_unit_id: p.org_unit_id, is_chief: p.is_chief, valid_from: p.valid_from, managerName: managerUnit ? (chiefNameByUnit.get(managerUnit) ?? null) : null, orgLabel: breadcrumbLabel(orgMaps, p.org_unit_id), - vacantSince: ended.at(-1) ?? p.valid_from, + vacantSince: lastEndByPosition.get(p.id) ?? p.valid_from, }; }); } diff --git a/lib/reports-data.ts b/lib/reports-data.ts index dbbf432..b948971 100644 --- a/lib/reports-data.ts +++ b/lib/reports-data.ts @@ -1,9 +1,8 @@ -import type { SupabaseClient } from "@supabase/supabase-js"; +import type { Tx } from "./db"; import { ancestorsOf, loadOrgMaps, subtreeOf, type OrgMaps } from "./org"; import { loadPlacements } from "./placement"; import { deriveStatusAsOf, EVENT_DATE_OPEN, parseStatuses, todayIso, type OrgLookups, type ReportEmployee, type ReportEvent } from "./reports"; -import { fetchAllRows } from "./supabase/query"; -import type { Database, EmploymentType, HistoryEventType } from "./supabase/types"; +import type { EmploymentType, HistoryEventType } from "./supabase/types"; // Shared by the Berichte page and /api/export/* so they can never drift on // what "the current view" means — same filters, same stichtag/event-window @@ -40,13 +39,13 @@ export function lookupsFromOrgMaps(orgMaps: OrgMaps, locations: { id: string; na return { divisionName, departmentName, teamName, locationName: new Map(locations.map((l) => [l.id, l.name])) }; } -export async function loadOrgLookups(supabase: SupabaseClient): Promise<{ +export async function loadOrgLookups(tx: Tx): Promise<{ lookups: OrgLookups; orgMaps: OrgMaps; divisions: { id: string; name: string }[]; locations: { id: string; name: string }[]; }> { - const orgMaps = await loadOrgMaps(supabase); + const orgMaps = await loadOrgMaps(tx); const locations = orgMaps.locationList.map((l) => ({ id: l.id, name: l.name })); return { @@ -60,37 +59,61 @@ export async function loadOrgLookups(supabase: SupabaseClient): Promis }; } -const SNAPSHOT_EMPLOYEE_COLUMNS = - "id, first_name, last_name, job_title, location_id, employment_type, contract_type, entry_date, exit_date, weekly_hours, source, paygrade, birth_date, gender, karenz_start_date, karenz_return_date, worker_type, collective_agreement, work_days, is_betriebsrat, has_dienstwagen, is_laterale_fuehrung, is_c_level"; +const SNAPSHOT_EMPLOYEE_COLUMNS = [ + "id", + "first_name", + "last_name", + "job_title", + "location_id", + "employment_type", + "contract_type", + "entry_date", + "exit_date", + "weekly_hours", + "source", + "paygrade", + "birth_date", + "gender", + "karenz_start_date", + "karenz_return_date", + "worker_type", + "collective_agreement", + "work_days", + "is_betriebsrat", + "has_dienstwagen", + "is_laterale_fuehrung", + "is_c_level", +] as const; -// employee_id -> number of employee_dependents rows. Selects only the FK -// column (no dependent PII needed) since only per-employee counts feed the -// has_dependents/avg_dependents report dimensions; counted client-side -// since the Supabase JS client has no `count(*) group by employee_id` -// shorthand. Shared by the Bestand pivot and the full employees export. -export async function loadDependentsCounts(supabase: SupabaseClient): Promise> { - const rows = await fetchAllRows(() => supabase.from("employee_dependents").select("employee_id").order("employee_id")); - const counts = new Map(); - for (const d of rows) counts.set(d.employee_id, (counts.get(d.employee_id) ?? 0) + 1); - return counts; +// Anzahl der Angehörigen je Person. Nur der Fremdschlüssel wird gelesen — +// für die Berichtsdimensionen zählt die Anzahl, nicht wer es ist. +export async function loadDependentsCounts(tx: Tx): Promise> { + // Am direkten Zugang zählt die Datenbank, statt dass die Anwendung alle + // Zeilen holt und sie selbst durchgeht. + const rows = await tx + .selectFrom("employee_dependents") + .select(({ fn }) => ["employee_id", fn.countAll().as("anzahl")]) + .groupBy("employee_id") + .execute(); + return new Map(rows.map((r) => [r.employee_id, Number(r.anzahl)])); } // Bestand zum Stichtag: Status *und* Einordnung werden auf `asOf` aufgelöst. -export async function loadSnapshotEmployees(supabase: SupabaseClient, filters: SnapshotFilters): Promise { +export async function loadSnapshotEmployees(tx: Tx, filters: SnapshotFilters): Promise { const asOf = filters.asOf || todayIso(); function snapshotQuery() { - let query = supabase.from("employees").select(SNAPSHOT_EMPLOYEE_COLUMNS).order("id"); - if (filters.location) query = query.eq("location_id", filters.location); - if (filters.employment) query = query.eq("employment_type", filters.employment as EmploymentType); - return query; + let q = tx.selectFrom("employees").select([...SNAPSHOT_EMPLOYEE_COLUMNS]).orderBy("id"); + if (filters.location) q = q.where("location_id", "=", filters.location); + if (filters.employment) q = q.where("employment_type", "=", filters.employment as EmploymentType); + return q; } const [data, dependentsCounts, placements, orgMaps] = await Promise.all([ - fetchAllRows(snapshotQuery), - loadDependentsCounts(supabase), - loadPlacements(supabase, { asOf }), - filters.division ? loadOrgMaps(supabase) : Promise.resolve(null), + snapshotQuery().execute(), + loadDependentsCounts(tx), + loadPlacements(tx, { asOf }), + filters.division ? loadOrgMaps(tx) : Promise.resolve(null), ]); // Der Bereichsfilter meint den ganzen Teilbaum: „Produktion" schliesst @@ -146,40 +169,38 @@ export async function loadSnapshotEmployees(supabase: SupabaseClient, // from/to: "" (unset) falls back to the current calendar year; the literal // sentinel EVENT_DATE_OPEN means that side of the interval is intentionally // unbounded (e.g. "alle Ereignisse bis heute", no start date). -export async function loadEventHistory(supabase: SupabaseClient, filters: EventFilters): Promise { +export async function loadEventHistory(tx: Tx, filters: EventFilters): Promise { const currentYear = new Date().getFullYear(); const from = filters.from === EVENT_DATE_OPEN ? undefined : filters.from || `${currentYear}-01-01`; const to = filters.to === EVENT_DATE_OPEN ? undefined : filters.to || `${currentYear}-12-31`; function historyQuery() { - let query = supabase.from("employee_history").select("employee_id, event_date, event_type, description").order("id"); - if (from) query = query.gte("event_date", from); - if (to) query = query.lte("event_date", to); - if (filters.eventType) query = query.eq("event_type", filters.eventType); - return query; + let q = tx + .selectFrom("employee_history") + .select(["employee_id", "event_date", "event_type", "description"]) + .orderBy("id"); + if (from) q = q.where("event_date", ">=", from); + if (to) q = q.where("event_date", "<=", to); + if (filters.eventType) q = q.where("event_type", "=", filters.eventType); + return q; } const [history, employees, assignments, orgMaps] = await Promise.all([ - fetchAllRows(historyQuery), - fetchAllRows(() => supabase.from("employees").select("id, first_name, last_name, job_title, location_id").order("id")), - fetchAllRows(() => - supabase - .from("position_assignments") - .select("employee_id, valid_from, valid_to, om_positions!inner(org_unit_id)") - .order("employee_id") - ), - filters.division ? loadOrgMaps(supabase) : Promise.resolve(null), + historyQuery().execute(), + tx.selectFrom("employees").select(["id", "first_name", "last_name", "job_title", "location_id"]).orderBy("id").execute(), + tx + .selectFrom("position_assignments as a") + .innerJoin("om_positions as p", "p.id", "a.position_id") + .select(["a.employee_id", "a.valid_from", "a.valid_to", "p.org_unit_id"]) + .orderBy("a.employee_id") + .execute(), + filters.division ? loadOrgMaps(tx) : Promise.resolve(null), ]); const spans = new Map(); - for (const a of assignments as unknown as { - employee_id: string; - valid_from: string; - valid_to: string | null; - om_positions: { org_unit_id: string }; - }[]) { + for (const a of assignments) { const list = spans.get(a.employee_id) ?? []; - list.push({ from: a.valid_from, to: a.valid_to, unitId: a.om_positions.org_unit_id }); + list.push({ from: a.valid_from, to: a.valid_to, unitId: a.org_unit_id }); spans.set(a.employee_id, list); } @@ -193,7 +214,7 @@ export async function loadEventHistory(supabase: SupabaseClient, filte if (filters.location && emp.location_id !== filters.location) continue; const unitId = - spans.get(h.employee_id)?.find((s) => s.from <= h.event_date && (s.to === null || s.to > h.event_date))?.unitId ?? null; + spans.get(h.employee_id)?.find((s2) => s2.from <= h.event_date && (s2.to === null || s2.to > h.event_date))?.unitId ?? null; if (allowedUnits && (!unitId || !allowedUnits.has(unitId))) continue; events.push({ diff --git a/lib/supabase/admin.ts b/lib/supabase/admin.ts deleted file mode 100644 index 575fdd1..0000000 --- a/lib/supabase/admin.ts +++ /dev/null @@ -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(supabaseUrl, serviceRoleKey, { - auth: { autoRefreshToken: false, persistSession: false }, - }); -} diff --git a/lib/supabase/auth.ts b/lib/supabase/auth.ts deleted file mode 100644 index 5e142e6..0000000 --- a/lib/supabase/auth.ts +++ /dev/null @@ -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): Promise { - 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; -} diff --git a/lib/supabase/query.ts b/lib/supabase/query.ts deleted file mode 100644 index 3d6d2a8..0000000 --- a/lib/supabase/query.ts +++ /dev/null @@ -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 = { - range: (from: number, to: number) => PromiseLike<{ data: Row[] | null; error: unknown }>; -}; - -export async function fetchAllRows(buildQuery: () => PagedQuery): Promise { - 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; -} diff --git a/tests/integration/employee-status-filter.test.ts b/tests/integration/employee-status-filter.test.ts index 9bca6e0..3675526 100644 --- a/tests/integration/employee-status-filter.test.ts +++ b/tests/integration/employee-status-filter.test.ts @@ -1,9 +1,11 @@ -import { describe, expect, it } from "vitest"; -import { applyDerivedStatusFilter } from "@/lib/employee-status-filter"; +import { Kysely, PostgresDialect } from "kysely"; +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 { deriveStatusAsOf } from "@/lib/reports"; import type { EmploymentStatus } from "@/lib/supabase/types"; -import { adminClient } from "./helpers"; // 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 @@ -13,22 +15,36 @@ import { adminClient } from "./helpers"; // // Only a real database can settle it, so this runs both over the whole // 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({ + 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(); + afterAll(async () => { + await db.destroy(); + }); + async function idsFromDatabase(statuses: EmploymentStatus[]): Promise> { - const query = adminClient.from("employees").select("id"); - const { data, error } = await applyDerivedStatusFilter(query, statuses, asOf); - if (error) throw new Error(error.message); - return new Set((data ?? []).map((r) => r.id)); + const rows = await db + .selectFrom("employees") + .select("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> { - const { data, error } = await adminClient - .from("employees") - .select("id, entry_date, exit_date, karenz_start_date, karenz_return_date"); - if (error) throw new Error(error.message); - return new Set((data ?? []).filter((e) => statuses.includes(deriveStatusAsOf(e, asOf))).map((e) => e.id)); + const rows = await db + .selectFrom("employees") + .select(["id", "entry_date", "exit_date", "karenz_start_date", "karenz_return_date"]) + .execute(); + return new Set(rows.filter((e) => statuses.includes(deriveStatusAsOf(e, asOf))).map((e) => e.id)); } async function expectSameSet(statuses: EmploymentStatus[]) { @@ -68,12 +84,11 @@ describe("derived status filter matches deriveStatusAsOf", () => { }); it("returns everyone when no status is selected", async () => { - const { count: total } = await adminClient.from("employees").select("id", { count: "exact", head: true }); - const { count: filtered } = await applyDerivedStatusFilter( - adminClient.from("employees").select("id", { count: "exact", head: true }), - [], - asOf - ); - expect(filtered).toBe(total); + const total = await db + .selectFrom("employees") + .select(({ fn }) => fn.countAll().as("anzahl")) + .executeTakeFirstOrThrow(); + const filtered = await idsFromDatabase([]); + expect(filtered.size).toBe(Number(total.anzahl)); }); }); diff --git a/tests/unit/security.test.ts b/tests/unit/security.test.ts index 000b89f..5717b4f 100644 --- a/tests/unit/security.test.ts +++ b/tests/unit/security.test.ts @@ -9,14 +9,18 @@ import { parseMeasure, parseSplitDimension, } from "@/lib/reports"; -import { sanitizeIlikeTerm } from "@/lib/supabase/query"; -// The route under test imports lib/supabase/admin.ts, which is guarded by -// `import "server-only"` — that throws when loaded outside Next's own -// server compilation (e.g. here, under plain Vitest/Node). Mock it out: -// these tests only exercise the auth guard, which returns before the real -// admin client is ever created. -vi.mock("@/lib/supabase/admin", () => ({ createAdminClient: vi.fn() })); +// Die Cron-Route lädt lib/db, das über `import "server-only"` abgesichert +// ist und beim Laden eine DATABASE_URL verlangt — beides gibt es unter +// reinem Vitest nicht. Weggemockt: geprüft wird hier nur der +// Geheimnis-Abgleich, der zurückkehrt, bevor irgendeine Verbindung +// entsteht. +// `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", () => { it("prefixes values that would be read as a formula by Excel/Sheets", () => { @@ -37,20 +41,11 @@ describe("sanitizeForSpreadsheetCell", () => { }); }); -describe("sanitizeIlikeTerm", () => { - it("strips PostgREST or-filter delimiter characters", () => { - expect(sanitizeIlikeTerm("a,b")).toBe("ab"); - expect(sanitizeIlikeTerm("a(b)c")).toBe("abc"); - // An attempt to close the current ilike condition and append another - // 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"); - }); -}); +// Der Test zu sanitizeIlikeTerm ist entfallen, weil die Funktion es ist. +// Sie entschärfte Zeichen, die in der Filtersyntax der alten API-Schicht +// strukturelle Bedeutung hatten. Am direkten Datenbankzugang wird der +// Suchbegriff als Parameter gebunden — ein Komma oder eine Klammer darin +// ist schlicht ein Zeichen. Die Lücke ist nicht abgesichert, sondern weg. describe("report query-string parsing", () => { 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 res = await GET(request); expect(res.status).toBe(401); - vi.doUnmock("@/lib/supabase/server"); + vi.doUnmock("@/lib/auth/session"); }); });