Die Datenbank stand seit dem Cut-over auf org_units/om_positions/
position_assignments, die Anwendung fragte weiter nach employees.division_id,
team_id und manager_id — Spalten, die es nicht mehr gab. Die Oberfläche war
deshalb leer, obwohl die Daten vollständig da waren. Das ist jetzt behoben,
und zwar nicht durch Nachbau der alten Begriffe, sondern indem sie verschwinden.
Neu ist eine dünne Schicht, die die Verkettung Person → Besetzung →
Planstelle → Einheit einmal auflöst (lib/placement.ts) und der Baum als reine
Funktionen darauf (lib/org.ts): Vorfahrenkette, Teilbaum, Brotkrume. Alles
Weitere hängt daran.
Was sich dadurch von selbst erledigt hat:
- Das Organigramm musste drei Quellen versöhnen, weil keine den ganzen
Zeitstrahl abdeckte. position_assignments ist zeitabhängig, also
beantwortet eine Abfrage "wer besetzte am Stichtag welche Planstelle" —
für Vergangenheit und Zukunft gleichermassen. Wer keine Planstelle hatte,
war nicht da; eine zweite Zugehörigkeitsregel braucht es nicht mehr.
- Die Struktursicht war auf genau vier Ebenen verdrahtet und rendert jetzt
rekursiv über parent_id. Liste und Grafik entstehen aus *einem* Baum;
vorher lag dieselbe Hierarchie zweimal vor und konnte auseinanderlaufen.
- Eine offene Stelle ist keine eigene Tabelle mehr, sondern eine Planstelle
ohne laufende Besetzung — das Komplement kann nicht aus dem Tritt geraten.
- Eine Versetzung ist der Wechsel auf eine Zielplanstelle statt Zielteam
plus frei getipptem Titel. Sie kann damit nicht mehr dort landen, wo es
keine Stelle gibt, und die Tätigkeit kommt aus dem Job-Katalog.
- Beim Anlegen einer Planstelle entfällt die Suche nach der vorgesetzten
Person: sie ergibt sich aus der Einheit, die Frage kann nicht mehr falsch
beantwortet werden.
Zwei Auswertungen werden dabei richtiger, nicht nur anders. Ein
Stichtagsbericht gruppierte bisher nach der *heutigen* Zuordnung, weil es
keine Historie gab; er löst sie jetzt zum Stichtag auf. Und ein Ereignis
trägt die Einheit, in der die Person am Tag des Ereignisses sass — vorher
stand ein Austritt von vor zwei Jahren unter einem Team, in das sie nie
versetzt worden war. Der Bereichsfilter greift überall auf den ganzen
Teilbaum; auf den Bereich allein angewandt lieferte er nur die
Bereichsleitung.
Gelöscht: die Reorganisations-Werkbank samt Szenarien und Zügen (sie
verschob Teams und Abteilungen zwischen Bereichen — Objekte, die es nicht
mehr gibt; im OM-Modell ist das ein Umhängen von parent_id), die
Mitarbeiter- und Vorgesetztensuche, die nur sie und die Ausschreibung
brauchten, und aus lib/supabase/types.ts die Tabellen divisions,
departments, teams, positions und employee_assignments.
Die beiliegende Migration räumt die Datenbank entsprechend auf. Sie entfernt
auch Funktionen, die der Cut-over verfehlt hat: create_position,
delete_position und undo_reorg existierten zusätzlich in einer
jsonb-Variante und tauchen deshalb weiter in der PostgREST-Schnittstelle auf,
obwohl ihre Tabellen weg sind — ein Aufruf wäre erst zur Laufzeit
gescheitert. An ihre Stelle treten create_position und delete_position im
OM-Sinn; letzteres schliesst eine früher besetzte Planstelle, statt sie zu
löschen, sonst verschwände mit ihr die Besetzungshistorie.
Typecheck, Lint, Build und 182 Tests sind grün. Die Integrationstests sind
mitgezogen, aber weiterhin ungelaufen — dafür braucht es eine laufende
lokale Datenbank.
202 lines
8.8 KiB
TypeScript
202 lines
8.8 KiB
TypeScript
import { createClient, type SupabaseClient } from "@supabase/supabase-js";
|
|
import { randomUUID } from "node:crypto";
|
|
import type { Database, EmploymentStatus } from "@/lib/supabase/types";
|
|
|
|
const URL = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
|
const ANON_KEY = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
|
const SERVICE_ROLE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
|
|
|
if (!URL || !ANON_KEY || !SERVICE_ROLE_KEY) {
|
|
throw new Error(
|
|
"Missing Supabase env vars for integration tests. Start local Supabase (`npx supabase start`) and run " +
|
|
"`npm run test:integration`, which loads .env.test.local automatically. See README.md."
|
|
);
|
|
}
|
|
|
|
export const adminClient: SupabaseClient<Database> = createClient(URL, SERVICE_ROLE_KEY, {
|
|
auth: { autoRefreshToken: false, persistSession: false },
|
|
});
|
|
|
|
export function anonClient(): SupabaseClient<Database> {
|
|
return createClient(URL!, ANON_KEY!, { auth: { autoRefreshToken: false, persistSession: false } });
|
|
}
|
|
|
|
export type TestUser = { id: string; email: string; password: string };
|
|
|
|
// Creates a real auth.users row (via the admin API) with no profiles row —
|
|
// this is exactly the "signed up but never provisioned" state §2.3
|
|
// describes: authenticated, but not HR.
|
|
export async function createBareAuthUser(): Promise<TestUser> {
|
|
const email = `test-${randomUUID()}@example.test`;
|
|
const password = `Test-${randomUUID()}!`;
|
|
const { data, error } = await adminClient.auth.admin.createUser({ email, password, email_confirm: true });
|
|
if (error || !data.user) throw new Error(`createBareAuthUser failed: ${error?.message}`);
|
|
return { id: data.user.id, email, password };
|
|
}
|
|
|
|
export async function createHrUser(opts: { active: boolean }): Promise<TestUser> {
|
|
const user = await createBareAuthUser();
|
|
const { error } = await adminClient
|
|
.from("profiles")
|
|
.insert({ id: user.id, email: user.email, full_name: "Integrationstest HR", role: "hr", is_active: opts.active });
|
|
if (error) throw new Error(`createHrUser profile insert failed: ${error.message}`);
|
|
return user;
|
|
}
|
|
|
|
export async function deleteTestUser(user: TestUser): Promise<void> {
|
|
await adminClient.from("profiles").delete().eq("id", user.id);
|
|
await adminClient.auth.admin.deleteUser(user.id);
|
|
}
|
|
|
|
export async function signInAs(user: TestUser): Promise<SupabaseClient<Database>> {
|
|
const client = anonClient();
|
|
const { error } = await client.auth.signInWithPassword({ email: user.email, password: user.password });
|
|
if (error) throw new Error(`signInAs(${user.email}) failed: ${error.message}`);
|
|
return client;
|
|
}
|
|
|
|
// Aus dem Seed gezogen. Die Einordnung steht nicht mehr auf der Person, sie
|
|
// kommt über die laufende Besetzung — deshalb liefert das hier gleich die
|
|
// Planstelle und ihre Einheit mit.
|
|
export async function pickSeededEmployee(filter: Partial<{ status: EmploymentStatus; isChief: boolean }> = {}): Promise<{
|
|
id: string;
|
|
status: string;
|
|
position_id: string;
|
|
org_unit_id: string;
|
|
is_chief: boolean;
|
|
}> {
|
|
let q = adminClient
|
|
.from("position_assignments")
|
|
.select("employee_id, om_positions!inner(id, org_unit_id, is_chief), employees!inner(id, status)")
|
|
.is("valid_to", null)
|
|
.limit(1);
|
|
if (filter.status) q = q.eq("employees.status", filter.status);
|
|
if (filter.isChief !== undefined) q = q.eq("om_positions.is_chief", filter.isChief);
|
|
|
|
const { data, error } = await q.maybeSingle();
|
|
if (error || !data) throw new Error(`pickSeededEmployee failed: ${error?.message ?? "no matching row"}`);
|
|
const row = data as unknown as {
|
|
employee_id: string;
|
|
om_positions: { id: string; org_unit_id: string; is_chief: boolean };
|
|
employees: { status: string };
|
|
};
|
|
return {
|
|
id: row.employee_id,
|
|
status: row.employees.status,
|
|
position_id: row.om_positions.id,
|
|
org_unit_id: row.om_positions.org_unit_id,
|
|
is_chief: row.om_positions.is_chief,
|
|
};
|
|
}
|
|
|
|
/** Eine Organisationseinheit vom Typ Team, nach Möglichkeit eine andere als die gegebene. */
|
|
export async function pickSeededUnit(excludeUnitId?: string): Promise<{ id: string }> {
|
|
const { data, error } = await adminClient.from("org_units").select("id").eq("unit_type", "Team").limit(2);
|
|
if (error || !data?.length) throw new Error(`pickSeededUnit failed: ${error?.message}`);
|
|
return data.find((u) => u.id !== excludeUnitId) ?? data[0];
|
|
}
|
|
|
|
/**
|
|
* Eine heute unbesetzte Planstelle. Einstellung und Versetzung setzen im
|
|
* OM-Modell eine freie Zielplanstelle voraus — ohne die gibt es nichts zu
|
|
* testen, deshalb legt der Aufrufer sonst selbst eine an.
|
|
*/
|
|
export async function pickVacantPosition(): Promise<{ id: string; org_unit_id: string } | null> {
|
|
const { data: positions } = await adminClient.from("om_positions").select("id, org_unit_id").is("valid_to", null);
|
|
const { data: taken } = await adminClient.from("position_assignments").select("position_id").is("valid_to", null);
|
|
const besetzt = new Set((taken ?? []).map((a) => a.position_id));
|
|
return (positions ?? []).find((p) => !besetzt.has(p.id)) ?? null;
|
|
}
|
|
|
|
export async function pickSeededLocation(): Promise<{ id: string }> {
|
|
const { data, error } = await adminClient.from("locations").select("id").limit(1).maybeSingle();
|
|
if (error || !data) throw new Error(`pickSeededLocation failed: ${error?.message ?? "no rows"}`);
|
|
return data;
|
|
}
|
|
|
|
/** Wer die Leitungsplanstelle einer Einheit laufend innehat, falls jemand. */
|
|
export async function chiefOfUnit(orgUnitId: string): Promise<string | null> {
|
|
const { data, error } = await adminClient
|
|
.from("om_positions")
|
|
.select("position_assignments!inner(employee_id, valid_to)")
|
|
.eq("org_unit_id", orgUnitId)
|
|
.eq("is_chief", true)
|
|
.is("valid_to", null)
|
|
.is("position_assignments.valid_to", null)
|
|
.maybeSingle();
|
|
if (error) throw new Error(`chiefOfUnit(${orgUnitId}) failed: ${error.message}`);
|
|
const row = data as unknown as { position_assignments: { employee_id: string }[] } | null;
|
|
return row?.position_assignments[0]?.employee_id ?? null;
|
|
}
|
|
|
|
// YYYY-MM-DD, offset from today — for building "wirksam ab" test payloads
|
|
// without hardcoding dates that eventually go stale.
|
|
export function isoDateOffset(days: number): string {
|
|
const d = new Date();
|
|
d.setDate(d.getDate() + days);
|
|
return d.toISOString().slice(0, 10);
|
|
}
|
|
|
|
// Stellt eine Wegwerf-Person auf `positionId` ein — über die echte
|
|
// hire_employee-RPC, nicht per Insert, damit jeder Mutationstest von einem
|
|
// Zustand ausgeht, den die Anwendung selbst herstellen kann. Aufräumen mit
|
|
// deleteTestEmployee.
|
|
export async function hireTestEmployee(
|
|
hrClient: SupabaseClient<Database>,
|
|
positionId: string,
|
|
overrides: Partial<Record<string, unknown>> = {}
|
|
): Promise<string> {
|
|
const location = await pickSeededLocation();
|
|
const payload = {
|
|
first_name: "Integrationstest",
|
|
last_name: `Person-${randomUUID().slice(0, 8)}`,
|
|
gender: "w",
|
|
birth_date: "1990-01-01",
|
|
location_id: location.id,
|
|
// Die Tätigkeit kommt aus dem Job der Planstelle; sie wird nicht
|
|
// mitgegeben, sonst könnten die beiden auseinanderlaufen.
|
|
position_id: positionId,
|
|
entry_date: isoDateOffset(-30),
|
|
source: "Extern",
|
|
...overrides,
|
|
};
|
|
const { data, error } = await hrClient.rpc("hire_employee", { payload });
|
|
if (error || !data) throw new Error(`hireTestEmployee failed: ${error?.message ?? "no id returned"}`);
|
|
return data;
|
|
}
|
|
|
|
// employees has no cascade from audit_log (target_employee_id is a plain
|
|
// FK, immutable log by design) — clear those rows first so the employee
|
|
// delete itself doesn't fail with a foreign key violation. employee_history
|
|
// and pending_org_changes do cascade on employee_id.
|
|
export async function deleteTestEmployee(employeeId: string): Promise<void> {
|
|
await adminClient.from("audit_log").delete().eq("target_employee_id", employeeId);
|
|
await adminClient.from("employees").delete().eq("id", employeeId);
|
|
}
|
|
|
|
// Legt eine Wegwerf-Planstelle in `orgUnitId` an, über die echte
|
|
// create_position-RPC. Die vorgesetzte Person wird nicht mehr angegeben —
|
|
// sie ergibt sich aus der Einheit. Aufräumen mit deleteTestPosition, und
|
|
// zwar *vor* den Personen, die darauf sassen.
|
|
export async function createTestPosition(
|
|
hrClient: SupabaseClient<Database>,
|
|
orgUnitId: string,
|
|
overrides: Partial<Record<string, unknown>> = {}
|
|
): Promise<string> {
|
|
const payload = {
|
|
org_unit_id: orgUnitId,
|
|
job_title: `Integrationstest-Tätigkeit-${randomUUID().slice(0, 8)}`,
|
|
is_chief: false,
|
|
...overrides,
|
|
};
|
|
const { data, error } = await hrClient.rpc("create_position", { payload });
|
|
if (error || !data) throw new Error(`createTestPosition failed: ${error?.message ?? "no id returned"}`);
|
|
return data;
|
|
}
|
|
|
|
export async function deleteTestPosition(positionId: string): Promise<void> {
|
|
// Besetzungen hängen mit on delete cascade daran, der Job bleibt im
|
|
// Katalog — er ist geteilt und gehört keiner einzelnen Planstelle.
|
|
await adminClient.from("om_positions").delete().eq("id", positionId);
|
|
}
|