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