Talk to PostgreSQL directly, and let the pooled connection forget

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

Was dabei ersatzlos verschwindet:

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

Nebenbei besser geworden, weil der direkte Zugang es erlaubt:

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

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

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

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

View File

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

View File

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