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.
109 lines
3.9 KiB
TypeScript
109 lines
3.9 KiB
TypeScript
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, loadReportingLines } from "@/lib/placement";
|
|
import { loadOpenPositions } from "@/lib/positions";
|
|
|
|
type PageProps = { params: Promise<{ id: string }> };
|
|
|
|
export default async function EmployeeDetailPage({ params }: PageProps) {
|
|
const { id } = await params;
|
|
const today = todayIso();
|
|
|
|
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) return null;
|
|
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])),
|
|
};
|
|
});
|
|
|
|
if (!data) notFound();
|
|
const { employee, line, reports, history, dependents, notes, orgMaps, placement, openPositions, byId } = data;
|
|
|
|
return (
|
|
<EmployeeDetail
|
|
employee={employee}
|
|
placement={
|
|
placement && {
|
|
positionNumber: placement.positionNumber,
|
|
jobTitle: placement.jobTitle,
|
|
isChief: placement.isChief,
|
|
current: placement.current,
|
|
}
|
|
}
|
|
breadcrumb={breadcrumbLabel(orgMaps, placement?.orgUnitId)}
|
|
manager={(line?.acting_manager_id ? byId.get(line.acting_manager_id) : null) ?? null}
|
|
// Nur wenn eine Vertretung im Spiel ist — sonst stünde dieselbe Person
|
|
// zweimal da.
|
|
formalManager={
|
|
line?.formal_manager_id && line.formal_manager_id !== line.acting_manager_id
|
|
? (byId.get(line.formal_manager_id) ?? null)
|
|
: null
|
|
}
|
|
directReports={reports.flatMap((r) => {
|
|
const e = byId.get(r.employee_id);
|
|
return e ? [e] : [];
|
|
})}
|
|
history={history ?? []}
|
|
dependents={dependents ?? []}
|
|
notes={notes ?? []}
|
|
locations={orgMaps.locationList}
|
|
openPositions={openPositions}
|
|
/>
|
|
);
|
|
}
|