Stop waiting on the network eleven times per page

The app got slower as pages grew, and the reason was not the queries. It
was their number.

A transaction is pinned to one connection, and a connection runs queries
one after another. Every Promise.all in a withUser block looked like
concurrency and was a queue. Measured against the real database: the
round trip is ~36 ms, ten trivial `select 1` over one connection take
343 ms, over ten connections 39 ms. Nothing here is slow — the whole
dashboard payload is under 200 kB, and every table is around a thousand
rows.

More connections is the wrong answer: the RLS session context is per
transaction, so parallel reads mean parallel transactions, and those
multiply the connections the database will grant. Fewer round trips
instead. Postgres will return each sub-select as its own JSON column of
one result.

Per page view, counting the transaction frame:

  shell (paid by every page)  10 → 4
  overview                    14 → 5
  employee file               14 → 7
  employee list                8 → 6

The overview plus its shell went from 24 round trips to 9 — about 860 ms
of pure waiting down to about 320 ms.

The one trap is documented where it bites: inside json_agg, Postgres
formats values itself and the driver's parsers (lib/db/pool.ts) never
see them. Dates, numerics and uuids come out identical; timestamptz does
not — "+00:00" where the driver gives "…Z". Timestamps are compared as
strings in lib/history.ts to decide what happened later, and those two
forms sort against each other wrongly. Every timestamptz in a bundled
query therefore goes through zeitstempel(), which was checked
character-for-character against the driver.

Four loaders moved out of their pages into lib/ so the number of round
trips can be measured without building a React tree, and so the new path
could be held against the old one field by field: same rows, same order,
same strings, for the overview and for four employee files chosen to
differ (with history, a chief, a planned entry, one with dependents).

withUser now counts the queries in each transaction and says so in
development past a threshold. Without that, this grows back: each new
tile brings its own query, and nobody notices until everybody does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 19:31:36 +02:00
parent 6957b95a97
commit 91b2b3406b
13 changed files with 753 additions and 336 deletions

View File

@@ -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;

View File

@@ -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<string>().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<string>().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.

View File

@@ -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 || "";

View File

@@ -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<string>().as("anzahl"))
.where("event_type", "in", [...types])
.where("event_date", ">=", yearStart)
.where("event_date", "<=", yearEnd)
.executeTakeFirst();
const [
drafts,
staffRows,
hiresYtd,
exitsYtd,
openPositions,
orgMaps,
placements,
upcomingHires,
upcomingExits,
upcomingReturns,
history,
] = await Promise.all([
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

140
lib/dashboard-data.ts Normal file
View File

@@ -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<string>().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,
};
}

View File

@@ -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<Schema>({
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<Schema>;
@@ -49,11 +75,26 @@ export type Tx = Transaction<Schema>;
* greift keine Policy und es kommt nichts zurück, was auch richtig ist.
*/
export async function withUser<T>(userId: string | null, fn: (tx: Tx) => Promise<T>): Promise<T> {
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.`
);
}
}
}
/**

69
lib/db/json.ts Normal file
View File

@@ -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<unknown> | string): RawBuilder<string> {
const ref = typeof spalte === "string" ? sql.ref(spalte) : spalte;
return sql<string>`to_char(${ref} at time zone 'utc', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')`;
}

125
lib/employee-detail-data.ts Normal file
View File

@@ -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])),
};
}

View File

@@ -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<Database["public"]["Tables"]["employee_notes"]["Row"], "created_at" | "done_at"> & {
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<OpenNote[]> {
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<OpenNote[]> {
};
});
}
export async function loadOpenNotes(tx: Tx): Promise<OpenNote[]> {
const { notes } = await tx
.selectNoFrom((eb) => [jsonArrayFrom(offeneNotizenAbfrage(eb)).as("notes")])
.executeTakeFirstOrThrow();
return baueOffeneNotizen(notes as NotizZeile[]);
}

View File

@@ -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<Schema, never>;
// 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<string, OrgUnit>;
@@ -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<OrgMaps> {
const [units, locations] = await Promise.all([
tx
.selectFrom("org_units")
.select(["id", "org_number", "name", "parent_id", "unit_type"])
.orderBy("org_number")
.execute(),
tx.selectFrom("locations").selectAll().orderBy("name").execute(),
]);
// 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[]);
}

View File

@@ -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<string, Placement
return byEmployee;
}
/**
* Die Besetzungen als *Teilabfrage* — zum Einhängen in die eine Abfrage, die
* eine Seite ohnehin stellt (lib/db/json.ts).
*
* 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.
*/
export function besetzungenAbfrage(eb: OrgEb, employeeIds?: string[]) {
const q = eb
.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 employeeIds ? q.where("pa.employee_id", "in", employeeIds) : q;
}
export async function loadPlacements(
tx: Tx,
{ asOf, employeeIds }: { asOf: string; employeeIds?: string[] }
@@ -148,6 +177,25 @@ export type ReportingLine = {
* bei der Detailseite wandern damit neun Zeilen über die Leitung statt
* achthundert.
*/
/**
* Die Berichtslinien als *Teilabfrage* — zum Einhängen in die eine Abfrage,
* die eine Seite ohnehin stellt (lib/db/json.ts).
*
* `filter` schränkt die Funktion selbst ein, nicht das Ergebnis im Speicher.
*/
export function berichtslinienAbfrage(
eb: OrgEb,
asOf: string,
filter?: { employeeId?: string; actingManagerId?: string }
) {
let q = eb
.selectFrom(sql<ReportingLine>`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,

View File

@@ -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<OpenPositionResolved[]> {
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<OpenPositionResolved[]> {
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<string, string>();
for (const e of ended) {
@@ -148,3 +171,23 @@ export async function loadOpenPositions(tx: Tx): Promise<OpenPositionResolved[]>
};
});
}
/**
* 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<OpenPositionResolved[]> {
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);
}

76
lib/shell-data.ts Normal file
View File

@@ -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<string, unknown>; 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<ShellData | null> {
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[]),
};
}