Files
alpenwerk-hr/actions/employees.ts
Maximilian Stubhan b3a0af2b8f 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.
2026-07-31 08:45:26 +02:00

159 lines
5.4 KiB
TypeScript

"use server";
import { revalidatePath } from "next/cache";
import { currentUserId } from "@/lib/auth/session";
import { withUser } from "@/lib/db";
import { callFunction, runMutation, type ActionResult, type MutationFn } from "@/lib/db/rpc";
import type { CollectiveAgreement, NoteCategory, RelationshipType, Weekday, WorkerType } from "@/lib/supabase/types";
async function callRpc(fn: MutationFn, payload: Record<string, unknown>, revalidate: string[]): Promise<ActionResult> {
const result = await runMutation(await currentUserId(), fn, payload);
if (!result.success) return result;
for (const path of revalidate) revalidatePath(path);
return result;
}
export async function hireEmployee(payload: {
first_name: string;
last_name: string;
title_prefix?: string[];
title_suffix?: string[];
gender: "m" | "w";
birth_date: string;
sv_nummer?: string;
phone?: string;
position_id?: string;
team_id?: string;
job_title?: string;
location_id: string;
entry_date: string;
contract_type?: "unbefristet" | "befristet";
contract_end_date?: string;
employment_type?: "Vollzeit" | "Teilzeit";
weekly_hours?: number;
paygrade?: "A" | "B" | "C" | "D" | "E" | "F";
source: "Intern" | "Extern";
worker_type?: WorkerType;
collective_agreement?: CollectiveAgreement;
work_days?: Weekday[];
is_betriebsrat?: boolean;
has_dienstwagen?: boolean;
is_laterale_fuehrung?: boolean;
is_c_level?: boolean;
}): Promise<ActionResult & { employeeId?: string }> {
// Einzige Mutation, deren Rückgabewert gebraucht wird: die neue
// Personen-Kennung, damit die Oberfläche direkt auf die Akte springen kann.
try {
const employeeId = await withUser(await currentUserId(), (tx) =>
callFunction(tx, "hire_employee", payload as Record<string, unknown>)
);
revalidatePath("/employees");
revalidatePath("/");
revalidatePath("/positions");
return { success: true, employeeId: employeeId as string };
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : "Unbekannter Fehler." };
}
}
export async function terminateEmployee(payload: {
employee_id: string;
exit_date: string;
exit_reason: string;
note?: string;
}): Promise<ActionResult> {
return callRpc("terminate_employee", payload, [`/employees/${payload.employee_id}`, "/employees", "/"]);
}
export async function transferEmployee(payload: {
employee_id: string;
effective_date: string;
/** Die Zielplanstelle; Bereich, Abteilung und Team ergeben sich aus ihrer Einheit. */
target_position_id: string;
}): Promise<ActionResult> {
return callRpc("transfer_employee", payload, [`/employees/${payload.employee_id}`, "/employees"]);
}
export async function promoteEmployee(payload: {
employee_id: string;
effective_date: string;
new_title: string;
new_paygrade?: "A" | "B" | "C" | "D" | "E" | "F";
}): Promise<ActionResult> {
return callRpc("promote_employee", payload, [`/employees/${payload.employee_id}`, "/employees"]);
}
export async function startKarenz(payload: {
employee_id: string;
karenz_start_date: string;
planned_return_date: string;
absence_type: string;
note?: string;
}): Promise<ActionResult> {
return callRpc("start_karenz", payload, [`/employees/${payload.employee_id}`, "/employees", "/"]);
}
export async function adjustKarenzReturn(payload: {
employee_id: string;
new_return_date: string;
note?: string;
}): Promise<ActionResult> {
return callRpc("adjust_karenz_return", payload, [`/employees/${payload.employee_id}`]);
}
export async function recordKarenzReturn(payload: {
employee_id: string;
return_date: string;
employment_mode: "unverändert" | "Vollzeit" | "Teilzeit";
weekly_hours?: number;
}): Promise<ActionResult> {
return callRpc("record_karenz_return", payload, [`/employees/${payload.employee_id}`, "/employees", "/"]);
}
export async function changeEmployeeData(payload: {
employee_id: string;
effective_date: string;
person: Record<string, unknown>;
contract: Record<string, unknown>;
role: Record<string, unknown>;
}): Promise<ActionResult> {
return callRpc("change_employee_data", payload, [`/employees/${payload.employee_id}`, "/employees"]);
}
export async function rehireEmployee(payload: { employee_id: string; rehire_date: string }): Promise<ActionResult> {
return callRpc("rehire_employee", payload, [`/employees/${payload.employee_id}`, "/employees", "/"]);
}
export async function addEmployeeDependent(payload: {
employee_id: string;
first_name: string;
last_name: string;
relationship: RelationshipType;
sv_nummer?: string;
birth_date: string;
effective_date: string;
}): Promise<ActionResult> {
return callRpc("add_employee_dependent", payload, [`/employees/${payload.employee_id}`]);
}
export async function deleteEmployeeDependent(payload: {
dependent_id: string;
employee_id: string;
effective_date: string;
}): Promise<ActionResult> {
return callRpc("delete_employee_dependent", payload, [`/employees/${payload.employee_id}`]);
}
export async function addEmployeeNote(payload: {
employee_id: string;
category: NoteCategory;
note_text: string;
due_date?: string;
}): Promise<ActionResult> {
return callRpc("add_employee_note", payload, [`/employees/${payload.employee_id}`, "/"]);
}
export async function completeEmployeeNote(payload: { note_id: string; employee_id: string }): Promise<ActionResult> {
return callRpc("complete_employee_note", payload, [`/employees/${payload.employee_id}`, "/"]);
}