diff --git a/app/(app)/employees/[id]/page.tsx b/app/(app)/employees/[id]/page.tsx index 5557c80..c12e473 100644 --- a/app/(app)/employees/[id]/page.tsx +++ b/app/(app)/employees/[id]/page.tsx @@ -3,9 +3,8 @@ 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, loadReportingLines, orgAsOf } from "@/lib/placement"; -import { loadOpenPositions } from "@/lib/positions"; +import { breadcrumbLabel } from "@/lib/org"; +import { loadEmployeeDetail } from "@/lib/employee-detail-data"; type PageProps = { params: Promise<{ id: string }> }; @@ -13,72 +12,9 @@ export default async function EmployeeDetailPage({ params }: PageProps) { const { id } = await params; const today = todayIso(); - const data = await withUser(await currentUserId(), async (tx) => { - // Die Person zuerst, allein: erst aus Eintritt und Austritt ergibt sich - // der Stichtag, zu dem ihre Organisation überhaupt eine Antwort hat. - // Eine Person, die am 01.09. anfängt, hat heute keine laufende Besetzung - // — zu heute gefragt lieferte om_reporting_lines() nichts, und die Akte - // behauptete „Keine Führungskraft", obwohl das Team eine hat. - const employee = await tx.selectFrom("employees").selectAll().where("id", "=", id).executeTakeFirst(); - if (!employee) return null; - - const asOf = orgAsOf(employee, today); - - // 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 [ownLines, reports, history, dependents, notes, orgMaps, placements, openPositions] = await Promise.all([ - loadReportingLines(tx, asOf, { employeeId: id }), - loadReportingLines(tx, asOf, { 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, employeeIds: [id] }), - // Die offenen Planstellen bleiben bei heute: sie gehören zur - // Organisation, nicht zu dieser Person. - loadOpenPositions(tx), - ]); - - const line = ownLines[0] ?? null; - - // 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 relatedRows = relatedIds.length - ? await tx - .selectFrom("employees") - .select(["id", "first_name", "last_name", "job_title", "status"]) - .where("id", "in", relatedIds) - .execute() - : []; - - return { - employee, - line, - reports, - history, - dependents, - notes, - orgMaps, - placement: placements.get(id) ?? null, - openPositions, - byId: new Map(relatedRows.map((e) => [e.id, e])), - }; - }); + // Was die Akte liest und in wie vielen Rundreisen, steht in + // lib/employee-detail-data.ts. + const data = await withUser(await currentUserId(), (tx) => loadEmployeeDetail(tx, id, today)); if (!data) notFound(); const { employee, line, reports, history, dependents, notes, orgMaps, placement, openPositions, byId } = data; diff --git a/app/(app)/employees/page.tsx b/app/(app)/employees/page.tsx index d490a34..7b6eb46 100644 --- a/app/(app)/employees/page.tsx +++ b/app/(app)/employees/page.tsx @@ -7,10 +7,11 @@ import { Pagination } from "@/components/ui/Pagination"; import { StatusChip } from "@/components/ui/StatusChip"; import { currentUserId } from "@/lib/auth/session"; import { sql, withUser } from "@/lib/db"; +import { jsonArrayFrom, jsonObjectFrom } from "@/lib/db/json"; import { istPersonalnummer, suchMuster } from "@/lib/employee-search"; import { derivedStatusFilter } from "@/lib/employee-status-filter"; import { fmtDate, fmtName, todayIso } from "@/lib/format"; -import { breadcrumbLabel, divisionOf, loadOrgMaps, subtreeOf, unitOf } from "@/lib/org"; +import { breadcrumbLabel, divisionOf, loadOrgMaps, subtreeOf, unitOf, type OrgEb } from "@/lib/org"; import { loadPlacements } from "@/lib/placement"; import type { EmploymentStatus } from "@/lib/supabase/types"; @@ -62,8 +63,11 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps // vorher brauchte es zwei getrennte Select-Formen, weil der Typparser der // API-Schicht einen bedingt zusammengesetzten Select-String nicht // auflösen konnte. - const base = (nurNamen: boolean) => { - let q = tx.selectFrom("employees"); + // Der Ausdrucksbauer wird durchgereicht, damit dieselbe Filterkette + // einmal für die Seite und einmal für die Zählung in *einer* Abfrage + // stehen kann. + const base = (nurNamen: boolean, eb: OrgEb = tx as never) => { + let q = eb.selectFrom("employees"); if (unitFilter) { // Nach Organisationseinheit gefiltert wird über die *laufende* @@ -150,33 +154,37 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps : 0; const nurNamen = sucheNachNamen && namensTreffer > 0; - const [rows, total] = await Promise.all([ - base(nurNamen) - .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(nurNamen) - .select(({ fn }) => fn.countAll().as("anzahl")) - .executeTakeFirst(), - ]); + // Seite und Gesamtzahl in *einer* Rundreise. Als Promise.all sah das nach + // Gleichzeitigkeit aus und war keine: eine Transaktion hängt an einer + // Verbindung, und darüber laufen Abfragen nacheinander (lib/db/json.ts). + const { rows, total } = await tx + .selectNoFrom((eb) => [ + jsonArrayFrom( + base(nurNamen, eb) + .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) + ).as("rows"), + jsonObjectFrom(base(nurNamen, eb).select(({ fn }) => fn.countAll().as("anzahl"))).as("total"), + ]) + .executeTakeFirstOrThrow(); // Die Einordnung kommt über die Planstelle — nur für die 15 Zeilen dieser // Seite, nicht für den ganzen Bestand. diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx index 95608d3..3211ae0 100644 --- a/app/(app)/layout.tsx +++ b/app/(app)/layout.tsx @@ -4,46 +4,23 @@ 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 { loadShellData } from "@/lib/shell-data"; export default async function AppLayout({ children }: { children: ReactNode }) { const userId = await currentUserId(); if (!userId) 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) => { - // Hier — und nicht im Proxy — fällt die Entscheidung über den Zugang. - // Der Proxy prüft nur, ob überhaupt jemand angemeldet ist; er hat keine - // Datenbankverbindung. Diese Abfrage läuft bei jedem Aufbau frisch, eine - // entzogene Freischaltung wirkt also sofort statt erst mit dem nächsten - // Sitzungstoken. Die eigentliche Grenze bleibt darunter RLS. - 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 [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), - ]); - - return { profile, openPositions, locations, drafts, openNotes }; - }); + // und damit nebenbei auf einem einheitlichen Lesestand. Was dabei in wie + // vielen Rundreisen gelesen wird, steht in lib/shell-data.ts. + const data = await withUser(userId, (tx) => loadShellData(tx, userId)); // `data` ist null, wenn die Person angemeldet, aber nicht freigeschaltet - // ist. Ohne den Grund in der Adresse stünde sie vor einer wortlosen - // Anmeldeseite und versuchte es endlos erneut. + // ist. Hier — und nicht im Proxy — fällt diese Entscheidung: der Proxy hat + // keine Datenbankverbindung. Sie wird bei jedem Aufbau frisch gestellt, eine + // entzogene Freischaltung wirkt also sofort statt erst mit dem nächsten + // Sitzungstoken. Ohne den Grund in der Adresse stünde die Person vor einer + // wortlosen Anmeldeseite und versuchte es endlos erneut. if (!data) redirect("/login?error=no_hr_access"); const userLabel = data.profile.full_name || data.profile.email || ""; diff --git a/app/(app)/page.tsx b/app/(app)/page.tsx index 59516fb..74aee6c 100644 --- a/app/(app)/page.tsx +++ b/app/(app)/page.tsx @@ -6,14 +6,12 @@ import { DraftsCard } from "@/components/dashboard/DraftsCard"; import { Card, CARD_CLASS, CardTitle } from "@/components/ui/Card"; import { actionBadgeStyle } from "@/lib/colors"; import { istEingeschraenkt, parseArten, parseZeitraum } from "@/lib/dashboard-filter"; +import { loadDashboardData } from "@/lib/dashboard-data"; import { addDaysIso, fmtDate, fmtName, todayIso } from "@/lib/format"; -import { divisionOf, loadOrgMaps } from "@/lib/org"; -import { loadPlacements } from "@/lib/placement"; -import { loadOpenPositions } from "@/lib/positions"; +import { divisionOf } from "@/lib/org"; import { deriveStatusAsOf } from "@/lib/reports"; import { currentUserId } from "@/lib/auth/session"; import { withUser } from "@/lib/db"; -import type { HistoryEventType } from "@/lib/supabase/types"; // Each KPI carries a colour already; the accent bar repeats it in a second // channel so the tiles are scannable as a row rather than six identical @@ -63,7 +61,6 @@ export default async function DashboardPage({ const zeitraum = parseZeitraum(params.tage); const arten = parseArten(params.arten); const bisIso = addDaysIso(today, zeitraum); - const zeigt = (art: "hire" | "exit" | "return") => arten.includes(art); const userId = await currentUserId(); @@ -87,114 +84,7 @@ export default async function DashboardPage({ 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 }), - - // Abgewählte Arten werden gar nicht erst gelesen — die Karte zeigt sie - // ohnehin nicht, und eine Abfrage, deren Ergebnis niemand ansieht, ist - // eine Abfrage zu viel. - zeigt("hire") - ? tx - .selectFrom("employees") - .select(["id", "first_name", "last_name", "entry_date"]) - .where("status", "=", "Geplant") - .where("entry_date", ">=", today) - .where("entry_date", "<=", bisIso) - .execute() - : Promise.resolve([]), - - zeigt("exit") - ? tx - .selectFrom("employees") - .select(["id", "first_name", "last_name", "exit_date"]) - .where("exit_date", "is not", null) - .where("exit_date", ">=", today) - .where("exit_date", "<=", bisIso) - .execute() - : Promise.resolve([]), - - zeigt("return") - ? 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", "<=", bisIso) - .execute() - : Promise.resolve([]), - - 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, - }; - }); - + } = await withUser(userId, (tx) => loadDashboardData(tx, { userId, today, yearStart, yearEnd, bisIso, arten })); // "Aktiv" means status Aktiv — somebody on Karenz is employed but not // active, and is counted by its own tile instead. FTE follows the same // set: Karenz contributes no capacity, so including it would overstate diff --git a/lib/dashboard-data.ts b/lib/dashboard-data.ts new file mode 100644 index 0000000..dfa2d8a --- /dev/null +++ b/lib/dashboard-data.ts @@ -0,0 +1,140 @@ +import type { Tx } from "./db"; +import { jsonArrayFrom, jsonObjectFrom, zeitstempel } from "./db/json"; +import { besetzungenAbfrage, pickPlacements } from "./placement"; +import { buildOrgMaps, orgMapsAbfragen, type OrgEb } from "./org"; +import { offeneStellenAbfrage, resolveOpenPositions, type OffeneStelle } from "./positions"; +import type { HistoryEventType } from "./supabase/types"; +import type { AnstehendArt } from "./dashboard-filter"; + +// Was die Übersichtsseite liest — in zwei Rundreisen statt in dreizehn. +// +// Hier und nicht in der Seite, damit sich die Zahl der Rundreisen messen und +// das Ergebnis gegen den alten Weg halten lässt, ohne eine React-Komponente +// aufzubauen. + +export type DashboardParams = { + userId: string | null; + today: string; + yearStart: string; + yearEnd: string; + bisIso: string; + arten: AnstehendArt[]; +}; + +export async function loadDashboardData(tx: Tx, p: DashboardParams) { + const { userId, today, yearStart, yearEnd, bisIso, arten } = p; + const zeigt = (art: AnstehendArt) => arten.includes(art); + // Elf Abfragen standen hier in einem Promise.all, das keine + // Gleichzeitigkeit war: eine Transaktion hängt an einer Verbindung, und + // über eine Verbindung laufen Abfragen nacheinander. Bei rund 36 ms + // Umlaufzeit war das eine knappe Sekunde Warten für Daten, die zusammen + // keine 200 kB wiegen. Jetzt: eine Rundreise, und eine zweite für die + // offenen Stellen, deren zweite Hälfte vom Ergebnis der ersten abhängt. + // Der Weg dahin steht in lib/db/json.ts. + const countIn = (eb: OrgEb, types: readonly HistoryEventType[]) => + eb + .selectFrom("employee_history") + .select(({ fn }) => fn.countAll().as("anzahl")) + .where("event_type", "in", [...types]) + .where("event_date", ">=", yearStart) + .where("event_date", "<=", yearEnd); + + const g = await tx + .selectNoFrom((eb) => [ + jsonArrayFrom( + eb + .selectFrom("hire_drafts") + .select(["id", "step", "payload"]) + .select((x) => zeitstempel(x.ref("updated_at")).as("updated_at")) + .where("created_by", "=", userId ?? "") + .orderBy("updated_at", "desc") + ).as("drafts"), + + jsonArrayFrom( + eb + .selectFrom("employees") + .select(["id", "weekly_hours", "entry_date", "exit_date", "karenz_start_date", "karenz_return_date"]) + .orderBy("id") + ).as("staffRows"), + + // 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. + jsonObjectFrom(countIn(eb, ["Eintritt", "Wiedereintritt"])).as("hiresYtd"), + jsonObjectFrom(countIn(eb, ["Austritt"])).as("exitsYtd"), + + ...orgMapsAbfragen(eb), + jsonArrayFrom(offeneStellenAbfrage(eb, today)).as("open"), + jsonArrayFrom(besetzungenAbfrage(eb)).as("placementRows"), + + // Abgewählte Arten werden gar nicht erst gelesen — die Karte zeigt sie + // ohnehin nicht, und eine Teilabfrage, deren Ergebnis niemand ansieht, + // ist eine Teilabfrage zu viel. + jsonArrayFrom( + eb + .selectFrom("employees") + .select(["id", "first_name", "last_name", "entry_date"]) + .where("status", "=", "Geplant") + .where("entry_date", ">=", today) + .where("entry_date", "<=", bisIso) + .where((e) => e.lit(zeigt("hire"))) + ).as("upcomingHires"), + + jsonArrayFrom( + eb + .selectFrom("employees") + .select(["id", "first_name", "last_name", "exit_date"]) + .where("exit_date", "is not", null) + .where("exit_date", ">=", today) + .where("exit_date", "<=", bisIso) + .where((e) => e.lit(zeigt("exit"))) + ).as("upcomingExits"), + + jsonArrayFrom( + eb + .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", "<=", bisIso) + .where((e) => e.lit(zeigt("return"))) + ).as("upcomingReturns"), + + jsonArrayFrom( + eb + .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) + ).as("history"), + ]) + .executeTakeFirstOrThrow(); + + const orgMaps = buildOrgMaps(g.units as never, g.locations as never); + + return { + drafts: userId ? g.drafts : [], + staffRows: g.staffRows, + hiresYtd: Number(g.hiresYtd?.anzahl ?? 0), + exitsYtd: Number(g.exitsYtd?.anzahl ?? 0), + openPositions: await resolveOpenPositions(tx, orgMaps, g.open as OffeneStelle[], today), + orgMaps, + placements: pickPlacements(g.placementRows as never, today), + upcomingHires: g.upcomingHires, + upcomingExits: g.upcomingExits, + upcomingReturns: g.upcomingReturns, + history: g.history, + }; +} diff --git a/lib/db/index.ts b/lib/db/index.ts index fd11748..0353f55 100644 --- a/lib/db/index.ts +++ b/lib/db/index.ts @@ -1,4 +1,5 @@ import "server-only"; +import { AsyncLocalStorage } from "node:async_hooks"; import { Kysely, PostgresDialect, sql, type Transaction } from "kysely"; import { getPool } from "./pool"; import type { Schema } from "./schema"; @@ -36,8 +37,33 @@ import type { Schema } from "./schema"; // Der Pool wird als Funktion übergeben, nicht als fertige Instanz: Kysely // ruft sie erst bei der ersten Abfrage auf. So verlangt der Import dieses // Moduls noch keine Zugangsdaten — siehe getPool(). +// ═══ Wie viele Rundreisen eine Anfrage kostet ═══ +// +// Eine Transaktion hängt an einer Verbindung, und über eine Verbindung laufen +// Abfragen nacheinander — auch die in einem Promise.all. Bei rund 36 ms +// Umlaufzeit zur Datenbank ist die Zahl der Abfragen deshalb *die* Kennzahl +// für die Ladezeit einer Seite, und zwar eine, die man nicht schätzen muss. +// +// Sie wird darum mitgezählt und im Entwicklungsbetrieb gemeldet, sobald eine +// Transaktion viele davon braucht. Ohne diese Meldung wächst so etwas +// unbemerkt: jede neue Kachel bringt ihre eigene Abfrage mit, und dass die +// Seite langsamer wird, merkt man erst, wenn es alle merken. +const zaehler = new AsyncLocalStorage<{ abfragen: number }>(); + +export function zaehleAbfragen(): { abfragen: number } | undefined { + return zaehler.getStore(); +} + +/** Ab wann eine Transaktion im Entwicklungsbetrieb gemeldet wird. */ +const MELDESCHWELLE = Number(process.env.DB_QUERY_WARN ?? 6); + const db = new Kysely({ dialect: new PostgresDialect({ pool: async () => getPool() }), + log: (event) => { + const store = zaehler.getStore(); + if (store) store.abfragen++; + if (event.level === "error") console.error("Abfrage fehlgeschlagen:", event.error); + }, }); export type Tx = Transaction; @@ -49,11 +75,26 @@ export type Tx = Transaction; * greift keine Policy und es kommt nichts zurück, was auch richtig ist. */ export async function withUser(userId: string | null, fn: (tx: Tx) => Promise): Promise { - return db.transaction().execute(async (tx) => { - // Erste Anweisung der Transaktion, vor allem anderen. - await sql`select set_config('app.user_id', ${userId ?? ""}, true)`.execute(tx); - return fn(tx); - }); + const stand = { abfragen: 0 }; + const start = performance.now(); + try { + return await zaehler.run(stand, () => + db.transaction().execute(async (tx) => { + // Erste Anweisung der Transaktion, vor allem anderen. + await sql`select set_config('app.user_id', ${userId ?? ""}, true)`.execute(tx); + return fn(tx); + }) + ); + } finally { + // Nur im Entwicklungsbetrieb: in der Produktion gehörte das in die + // Ablaufverfolgung, nicht auf die Konsole. + if (process.env.NODE_ENV !== "production" && stand.abfragen > MELDESCHWELLE) { + console.warn( + `[db] ${stand.abfragen} Abfragen in einer Transaktion, ${Math.round(performance.now() - start)} ms — ` + + `sie laufen nacheinander über eine Verbindung. Bündeln: siehe lib/db/json.ts.` + ); + } + } } /** diff --git a/lib/db/json.ts b/lib/db/json.ts new file mode 100644 index 0000000..81970a9 --- /dev/null +++ b/lib/db/json.ts @@ -0,0 +1,69 @@ +// Kein `server-only` hier, anders als in ./index.ts und ./pool.ts: diese Datei +// baut nur Abfragen zusammen und hält weder Verbindung noch Zugangsdaten. Mit +// der Sperre wären die reinen Tests von lib/org.ts nicht mehr ladbar, obwohl +// dort nur ein Baum aus Zeilen gebaut wird. +import { sql, type Expression, type RawBuilder } from "kysely"; +import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/postgres"; + +// Mehrere unabhängige Lesevorgänge in **einer** Rundreise. +// +// ═══ Warum das nötig ist ═══ +// +// Eine Transaktion hängt an genau einer Verbindung, und über eine Verbindung +// laufen Abfragen nacheinander — auch die, die in einem Promise.all stehen. +// Der Treiber stellt sie in eine Schlange. `Promise.all` sieht nach +// Gleichzeitigkeit aus und ist hier keine. +// +// Gemessen an der echten Datenbank: die Umlaufzeit beträgt rund 36 ms, zehn +// belanglose `select 1` über eine Verbindung brauchen 343 ms, über zehn +// Verbindungen 39 ms. Der Aufbau der Übersichtsseite — zehn Abfragen, die +// zusammen keine 200 kB liefern — kostete so knapp eine Sekunde, fast +// ausschliesslich Warten. +// +// Mehr Verbindungen sind trotzdem nicht die Antwort: der Sitzungskontext für +// RLS gilt je Transaktion (siehe ./index.ts), und mehrere Transaktionen je +// Anfrage vervielfachen die Verbindungen, die die Datenbank zulässt. Also +// weniger Rundreisen statt mehr Leitungen: Postgres kann jede Teilabfrage als +// JSON-Spalte in *einem* Ergebnis liefern. +// +// const { einheiten, standorte } = await tx +// .selectNoFrom((eb) => [ +// jsonArrayFrom(eb.selectFrom("org_units").select([...])).as("einheiten"), +// jsonArrayFrom(eb.selectFrom("locations").selectAll()).as("standorte"), +// ]) +// .executeTakeFirstOrThrow(); +// +// Typisiert wie jede andere Kysely-Abfrage, mit Parametern, ohne Handarbeit +// an der Zeichenkette. +// +// ═══ Die eine Falle ═══ +// +// Innerhalb von json_agg formatiert Postgres die Werte selbst, und der +// Treiber kommt nicht mehr daran (lib/db/pool.ts stellt ihn dort auf die +// Formen um, die die Typen beschreiben). Für die meisten Typen macht das +// nichts — im Gegenteil: +// +// date → "2022-03-30" wie ausserhalb +// numeric → 38.5 wie ausserhalb +// uuid → Zeichenkette wie ausserhalb +// timestamptz → "2026-08-03T12:08:06.272938+00:00" +// ← **anders**: ausserhalb "…272Z" +// +// Der Unterschied ist nicht kosmetisch. Zeitstempel werden im Projekt als +// Zeichenketten verglichen (lib/history.ts entscheidet daran, was später +// passiert ist), und "+00:00" sortiert gegen "Z" falsch herum. Deshalb geht +// **jede** timestamptz-Spalte in einer gebündelten Abfrage durch zeitstempel(). + +export { jsonArrayFrom, jsonObjectFrom }; + +/** + * Eine timestamptz-Spalte in der Form, die der Treiber ausserhalb von JSON + * liefert — ISO-8601 in UTC, auf Millisekunden gekürzt. + * + * Ohne das käme aus einer gebündelten Abfrage eine andere Zeichenkette als + * aus derselben Abfrage einzeln gestellt. + */ +export function zeitstempel(spalte: Expression | string): RawBuilder { + const ref = typeof spalte === "string" ? sql.ref(spalte) : spalte; + return sql`to_char(${ref} at time zone 'utc', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')`; +} diff --git a/lib/employee-detail-data.ts b/lib/employee-detail-data.ts new file mode 100644 index 0000000..4884849 --- /dev/null +++ b/lib/employee-detail-data.ts @@ -0,0 +1,125 @@ +import type { Tx } from "./db"; +import { jsonArrayFrom, zeitstempel } from "./db/json"; +import { todayIso } from "./format"; +import { buildOrgMaps, orgMapsAbfragen } from "./org"; +import { berichtslinienAbfrage, besetzungenAbfrage, orgAsOf, pickPlacements } from "./placement"; +import { offeneStellenAbfrage, resolveOpenPositions, type OffeneStelle } from "./positions"; + +// Was die Mitarbeiterakte liest — in vier Rundreisen statt in zwölf. +// +// Vorher: die Person, dann acht Dinge in einem Promise.all, dann die Namen der +// Beteiligten. Das Promise.all war keine Gleichzeitigkeit — eine Transaktion +// hängt an einer Verbindung (lib/db/json.ts). Bei rund 36 ms Umlaufzeit war +// diese Seite damit die teuerste der Anwendung. +// +// Die drei Tabellen mit `selectAll()` stehen hier ausgeschrieben. Das ist +// keine Umständlichkeit: innerhalb von JSON formatiert Postgres timestamptz +// anders als der Treiber sonst, und `created_at` entscheidet in lib/history.ts +// darüber, was später passiert ist. Ausgeschrieben ist sichtbar, welche Spalte +// durch zeitstempel() geht — bei selectAll() wäre es unsichtbar falsch. + +export async function loadEmployeeDetail(tx: Tx, id: string, today: string = todayIso()) { + // Die Person zuerst, allein: erst aus Eintritt und Austritt ergibt sich der + // Stichtag, zu dem ihre Organisation überhaupt eine Antwort hat. Eine + // Person, die am 01.09. anfängt, hat heute keine laufende Besetzung — zu + // heute gefragt lieferte om_reporting_lines() nichts, und die Akte + // behauptete „Keine Führungskraft", obwohl das Team eine hat. + const employee = await tx.selectFrom("employees").selectAll().where("id", "=", id).executeTakeFirst(); + if (!employee) return null; + + const asOf = orgAsOf(employee, today); + + const g = await tx + .selectNoFrom((eb) => [ + // Vorgesetzte und direkte Berichte stehen nirgends als Spalte — sie + // kommen aus om_reporting_lines(). Beide schränken *in* der Funktion + // ein, es wandern also neun Zeilen über die Leitung und nicht achthundert. + jsonArrayFrom(berichtslinienAbfrage(eb, asOf, { employeeId: id })).as("ownLines"), + jsonArrayFrom(berichtslinienAbfrage(eb, asOf, { actingManagerId: id })).as("reports"), + + jsonArrayFrom( + eb + .selectFrom("employee_history") + .select(["id", "employee_id", "event_date", "event_type", "description", "changes", "pending_id"]) + .select((x) => zeitstempel(x.ref("created_at")).as("created_at")) + .where("employee_id", "=", id) + .orderBy("event_date", "desc") + .orderBy("created_at", "desc") + ).as("history"), + + jsonArrayFrom( + eb + .selectFrom("employee_dependents") + .select(["id", "employee_id", "first_name", "last_name", "relationship", "sv_nummer", "birth_date"]) + .select((x) => zeitstempel(x.ref("created_at")).as("created_at")) + .where("employee_id", "=", id) + .orderBy("created_at") + ).as("dependents"), + + jsonArrayFrom( + eb + .selectFrom("employee_notes") + .select([ + "id", + "employee_id", + "author_user_id", + "author_name", + "category", + "note_text", + "due_date", + "done", + "done_by", + ]) + .select((x) => [ + zeitstempel(x.ref("created_at")).as("created_at"), + zeitstempel(x.ref("done_at")).as("done_at"), + ]) + .where("employee_id", "=", id) + .orderBy("created_at", "desc") + ).as("notes"), + + ...orgMapsAbfragen(eb), + jsonArrayFrom(besetzungenAbfrage(eb, [id])).as("placementRows"), + // Die offenen Planstellen bleiben bei heute: sie gehören zur + // Organisation, nicht zu dieser Person. + jsonArrayFrom(offeneStellenAbfrage(eb, today)).as("open"), + ]) + .executeTakeFirstOrThrow(); + + const orgMaps = buildOrgMaps(g.units as never, g.locations as never); + const line = g.ownLines[0] ?? null; + + // 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, ...g.reports.map((r) => r.employee_id)].filter( + (x): x is string => Boolean(x) + ) + ) + ); + + const [relatedRows, openPositions] = await Promise.all([ + relatedIds.length + ? tx + .selectFrom("employees") + .select(["id", "first_name", "last_name", "job_title", "status"]) + .where("id", "in", relatedIds) + .execute() + : Promise.resolve([]), + resolveOpenPositions(tx, orgMaps, g.open as OffeneStelle[], today), + ]); + + return { + employee, + line, + reports: g.reports, + history: g.history, + dependents: g.dependents, + notes: g.notes, + orgMaps, + placement: pickPlacements(g.placementRows as never, asOf).get(id) ?? null, + openPositions, + byId: new Map(relatedRows.map((e) => [e.id, e])), + }; +} diff --git a/lib/notes.ts b/lib/notes.ts index 4446b0a..6f20d7b 100644 --- a/lib/notes.ts +++ b/lib/notes.ts @@ -1,11 +1,21 @@ import type { Tx } from "./db"; -import type { Database } from "./supabase/types"; +import { jsonArrayFrom, zeitstempel } from "./db/json"; import { fmtName } from "./format"; +import type { OrgEb } from "./org"; +import type { Database } from "./supabase/types"; export type OpenNote = Database["public"]["Tables"]["employee_notes"]["Row"] & { employeeName: string; }; +/** Was die Teilabfrage liefert: die Notiz plus den Namen aus dem Join. */ +export type NotizZeile = Omit & { + created_at: string; + done_at: string | null; + first_name: string | null; + last_name: string | null; +}; + // „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). @@ -13,16 +23,39 @@ export type OpenNote = Database["public"]["Tables"]["employee_notes"]["Row"] & { // 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 + +/** + * Die offenen Notizen als *Teilabfrage* — zum Einhängen in die eine Abfrage, + * die eine Seite ohnehin stellt (lib/db/json.ts). + * + * Die beiden Zeitstempel gehen durch zeitstempel(): innerhalb von JSON + * formatiert Postgres sie anders als der Treiber es sonst täte, und der + * Unterschied fällt erst beim Vergleichen auf. + */ +export function offeneNotizenAbfrage(eb: OrgEb) { + return eb .selectFrom("employee_notes as n") .leftJoin("employees as e", "e.id", "n.employee_id") - .selectAll("n") - .select(["e.first_name", "e.last_name"]) + .select([ + "n.id", + "n.employee_id", + "n.author_user_id", + "n.author_name", + "n.category", + "n.note_text", + "n.due_date", + "n.done", + "n.done_by", + "e.first_name", + "e.last_name", + ]) + .select((x) => [zeitstempel(x.ref("n.created_at")).as("created_at"), zeitstempel(x.ref("n.done_at")).as("done_at")]) .where("n.done", "=", false) - .orderBy("n.created_at", "desc") - .execute(); + .orderBy("n.created_at", "desc"); +} +/** Der reine Teil: aus den Zeilen die Notizen mit lesbarem Namen. */ +export function baueOffeneNotizen(rows: NotizZeile[]): OpenNote[] { return rows.map((row) => { const { first_name, last_name, ...note } = row; return { @@ -31,3 +64,10 @@ export async function loadOpenNotes(tx: Tx): Promise { }; }); } + +export async function loadOpenNotes(tx: Tx): Promise { + const { notes } = await tx + .selectNoFrom((eb) => [jsonArrayFrom(offeneNotizenAbfrage(eb)).as("notes")]) + .executeTakeFirstOrThrow(); + return baueOffeneNotizen(notes as NotizZeile[]); +} diff --git a/lib/org.ts b/lib/org.ts index 9296189..82813f0 100644 --- a/lib/org.ts +++ b/lib/org.ts @@ -1,6 +1,15 @@ +import type { ExpressionBuilder } from "kysely"; import type { Tx } from "./db"; +import { jsonArrayFrom } from "./db/json"; +import type { Schema } from "./db/schema"; import type { Database } from "./supabase/types"; +/** + * Der Ausdrucksbauer einer Abfrage ohne eigene Tabelle (selectNoFrom) — das + * ist der Ort, an dem Teilabfragen zusammengehängt werden. + */ +export type OrgEb = ExpressionBuilder; + // Die Organisation ist ein Baum, keine drei Tabellen mehr. Alles, was früher // aus divisions/departments/teams zusammengesteckt wurde, ergibt sich jetzt // aus org_units.parent_id — und damit funktioniert es auch für eine fünfte @@ -16,7 +25,7 @@ export type OrgUnit = { unit_type: OrgUnitType; }; -type Location = Database["public"]["Tables"]["locations"]["Row"]; +export type Location = Database["public"]["Tables"]["locations"]["Row"]; export type OrgMaps = { units: Map; @@ -31,15 +40,30 @@ export type OrgMaps = { // Die Referenzdaten sind winzig (60 Einheiten, 5 Standorte) — sie werden // ganz geladen und im Speicher verknüpft, statt je Zeile nachzuschlagen. +/** + * Einheiten und Standorte als *Teilabfragen* — zum Einhängen in die eine + * Abfrage, die eine Seite ohnehin stellt. + * + * Fast jede Seite braucht den Baum. Als eigene Abfrage wäre das je Seitenauf- + * bau eine zusätzliche Rundreise für 65 Zeilen, die sich selten ändern; als + * Teilabfrage kostet sie nichts (lib/db/json.ts). + */ +export function orgMapsAbfragen(eb: OrgEb) { + return [ + jsonArrayFrom( + eb.selectFrom("org_units").select(["id", "org_number", "name", "parent_id", "unit_type"]).orderBy("org_number") + ).as("units"), + jsonArrayFrom(eb.selectFrom("locations").selectAll().orderBy("name")).as("locations"), + ] as const; +} + 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(), - ]); + // Beides in einer Rundreise. Ein Promise.all wäre hier keine + // Gleichzeitigkeit gewesen, sondern eine Schlange — der Grund steht in + // lib/db/json.ts. + const { units, locations } = await tx + .selectNoFrom((eb) => [...orgMapsAbfragen(eb)]) + .executeTakeFirstOrThrow(); return buildOrgMaps(units as OrgUnit[], locations as Location[]); } diff --git a/lib/placement.ts b/lib/placement.ts index 58df265..8bd51de 100644 --- a/lib/placement.ts +++ b/lib/placement.ts @@ -1,5 +1,6 @@ import { sql, type Tx } from "./db"; import { addDaysIso } from "./format"; +import type { OrgEb } from "./org"; /** * Der Stichtag, zu dem die Organisation *dieser* Person betrachtet wird. @@ -97,6 +98,34 @@ export function pickPlacements(rows: Row[], asOf: string): Map`om_reporting_lines(${asOf}::date)`.as("l")) + .select(["l.employee_id", "l.position_id", "l.org_unit_id", "l.is_chief", "l.formal_manager_id", "l.acting_manager_id"]); + if (filter?.employeeId) q = q.where("l.employee_id", "=", filter.employeeId); + if (filter?.actingManagerId) q = q.where("l.acting_manager_id", "=", filter.actingManagerId); + return q; +} + export async function loadReportingLines( tx: Tx, asOf: string, diff --git a/lib/positions.ts b/lib/positions.ts index c2f365c..c4ffa8a 100644 --- a/lib/positions.ts +++ b/lib/positions.ts @@ -1,6 +1,7 @@ import type { Tx } from "./db"; +import { jsonArrayFrom } from "./db/json"; import { fmtName, todayIso } from "./format"; -import { breadcrumbLabel, loadOrgMaps, type OrgMaps } from "./org"; +import { breadcrumbLabel, buildOrgMaps, orgMapsAbfragen, type OrgEb, type OrgMaps } from "./org"; // Eine offene Stelle ist keine eigene Sache mehr. Sie ist eine Planstelle // ohne laufende Besetzung — Vakanz ist eine Eigenschaft der Planstelle, kein @@ -42,86 +43,108 @@ function managerUnitFor(maps: OrgMaps, orgUnitId: string, isChief: boolean): str return maps.units.get(orgUnitId)?.parent_id ?? null; } -export async function loadOpenPositions(tx: Tx): Promise { - const asOf = todayIso(); +export type OffeneStelle = { + id: string; + position_number: string; + org_unit_id: string; + is_chief: boolean; + valid_from: string; + valid_to: string | null; + title: string; +}; - const [orgMaps, open] = await Promise.all([ - loadOrgMaps(tx), - // Unbesetzt heisst: keine Zuordnung, die noch gilt — **auch keine, die - // erst beginnt.** - // - // Der Unterschied ist kein Feinschliff. Wer unterschrieben hat und am - // 24.09. anfängt, belegt die Planstelle heute schon; sie steht nur noch - // nicht besetzt da. Die frühere Fassung fragte „sitzt heute jemand - // darauf?" und listete solche Stellen als offen — mit „seit 2 Tagen - // unbesetzt" daneben. Aus dieser Liste speist sich auch die Auswahl im - // Einstellungsassistenten, also lud sie dazu ein, dieselbe Stelle ein - // zweites Mal zu besetzen. Aufgefallen wäre das erst am Teilindex der - // Datenbank, nach dem Gespräch mit der zweiten Person. - // - // Eine beendete Zuordnung (valid_to in der Vergangenheit) gibt die Stelle - // dagegen wieder frei — deshalb bleibt die Bedingung auf valid_to. - // +/** + * Die unbesetzten Planstellen als *Teilabfrage*. + * + * Als eigene Funktion, damit eine Seite sie zusammen mit ihren übrigen + * Lesevorgängen in einer Rundreise holen kann statt in einer eigenen — siehe + * lib/db/json.ts. Wer nichts weiter zu holen hat, nimmt loadOpenPositions(). + * + * Unbesetzt heisst: keine Zuordnung, die noch gilt — **auch keine, die erst + * beginnt.** + * + * Der Unterschied ist kein Feinschliff. Wer unterschrieben hat und am 24.09. + * anfängt, belegt die Planstelle heute schon; sie steht nur noch nicht besetzt + * da. Die frühere Fassung fragte „sitzt heute jemand darauf?" und listete + * solche Stellen als offen — mit „seit 2 Tagen unbesetzt" daneben. Aus dieser + * Liste speist sich auch die Auswahl im Einstellungsassistenten, also lud sie + * dazu ein, dieselbe Stelle ein zweites Mal zu besetzen. Aufgefallen wäre das + * erst am Teilindex der Datenbank, nach dem Gespräch mit der zweiten Person. + * + * Eine beendete Zuordnung (valid_to in der Vergangenheit) gibt die Stelle + * dagegen wieder frei — deshalb bleibt die Bedingung auf valid_to. + * + * Künftige Planstellen bleiben drin. Sie sind der Grund, warum diese Ansicht + * existiert: eine Stelle, die zum Quartalswechsel entsteht, muss vorher + * sichtbar und planbar sein. + */ +export function offeneStellenAbfrage(eb: OrgEb, asOf: string) { + return eb + .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", "p.valid_to", "j.title"]) + .where((e) => e.or([e("p.valid_to", "is", null), e("p.valid_to", ">", asOf)])) // 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", "p.valid_to", "j.title"]) - // Künftige Planstellen bleiben drin. Sie sind der Grund, warum diese - // Ansicht existiert: eine Stelle, die zum Quartalswechsel entsteht, - // muss vorher sichtbar und planbar sein. Vorher fielen sie durch das - // Raster und tauchten am Stichtag unangekündigt auf. - .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((e2) => e2.or([e2("a.valid_to", "is", null), e2("a.valid_to", ">", asOf)])) - ) + .where((e) => + e.not( + e.exists( + e + .selectFrom("position_assignments as a") + .select("a.id") + .whereRef("a.position_id", "=", "p.id") + .where((e2) => e2.or([e2("a.valid_to", "is", null), e2("a.valid_to", ">", asOf)])) ) ) - .orderBy("p.position_number") - .execute(), - ]); + ) + .orderBy("p.position_number"); +} +/** + * Der zweite Teil: die beiden Nachschläge, die erst gestellt werden können, + * wenn feststeht, welche Stellen offen sind — seit wann sie leer stehen, und + * wer sie führen würde. Beide in einer Rundreise. + */ +export async function resolveOpenPositions( + tx: Tx, + orgMaps: OrgMaps, + open: OffeneStelle[], + asOf: string = todayIso() +): Promise { if (open.length === 0) return []; const positionIds = open.map((p) => p.id); + 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)) + ) + ); - // 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 { ended, chiefs } = await tx + .selectNoFrom((eb) => [ + jsonArrayFrom( + eb + .selectFrom("position_assignments") + .select(["position_id", "valid_to"]) + .where("position_id", "in", positionIds) + .where("valid_to", "is not", null) + ).as("ended"), + jsonArrayFrom( + eb + .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) + // Ohne Einheiten darf hier keine Liste stehen: `in ()` ist ein + // Syntaxfehler, und `in ('')` bricht an der uuid-Umwandlung ab + // statt nichts zu liefern. Dann also eine Bedingung, die nie zutrifft. + .where((e) => (chiefUnitIds.length > 0 ? e("p.org_unit_id", "in", chiefUnitIds) : e.lit(false))) + ).as("chiefs"), + ]) + .executeTakeFirstOrThrow(); const lastEndByPosition = new Map(); for (const e of ended) { @@ -148,3 +171,23 @@ export async function loadOpenPositions(tx: Tx): Promise }; }); } + +/** + * Der bequeme Weg: Organisationsbaum und offene Stellen in einer Rundreise, + * die beiden Nachschläge in einer zweiten. + * + * Zwei sind das Minimum — was in der zweiten steht, hängt vom Ergebnis der + * ersten ab. + */ +export async function loadOpenPositions(tx: Tx): Promise { + const asOf = todayIso(); + + const { units, locations, open } = await tx + .selectNoFrom((eb) => [ + ...orgMapsAbfragen(eb), + jsonArrayFrom(offeneStellenAbfrage(eb, asOf)).as("open"), + ]) + .executeTakeFirstOrThrow(); + + return resolveOpenPositions(tx, buildOrgMaps(units as never, locations as never), open as OffeneStelle[], asOf); +} diff --git a/lib/shell-data.ts b/lib/shell-data.ts new file mode 100644 index 0000000..f387db5 --- /dev/null +++ b/lib/shell-data.ts @@ -0,0 +1,76 @@ +import type { Tx } from "./db"; +import { jsonArrayFrom, jsonObjectFrom, zeitstempel } from "./db/json"; +import { todayIso } from "./format"; +import { baueOffeneNotizen, offeneNotizenAbfrage, type NotizZeile, type OpenNote } from "./notes"; +import { buildOrgMaps, orgMapsAbfragen, type Location } from "./org"; +import { + offeneStellenAbfrage, + resolveOpenPositions, + type OffeneStelle, + type OpenPositionResolved, +} from "./positions"; + +// Was die Hülle jeder Seite braucht — in zwei Rundreisen statt in sechs. +// +// Vorher stand das im Layout selbst, als Promise.all, das wie Gleichzeitigkeit +// aussah und keine war: eine Transaktion hängt an einer Verbindung, und über +// eine Verbindung laufen Abfragen nacheinander. Bei rund 36 ms Umlaufzeit +// kostete diese Hülle — die **jede** Seite mitlädt — eine halbe Sekunde +// Warten für ein paar Kilobyte. Der Weg dahin steht in lib/db/json.ts. +// +// Hier und nicht im Layout, damit sich die Zahl der Rundreisen messen lässt, +// ohne eine React-Komponente aufzubauen. + +export type ShellData = { + profile: { full_name: string | null; email: string | null; role: string | null; is_active: boolean | null }; + openPositions: OpenPositionResolved[]; + locations: Location[]; + drafts: { id: string; step: number; payload: Record; updated_at: string }[]; + openNotes: OpenNote[]; +}; + +/** + * `null` heisst: angemeldet, aber nicht als HR freigeschaltet. + * + * Die Zugangsprüfung fragt gleichzeitig mit dem Rest statt davor. Das liest + * ein paar Zeilen mehr, als eine gesperrte Person sehen dürfte, wirft sie aber + * weg, ohne sie je auszuliefern — und die eigentliche Grenze ist ohnehin RLS, + * nicht die Reihenfolge hier. + */ +export async function loadShellData(tx: Tx, userId: string): Promise { + const asOf = todayIso(); + + const gelesen = await tx + .selectNoFrom((eb) => [ + jsonObjectFrom( + eb.selectFrom("profiles").select(["full_name", "email", "role", "is_active"]).where("id", "=", userId) + ).as("profile"), + ...orgMapsAbfragen(eb), + jsonArrayFrom(offeneStellenAbfrage(eb, asOf)).as("open"), + jsonArrayFrom(offeneNotizenAbfrage(eb)).as("notes"), + jsonArrayFrom( + eb + .selectFrom("hire_drafts") + .select(["id", "step", "payload"]) + .select((x) => zeitstempel(x.ref("updated_at")).as("updated_at")) + .where("created_by", "=", userId) + .orderBy("updated_at", "desc") + ).as("drafts"), + ]) + .executeTakeFirstOrThrow(); + + const profile = gelesen.profile; + if (profile?.role !== "hr" || profile?.is_active !== true) return null; + + const orgMaps = buildOrgMaps(gelesen.units as never, gelesen.locations as never); + + return { + profile, + // Die zweite Rundreise: was sie fragt, hängt davon ab, welche Stellen + // offen sind — das lässt sich nicht in die erste ziehen. + openPositions: await resolveOpenPositions(tx, orgMaps, gelesen.open as OffeneStelle[], asOf), + locations: gelesen.locations as Location[], + drafts: gelesen.drafts as ShellData["drafts"], + openNotes: baueOffeneNotizen(gelesen.notes as NotizZeile[]), + }; +}