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:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user