Files
alpenwerk-hr/actions/employees.ts
Maximilian Stubhan b87c8ad64c
Some checks failed
CI / Lint, Typen, Tests, Build (push) Failing after 5m40s
CI / Migrationen auf leerer Datenbank (push) Has been cancelled
Remove Supabase
The database moved to a container of our own; the platform is gone.
This takes out what was left of it — and, where the leftovers were load
bearing, moves rather than deletes.

Moved, not deleted:

  supabase/migrations/  -> db/migrations/      the schema's source of truth
  supabase/build-org.ts -> scripts/build-org.ts
  lib/supabase/types.ts -> lib/types.ts        52 import sites repointed

The bookkeeping needed care. It lived in `supabase_migrations.schema_migrations`,
and simply renaming the schema would have left the runner facing an empty
table: it would have called all 67 migrations pending and replayed them
against a database that is long since current. So the runner now creates
`migrationen.schema_migrations` and, once, copies the old rows across —
guarded so a second run does nothing and a fresh database skips it entirely.
Only then does migration 20260907100000 drop the old schema.

Deleted: the CLI config, the seed, the historical schema/function dumps
(nothing read them), scripts/umzug-von-supabase.sh (the move is done), and
both Supabase packages plus the CLI. Nothing in the application imported
them — the build now succeeds with no environment variables at all, which
is the proof.

Integration tests: six of them signed in through Supabase Auth and asserted
against the anon key and the service role. That model is gone, so the tests
were not portable — they are deleted. session-context and
employee-status-filter already ran on pg and are untouched; om-reporting is
ported to a direct connection because it guards a real risk (the reporting
line rule exists twice, once in SQL and once in TypeScript).

CI: the integration job started a Supabase stack. It now runs a postgres
service, applies deploy/db-init and every migration to an empty database —
that was the valuable part, and it still holds — then checks that a second
run is a no-op, which is what proves the bookkeeping works.

Docs: security-review.md audited a service-role key, a cookie adapter and
auth.users, none of which exist. Restating findings about removed components
would suggest today's system had been reviewed; it has not. It now records
what was removed and says a fresh review is due. data-model.md was already
marked obsolete and described the pre-OM schema; azure-migration.md was a
plan for a route not taken. Both deleted.

Verified: npm ci, typecheck, lint, 445 tests, build — all clean without the
packages. Integration tests skip cleanly with no database. Migration SQL and
the runner are reviewed but NOT executed: no Docker here, and the old
instance no longer resolves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 10:43:22 +02:00

320 lines
12 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 { OFFBOARDING_PUNKTE } from "@/lib/offboarding";
import { ONBOARDING_PUNKTE } from "@/lib/onboarding";
import type { CollectiveAgreement, DienstwagenArt, NoteCategory, RelationshipType, Weekday, WorkerType } from "@/lib/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: {
/**
* Wird eingegeben, nicht vergeben.
*
* Sie muss mit Loga und Interflex übereinstimmen; eine hier selbst gezogene
* Nummer wäre dort unbekannt und die Person hätte in drei Systemen zwei
* Nummern. Die Datenbank weist eine bereits vergebene Nummer ab.
*/
personnel_number: number;
first_name: string;
last_name: string;
title_prefix?: string[];
title_suffix?: string[];
gender: "m" | "w";
birth_date: string;
sv_nummer?: string;
/**
* Die **private** Adresse, freiwillig.
*
* Sie war einmal Pflicht, weil die Spalte NOT NULL war — für eine private
* Angabe die falsche Vorgabe: wer keine hat, musste eine erfinden. Bleibt
* eindeutig, wenn angegeben.
*/
email?: 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;
dienstwagen_art?: DienstwagenArt | null;
emergency_contact_name?: string;
emergency_contact_phone?: string;
emergency_contact_relation?: string;
is_laterale_fuehrung?: boolean;
is_c_level?: boolean;
has_kuendigungsschutz?: boolean;
/** Nur mit dem Kennzeichen zusammen — so verlangt es chk_kuendigungsschutz_bis. */
kuendigungsschutz_bis?: string | null;
}): 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(), async (tx) => {
const id = (await callFunction(tx, "hire_employee", payload as Record<string, unknown>)) as string;
// In derselben Transaktion: eine Einstellung ohne Checkliste wäre eine
// halb erfasste Einstellung, und sie später nachzureichen hiesse, dass
// jemand daran denken muss.
await callFunction(tx, "start_onboarding", { employee_id: id, item_keys: ONBOARDING_PUNKTE.map((x) => x.key) });
return id;
});
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> {
// No Show ist kein Austritt im gewohnten Sinn — die Person hat nie
// angefangen. Dafür gibt es nichts offzuboarden: keinen IT-Zugang, der
// eingerichtet wurde, keine GKK-Anmeldung, kein Dienstzettel. Die Liste
// entstünde leer und wäre ein Etikett ohne Inhalt.
if (payload.exit_reason === "No Show") {
return callRpc("terminate_employee", payload, [`/employees/${payload.employee_id}`, "/employees", "/"]);
}
try {
await withUser(await currentUserId(), async (tx) => {
await callFunction(tx, "terminate_employee", payload as unknown as Record<string, unknown>);
// In derselben Transaktion: ein Austritt ohne Checkliste wäre ein
// halb erfasster Austritt, und sie später anzulegen hiesse, dass
// jemand daran denken muss.
await callFunction(tx, "start_offboarding", {
employee_id: payload.employee_id,
item_keys: OFFBOARDING_PUNKTE.map((x) => x.key),
});
});
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : "Unbekannter Fehler." };
}
for (const path of [`/employees/${payload.employee_id}`, "/employees", "/"]) revalidatePath(path);
return { success: true };
}
/** Einen Punkt der Offboarding-Checkliste festhalten — Haken, Wert oder Kommentar. */
export async function setOffboardingTask(payload: {
employee_id: string;
item_key: string;
erledigt?: boolean;
wert?: string | null;
kommentar?: string | null;
}): Promise<ActionResult> {
return callRpc("set_offboarding_task", payload, [`/employees/${payload.employee_id}`]);
}
/** Legt die Offboarding-Checkliste nachträglich an — für Austritte von vor dieser Liste. */
export async function startOffboarding(employeeId: string): Promise<ActionResult> {
return callRpc(
"start_offboarding",
{ employee_id: employeeId, item_keys: OFFBOARDING_PUNKTE.map((x) => x.key) },
[`/employees/${employeeId}`]
);
}
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;
/** Wiedereingliederungs- oder Elternteilzeit, wenn reduziert zurückgekehrt wird. */
reduction_reason?: string;
/** Ende der Teilzeit, falls bekannt — nur mit einem Grund zusammen. */
teilzeit_bis?: string;
}): 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"]);
}
/**
* `position_id` ist Pflicht — die Datenbankfunktion verlangt sie seit jeher.
*
* Die alte Stelle taugt nicht als stille Vorgabe: sie kann inzwischen besetzt
* oder ausgelaufen sein. Sie fehlte hier nur in der Signatur, weshalb jede
* Wiedereinstellung an einer Meldung scheiterte, die im Dialog nicht zu
* beheben war.
*/
export async function rehireEmployee(payload: {
employee_id: string;
rehire_date: string;
position_id: string;
}): Promise<ActionResult> {
try {
await withUser(await currentUserId(), async (tx) => {
await callFunction(tx, "rehire_employee", payload as unknown as Record<string, unknown>);
// Auch bei der Wiedereinstellung: Dienstzettel, Bankverbindung und
// E-Card sind wieder zu erledigen. Die Punkte von damals stehen noch da
// und bleiben stehen — start_onboarding legt nur an, was fehlt, statt
// einen alten Haken zu löschen. Was wirklich neu zu tun ist, entscheidet
// HR an der Liste; ein Programm an ihrer Stelle würde raten.
await callFunction(tx, "start_onboarding", {
employee_id: payload.employee_id,
item_keys: ONBOARDING_PUNKTE.map((x) => x.key),
});
});
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : "Unbekannter Fehler." };
}
for (const path of [`/employees/${payload.employee_id}`, "/employees", "/"]) revalidatePath(path);
return { success: true };
}
/** Einen Punkt der Checkliste festhalten — Haken, Wert oder Kommentar. */
export async function setOnboardingTask(payload: {
employee_id: string;
item_key: string;
erledigt?: boolean;
wert?: string | null;
kommentar?: string | null;
}): Promise<ActionResult> {
return callRpc("set_onboarding_task", payload, [`/employees/${payload.employee_id}`]);
}
/** Legt die Checkliste nachträglich an — für Personen von vor dieser Liste. */
export async function startOnboarding(employeeId: string): Promise<ActionResult> {
return callRpc(
"start_onboarding",
{ employee_id: employeeId, item_keys: ONBOARDING_PUNKTE.map((x) => x.key) },
[`/employees/${employeeId}`]
);
}
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}`]);
}
/**
* Nimmt eine irrtümlich erfasste Stammdaten- oder Vertragsänderung zurück.
*
* Was zurückgesetzt wird und was stehen bleibt, entscheidet die Datenbank —
* sie prüft dabei erneut, ob der Eintrag überhaupt gelöscht werden darf. Die
* Oberfläche zeigt den Knopf nur dort, wo es geht (siehe lib/history.ts);
* kommt trotzdem eine Ablehnung zurück, wird deren Begründung angezeigt.
*/
export async function deleteHistoryEntry(payload: {
history_id: string;
employee_id: string;
}): Promise<ActionResult> {
return callRpc("delete_history_entry", { history_id: payload.history_id }, [`/employees/${payload.employee_id}`, "/audit"]);
}
/**
* Berichtigt Wert und/oder Datum eines Historieneintrags.
*
* Geändert wird nur das „Nachher" — was vor der Änderung galt, ist nicht
* nachträglich beschliessbar. Welcher Wert danach in den Stammdaten steht,
* leitet die Datenbank je Feld aus dem jüngsten Eintrag ab, der es trägt.
*/
export async function updateHistoryEntry(payload: {
history_id: string;
employee_id: string;
event_date?: string;
werte: { feld: string; nachher: string | null }[];
}): Promise<ActionResult> {
return callRpc(
"update_history_entry",
{ history_id: payload.history_id, event_date: payload.event_date, werte: payload.werte },
[`/employees/${payload.employee_id}`, "/audit"]
);
}
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}`, "/"]);
}