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>
320 lines
12 KiB
TypeScript
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}`, "/"]);
|
|
}
|