From 4b9c23472cf45ff8ce36e6f6386e299c894a1195 Mon Sep 17 00:00:00 2001 From: Maximilian Stubhan Date: Mon, 27 Jul 2026 12:47:14 +0200 Subject: [PATCH 01/64] SAP OM: org units, jobs, positions, and a derived reporting line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The org structure was three fixed tables — divisions -> departments -> teams — with people hanging directly off them and a hand-maintained manager_id. The depth was therefore wired into the schema: an Abteilungsleitung could not exist without a migration, and a team directly under a Bereich not at all. That is what this replaces. The SAP OM object types, one table each: O org_units recursive over parent_id C jobs catalogue, so many positions can share a job S om_positions belongs to exactly one org unit P employees existing table A012 om_positions.is_chief "ist Leiter von" A008 position_assignments "Inhaber ist", time-dependent Two consequences worth stating, because they are the point of the exercise: - GF/Bereich/Abteilung/Team are now a label (unit_type), not a structure. Adding a fifth level, or hanging a team straight off a Bereich, becomes a data question rather than a migration. - Nobody hangs off an org unit any more: person -> position -> unit. A vacancy stops being its own concept — it is a position with no current assignment. The reporting line is derived rather than stored: an ordinary position reports to the chief of its own unit, a chief to the chief of the parent unit, and if that chief is vacant or on a long-term absence it keeps climbing. An unfilled Abteilungsleitung therefore needs no special case — it is simply skipped. Both ids come back, formal and acting, so the UI can show a stand-in as a stand-in instead of passing it off as the real manager. The rule exists twice, as om_reporting_lines() in SQL and resolveReportingLines() in TypeScript, because the as-of chart computes it per date in the app and a round trip per date change would buy nothing. Two copies drift silently — the org chart would just show a different manager than the export — so an integration test runs both over the whole roster and requires identical answers, plus that every line terminates at the top. Unit tests cover the rule itself: unfilled levels, several absent levels in a row, nobody above, a chief who also leads the parent unit, and a cycle in parent_id, which is an ordinary column an import could get wrong. Additive so far. The old tables still stand and the app still reads them; the cut-over follows. --- lib/om-reporting.ts | 85 ++++++++++ lib/supabase/types.ts | 93 +++++++++++ .../20260727120000_sap_om_org_model.sql | 123 ++++++++++++++ .../20260727120100_om_reporting_lines.sql | 101 ++++++++++++ tests/integration/om-reporting.test.ts | 110 +++++++++++++ tests/unit/om-reporting.test.ts | 154 ++++++++++++++++++ 6 files changed, 666 insertions(+) create mode 100644 lib/om-reporting.ts create mode 100644 supabase/migrations/20260727120000_sap_om_org_model.sql create mode 100644 supabase/migrations/20260727120100_om_reporting_lines.sql create mode 100644 tests/integration/om-reporting.test.ts create mode 100644 tests/unit/om-reporting.test.ts diff --git a/lib/om-reporting.ts b/lib/om-reporting.ts new file mode 100644 index 0000000..b73c386 --- /dev/null +++ b/lib/om-reporting.ts @@ -0,0 +1,85 @@ +// Die SAP-OM-Berichtslinie, abgeleitet aus dem Organisationsbaum. +// +// Dieselbe Regel steckt als om_reporting_lines() in der Datenbank. Zwei +// Fassungen derselben Regel driften auseinander, deshalb prüft +// tests/integration/om-reporting.test.ts beide gegen denselben Bestand. +// Hier liegt sie zusätzlich, weil das Organigramm zu einem Stichtag ohnehin +// im Anwendungscode gerechnet wird und ein Datenbank-Roundtrip je +// Stichtagswechsel nichts brächte. +// +// Regel: +// Wer eine gewöhnliche Planstelle innehat, berichtet an die Leitung der +// eigenen Einheit. Wer selbst die Leitung innehat, an die Leitung der +// übergeordneten Einheit. +// +// Aufwärtsregel: Ist diese Leitung unbesetzt oder langzeitabwesend, geht es +// weiter nach oben. Eine unbesetzte Abteilungsleitung braucht damit keine +// Sonderbehandlung — sie wird übersprungen. + +export type OmUnit = { id: string; parentId: string | null }; + +export type OmHolder = { + employeeId: string; + positionId: string; + orgUnitId: string; + isChief: boolean; + /** Langzeitabwesend am Stichtag. */ + absent: boolean; +}; + +export type OmReportingLine = { + employeeId: string; + positionId: string; + orgUnitId: string; + isChief: boolean; + /** Zuständige Leitung, auch wenn abwesend. Null, wenn es keine gibt. */ + formalManagerId: string | null; + /** Nächste besetzte und anwesende Leitung ab der zuständigen Einheit aufwärts. */ + actingManagerId: string | null; +}; + +/** + * `units` und `holders` beschreiben den Stand zu genau einem Stichtag — + * gültige Einheiten und laufende Besetzungen. Die Zeitlogik bleibt bewusst + * draußen, damit diese Funktion nur eine Sache tut. + */ +export function resolveReportingLines(units: OmUnit[], holders: OmHolder[]): OmReportingLine[] { + const parentOf = new Map(units.map((u) => [u.id, u.parentId])); + const chiefOfUnit = new Map(); + for (const h of holders) { + if (h.isChief) chiefOfUnit.set(h.orgUnitId, h); + } + + return holders.map((h) => { + // Leitungen suchen ab der übergeordneten Einheit, alle anderen ab der + // eigenen — sonst berichtete eine Leitung an sich selbst. + const baseUnitId = h.isChief ? (parentOf.get(h.orgUnitId) ?? null) : h.orgUnitId; + + const formal = baseUnitId ? (chiefOfUnit.get(baseUnitId) ?? null) : null; + + // Aufwärts, bis eine besetzte und anwesende Leitung gefunden ist. Der + // Zyklusschutz ist kein Selbstzweck: parent_id ist eine gewöhnliche + // Spalte, und ein fehlerhafter Import kann einen Ring erzeugen. + let actingManagerId: string | null = null; + const seen = new Set(); + let unitId: string | null = baseUnitId; + while (unitId && !seen.has(unitId)) { + seen.add(unitId); + const chief = chiefOfUnit.get(unitId); + if (chief && !chief.absent && chief.employeeId !== h.employeeId) { + actingManagerId = chief.employeeId; + break; + } + unitId = parentOf.get(unitId) ?? null; + } + + return { + employeeId: h.employeeId, + positionId: h.positionId, + orgUnitId: h.orgUnitId, + isChief: h.isChief, + formalManagerId: formal?.employeeId ?? null, + actingManagerId, + }; + }); +} diff --git a/lib/supabase/types.ts b/lib/supabase/types.ts index 672f613..724f655 100644 --- a/lib/supabase/types.ts +++ b/lib/supabase/types.ts @@ -18,6 +18,8 @@ export type NoteCategory = "Allgemein" | "Vertraulich" | "Personalgespräch" | " // union (not a string literal) so a future hr_admin/hr_user split, if ever // technically required, is a type-level addition, not a rewrite. export type ProfileRole = "hr"; +/** Etikett einer Organisationseinheit; die Struktur steckt in parent_id. */ +export type OrgUnitType = "Gesellschaft" | "Bereich" | "Abteilung" | "Team"; export type HistoryEventType = | "Eintritt" | "Beförderung" @@ -395,6 +397,86 @@ export type Database = { }; // Written exclusively by trg_track_employee_assignment; RLS grants HR // read access only, hence no Insert/Update shapes worth modelling. + // ── SAP-OM-Modell ────────────────────────────────────────── + // O: rekursiv über parent_id, unit_type ist nur ein Etikett. + org_units: NoRelationships & { + Row: { + id: string; + org_number: string; + name: string; + parent_id: string | null; + unit_type: OrgUnitType; + valid_from: string; + valid_to: string | null; + created_at: string; + }; + Insert: { + id?: string; + org_number: string; + name: string; + parent_id?: string | null; + unit_type: OrgUnitType; + valid_from?: string; + valid_to?: string | null; + created_at?: string; + }; + Update: Partial; + }; + // C: Katalog der Tätigkeiten. + jobs: NoRelationships & { + Row: { id: string; code: string; title: string; created_at: string }; + Insert: { id?: string; code: string; title: string; created_at?: string }; + Update: Partial; + }; + // S: Planstelle. Heisst om_positions, weil `positions` noch die alte + // Tabelle für offene Stellen ist, bis der Umstieg abgeschlossen ist. + om_positions: { + Row: { + id: string; + position_number: string; + org_unit_id: string; + job_id: string; + is_chief: boolean; + valid_from: string; + valid_to: string | null; + created_at: string; + }; + Insert: { + id?: string; + position_number: string; + org_unit_id: string; + job_id: string; + is_chief?: boolean; + valid_from?: string; + valid_to?: string | null; + created_at?: string; + }; + Update: Partial; + Relationships: []; + }; + // A008: Person besetzt Planstelle, zeitabhängig. + position_assignments: { + Row: { + id: string; + position_id: string; + employee_id: string; + valid_from: string; + valid_to: string | null; + created_at: string; + }; + Insert: { + id?: string; + position_id: string; + employee_id: string; + valid_from: string; + valid_to?: string | null; + created_at?: string; + }; + Update: Partial; + // Einbettung auf die Planstelle, damit die Berichtslinie in einer + // Abfrage geladen werden kann. + Relationships: [{ foreignKeyName: "position_assignments_position_id_fkey"; columns: ["position_id"]; referencedRelation: "om_positions"; referencedColumns: ["id"] }]; + }; employee_assignments: NoRelationships & { Row: { id: string; @@ -435,6 +517,17 @@ export type Database = { apply_reorg: { Args: { payload: Record }; Returns: string }; undo_reorg: { Args: { payload: Record }; Returns: void }; apply_due_pending_changes: { Args: Record; Returns: number }; + om_reporting_lines: { + Args: { p_as_of?: string }; + Returns: { + employee_id: string; + position_id: string; + org_unit_id: string; + is_chief: boolean; + formal_manager_id: string | null; + acting_manager_id: string | null; + }[]; + }; }; }; }; diff --git a/supabase/migrations/20260727120000_sap_om_org_model.sql b/supabase/migrations/20260727120000_sap_om_org_model.sql new file mode 100644 index 0000000..e454623 --- /dev/null +++ b/supabase/migrations/20260727120000_sap_om_org_model.sql @@ -0,0 +1,123 @@ +-- Organisationsmanagement nach SAP-OM-Vorbild. +-- +-- Bisher: drei feste Tabellen (divisions -> departments -> teams) und +-- Personen, die direkt daran hängen (employees.division_id/team_id) mit +-- einer frei gepflegten manager_id. Damit ist die Hierarchie in ihrer Tiefe +-- fest verdrahtet — eine Abteilungsleitung liess sich nicht abbilden, ohne +-- das Schema zu ändern, und ein Team direkt unter einem Bereich gar nicht. +-- +-- SAP OM löst das über wenige Objekttypen und Verknüpfungen dazwischen: +-- +-- O Organisationseinheit org_units (rekursiv über parent_id) +-- C Stelle / Job jobs (Katalog) +-- S Planstelle positions (gehört zu genau einer O) +-- P Person employees (besetzt eine S) +-- +-- A003 "gehört zu" positions.org_unit_id +-- A012 "ist Leiter von" positions.is_chief +-- A008 "Inhaber ist" position_assignments +-- +-- Die Berichtslinie wird daraus abgeleitet statt gepflegt, siehe die +-- folgende Migration. Ebenen sind nur noch ein Etikett (unit_type), keine +-- Struktur — eine fünfte Ebene ist damit eine Datenfrage, keine Migration. + +-- ── O: Organisationseinheit ──────────────────────────────────────── +create type org_unit_type as enum ('Gesellschaft', 'Bereich', 'Abteilung', 'Team'); + +create table org_units ( + id uuid primary key default gen_random_uuid(), + org_number text not null unique, + name text not null, + -- Die Hierarchie selbst. Null nur für die Wurzel. + parent_id uuid references org_units(id), + -- Nur Beschriftung und Nummernkreis-Konvention; die Struktur steckt in + -- parent_id. Eine Abteilung unter einer Abteilung wäre technisch möglich + -- und ist bewusst nicht verboten. + unit_type org_unit_type not null, + valid_from date not null default current_date, + valid_to date, + created_at timestamptz not null default now(), + constraint chk_org_unit_range check (valid_to is null or valid_to > valid_from), + constraint chk_org_unit_not_own_parent check (parent_id is null or parent_id <> id) +); + +create index on org_units (parent_id); +create index on org_units (unit_type); +-- Genau eine Wurzel: ohne das kann ein Fehlgriff beim Import einen zweiten +-- Baum aufmachen, und die Ableitung der Berichtslinie liefe ins Leere. +create unique index org_units_single_root on org_units ((parent_id is null)) where parent_id is null; + +comment on table org_units is 'SAP-OM-Objekttyp O. Rekursiv über parent_id; unit_type ist nur ein Etikett.'; + +-- ── C: Stelle / Job-Katalog ──────────────────────────────────────── +-- Trennt die Tätigkeitsbeschreibung von der einzelnen Planstelle: viele +-- Planstellen teilen sich einen Job. Bisher war job_title Freitext je +-- Person, weshalb "Schlosser:in" und "Schlosser" nebeneinander existieren +-- konnten und keine Auswertung über Tätigkeiten möglich war. +create table jobs ( + id uuid primary key default gen_random_uuid(), + code text not null unique, + title text not null unique, + created_at timestamptz not null default now() +); + +comment on table jobs is 'SAP-OM-Objekttyp C. Katalog der Tätigkeiten; Planstellen verweisen darauf.'; + +-- ── S: Planstelle ────────────────────────────────────────────────── +-- Anders als die bisherige positions-Tabelle, die nur *offene* Stellen +-- führte: hier bekommt jede Person eine Planstelle. Eine offene Stelle ist +-- schlicht eine Planstelle ohne laufende Besetzung — Vakanz ist damit eine +-- Eigenschaft der Planstelle, kein eigenes Objekt. +create table om_positions ( + id uuid primary key default gen_random_uuid(), + position_number text not null unique, + org_unit_id uuid not null references org_units(id), + job_id uuid not null references jobs(id), + -- A012 "ist Leiter von": diese Planstelle führt ihre Organisationseinheit. + is_chief boolean not null default false, + valid_from date not null default current_date, + valid_to date, + created_at timestamptz not null default now(), + constraint chk_om_position_range check (valid_to is null or valid_to > valid_from) +); + +create index on om_positions (org_unit_id); +create index on om_positions (job_id); +-- Höchstens eine Leitungsplanstelle je Einheit, solange sie gültig ist. +create unique index om_positions_one_chief on om_positions (org_unit_id) where is_chief and valid_to is null; + +comment on table om_positions is 'SAP-OM-Objekttyp S. is_chief entspricht der Verknüpfung A012 "ist Leiter von".'; + +-- ── A008: Person besetzt Planstelle ──────────────────────────────── +create table position_assignments ( + id uuid primary key default gen_random_uuid(), + position_id uuid not null references om_positions(id) on delete cascade, + employee_id uuid not null references employees(id) on delete cascade, + valid_from date not null, + valid_to date, + created_at timestamptz not null default now(), + constraint chk_assignment_range check (valid_to is null or valid_to > valid_from) +); + +create index on position_assignments (position_id); +create index on position_assignments (employee_id); +-- Eine Planstelle ist zu einem Zeitpunkt von höchstens einer Person besetzt, +-- und eine Person hat höchstens eine laufende Planstelle. Beides sind die +-- Invarianten, auf die sich die Ableitung der Berichtslinie stützt. +create unique index position_assignments_one_holder on position_assignments (position_id) where valid_to is null; +create unique index position_assignments_one_position on position_assignments (employee_id) where valid_to is null; + +comment on table position_assignments is 'SAP-OM-Verknüpfung A008 "Inhaber ist", zeitabhängig.'; + +-- ── RLS, wie bei allen anderen Tabellen ──────────────────────────── +alter table org_units enable row level security; +alter table jobs enable row level security; +alter table om_positions enable row level security; +alter table position_assignments enable row level security; + +create policy "org_units_hr_all" on org_units for all using (is_hr_user()) with check (is_hr_user()); +create policy "jobs_hr_all" on jobs for all using (is_hr_user()) with check (is_hr_user()); +create policy "om_positions_hr_all" on om_positions for all using (is_hr_user()) with check (is_hr_user()); +create policy "position_assignments_hr_all" on position_assignments for all using (is_hr_user()) with check (is_hr_user()); + +grant all on table org_units, jobs, om_positions, position_assignments to anon, authenticated, service_role; diff --git a/supabase/migrations/20260727120100_om_reporting_lines.sql b/supabase/migrations/20260727120100_om_reporting_lines.sql new file mode 100644 index 0000000..d46ccbe --- /dev/null +++ b/supabase/migrations/20260727120100_om_reporting_lines.sql @@ -0,0 +1,101 @@ +-- Die Berichtslinie wird abgeleitet, nicht gepflegt. +-- +-- Bisher stand sie als employees.manager_id in der Tabelle und wurde von +-- resolve_manager_for() bei jeder Mutation neu geraten. Damit konnte sie von +-- der Organisationsstruktur abweichen, und tat es auch. +-- +-- SAP-OM-Regel, hier eins zu eins: +-- +-- Wer eine gewöhnliche Planstelle innehat, berichtet an die Leitung der +-- eigenen Organisationseinheit. Wer selbst die Leitung innehat, berichtet +-- an die Leitung der übergeordneten Einheit. +-- +-- Dazu kommt die Aufwärtsregel: Ist diese Leitungsplanstelle unbesetzt oder +-- ihre Inhaberin langzeitabwesend, geht es weiter nach oben, bis eine +-- besetzte und anwesende Leitung gefunden ist. Genau deshalb braucht eine +-- unbesetzte Abteilungsleitung keine Sonderbehandlung — sie wird schlicht +-- übersprungen. +-- +-- Beides wird zurückgegeben: die formale Leitung (auch wenn abwesend) und +-- die tatsächliche. Nur so lässt sich in der Oberfläche zeigen, dass eine +-- Vertretung im Spiel ist, statt sie stillschweigend als die echte +-- Führungskraft auszugeben. + +create or replace function om_reporting_lines(p_as_of date default current_date) +returns table ( + employee_id uuid, + position_id uuid, + org_unit_id uuid, + is_chief boolean, + formal_manager_id uuid, + acting_manager_id uuid +) +language sql +stable +as $$ + with recursive + -- Laufende Besetzungen: Planstelle und Zuordnung müssen beide am Stichtag + -- gültig sein. + holder as ( + select pa.employee_id, pa.position_id, p.org_unit_id, p.is_chief + from position_assignments pa + join om_positions p on p.id = pa.position_id + where pa.valid_from <= p_as_of and (pa.valid_to is null or pa.valid_to > p_as_of) + and p.valid_from <= p_as_of and (p.valid_to is null or p.valid_to > p_as_of) + ), + -- Leitung je Einheit, samt Abwesenheit am Stichtag. Die Ableitung ist + -- dieselbe wie in deriveStatusAsOf() auf der Anwendungsseite. + chief as ( + select h.org_unit_id, h.employee_id, + (e.karenz_start_date is not null + and e.karenz_start_date <= p_as_of + and (e.karenz_return_date is null or p_as_of < e.karenz_return_date)) as absent + from holder h + join employees e on e.id = h.employee_id + where h.is_chief + ), + -- Vorfahrenkette je Einheit; Tiefe 0 ist die Einheit selbst. Bei rund + -- sechzig Einheiten ist das billig, und es macht die Suche nach der + -- nächsten geeigneten Leitung zu einem einfachen "erster Treffer". + ancestry as ( + select u.id as unit_id, u.id as ancestor_id, u.parent_id, 0 as depth + from org_units u + union all + select a.unit_id, p.id, p.parent_id, a.depth + 1 + from ancestry a + join org_units p on p.id = a.parent_id + ), + -- Die Einheit, ab der gesucht wird: für eine Leitung die übergeordnete, + -- sonst die eigene. + base as ( + select h.employee_id, h.position_id, h.org_unit_id, h.is_chief, + case when h.is_chief then u.parent_id else h.org_unit_id end as base_unit_id + from holder h + join org_units u on u.id = h.org_unit_id + ) + select + b.employee_id, + b.position_id, + b.org_unit_id, + b.is_chief, + -- Formale Leitung: die der Ausgangseinheit, unabhängig von Abwesenheit. + (select c.employee_id from chief c where c.org_unit_id = b.base_unit_id) as formal_manager_id, + -- Tatsächliche Leitung: die nächste besetzte und anwesende oberhalb, + -- die Ausgangseinheit eingeschlossen. + ( + select c.employee_id + from ancestry a + join chief c on c.org_unit_id = a.ancestor_id + where a.unit_id = b.base_unit_id + and not c.absent + and c.employee_id <> b.employee_id + order by a.depth + limit 1 + ) as acting_manager_id + from base b; +$$; + +comment on function om_reporting_lines(date) is + 'Leitet die Berichtslinie zum Stichtag aus dem Organisationsbaum ab. formal_manager_id ist die zuständige Leitung, acting_manager_id die nächste besetzte und anwesende darüber.'; + +grant execute on function om_reporting_lines(date) to anon, authenticated, service_role; diff --git a/tests/integration/om-reporting.test.ts b/tests/integration/om-reporting.test.ts new file mode 100644 index 0000000..06f385a --- /dev/null +++ b/tests/integration/om-reporting.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from "vitest"; +import { todayIso } from "@/lib/format"; +import { resolveReportingLines, type OmHolder, type OmUnit } from "@/lib/om-reporting"; +import { adminClient } from "./helpers"; + +// Die Berichtslinien-Regel existiert zweimal: als om_reporting_lines() in +// der Datenbank und als resolveReportingLines() im Anwendungscode. Zwei +// Fassungen derselben Regel driften auseinander, und die Abweichung fällt +// niemandem auf — im Organigramm stünde einfach eine andere Führungskraft +// als im Export. Also über den gesamten Bestand gegeneinanderhalten. +describe("om_reporting_lines stimmt mit resolveReportingLines überein", () => { + const asOf = todayIso(); + + async function fromDatabase() { + const { data, error } = await adminClient.rpc("om_reporting_lines", { p_as_of: asOf }); + if (error) throw new Error(error.message); + return data ?? []; + } + + async function fromTypeScript() { + const [{ data: units }, { data: assignments }] = await Promise.all([ + adminClient.from("org_units").select("id, parent_id"), + adminClient + .from("position_assignments") + .select("employee_id, position_id, valid_from, valid_to, om_positions(org_unit_id, is_chief, valid_from, valid_to)") + .lte("valid_from", asOf) + .or(`valid_to.is.null,valid_to.gt.${asOf}`), + ]); + + const { data: employees } = await adminClient + .from("employees") + .select("id, karenz_start_date, karenz_return_date"); + const absentById = new Map( + (employees ?? []).map((e) => [ + e.id, + Boolean( + e.karenz_start_date && e.karenz_start_date <= asOf && (!e.karenz_return_date || asOf < e.karenz_return_date) + ), + ]) + ); + + const omUnits: OmUnit[] = (units ?? []).map((u) => ({ id: u.id, parentId: u.parent_id })); + const holders: OmHolder[] = (assignments ?? []) + .filter((a) => { + const p = a.om_positions as unknown as { valid_from: string; valid_to: string | null } | null; + return p && p.valid_from <= asOf && (p.valid_to === null || p.valid_to > asOf); + }) + .map((a) => { + const p = a.om_positions as unknown as { org_unit_id: string; is_chief: boolean }; + return { + employeeId: a.employee_id, + positionId: a.position_id, + orgUnitId: p.org_unit_id, + isChief: p.is_chief, + absent: absentById.get(a.employee_id) ?? false, + }; + }); + + return resolveReportingLines(omUnits, holders); + } + + it("liefert für jede Person dieselbe formale und tatsächliche Führungskraft", async () => { + const [db, ts] = await Promise.all([fromDatabase(), fromTypeScript()]); + + expect(db.length).toBe(ts.length); + expect(db.length).toBeGreaterThan(0); + + const tsById = new Map(ts.map((l) => [l.employeeId, l])); + const abweichungen = db + .map((row) => { + const mine = tsById.get(row.employee_id); + if (!mine) return `${row.employee_id}: fehlt in der TypeScript-Fassung`; + if (mine.actingManagerId !== row.acting_manager_id) + return `${row.employee_id}: acting DB=${row.acting_manager_id} TS=${mine.actingManagerId}`; + if (mine.formalManagerId !== row.formal_manager_id) + return `${row.employee_id}: formal DB=${row.formal_manager_id} TS=${mine.formalManagerId}`; + return null; + }) + .filter(Boolean); + + expect(abweichungen.slice(0, 10)).toEqual([]); + }); + + it("gibt genau einer Person keine Führungskraft — der obersten Leitung", async () => { + const db = await fromDatabase(); + const wurzel = db.filter((r) => r.acting_manager_id === null); + expect(wurzel).toHaveLength(1); + }); + + it("erzeugt keine Berichtslinie auf sich selbst", async () => { + const db = await fromDatabase(); + expect(db.filter((r) => r.acting_manager_id === r.employee_id)).toEqual([]); + }); + + it("lässt jede Berichtslinie an der obersten Leitung enden", async () => { + // Ein Ring in den abgeleiteten Linien wäre im Organigramm ein Teilbaum, + // der nie gerendert wird — und niemand würde es merken. + const db = await fromDatabase(); + const managerOf = new Map(db.map((r) => [r.employee_id, r.acting_manager_id])); + for (const start of db) { + const gesehen = new Set(); + let cur: string | null = start.employee_id; + while (cur && !gesehen.has(cur)) { + gesehen.add(cur); + cur = managerOf.get(cur) ?? null; + } + expect(cur, `Ring in der Berichtslinie ab ${start.employee_id}`).toBeNull(); + } + }); +}); diff --git a/tests/unit/om-reporting.test.ts b/tests/unit/om-reporting.test.ts new file mode 100644 index 0000000..9cea39e --- /dev/null +++ b/tests/unit/om-reporting.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from "vitest"; +import { resolveReportingLines, type OmHolder, type OmUnit } from "@/lib/om-reporting"; + +// Vier Ebenen wie in der Zielstruktur: Gesellschaft -> Bereich -> Abteilung +// -> Team. Die Hierarchie steckt allein in parent_id; unit_type ist ein +// Etikett und für die Ableitung ohne Bedeutung. +const UNITS: OmUnit[] = [ + { id: "gf", parentId: null }, + { id: "bereich", parentId: "gf" }, + { id: "abteilung", parentId: "bereich" }, + { id: "team", parentId: "abteilung" }, +]; + +function holder(employeeId: string, orgUnitId: string, isChief: boolean, absent = false): OmHolder { + return { employeeId, positionId: `pos-${employeeId}`, orgUnitId, isChief, absent }; +} + +function lineFor(employeeId: string, holders: OmHolder[], units: OmUnit[] = UNITS) { + return resolveReportingLines(units, holders).find((l) => l.employeeId === employeeId)!; +} + +describe("Berichtslinie aus dem Organisationsbaum", () => { + const full = [ + holder("gf-person", "gf", true), + holder("bl", "bereich", true), + holder("al", "abteilung", true), + holder("tl", "team", true), + holder("ma", "team", false), + ]; + + it("lässt Mitarbeitende an die Leitung der eigenen Einheit berichten", () => { + expect(lineFor("ma", full).actingManagerId).toBe("tl"); + }); + + it("lässt eine Leitung an die Leitung der übergeordneten Einheit berichten", () => { + expect(lineFor("tl", full).actingManagerId).toBe("al"); + expect(lineFor("al", full).actingManagerId).toBe("bl"); + expect(lineFor("bl", full).actingManagerId).toBe("gf-person"); + }); + + it("gibt der obersten Leitung keine Führungskraft", () => { + const gf = lineFor("gf-person", full); + expect(gf.actingManagerId).toBeNull(); + expect(gf.formalManagerId).toBeNull(); + }); +}); + +describe("unbesetzte Leitung", () => { + // Der eigentliche Grund für die Aufwärtsregel: eine Abteilung ohne + // Leitung soll die Kette nicht abreißen lassen und braucht keine + // Sonderbehandlung im Code. + const ohneAbteilungsleitung = [ + holder("gf-person", "gf", true), + holder("bl", "bereich", true), + holder("tl", "team", true), + holder("ma", "team", false), + ]; + + it("überspringt eine unbesetzte Abteilungsleitung", () => { + expect(lineFor("tl", ohneAbteilungsleitung).actingManagerId).toBe("bl"); + }); + + it("nennt die unbesetzte Ebene auch nicht als formale Leitung", () => { + expect(lineFor("tl", ohneAbteilungsleitung).formalManagerId).toBeNull(); + }); + + it("überspringt mehrere unbesetzte Ebenen hintereinander", () => { + const nurGf = [holder("gf-person", "gf", true), holder("ma", "team", false)]; + expect(lineFor("ma", nurGf).actingManagerId).toBe("gf-person"); + }); + + it("lässt die Führungskraft leer, wenn oberhalb niemand besetzt ist", () => { + const allein = [holder("ma", "team", false)]; + expect(lineFor("ma", allein).actingManagerId).toBeNull(); + }); +}); + +describe("abwesende Leitung", () => { + const teamleitungAbwesend = [ + holder("gf-person", "gf", true), + holder("bl", "bereich", true), + holder("al", "abteilung", true), + holder("tl", "team", true, true), + holder("ma", "team", false), + ]; + + it("hebt die Berichtslinie auf die nächste anwesende Ebene", () => { + expect(lineFor("ma", teamleitungAbwesend).actingManagerId).toBe("al"); + }); + + it("nennt weiterhin die formal zuständige Leitung, damit die Vertretung erkennbar bleibt", () => { + // Ohne das würde die Oberfläche die Vertretung als die echte + // Führungskraft ausgeben. + expect(lineFor("ma", teamleitungAbwesend).formalManagerId).toBe("tl"); + }); + + it("steigt über mehrere abwesende Ebenen hinweg", () => { + const zweiAbwesend = [ + holder("gf-person", "gf", true), + holder("bl", "bereich", true), + holder("al", "abteilung", true, true), + holder("tl", "team", true, true), + holder("ma", "team", false), + ]; + expect(lineFor("ma", zweiAbwesend).actingManagerId).toBe("bl"); + expect(lineFor("ma", zweiAbwesend).formalManagerId).toBe("tl"); + }); + + it("gibt die abwesende Leitung selbst an ihre eigene übergeordnete Ebene", () => { + expect(lineFor("tl", teamleitungAbwesend).actingManagerId).toBe("al"); + }); +}); + +describe("Randfälle", () => { + it("lässt niemanden an sich selbst berichten", () => { + // Eine Leitung, deren übergeordnete Einheit sie ebenfalls führt. + const doppelrolle = [holder("chef", "bereich", true), holder("chef2", "abteilung", true)]; + const units: OmUnit[] = [ + { id: "bereich", parentId: null }, + { id: "abteilung", parentId: "bereich" }, + ]; + expect(lineFor("chef", doppelrolle, units).actingManagerId).toBeNull(); + }); + + it("bricht bei einem Ring in parent_id ab, statt ewig zu laufen", () => { + // parent_id ist eine gewöhnliche Spalte; ein fehlerhafter Import kann + // einen Ring erzeugen. + const ring: OmUnit[] = [ + { id: "a", parentId: "b" }, + { id: "b", parentId: "a" }, + ]; + const holders = [holder("ma", "a", false)]; + expect(() => resolveReportingLines(ring, holders)).not.toThrow(); + expect(lineFor("ma", holders, ring).actingManagerId).toBeNull(); + }); + + it("kommt mit mehreren Teams unter derselben Abteilung zurecht", () => { + const units: OmUnit[] = [ + { id: "abteilung", parentId: null }, + { id: "team-a", parentId: "abteilung" }, + { id: "team-b", parentId: "abteilung" }, + ]; + const holders = [ + holder("al", "abteilung", true), + holder("tl-a", "team-a", true), + holder("tl-b", "team-b", true), + holder("ma-a", "team-a", false), + holder("ma-b", "team-b", false), + ]; + expect(lineFor("ma-a", holders, units).actingManagerId).toBe("tl-a"); + expect(lineFor("ma-b", holders, units).actingManagerId).toBe("tl-b"); + expect(lineFor("tl-b", holders, units).actingManagerId).toBe("al"); + }); +}); From 35f17858d8b35906170d65dc82cd482b19688f3f Mon Sep 17 00:00:00 2001 From: Maximilian Stubhan Date: Mon, 27 Jul 2026 12:52:02 +0200 Subject: [PATCH 02/64] Build the org tree in the OM model as a tested pure function MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Constructing the tree is where parent links, chief positions and number ranges get wired up wrongly without anyone noticing — a team under the wrong Bereich looks perfectly plausible in the org chart. So the construction is a pure function taking an id generator, and the checks that matter are asserted rather than eyeballed: exactly one root, every unit's parent of the expected type, every unit reaching the root, one chief position per unit, unique numbers in the right ranges, and every position pointing at a real unit and a real job. Also introduces the job catalogue this model needs. job_title was free text per person, so "Schlosser:in" and "Schlosser" could coexist and no breakdown by occupation was possible; jobs are now deduplicated by title and shared across positions. Every Abteilung gets a chief position, which is the level the old three-table model had no room for. Correction to the previous commit message: it claimed the cut-over could follow later while the old tables kept working. It cannot. The legacy resolve_manager_for() finds a Bereichsleitung by "division_id = X and team_id is null and org_level = 1", and an Abteilungsleitung satisfies the same predicate — its LIMIT 1 would then pick one of the two arbitrarily. The two models cannot both be correct once the new level is populated, so the remaining work is a single cut across the 11 RPCs that read the legacy columns, not a gradual migration. --- supabase/build-org.ts | 149 ++++++++++++++++++++++++++++++++ tests/unit/build-org.test.ts | 162 +++++++++++++++++++++++++++++++++++ 2 files changed, 311 insertions(+) create mode 100644 supabase/build-org.ts create mode 100644 tests/unit/build-org.test.ts diff --git a/supabase/build-org.ts b/supabase/build-org.ts new file mode 100644 index 0000000..22befda --- /dev/null +++ b/supabase/build-org.ts @@ -0,0 +1,149 @@ +// Baut den Organisationsbaum im SAP-OM-Modell aus der fachlichen +// Bereichsdefinition des Seeds. +// +// Bewusst frei von Zufall, Datenbank und IDs aus der Umgebung: die +// Konstruktion ist die Stelle, an der sich Nummernkreise, Leitungsplanstellen +// und die Elternbeziehung falsch verdrahten lassen, ohne dass es jemandem +// auffällt — ein Team unter dem falschen Bereich sieht im Organigramm +// plausibel aus. Deshalb ist sie eine reine Funktion mit Tests. + +export type OrgUnitType = "Gesellschaft" | "Bereich" | "Abteilung" | "Team"; + +export type TeamDef = { name: string; leadTitle: string; icTitles: string[]; baseSize: number }; +export type DeptDef = { name: string; leadTitle: string; teams: TeamDef[] }; +export type DivisionDef = { name: string; headTitle: string; departments: DeptDef[] }; + +export type BuiltUnit = { + id: string; + org_number: string; + name: string; + parent_id: string | null; + unit_type: OrgUnitType; +}; + +export type BuiltJob = { id: string; code: string; title: string }; + +export type BuiltPosition = { + id: string; + position_number: string; + org_unit_id: string; + job_id: string; + is_chief: boolean; +}; + +export type BuiltOrg = { + units: BuiltUnit[]; + jobs: BuiltJob[]; + positions: BuiltPosition[]; + /** Für jedes Team die Planstellen der Mitarbeitenden, in Reihenfolge. */ + icPositionsByTeam: Map; +}; + +// Nummernkreise wie im Altmodell, damit die Nummern der Einheiten über den +// Umstieg hinweg wiedererkennbar bleiben. +const PREFIX: Record = { + Gesellschaft: "10", + Bereich: "20", + Abteilung: "21", + Team: "22", +}; + +function orgNumber(type: OrgUnitType, counter: number): string { + return `${PREFIX[type]}${String(counter).padStart(6, "0")}`; +} + +/** Planstellennummern folgen dem bestehenden Muster ^6\d{7}$. */ +function positionNumber(counter: number): string { + return `6${String(counter).padStart(7, "0")}`; +} + +function jobCode(counter: number): string { + return `J${String(counter).padStart(4, "0")}`; +} + +/** + * `newId` wird hereingereicht, damit die Funktion in Tests deterministisch + * bleibt und im Seed randomUUID benutzt. + */ +export function buildOrg( + companyName: string, + divisions: DivisionDef[], + newId: () => string +): BuiltOrg { + const units: BuiltUnit[] = []; + const positions: BuiltPosition[] = []; + const icPositionsByTeam = new Map(); + + // Job-Katalog: gleiche Tätigkeit, ein Eintrag. Vorher war job_title + // Freitext je Person, weshalb sich Tätigkeiten nicht auswerten liessen. + const jobIdByTitle = new Map(); + const jobs: BuiltJob[] = []; + function jobFor(title: string): string { + const existing = jobIdByTitle.get(title); + if (existing) return existing; + const job = { id: newId(), code: jobCode(jobs.length + 1), title }; + jobs.push(job); + jobIdByTitle.set(title, job.id); + return job.id; + } + + let unitCounter = { Gesellschaft: 0, Bereich: 0, Abteilung: 0, Team: 0 }; + let positionCounter = 0; + + function addUnit(name: string, type: OrgUnitType, parentId: string | null): BuiltUnit { + unitCounter = { ...unitCounter, [type]: unitCounter[type] + 1 }; + const unit: BuiltUnit = { + id: newId(), + org_number: orgNumber(type, unitCounter[type] * (type === "Team" ? 1000 : type === "Abteilung" ? 10000 : 100000)), + name, + parent_id: parentId, + unit_type: type, + }; + units.push(unit); + return unit; + } + + function addPosition(unitId: string, title: string, isChief: boolean): BuiltPosition { + positionCounter += 1; + const position: BuiltPosition = { + id: newId(), + position_number: positionNumber(positionCounter), + org_unit_id: unitId, + job_id: jobFor(title), + is_chief: isChief, + }; + positions.push(position); + return position; + } + + const company = addUnit(companyName, "Gesellschaft", null); + addPosition(company.id, "Geschäftsführer:in", true); + // Die Assistenz hängt an der Gesellschaft, führt sie aber nicht — genau + // die Unterscheidung, die es im Altmodell nicht gab. + addPosition(company.id, "Assistenz der Geschäftsführung", false); + + for (const div of divisions) { + const bereich = addUnit(div.name, "Bereich", company.id); + addPosition(bereich.id, div.headTitle, true); + + for (const dept of div.departments) { + const abteilung = addUnit(dept.name, "Abteilung", bereich.id); + // Die Ebene, die im Altmodell gefehlt hat: eine Abteilung hat jetzt + // eine eigene Leitungsplanstelle. + addPosition(abteilung.id, dept.leadTitle, true); + + for (const team of dept.teams) { + const teamUnit = addUnit(team.name, "Team", abteilung.id); + addPosition(teamUnit.id, team.leadTitle, true); + + const size = Math.max(1, team.baseSize); + const icPositions = Array.from({ length: size }, (_, i) => + addPosition(teamUnit.id, team.icTitles[i % team.icTitles.length], false) + ); + icPositionsByTeam.set(teamUnit.id, icPositions); + } + } + } + + return { units, jobs, positions, icPositionsByTeam }; +} diff --git a/tests/unit/build-org.test.ts b/tests/unit/build-org.test.ts new file mode 100644 index 0000000..c8f77d9 --- /dev/null +++ b/tests/unit/build-org.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, it } from "vitest"; +import { buildOrg, type DivisionDef } from "@/supabase/build-org"; + +// Die Konstruktion des Org-Baums ist die Stelle, an der sich Elternbezüge +// und Leitungsplanstellen falsch verdrahten lassen, ohne dass es auffällt: +// ein Team unter dem falschen Bereich sieht im Organigramm plausibel aus. + +const DIVISIONS: DivisionDef[] = [ + { + name: "Produktion", + headTitle: "Bereichsleitung Produktion", + departments: [ + { + name: "Fertigung", + leadTitle: "Abteilungsleitung Fertigung", + teams: [ + { name: "Montage", leadTitle: "Teamleitung Montage", icTitles: ["Monteur:in", "Anlagenführer:in"], baseSize: 3 }, + { name: "CNC", leadTitle: "Teamleitung CNC", icTitles: ["CNC-Fräser:in"], baseSize: 2 }, + ], + }, + { + name: "Instandhaltung", + leadTitle: "Abteilungsleitung Instandhaltung", + teams: [{ name: "Mechanik", leadTitle: "Teamleitung Mechanik", icTitles: ["Schlosser:in"], baseSize: 2 }], + }, + ], + }, + { + name: "IT", + headTitle: "Bereichsleitung IT", + departments: [ + { + name: "Infrastruktur", + leadTitle: "Abteilungsleitung Infrastruktur", + teams: [{ name: "IT-Support", leadTitle: "Teamleitung IT-Support", icTitles: ["Systemadministrator:in"], baseSize: 2 }], + }, + ], + }, +]; + +function build() { + let n = 0; + return buildOrg("Alpenwerk Industrie GmbH", DIVISIONS, () => `id-${++n}`); +} + +describe("Struktur", () => { + it("hat genau eine Wurzel, und das ist die Gesellschaft", () => { + const { units } = build(); + const roots = units.filter((u) => u.parent_id === null); + expect(roots).toHaveLength(1); + expect(roots[0].unit_type).toBe("Gesellschaft"); + }); + + it("hängt jede Einheit unter den richtigen Typ", () => { + const { units } = build(); + const byId = new Map(units.map((u) => [u.id, u])); + const erwartetesElternteil = { Bereich: "Gesellschaft", Abteilung: "Bereich", Team: "Abteilung" } as const; + + for (const u of units) { + if (u.unit_type === "Gesellschaft") continue; + const parent = byId.get(u.parent_id!); + expect(parent?.unit_type, `${u.name} hängt falsch`).toBe(erwartetesElternteil[u.unit_type]); + } + }); + + it("baut die Ebenen vollständig auf", () => { + const { units } = build(); + const zahl = (t: string) => units.filter((u) => u.unit_type === t).length; + expect(zahl("Gesellschaft")).toBe(1); + expect(zahl("Bereich")).toBe(2); + expect(zahl("Abteilung")).toBe(3); + expect(zahl("Team")).toBe(4); + }); + + it("führt jede Einheit über parent_id auf die Wurzel zurück", () => { + // Ein abgehängter Teilbaum würde im Organigramm nie gerendert. + const { units } = build(); + const byId = new Map(units.map((u) => [u.id, u])); + for (const start of units) { + const gesehen = new Set(); + let cur = start; + while (cur.parent_id && !gesehen.has(cur.id)) { + gesehen.add(cur.id); + cur = byId.get(cur.parent_id)!; + } + expect(cur.parent_id, `${start.name} erreicht die Wurzel nicht`).toBeNull(); + } + }); + + it("vergibt eindeutige Org-Nummern im richtigen Nummernkreis", () => { + const { units } = build(); + expect(new Set(units.map((u) => u.org_number)).size).toBe(units.length); + const prefix = { Gesellschaft: "10", Bereich: "20", Abteilung: "21", Team: "22" } as const; + for (const u of units) expect(u.org_number.startsWith(prefix[u.unit_type]), u.name).toBe(true); + }); +}); + +describe("Planstellen", () => { + it("gibt jeder Einheit genau eine Leitungsplanstelle", () => { + // Der Unique-Index in der Datenbank erzwingt das ebenfalls; hier soll + // der Seed gar nicht erst dagegenlaufen. + const { units, positions } = build(); + for (const u of units) { + const chiefs = positions.filter((p) => p.org_unit_id === u.id && p.is_chief); + expect(chiefs, `${u.name}`).toHaveLength(1); + } + }); + + it("legt auch für die Abteilung eine Leitung an", () => { + // Die Ebene, die im Altmodell gefehlt hat. + const { units, positions } = build(); + const abteilungen = units.filter((u) => u.unit_type === "Abteilung"); + expect(abteilungen.length).toBeGreaterThan(0); + for (const a of abteilungen) { + expect(positions.some((p) => p.org_unit_id === a.id && p.is_chief)).toBe(true); + } + }); + + it("erzeugt für jedes Team so viele Mitarbeiter-Planstellen wie vorgesehen", () => { + const { units, icPositionsByTeam } = build(); + const montage = units.find((u) => u.name === "Montage")!; + expect(icPositionsByTeam.get(montage.id)).toHaveLength(3); + expect(icPositionsByTeam.get(montage.id)!.every((p) => !p.is_chief)).toBe(true); + }); + + it("vergibt eindeutige Planstellennummern nach dem bestehenden Muster", () => { + const { positions } = build(); + expect(new Set(positions.map((p) => p.position_number)).size).toBe(positions.length); + for (const p of positions) expect(p.position_number).toMatch(/^6\d{7}$/); + }); + + it("hängt jede Planstelle an eine existierende Einheit", () => { + const { units, positions } = build(); + const ids = new Set(units.map((u) => u.id)); + for (const p of positions) expect(ids.has(p.org_unit_id), p.position_number).toBe(true); + }); +}); + +describe("Job-Katalog", () => { + it("führt jede Tätigkeit genau einmal", () => { + // "Schlosser:in" darf nicht als zwei Einträge existieren, sonst ist eine + // Auswertung nach Tätigkeit wertlos. + const { jobs } = build(); + expect(new Set(jobs.map((j) => j.title)).size).toBe(jobs.length); + expect(new Set(jobs.map((j) => j.code)).size).toBe(jobs.length); + }); + + it("teilt denselben Job über mehrere Planstellen", () => { + const { positions, jobs } = build(); + const jobById = new Map(jobs.map((j) => [j.id, j])); + const monteur = jobs.find((j) => j.title === "Monteur:in")!; + const stellen = positions.filter((p) => p.job_id === monteur.id); + expect(stellen.length).toBeGreaterThan(1); + expect(jobById.get(stellen[0].job_id)!.title).toBe("Monteur:in"); + }); + + it("verweist jede Planstelle auf einen existierenden Job", () => { + const { positions, jobs } = build(); + const ids = new Set(jobs.map((j) => j.id)); + for (const p of positions) expect(ids.has(p.job_id), p.position_number).toBe(true); + }); +}); From cce5c6b0ce8ae7219ded5f424fdb06e12aecb440 Mon Sep 17 00:00:00 2001 From: Maximilian Stubhan Date: Mon, 27 Jul 2026 12:58:16 +0200 Subject: [PATCH 03/64] Cut-over SQL: migrate the existing org data into the OM model, drop the old MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One script for the Supabase SQL editor. It transforms rather than wipes: divisions/departments/teams become org_units, every employee gets a position and an assignment, so the org chart is populated the moment it finishes. The Abteilungsleitung positions are created *vacant*. Nobody holds them, and inventing holders would be worse than a visible gap — the upward rule skips an unfilled chief, so the reporting line stays unbroken either way. Mutations are rewritten onto the model. The reporting line is derived now, which removes manager bookkeeping from all of them: terminate_employee no longer reassigns direct reports at all, because they roll up on their own. Transfer becomes what it is in OM — end one assignment, begin another. apply_reorg, undo_reorg, create_position, delete_position and staff_position_internally are dropped rather than rewritten: they need the UI to move to org units first, so rewriting them now would be guesswork. Those screens are out until the port. Written by inspection, not by running it — Docker is not up and the project is not linked, so this is unverified SQL. Re-reading the first draft caught five defects that would each have aborted it: a window function inside a JOIN condition, a jobs insert placed after the positions referencing it, row_number() computed twice for a mapping that has to agree, a DROP VIEW naming a view that does not exist while the real one (employees_directory) depends on the columns being dropped, and exit_date = entry_date violating the assignment range check. There may be more. --- .../migrations/20260727120200_om_cutover.sql | 522 ++++++++++++++++++ 1 file changed, 522 insertions(+) create mode 100644 supabase/migrations/20260727120200_om_cutover.sql diff --git a/supabase/migrations/20260727120200_om_cutover.sql b/supabase/migrations/20260727120200_om_cutover.sql new file mode 100644 index 0000000..1dc0467 --- /dev/null +++ b/supabase/migrations/20260727120200_om_cutover.sql @@ -0,0 +1,522 @@ +-- Umstieg auf das SAP-OM-Modell: Altbestand überführen, Altmodell entfernen. +-- +-- Läuft nach 20260727120000 (Tabellen) und 20260727120100 (Berichtslinie). +-- +-- Warum ein Schnitt und keine schrittweise Migration: Sobald eine +-- Abteilungsleitung besetzt ist, liefert das alte resolve_manager_for() +-- falsche Ergebnisse. Es sucht die Bereichsleitung über +-- "division_id = X and team_id is null and org_level = 1" — eine +-- Abteilungsleitung erfüllt dieselbe Bedingung, und das LIMIT 1 greift dann +-- willkürlich eine von beiden. +-- +-- Der Bestand wird überführt, nicht gelöscht. Direkt nach dem Ausführen ist +-- das Organigramm gefüllt. +-- +-- Die Abteilungsleitungen entstehen als *unbesetzte* Planstellen: es gibt +-- niemanden, der sie innehat, und erfundene Zuordnungen wären schlechter als +-- eine sichtbare Lücke. Die Aufwärtsregel überspringt sie, bis sie besetzt +-- sind — die Berichtslinie bleibt durchgängig. + +begin; + +-- ═══ 1. Organisationseinheiten ═══════════════════════════════════ + +-- Wurzel. Die bisherige Pseudo-Division "Geschäftsführung" wird sie, damit +-- die Personen, die daran hingen, ihre Einheit behalten. +insert into org_units (id, org_number, name, parent_id, unit_type, valid_from) +select id, '10000000', 'Alpenwerk Industrie GmbH', null, 'Gesellschaft', '2000-01-01' +from divisions where name = 'Geschäftsführung'; + +-- Falls es sie nicht gab, eine neue Wurzel anlegen. +insert into org_units (org_number, name, parent_id, unit_type, valid_from) +select '10000000', 'Alpenwerk Industrie GmbH', null, 'Gesellschaft', '2000-01-01' +where not exists (select 1 from org_units where unit_type = 'Gesellschaft'); + +insert into org_units (id, org_number, name, parent_id, unit_type, valid_from) +select d.id, d.org_number, d.name, + (select id from org_units where unit_type = 'Gesellschaft'), + 'Bereich', '2000-01-01' +from divisions d +where d.name <> 'Geschäftsführung'; + +insert into org_units (id, org_number, name, parent_id, unit_type, valid_from) +select dep.id, dep.org_number, dep.name, + coalesce((select u.id from org_units u where u.id = dep.division_id), + (select id from org_units where unit_type = 'Gesellschaft')), + 'Abteilung', '2000-01-01' +from departments dep; + +insert into org_units (id, org_number, name, parent_id, unit_type, valid_from) +select t.id, t.org_number, t.name, t.department_id, 'Team', '2000-01-01' +from teams t; + +-- ═══ 2. Job-Katalog ══════════════════════════════════════════════ +-- Vollständig *vor* den Planstellen, die darauf verweisen. Enthält auch die +-- Titel der neuen Abteilungsleitungen. +insert into jobs (code, title) +select 'J' || lpad(row_number() over (order by title)::text, 4, '0'), title +from ( + select distinct job_title as title from employees where job_title is not null + union + select distinct title from positions where title is not null + union + select distinct 'Abteilungsleitung ' || name from org_units where unit_type = 'Abteilung' +) t +on conflict (title) do nothing; + +-- ═══ 3. Planstellen und Besetzungen ══════════════════════════════ + +-- Die Zuordnung Person -> Planstelle wird einmal festgelegt und dann von +-- beiden Inserts benutzt. Zwei unabhängig berechnete Fensterfunktionen +-- wären hier die klassische Fehlerquelle: sie sehen gleich aus und ordnen +-- doch verschieden. +create temporary table om_pos_map ( + employee_id uuid primary key, + position_id uuid not null default gen_random_uuid(), + seq bigint +) on commit drop; + +insert into om_pos_map (employee_id, seq) +select id, row_number() over (order by org_level, personnel_number) from employees; + +insert into om_positions (id, position_number, org_unit_id, job_id, is_chief, valid_from) +select + m.position_id, + '6' || lpad(m.seq::text, 7, '0'), + case + when e.org_level = 0 then (select id from org_units where unit_type = 'Gesellschaft') + when e.org_level = 1 then coalesce(e.division_id, (select id from org_units where unit_type = 'Gesellschaft')) + else coalesce(e.team_id, (select id from org_units where unit_type = 'Gesellschaft')) + end, + (select j.id from jobs j where j.title = e.job_title), + (e.org_level <= 1 or (e.org_level = 2 and e.is_lead)), + e.entry_date +from employees e +join om_pos_map m on m.employee_id = e.id; + +-- Wer ausgetreten ist, hat eine beendete Besetzung: die Planstelle ist +-- wieder frei, die Historie bleibt. +-- +-- greatest(): employees erlaubt exit_date = entry_date, die Prüfregel auf +-- position_assignments verlangt aber valid_to > valid_from. Ein +-- gleichtägiger Ein- und Austritt würde die Migration sonst abbrechen. +insert into position_assignments (position_id, employee_id, valid_from, valid_to) +select m.position_id, e.id, e.entry_date, + case when e.exit_date is null then null else greatest(e.exit_date, e.entry_date + 1) end +from employees e +join om_pos_map m on m.employee_id = e.id; + +-- Unbesetzte Abteilungsleitungen — die Ebene, die im Altmodell fehlte. +insert into om_positions (position_number, org_unit_id, job_id, is_chief, valid_from) +select '69' || lpad(row_number() over (order by u.org_number)::text, 6, '0'), + u.id, + (select id from jobs where title = 'Abteilungsleitung ' || u.name), + true, + current_date +from org_units u +where u.unit_type = 'Abteilung' + and not exists (select 1 from om_positions p where p.org_unit_id = u.id and p.is_chief); + +-- Bisher offene Stellen werden unbesetzte Planstellen. is_chief nur, wenn +-- die Einheit noch keine Leitung hat — der Unique-Index liesse es sonst +-- ohnehin nicht zu. +insert into om_positions (position_number, org_unit_id, job_id, is_chief, valid_from) +select p.position_number, p.team_id, + (select id from jobs j where j.title = p.title), + p.is_lead and not exists (select 1 from om_positions o where o.org_unit_id = p.team_id and o.is_chief), + p.valid_from +from positions p +where p.status = 'open' + and exists (select 1 from org_units u where u.id = p.team_id); + +-- Abbruch, bevor das Altmodell fällt: lieber eine gescheiterte Migration +-- als ein halb überführter Bestand ohne Rückweg. +do $$ +declare v_fehlend int; +begin + select count(*) into v_fehlend + from employees e + where not exists (select 1 from position_assignments pa where pa.employee_id = e.id); + if v_fehlend > 0 then + raise exception 'Abbruch: % Mitarbeitende ohne Planstelle.', v_fehlend; + end if; + + select count(*) into v_fehlend from om_positions where job_id is null; + if v_fehlend > 0 then + raise exception 'Abbruch: % Planstellen ohne Job.', v_fehlend; + end if; + + select count(*) into v_fehlend + from org_units u + where u.parent_id is null and u.unit_type <> 'Gesellschaft'; + if v_fehlend > 0 then + raise exception 'Abbruch: % Einheiten ohne Elternteil.', v_fehlend; + end if; +end $$; + +-- ═══ 4. Altmodell entfernen ══════════════════════════════════════ + +-- Vorgemerkte Änderungen verweisen über team_id auf das Altmodell. Sie sind +-- transient; halb übersetzt wären sie schlimmer als verworfen. +delete from pending_org_changes where status = 'pending'; + +drop trigger if exists trg_track_employee_assignment on employees; +drop function if exists fn_track_employee_assignment(); +drop table if exists employee_assignments; + +-- Die View selektiert team_id/division_id/manager_id/org_level/is_lead und +-- blockiert damit das Entfernen dieser Spalten. Sie wird von der Anwendung +-- nirgends benutzt und ersatzlos entfernt; die Ableitung über +-- om_reporting_lines() tritt an ihre Stelle. +drop view if exists employees_directory; + +-- Funktionen auf den Alt-Spalten. Die weiterhin benötigten werden in +-- Abschnitt 5 neu angelegt; Reorganisation und Ausschreibung folgen mit der +-- Umstellung der Oberfläche. +drop function if exists resolve_manager_for(uuid, boolean, uuid); +drop function if exists staff_position_internally(jsonb); +drop function if exists create_position(jsonb); +drop function if exists delete_position(uuid); +drop function if exists apply_reorg(jsonb); +drop function if exists undo_reorg(uuid); +drop function if exists hire_employee(jsonb); +drop function if exists terminate_employee(jsonb); +drop function if exists transfer_employee(jsonb); +drop function if exists rehire_employee(jsonb); +drop function if exists record_karenz_return(jsonb); +drop function if exists apply_due_pending_changes(); + +alter table employees + drop column if exists division_id, + drop column if exists team_id, + drop column if exists manager_id, + drop column if exists org_level, + drop column if exists is_lead; + +drop table if exists positions; +drop table if exists teams; +drop table if exists departments; +drop table if exists divisions; + +-- ═══ 5. Mutationen im neuen Modell ═══════════════════════════════ +-- Die Berichtslinie wird nicht mehr mitgeschrieben, sondern abgeleitet. +-- Das entfernt aus jeder dieser Funktionen die Manager-Nachführung — beim +-- Austritt etwa entfällt das Umhängen der direkten Berichte vollständig, +-- weil sie ohnehin auf die nächste besetzte Ebene hochrutschen. + +create or replace function hire_employee(payload jsonb) +returns uuid language plpgsql as $$ +declare + v_id uuid; + v_position_id uuid := (payload->>'position_id')::uuid; + v_entry date := (payload->>'entry_date')::date; + v_besetzt uuid; +begin + perform require_hr_admin(); + + if v_position_id is null then + raise exception 'Es muss eine Planstelle angegeben werden.'; + end if; + + select pa.employee_id into v_besetzt + from position_assignments pa + where pa.position_id = v_position_id and pa.valid_to is null; + if v_besetzt is not null then + raise exception 'Diese Planstelle ist bereits besetzt.'; + end if; + + insert into employees ( + first_name, last_name, gender, birth_date, sv_nummer, nationality, email, phone, + address, postal_code, city, address_country, location_id, job_title, + employment_type, weekly_hours, contract_type, contract_end_date, paygrade, + source, status, entry_date, title_prefix, title_suffix, + worker_type, collective_agreement, work_days, + is_betriebsrat, has_dienstwagen, is_laterale_fuehrung, is_c_level + ) + values ( + payload->>'first_name', payload->>'last_name', (payload->>'gender')::gender_type, + (payload->>'birth_date')::date, payload->>'sv_nummer', + coalesce(payload->>'nationality', 'Österreich'), payload->>'email', payload->>'phone', + payload->>'address', payload->>'postal_code', payload->>'city', + coalesce(payload->>'address_country', 'Österreich'), + (payload->>'location_id')::uuid, + (select j.title from om_positions p join jobs j on j.id = p.job_id where p.id = v_position_id), + coalesce((payload->>'employment_type')::employment_type, 'Vollzeit'), + coalesce((payload->>'weekly_hours')::numeric, 38.5), + coalesce((payload->>'contract_type')::contract_type, 'unbefristet'), + nullif(payload->>'contract_end_date', '')::date, + coalesce((payload->>'paygrade')::paygrade_type, 'B'), + coalesce((payload->>'source')::source_type, 'Extern'), + case when v_entry > current_date then 'Geplant' else 'Aktiv' end::employment_status, + v_entry, + coalesce(array(select jsonb_array_elements_text(payload->'title_prefix')), '{}'), + coalesce(array(select jsonb_array_elements_text(payload->'title_suffix')), '{}'), + coalesce((payload->>'worker_type')::worker_type, 'Angestellte:r'), + coalesce((payload->>'collective_agreement')::collective_agreement, 'Süßwaren'), + coalesce(array(select jsonb_array_elements_text(payload->'work_days'))::weekday[], '{Mo,Di,Mi,Do,Fr}'), + coalesce((payload->>'is_betriebsrat')::boolean, false), + coalesce((payload->>'has_dienstwagen')::boolean, false), + coalesce((payload->>'is_laterale_fuehrung')::boolean, false), + coalesce((payload->>'is_c_level')::boolean, false) + ) + returning id into v_id; + + insert into position_assignments (position_id, employee_id, valid_from) + values (v_position_id, v_id, v_entry); + + insert into employee_history (employee_id, event_date, event_type, description) + values (v_id, v_entry, 'Eintritt', 'Eintritt auf Planstelle ' || + (select position_number from om_positions where id = v_position_id)); + + insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details) + values (auth.uid(), current_actor_name(), 'Neueinstellung', + payload->>'first_name' || ' ' || payload->>'last_name', v_id, 'Eintritt am ' || v_entry); + + return v_id; +end; +$$; + +create or replace function terminate_employee(payload jsonb) +returns void language plpgsql as $$ +declare + v_employee_id uuid := (payload->>'employee_id')::uuid; + v_exit date := (payload->>'exit_date')::date; + v_name text; +begin + perform require_hr_admin(); + select first_name || ' ' || last_name into v_name from employees where id = v_employee_id; + + update employees set + status = case when v_exit <= current_date then 'Ausgetreten' else status end, + exit_date = v_exit, + exit_reason = payload->>'exit_reason' + where id = v_employee_id; + + -- Die Planstelle wird frei. Direkte Berichte müssen nicht umgehängt + -- werden: die Berichtslinie wird abgeleitet und rutscht von selbst auf + -- die nächste besetzte Ebene. + update position_assignments set valid_to = v_exit + where employee_id = v_employee_id and valid_to is null; + + insert into employee_history (employee_id, event_date, event_type, description) + values (v_employee_id, v_exit, 'Austritt', 'Austritt (' || coalesce(payload->>'exit_reason', '-') || ')'); + + insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details) + values (auth.uid(), current_actor_name(), 'Austritt', v_name, v_employee_id, 'Austritt am ' || v_exit); +end; +$$; + +-- Versetzung ist im OM-Modell ein Wechsel der Planstelle: die alte +-- Besetzung endet, die neue beginnt. Bereich, Abteilung und Team ergeben +-- sich aus der Einheit der Zielplanstelle und werden nicht mehr mitgeführt. +create or replace function transfer_employee(payload jsonb) +returns void language plpgsql as $$ +declare + v_employee_id uuid := (payload->>'employee_id')::uuid; + v_target_position uuid := (payload->>'target_position_id')::uuid; + v_effective date := coalesce(nullif(payload->>'effective_date','')::date, current_date); + v_name text; + v_besetzt uuid; +begin + perform require_hr_admin(); + select first_name || ' ' || last_name into v_name from employees where id = v_employee_id; + + select pa.employee_id into v_besetzt + from position_assignments pa + where pa.position_id = v_target_position and pa.valid_to is null; + if v_besetzt is not null and v_besetzt <> v_employee_id then + raise exception 'Die Zielplanstelle ist bereits besetzt.'; + end if; + + if v_effective <= current_date then + update position_assignments set valid_to = v_effective + where employee_id = v_employee_id and valid_to is null; + insert into position_assignments (position_id, employee_id, valid_from) + values (v_target_position, v_employee_id, v_effective); + update employees set job_title = + (select j.title from om_positions p join jobs j on j.id = p.job_id where p.id = v_target_position) + where id = v_employee_id; + else + insert into pending_org_changes (employee_id, change_type, effective_date, payload) + values (v_employee_id, 'transfer', v_effective, + jsonb_build_object('target_position_id', v_target_position)); + end if; + + insert into employee_history (employee_id, event_date, event_type, description) + values (v_employee_id, v_effective, 'Versetzung', 'Versetzung auf Planstelle ' || + (select position_number from om_positions where id = v_target_position)); + + insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details) + values (auth.uid(), current_actor_name(), 'Versetzung', v_name, v_employee_id, 'Wirksam ab ' || v_effective); +end; +$$; + +create or replace function rehire_employee(payload jsonb) +returns void language plpgsql as $$ +declare + v_employee_id uuid := (payload->>'employee_id')::uuid; + v_date date := (payload->>'rehire_date')::date; + v_position_id uuid := (payload->>'position_id')::uuid; + v_name text; +begin + perform require_hr_admin(); + select first_name || ' ' || last_name into v_name from employees where id = v_employee_id; + + if v_position_id is null then + raise exception 'Für die Wiedereinstellung muss eine Planstelle angegeben werden.'; + end if; + + update employees set + status = case when v_date <= current_date then 'Aktiv' else 'Geplant' end, + entry_date = v_date, + exit_date = null, + exit_reason = null + where id = v_employee_id; + + insert into position_assignments (position_id, employee_id, valid_from) + values (v_position_id, v_employee_id, v_date); + + insert into employee_history (employee_id, event_date, event_type, description) + values (v_employee_id, v_date, 'Wiedereintritt', 'Wiedereinstellung zum ' || v_date); + + insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details) + values (auth.uid(), current_actor_name(), 'Wiedereinstellung', v_name, v_employee_id, 'Wiedereintritt am ' || v_date); +end; +$$; + +create or replace function record_karenz_return(payload jsonb) +returns void language plpgsql as $$ +declare + v_employee_id uuid := (payload->>'employee_id')::uuid; + v_return_date date := (payload->>'return_date')::date; + v_name text; + v_employment_type employment_type; + v_weekly_hours numeric; + v_karenz_start date; + v_absence_type text; +begin + perform require_hr_admin(); + select first_name || ' ' || last_name, karenz_start_date, absence_type + into v_name, v_karenz_start, v_absence_type + from employees where id = v_employee_id; + + if v_karenz_start is not null and v_return_date <= v_karenz_start then + raise exception 'Das Rückkehrdatum muss nach dem Beginn der Langzeitabwesenheit (%) liegen.', v_karenz_start; + end if; + + if payload->>'employment_mode' = 'Vollzeit' then + v_employment_type := 'Vollzeit'; v_weekly_hours := 38.5; + elsif payload->>'employment_mode' = 'Teilzeit' then + v_employment_type := 'Teilzeit'; v_weekly_hours := (payload->>'weekly_hours')::numeric; + end if; + + if v_return_date <= current_date then + -- Keine Manager-Nachführung mehr nötig: wer aus der Abwesenheit + -- zurückkehrt, ist wieder anwesend, und die abgeleitete Berichtslinie + -- fällt automatisch von der Vertretung auf ihn zurück. + update employees set + status = 'Aktiv', + karenz_return_date = null, + karenz_start_date = null, + absence_type = null, + employment_type = coalesce(v_employment_type, employment_type), + weekly_hours = coalesce(v_weekly_hours, weekly_hours) + where id = v_employee_id; + else + update employees set karenz_return_date = v_return_date where id = v_employee_id; + insert into pending_org_changes (employee_id, change_type, effective_date, payload) + values (v_employee_id, 'karenz_return', v_return_date, + jsonb_build_object('employment_type', v_employment_type, 'weekly_hours', v_weekly_hours)); + end if; + + insert into employee_history (employee_id, event_date, event_type, description) + values (v_employee_id, v_return_date, 'Rückkehr', + 'Rückkehr aus ' || coalesce(v_absence_type, 'Langzeitabwesenheit') || ' am ' || v_return_date); + + insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details) + values (auth.uid(), current_actor_name(), 'Rückkehr', v_name, v_employee_id, 'Rückkehr am ' || v_return_date); +end; +$$; + +create or replace function apply_due_pending_changes() +returns int language plpgsql security definer set search_path = public as $$ +declare + v_rec record; + v_count int := 0; +begin + for v_rec in + select * from pending_org_changes + where status = 'pending' and effective_date <= current_date + order by effective_date, created_at + loop + if v_rec.change_type = 'transfer' then + update position_assignments set valid_to = v_rec.effective_date + where employee_id = v_rec.employee_id and valid_to is null; + insert into position_assignments (position_id, employee_id, valid_from) + values ((v_rec.payload->>'target_position_id')::uuid, v_rec.employee_id, v_rec.effective_date); + update employees set job_title = ( + select j.title from om_positions p join jobs j on j.id = p.job_id + where p.id = (v_rec.payload->>'target_position_id')::uuid + ) where id = v_rec.employee_id; + + elsif v_rec.change_type = 'promotion' then + update employees set + job_title = coalesce(v_rec.payload->>'new_title', job_title), + paygrade = coalesce((v_rec.payload->>'new_paygrade')::paygrade_type, paygrade) + where id = v_rec.employee_id; + + elsif v_rec.change_type = 'karenz_start' then + update employees set + status = 'Karenz', + karenz_return_date = (v_rec.payload->>'planned_return_date')::date, + absence_type = coalesce(nullif(v_rec.payload->>'absence_type', ''), absence_type) + where id = v_rec.employee_id; + + elsif v_rec.change_type = 'karenz_return' then + update employees set + status = 'Aktiv', + karenz_return_date = null, + karenz_start_date = null, + absence_type = null, + employment_type = coalesce((v_rec.payload->>'employment_type')::employment_type, employment_type), + weekly_hours = coalesce((v_rec.payload->>'weekly_hours')::numeric, weekly_hours) + where id = v_rec.employee_id; + + elsif v_rec.change_type = 'contract_change' then + update employees set + first_name = coalesce(v_rec.payload->'person'->>'first_name', first_name), + last_name = coalesce(v_rec.payload->'person'->>'last_name', last_name), + gender = coalesce((v_rec.payload->'person'->>'gender')::gender_type, gender), + birth_date = coalesce((v_rec.payload->'person'->>'birth_date')::date, birth_date), + sv_nummer = coalesce(v_rec.payload->'person'->>'sv_nummer', sv_nummer), + nationality = coalesce(v_rec.payload->'person'->>'nationality', nationality), + address = coalesce(v_rec.payload->'person'->>'address', address), + postal_code = coalesce(v_rec.payload->'person'->>'postal_code', postal_code), + city = coalesce(v_rec.payload->'person'->>'city', city), + address_country = coalesce(v_rec.payload->'person'->>'address_country', address_country), + email = coalesce(v_rec.payload->'person'->>'email', email), + phone = coalesce(v_rec.payload->'person'->>'phone', phone), + employment_type = coalesce((v_rec.payload->'contract'->>'employment_type')::employment_type, employment_type), + weekly_hours = coalesce((v_rec.payload->'contract'->>'weekly_hours')::numeric, weekly_hours), + contract_type = coalesce((v_rec.payload->'contract'->>'contract_type')::contract_type, contract_type) + where id = v_rec.employee_id; + + elsif v_rec.change_type = 'dependent_add' then + insert into employee_dependents (employee_id, first_name, last_name, relationship, sv_nummer, birth_date) + values (v_rec.employee_id, v_rec.payload->>'first_name', v_rec.payload->>'last_name', + (v_rec.payload->>'relationship')::relationship_type, + nullif(v_rec.payload->>'sv_nummer', ''), (v_rec.payload->>'birth_date')::date); + + elsif v_rec.change_type = 'dependent_remove' then + delete from employee_dependents where id = (v_rec.payload->>'dependent_id')::uuid; + end if; + + update pending_org_changes set status = 'applied', applied_at = now() where id = v_rec.id; + v_count := v_count + 1; + end loop; + + return v_count; +end; +$$; + +commit; From c2366e34083dd415762d4fb152b062eb30234fe2 Mon Sep 17 00:00:00 2001 From: Maximilian Stubhan Date: Mon, 27 Jul 2026 14:39:57 +0200 Subject: [PATCH 04/64] Make the cut-over script safe to paste, and record the Azure design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mapping table was declared ON COMMIT DROP. In the Supabase SQL editor the transaction boundaries are not ours to assume, and a mapping table that vanished between the two inserts would leave positions without assignments and be miserable to diagnose. It is now dropped explicitly once both inserts have run. docs/azure-migration.md is the design for the Azure move, for review before any code changes. Its main finding corrects what I said when I laid out the options: I claimed that dropping Supabase would push the security boundary into application code. It does not. auth.uid() appears 70 times, but only one of them matters — inside is_hr_user(), which all 58 policies call. Swapping the source of the user id there leaves every policy valid, so the database stays the boundary. The risk moves elsewhere, and the design says so plainly: the user id arrives via set_config(..., true), which is transaction-local. Outside a transaction it sticks to the pooled connection, and the next request on that connection runs as the previous user. So the plan makes that structurally impossible — a single access function that owns the transaction, a lint rule against importing the pool anywhere else, a database role without BYPASSRLS so a missing context returns nothing rather than everything, and a test that sends two requests over one pooled connection to prove the second cannot see the first. --- docs/azure-migration.md | 148 ++++++++++++++++++ .../migrations/20260727120200_om_cutover.sql | 7 +- 2 files changed, 154 insertions(+), 1 deletion(-) create mode 100644 docs/azure-migration.md diff --git a/docs/azure-migration.md b/docs/azure-migration.md new file mode 100644 index 0000000..c8b6d2b --- /dev/null +++ b/docs/azure-migration.md @@ -0,0 +1,148 @@ +# Umstieg auf Azure — Sicherheitsarchitektur + +Entwurf zur Abnahme. **Noch kein Code umgestellt.** + +Ziel: Azure Database for PostgreSQL (Flexible Server), Anmeldung über Entra ID, +Supabase als Abhängigkeit entfernt. + +## Ausgangslage, gemessen + +| | Anzahl | +|---|---| +| RLS-Policies | 58 | +| `auth.uid()` / `auth.users` in Migrationen | 79 | +| Fremdschlüssel auf `auth.users` | 9 | +| Datenzugriffe in der App (`.from()`, `.rpc()`) | 50 | +| Dateien mit Supabase-Import | 10 | + +Die Anwendung läuft mit dem **anon-Key** (`lib/supabase/server.ts`, +`client.ts`); der Service-Role-Key kommt nur in `lib/supabase/admin.ts` vor. +Die RLS-Policies sind damit die tatsächliche Sicherheitsgrenze — nicht der +Proxy und nicht der Anwendungscode. + +## Der entscheidende Befund + +`auth.uid()` erscheint 70-mal, aber für die Absicherung zählt genau **eine** +Stelle: + +```sql +create or replace function is_hr_user() returns boolean +language sql security definer stable as $$ + select exists ( + select 1 from profiles p + where p.id = auth.uid() and p.role = 'hr' and p.is_active = true + ); +$$; +``` + +Alle 58 Policies rufen `is_hr_user()` auf. Wird hier die Herkunft der +Benutzerkennung ausgetauscht, **bleiben alle Policies unverändert gültig**. +Die Sicherheitsarchitektur wandert also *nicht* in den Anwendungscode — das +war meine Sorge bei Variante B, und sie ist ausgeräumt. + +Die übrigen ~55 Vorkommen stehen in Mutations-RPCs (`insert into audit_log +values (auth.uid(), …)`) und sind eine mechanische Ersetzung. + +## Zielarchitektur + +### 1. Benutzertabelle statt `auth.users` + +```sql +create table app_users ( + id uuid primary key default gen_random_uuid(), + entra_object_id uuid not null unique, -- oid aus dem Entra-Token + email text not null, + created_at timestamptz not null default now() +); +``` + +Die neun Fremdschlüssel zeigen künftig hierauf. `profiles.id` bleibt der +Schlüssel, an dem `role` und `is_active` hängen — die HR-Freischaltung +funktioniert unverändert. + +### 2. Sitzungskontext statt `auth.uid()` + +```sql +create or replace function current_app_user() returns uuid +language sql stable as $$ + select nullif(current_setting('app.user_id', true), '')::uuid; +$$; +``` + +`auth.uid()` → `current_app_user()`, überall. `is_hr_user()` bleibt sonst +Wort für Wort gleich. + +### 3. Der kritische Punkt: wie der Kontext gesetzt wird + +**Hier entscheidet sich, ob die Migration sicher ist.** + +Jeder Datenbankzugriff muss in einer Transaktion laufen, die zuerst +`set local app.user_id` ausführt: + +```ts +await db.transaction(async (tx) => { + await tx.execute(sql`select set_config('app.user_id', ${userId}, true)`); + return tx.select()…; +}); +``` + +Das dritte Argument `true` bedeutet *transaktionslokal*. Ohne Transaktion +bliebe die Einstellung an der Verbindung hängen — und die nächste Anfrage, +die dieselbe Verbindung aus dem Pool zieht, liefe **mit der Kennung des +vorherigen Benutzers**. Das ist genau die Art Fehler, die in einem Test nie +auffällt und im Betrieb Personaldaten quer über Benutzer hinweg preisgibt. + +Deshalb: **kein direkter Zugriff auf den Pool.** Es gibt eine einzige +Zugriffsfunktion, die die Transaktion und `set_config` erzwingt, und eine +Lint-Regel, die den Import des Pools außerhalb dieser Datei verbietet. +Das muss strukturell unmöglich sein, nicht per Konvention. + +Zusätzlich verbindet sich die Anwendung mit einer Datenbankrolle **ohne** +`BYPASSRLS`. Selbst wenn der Kontext fehlt, liefern die Policies dann nichts +zurück — statt alles. + +### 4. Anmeldung + +Entra ID über NextAuth (Azure-AD-Provider) oder MSAL. Nach der Validierung +des Tokens wird die `oid` auf `app_users.entra_object_id` abgebildet; existiert +kein Eintrag, wird einer angelegt — **ohne** `profiles`-Zeile, also ohne +Zugriff. Die Freischaltung bleibt ein bewusster Schritt, wie heute +(`is_active` ist per Vorgabe `false`). + +Damit erledigt sich die SSO-Frage aus der IT-Liste mit. + +### 5. Datenzugriff + +PostgREST entfällt; die 50 Aufrufe werden auf Drizzle umgestellt. Die sechs +`.rpc()`-Aufrufe sind trivial (direkter Funktionsaufruf), die 44 +`.from()`-Aufrufe sind Query-Builder-Umschreibungen. + +Das handgeschriebene `lib/supabase/types.ts` entfällt: Drizzle erzeugt die +Typen aus dem Schema, womit auch der Schema-Drift-Prüfer überflüssig wird. + +## Was bewusst gleich bleibt + +- **Alle 58 RLS-Policies**, unverändert +- Das gesamte Schema samt Enums, Arrays, `jsonb`, PL/pgSQL, partiellen Indizes +- `pgcrypto` und `pg_trgm` (beide auf Azure freigegeben) +- Die Geschäftslogik in den RPCs + +## Reihenfolge + +1. `app_users`, `current_app_user()`, Fremdschlüssel umhängen — additiv, gegen die bestehende Datenbank testbar +2. Zugriffsschicht mit erzwungener Transaktion + `set_config`, plus Test, der den Kontextverlust nachweist +3. Entra-ID-Anmeldung +4. Die 50 Datenzugriffe umstellen +5. Supabase-Pakete entfernen +6. Umzug der Datenbank per `pg_dump`/`pg_restore` + +Schritt 2 ist der einzige, bei dem ein Fehler still bleibt. Dafür braucht es +einen Test, der zwei Anfragen über dieselbe gepoolte Verbindung schickt und +prüft, dass die zweite die erste nicht sieht. + +## Offene Fragen an die Kunden-IT + +- Welcher Entra-Mandant, und wer legt die App-Registrierung an? +- Gruppenbasierte Freischaltung (Entra-Gruppe „HR") oder weiter manuell über `profiles.is_active`? +- Flexible Server: Version, Region, Netzwerkzugang (Private Endpoint oder Firewall-Regeln)? +- Wer betreibt und patcht? diff --git a/supabase/migrations/20260727120200_om_cutover.sql b/supabase/migrations/20260727120200_om_cutover.sql index 1dc0467..22db557 100644 --- a/supabase/migrations/20260727120200_om_cutover.sql +++ b/supabase/migrations/20260727120200_om_cutover.sql @@ -70,11 +70,14 @@ on conflict (title) do nothing; -- beiden Inserts benutzt. Zwei unabhängig berechnete Fensterfunktionen -- wären hier die klassische Fehlerquelle: sie sehen gleich aus und ordnen -- doch verschieden. +-- Kein "on commit drop": im SQL-Editor hängt es vom Transaktionsverhalten +-- ab, wann das greift, und eine zu früh verschwundene Zuordnungstabelle +-- wäre schwer zu diagnostizieren. Wird am Ende explizit entfernt. create temporary table om_pos_map ( employee_id uuid primary key, position_id uuid not null default gen_random_uuid(), seq bigint -) on commit drop; +); insert into om_pos_map (employee_id, seq) select id, row_number() over (order by org_level, personnel_number) from employees; @@ -154,6 +157,8 @@ begin end if; end $$; +drop table om_pos_map; + -- ═══ 4. Altmodell entfernen ══════════════════════════════════════ -- Vorgemerkte Änderungen verweisen über team_id auf das Altmodell. Sie sind From 4929252f45ee214cdaf9fc615eb52f71ef0502ea Mon Sep 17 00:00:00 2001 From: Maximilian Stubhan Date: Mon, 27 Jul 2026 15:06:10 +0200 Subject: [PATCH 05/64] Seed the OM model from scratch, and stop writing dates through UTC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Alle Daten gelöscht und neu aufgebaut: 60 Organisationseinheiten, 133 Jobs, 823 Planstellen, 852 Personen, 852 Besetzungen. Die Anmeldekonten bleiben stehen — ein Seed, der sich selbst aus der Anwendung aussperrt, ist keiner. Der Baum kommt aus buildOrg(); der Seed entscheidet nur noch, wer welche Planstelle besetzt. Damit fällt die halbe Datei weg: keine division_id, team_id, manager_id, org_level, is_lead mehr auf der Person. Zwei Dinge, die das Altmodell nicht abbilden konnte, stehen jetzt bewusst in den Daten: - Vakanz ist eine Planstelle ohne laufende Besetzung, keine eigene Tabelle. 14 Planstellen sind heute unbesetzt, drei davon mit einem Eintritt in der Zukunft — die Besetzung beginnt später, die Planstelle existiert schon. - Ausgetretene sind Vorgänger:innen auf heute besetzten Planstellen, nicht Karteileichen an einem Team. Vorher liessen sie deren Planstellen als vakant erscheinen. Drei Teamleitungen sind unbesetzt und zwei langzeitabwesend, damit die Hochroll-Regel überhaupt Daten hat: 76 der 809 Berichtslinien weichen von der formalen ab. Genau eine Person hat keine Vorgesetzte, die Geschäftsführung. Beim ersten scharfen Lauf hat der SVNR-Trigger mitten im Einfügen abgebrochen, mit bereits geleerter Datenbank. Ursache war nicht die Prüfziffer, sondern isoDate(): es ging über toISOString(), während makeSvNummer die lokalen Datumsteile liest. In Österreich verschiebt das jedes Datum um einen Tag — das gespeicherte Geburtsdatum passte nicht mehr zu dem in der SV-Nummer codierten. isoDate rechnet jetzt lokal, wie der Rest des Seeds auch. Damit so etwas nicht wieder erst die Datenbank leerräumt: pruefeInvarianten() läuft *vor* dem Löschen und prüft, was sonst erst die Unique-Indizes und Trigger abfangen — doppelte Besetzungen, überlappende Historie, Ereignisse nach dem Austritt, und jede SV-Nummer gegen ihr Geburtsdatum. Mit --dry-run schreibt der Seed gar nichts und meldet nur, was entstehen würde. --- .../migrations/20260727120200_om_cutover.sql | 12 + supabase/seed.ts | 585 ++++++++++++------ 2 files changed, 413 insertions(+), 184 deletions(-) diff --git a/supabase/migrations/20260727120200_om_cutover.sql b/supabase/migrations/20260727120200_om_cutover.sql index 22db557..7639df8 100644 --- a/supabase/migrations/20260727120200_om_cutover.sql +++ b/supabase/migrations/20260727120200_om_cutover.sql @@ -169,6 +169,12 @@ drop trigger if exists trg_track_employee_assignment on employees; drop function if exists fn_track_employee_assignment(); drop table if exists employee_assignments; +-- Leitet division_id aus team_id ab und haengt damit an einer Spalte, die +-- gleich faellt. Im neuen Modell uebernimmt die Einheit der Planstelle +-- diese Rolle, der Trigger wird ersatzlos entfernt. +drop trigger if exists trg_employees_set_org_unit on employees; +drop function if exists fn_set_employee_org_unit(); + -- Die View selektiert team_id/division_id/manager_id/org_level/is_lead und -- blockiert damit das Entfernen dieser Spalten. Sie wird von der Anwendung -- nirgends benutzt und ersatzlos entfernt; die Ableitung über @@ -203,6 +209,12 @@ drop table if exists teams; drop table if exists departments; drop table if exists divisions; +-- Mit der positions-Tabelle verschwindet ihr Trigger, nicht aber dessen +-- Funktion und die Nummernvergabe, die nur als Spaltenvorgabe dort benutzt +-- wurde. om_positions vergibt seine Nummern selbst. +drop function if exists fn_set_position_org_unit(); +drop function if exists generate_position_number(); + -- ═══ 5. Mutationen im neuen Modell ═══════════════════════════════ -- Die Berichtslinie wird nicht mehr mitgeschrieben, sondern abgeleitet. -- Das entfernt aus jeder dieser Funktionen die Manager-Nachführung — beim diff --git a/supabase/seed.ts b/supabase/seed.ts index c22ce30..80f0ee7 100644 --- a/supabase/seed.ts +++ b/supabase/seed.ts @@ -10,8 +10,9 @@ import { createClient } from "@supabase/supabase-js"; import { randomUUID } from "node:crypto"; // Explicit .ts extension: this file is run directly by Node (type-stripping, // ESM), where an extensionless relative import does not resolve. -import { svnrCheckDigit } from "../lib/svnr.ts"; +import { svnrCheckDigit, svnrErrorMessage, validateSvnr } from "../lib/svnr.ts"; import { ABSENCE_TYPES } from "../lib/absence.ts"; +import { buildOrg, type BuiltUnit, type DivisionDef } from "./build-org.ts"; const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL; const SERVICE_ROLE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY; @@ -49,8 +50,15 @@ function addDays(d: Date, days: number): Date { r.setDate(r.getDate() + days); return r; } +// Bewusst *nicht* über toISOString(): alle Daten hier entstehen aus lokalen +// Bestandteilen (new Date(jahr, monat, tag), addDays), und toISOString rechnet +// nach UTC um. In Österreich verschiebt das jedes Datum um einen Tag nach +// hinten — womit das gespeicherte Geburtsdatum nicht mehr zu dem passt, das +// makeSvNummer aus denselben lokalen Bestandteilen in die SV-Nummer schreibt. function isoDate(d: Date): string { - return d.toISOString().slice(0, 10); + const m = String(d.getMonth() + 1).padStart(2, "0"); + const day = String(d.getDate()).padStart(2, "0"); + return `${d.getFullYear()}-${m}-${day}`; } function randomDateBetween(start: Date, end: Date): Date { const t = start.getTime() + Math.random() * (end.getTime() - start.getTime()); @@ -141,9 +149,8 @@ const LOCATION_WEIGHTS: readonly (readonly [(typeof LOCATIONS)[number], number]) ]; // ── Org structure ──────────────────────────────────────────── -type TeamDef = { name: string; leadTitle: string; icTitles: string[]; baseSize: number }; -type DeptDef = { name: string; teams: TeamDef[] }; -type DivisionDef = { name: string; headTitle: string; departments: DeptDef[] }; +// TeamDef/DeptDef/DivisionDef kommen aus build-org.ts — dort steht auch, was +// daraus gebaut wird. const SCALE = 1.44; // brings the ~556-person base roster up to ~800 @@ -154,6 +161,7 @@ const DIVISIONS: DivisionDef[] = [ departments: [ { name: "Fertigung", + leadTitle: "Abteilungsleitung Fertigung", teams: [ { name: "Montage", leadTitle: "Teamleitung Montage", icTitles: ["Maschinenbediener:in", "Montagemitarbeiter:in", "Anlagenführer:in"], baseSize: 45 }, { name: "CNC-Fertigung", leadTitle: "Teamleitung CNC-Fertigung", icTitles: ["CNC-Fräser:in", "CNC-Dreher:in", "Zerspanungstechniker:in"], baseSize: 35 }, @@ -162,6 +170,7 @@ const DIVISIONS: DivisionDef[] = [ }, { name: "Instandhaltung", + leadTitle: "Abteilungsleitung Instandhaltung", teams: [ { name: "Elektrotechnik", leadTitle: "Teamleitung Elektrotechnik", icTitles: ["Elektrotechniker:in", "Automatisierungstechniker:in"], baseSize: 24 }, { name: "Mechanik", leadTitle: "Teamleitung Mechanik", icTitles: ["Industriemechaniker:in", "Schlosser:in"], baseSize: 22 }, @@ -175,6 +184,7 @@ const DIVISIONS: DivisionDef[] = [ departments: [ { name: "Logistik", + leadTitle: "Abteilungsleitung Logistik", teams: [ { name: "Lager", leadTitle: "Teamleitung Lager", icTitles: ["Lagerlogistiker:in", "Staplerfahrer:in", "Kommissionierer:in"], baseSize: 30 }, { name: "Versand", leadTitle: "Teamleitung Versand", icTitles: ["Versandmitarbeiter:in", "Speditionskaufmann/-frau"], baseSize: 20 }, @@ -183,6 +193,7 @@ const DIVISIONS: DivisionDef[] = [ }, { name: "Einkauf", + leadTitle: "Abteilungsleitung Einkauf", teams: [ { name: "Strategischer Einkauf", leadTitle: "Teamleitung Strategischer Einkauf", icTitles: ["Einkäufer:in", "Category Manager:in"], baseSize: 14 }, { name: "Operativer Einkauf", leadTitle: "Teamleitung Operativer Einkauf", icTitles: ["Operative:r Einkäufer:in", "Bestelldisponent:in"], baseSize: 14 }, @@ -196,6 +207,7 @@ const DIVISIONS: DivisionDef[] = [ departments: [ { name: "Vertrieb", + leadTitle: "Abteilungsleitung Vertrieb", teams: [ { name: "Key Account Management", leadTitle: "Teamleitung Key Account Management", icTitles: ["Key Account Manager:in", "Sales Manager:in"], baseSize: 16 }, { name: "Außendienst", leadTitle: "Teamleitung Außendienst", icTitles: ["Außendienstmitarbeiter:in", "Gebietsverkaufsleiter:in"], baseSize: 22 }, @@ -204,6 +216,7 @@ const DIVISIONS: DivisionDef[] = [ }, { name: "Marketing", + leadTitle: "Abteilungsleitung Marketing", teams: [ { name: "Brand Marketing", leadTitle: "Teamleitung Brand Marketing", icTitles: ["Brand Manager:in", "Produktmanager:in"], baseSize: 12 }, { name: "Digital Marketing", leadTitle: "Teamleitung Digital Marketing", icTitles: ["Digital Marketing Manager:in", "Social-Media-Manager:in"], baseSize: 12 }, @@ -217,6 +230,7 @@ const DIVISIONS: DivisionDef[] = [ departments: [ { name: "Produktentwicklung", + leadTitle: "Abteilungsleitung Produktentwicklung", teams: [ { name: "Rezeptur & Sensorik", leadTitle: "Teamleitung Rezeptur & Sensorik", icTitles: ["Lebensmitteltechniker:in", "Sensoriker:in"], baseSize: 16 }, { name: "Verpackungsentwicklung", leadTitle: "Teamleitung Verpackungsentwicklung", icTitles: ["Verpackungstechniker:in", "Packmittelentwickler:in"], baseSize: 12 }, @@ -224,6 +238,7 @@ const DIVISIONS: DivisionDef[] = [ }, { name: "Verfahrenstechnik", + leadTitle: "Abteilungsleitung Verfahrenstechnik", teams: [ { name: "Prozessoptimierung", leadTitle: "Teamleitung Prozessoptimierung", icTitles: ["Verfahrenstechniker:in", "Prozessingenieur:in"], baseSize: 14 }, { name: "Anlagentechnik", leadTitle: "Teamleitung Anlagentechnik", icTitles: ["Anlagentechniker:in", "Projektingenieur:in"], baseSize: 12 }, @@ -237,6 +252,7 @@ const DIVISIONS: DivisionDef[] = [ departments: [ { name: "Qualitätssicherung", + leadTitle: "Abteilungsleitung Qualitätssicherung", teams: [ { name: "Wareneingangsprüfung", leadTitle: "Teamleitung Wareneingangsprüfung", icTitles: ["Qualitätsprüfer:in", "Wareneingangskontrolleur:in"], baseSize: 14 }, { name: "Prozessaudit", leadTitle: "Teamleitung Prozessaudit", icTitles: ["Qualitätsauditor:in", "QM-Beauftragte:r"], baseSize: 10 }, @@ -244,6 +260,7 @@ const DIVISIONS: DivisionDef[] = [ }, { name: "Lebensmittelsicherheit", + leadTitle: "Abteilungsleitung Lebensmittelsicherheit", teams: [ { name: "Hygienemanagement", leadTitle: "Teamleitung Hygienemanagement", icTitles: ["Hygienebeauftragte:r", "Lebensmittelsicherheitsbeauftragte:r"], baseSize: 12 }, { name: "Zertifizierung", leadTitle: "Teamleitung Zertifizierung", icTitles: ["Zertifizierungsmanager:in", "QM-Sachbearbeiter:in"], baseSize: 10 }, @@ -257,6 +274,7 @@ const DIVISIONS: DivisionDef[] = [ departments: [ { name: "Business Applications", + leadTitle: "Abteilungsleitung Business Applications", teams: [ { name: "SAP-Team", leadTitle: "Teamleitung SAP-Team", icTitles: ["SAP-Consultant", "SAP-Entwickler:in"], baseSize: 12 }, { name: "Power Platform & Automatisierung", leadTitle: "Teamleitung Power Platform & Automatisierung", icTitles: ["Power Platform Developer:in", "Prozessautomatisierer:in"], baseSize: 10 }, @@ -264,6 +282,7 @@ const DIVISIONS: DivisionDef[] = [ }, { name: "Infrastruktur", + leadTitle: "Abteilungsleitung Infrastruktur", teams: [ { name: "Netzwerk & Security", leadTitle: "Teamleitung Netzwerk & Security", icTitles: ["Netzwerktechniker:in", "IT-Security-Spezialist:in"], baseSize: 12 }, { name: "IT-Support", leadTitle: "Teamleitung IT-Support", icTitles: ["IT-Support-Mitarbeiter:in", "Systemadministrator:in"], baseSize: 14 }, @@ -277,6 +296,7 @@ const DIVISIONS: DivisionDef[] = [ departments: [ { name: "Finanzen", + leadTitle: "Abteilungsleitung Finanzen", teams: [ { name: "Buchhaltung", leadTitle: "Teamleitung Buchhaltung", icTitles: ["Buchhalter:in", "Bilanzbuchhalter:in"], baseSize: 16 }, { name: "Treasury", leadTitle: "Teamleitung Treasury", icTitles: ["Treasury-Manager:in", "Finanzanalyst:in"], baseSize: 10 }, @@ -284,6 +304,7 @@ const DIVISIONS: DivisionDef[] = [ }, { name: "Controlling", + leadTitle: "Abteilungsleitung Controlling", teams: [ { name: "Konzerncontrolling", leadTitle: "Teamleitung Konzerncontrolling", icTitles: ["Controller:in", "Financial Analyst:in"], baseSize: 12 }, { name: "Werkscontrolling", leadTitle: "Teamleitung Werkscontrolling", icTitles: ["Werkscontroller:in", "Kostenrechner:in"], baseSize: 12 }, @@ -297,6 +318,7 @@ const DIVISIONS: DivisionDef[] = [ departments: [ { name: "HR Business Partner", + leadTitle: "Abteilungsleitung HR Business Partner", teams: [ { name: "Recruiting", leadTitle: "Teamleitung Recruiting", icTitles: ["Recruiter:in", "Talent Acquisition Manager:in"], baseSize: 10 }, { name: "Personalentwicklung", leadTitle: "Teamleitung Personalentwicklung", icTitles: ["Personalentwickler:in", "Trainer:in"], baseSize: 8 }, @@ -304,6 +326,7 @@ const DIVISIONS: DivisionDef[] = [ }, { name: "Personaladministration", + leadTitle: "Abteilungsleitung Personaladministration", teams: [ { name: "Gehaltsabrechnung", leadTitle: "Teamleitung Gehaltsabrechnung", icTitles: ["Payroll-Spezialist:in", "Personalverrechner:in"], baseSize: 10 }, { name: "HR-Systeme", leadTitle: "Teamleitung HR-Systeme", icTitles: ["HR-IT-Spezialist:in", "HRIS Manager:in"], baseSize: 8 }, @@ -328,13 +351,11 @@ type EmployeeRow = { address_country: string; email: string; phone: string; - team_id: string | null; - division_id: string; + // Die Einordnung in die Organisation steckt jetzt ausschliesslich in der + // Planstelle (position_assignments -> om_positions -> org_units). Keine + // division_id/team_id/manager_id mehr auf der Person. job_title: string; location_id: string; - manager_id: string | null; - org_level: number; - is_lead: boolean; employment_type: "Vollzeit" | "Teilzeit"; weekly_hours: number; contract_type: "unbefristet" | "befristet"; @@ -403,7 +424,7 @@ function paygradeForIc(): EmployeeRow["paygrade"] { ]); } -function newHireBase(jobTitle: string, orgLevel: number, isLead: boolean, teamId: string | null, divisionId: string, managerId: string | null) { +function newHireBase(jobTitle: string) { const gender: "m" | "w" = chance(0.48) ? "m" : "w"; const firstName = pick(gender === "m" ? MALE_FIRST_NAMES : FEMALE_FIRST_NAMES); const lastName = pick(LAST_NAMES); @@ -423,13 +444,8 @@ function newHireBase(jobTitle: string, orgLevel: number, isLead: boolean, teamId address_country: addressCountryFor(nationality), email: makeEmail(firstName, lastName), phone: `+43 664 ${randInt(1000000, 9999999)}`, - team_id: teamId, - division_id: divisionId, job_title: jobTitle, location_id: location.id, - manager_id: managerId, - org_level: orgLevel, - is_lead: isLead, }; } @@ -437,11 +453,27 @@ const employees: EmployeeRow[] = []; const history: HistoryRow[] = []; const icPoolForStatusAssignment: EmployeeRow[] = []; -function finalizeEmployee(base: ReturnType, opts: { paygrade: EmployeeRow["paygrade"] }): EmployeeRow { - const age = randInt(22, 60); +function finalizeEmployee( + base: ReturnType, + opts: { paygrade: EmployeeRow["paygrade"]; entryDate?: Date } +): EmployeeRow { + // Alter und Eintritt hängen zusammen: sonst entstehen Beschäftigte, die mit + // sechs Jahren angefangen haben. Ist der Eintritt vorgegeben (ausgetretene + // Vorgänger:innen, geplante Eintritte), richtet sich das Alter danach — + // sonst umgekehrt. + let age: number; + let entryDate: Date; + if (opts.entryDate) { + entryDate = opts.entryDate; + const tenureYears = Math.max(0, Math.floor((TODAY.getTime() - entryDate.getTime()) / (365.25 * 864e5))); + const minAge = Math.min(Math.max(22, 20 + tenureYears), 55); + age = randInt(minAge, 62); + } else { + age = randInt(22, 60); + const maxTenureYears = Math.min(15, age - 20); + entryDate = randomDateBetween(addDays(TODAY, -maxTenureYears * 365), addDays(TODAY, -30)); + } const birthDate = birthDateForAge(age); - const maxTenureYears = Math.min(15, age - 20); - const entryDate = randomDateBetween(addDays(TODAY, -maxTenureYears * 365), addDays(TODAY, -30)); const employmentType: "Vollzeit" | "Teilzeit" = chance(0.8) ? "Vollzeit" : "Teilzeit"; const weeklyHours = employmentType === "Vollzeit" ? 38.5 : pick([15, 18, 20, 25, 28, 30, 32, 35]); @@ -487,80 +519,45 @@ function finalizeEmployee(base: ReturnType, opts: { paygrade return row; } -type TeamRef = { id: string; org_number: string; name: string; department_id: string }; -type DeptRef = { id: string; org_number: string; name: string; division_id: string }; -type DivisionRef = { id: string; org_number: string; name: string }; +// ── Organisation im OM-Modell ──────────────────────────────── +// Der Baum kommt aus buildOrg(): reine Funktion, eigene Tests +// (tests/unit/build-org.test.ts). Der Seed entscheidet hier nur noch, *wer* +// welche Planstelle besetzt — die Struktur selbst ist nicht mehr seine Sache. +const COMPANY_NAME = "Alpenwerk Industrie GmbH"; -const divisionRows: DivisionRef[] = []; -const departmentRows: DeptRef[] = []; -const teamRows: TeamRef[] = []; +const scaledDivisions: DivisionDef[] = DIVISIONS.map((div) => ({ + ...div, + departments: div.departments.map((dept) => ({ + ...dept, + // -1, weil die Teamleitung im Altmodell Teil der Teamgrösse war und + // buildOrg sie zusätzlich zu icTitles anlegt. + teams: dept.teams.map((t) => ({ ...t, baseSize: Math.max(1, Math.round(t.baseSize * SCALE) - 1) })), + })), +})); -// Geschäftsführung: small division, no departments/teams — CEO and their -// assistant sit directly under it (§5). -const gfDivisionId = randomUUID(); -divisionRows.push({ id: gfDivisionId, org_number: "20900000", name: "Geschäftsführung" }); +const org = buildOrg(COMPANY_NAME, scaledDivisions, randomUUID); +const unitById = new Map(org.units.map((u) => [u.id, u])); +const jobTitleById = new Map(org.jobs.map((j) => [j.id, j.title])); -const ceo = finalizeEmployee( - newHireBase("Geschäftsführer:in", 0, true, null, gfDivisionId, null), - { paygrade: "F" } -); -const gfAssistant = finalizeEmployee( - newHireBase("Assistenz der Geschäftsführung", 3, false, null, gfDivisionId, ceo.id), - { paygrade: "C" } -); -employees.push(ceo, gfAssistant); +type AssignmentRow = { + position_id: string; + employee_id: string; + valid_from: string; + valid_to: string | null; +}; +const assignments: AssignmentRow[] = []; -let divisionCounter = 0; -let deptCounter = 0; -let teamCounter = 0; +// Wer welche Planstelle besetzt, wird bewusst nicht überall besetzt: Vakanz +// ist im OM-Modell keine eigene Tabelle mehr, sondern eine Planstelle ohne +// laufende Besetzung. Ein paar davon braucht es, damit "offene Stellen" und +// die Vertretungsregel bei fehlender Leitung überhaupt Daten haben. +const VAKANT_IC = 8; // offene Stellen ohne Nachfolge +const VAKANT_GEPLANT = 3; // offene Stellen mit Eintritt in der Zukunft +const VAKANT_LEITUNG = 3; // unbesetzte Leitungen -> Berichtslinie rollt hoch +const AUSGETRETEN = 40; // Vorgänger:innen auf heute besetzten Planstellen +const LANGZEITABWESEND = 12; +const GEPLANTER_AUSTRITT = 3; -for (const div of DIVISIONS) { - divisionCounter += 1; - const divisionId = randomUUID(); - const divisionOrgNumber = `20${String(divisionCounter * 100000).padStart(6, "0")}`; - divisionRows.push({ id: divisionId, org_number: divisionOrgNumber, name: div.name }); - - const divisionHead = finalizeEmployee( - newHireBase(div.headTitle, 1, true, null, divisionId, ceo.id), - { paygrade: "F" } - ); - employees.push(divisionHead); - - for (const dept of div.departments) { - deptCounter += 1; - const departmentId = randomUUID(); - const deptOrgNumber = `21${String(deptCounter * 10000).padStart(6, "0")}`; - departmentRows.push({ id: departmentId, org_number: deptOrgNumber, name: dept.name, division_id: divisionId }); - - for (const team of dept.teams) { - teamCounter += 1; - const teamId = randomUUID(); - const teamOrgNumber = `22${String(teamCounter * 1000).padStart(6, "0")}`; - teamRows.push({ id: teamId, org_number: teamOrgNumber, name: team.name, department_id: departmentId }); - - const size = Math.max(2, Math.round(team.baseSize * SCALE)); - - const teamLead = finalizeEmployee( - newHireBase(team.leadTitle, 2, true, teamId, divisionId, divisionHead.id), - { paygrade: "E" } - ); - employees.push(teamLead); - - for (let i = 0; i < size - 1; i++) { - const jobTitle = pick(team.icTitles); - const paygrade = paygradeForIc(); - const ic = finalizeEmployee( - newHireBase(jobTitle, 3, false, teamId, divisionId, teamLead.id), - { paygrade } - ); - employees.push(ic); - icPoolForStatusAssignment.push(ic); - } - } - } -} - -// ── Apply the target status distribution (§5) across the IC pool ──────── function shuffle(arr: T[]): T[] { const a = [...arr]; for (let i = a.length - 1; i > 0; i--) { @@ -569,24 +566,74 @@ function shuffle(arr: T[]): T[] { } return a; } -const shuffledIcs = shuffle(icPoolForStatusAssignment); -let cursor = 0; -// ~40 Ausgetreten -for (let i = 0; i < 40 && cursor < shuffledIcs.length; i++, cursor++) { - const e = shuffledIcs[cursor]; - const entryDate = new Date(e.entry_date); - const exitDate = randomDateBetween(addDays(entryDate, 90), TODAY); - e.status = "Ausgetreten"; - e.exit_date = isoDate(exitDate); - e.exit_reason = pick(EXIT_REASONS); - history.push({ employee_id: e.id, event_date: e.exit_date, event_type: "Austritt", description: `Austritt (${e.exit_reason})` }); +function paygradeForPosition(p: (typeof org.positions)[number], title: string): EmployeeRow["paygrade"] { + if (!p.is_chief) return title.startsWith("Assistenz") ? "C" : paygradeForIc(); + const type = unitById.get(p.org_unit_id)!.unit_type; + return type === "Gesellschaft" || type === "Bereich" ? "F" : "E"; } -// ~12 Langzeitabwesenheiten, über die Arten gestreut statt alle als Karenz — -// die Auswertung nach Art ist sonst nicht zu sehen. -for (let i = 0; i < 12 && cursor < shuffledIcs.length; i++, cursor++) { - const e = shuffledIcs[cursor]; +/** Besetzt eine Planstelle laufend und legt die Person an. */ +function occupy(p: (typeof org.positions)[number]): EmployeeRow { + const title = jobTitleById.get(p.job_id)!; + const e = finalizeEmployee(newHireBase(title), { paygrade: paygradeForPosition(p, title) }); + employees.push(e); + assignments.push({ position_id: p.id, employee_id: e.id, valid_from: e.entry_date, valid_to: null }); + return e; +} + +const chiefPositions = org.positions.filter((p) => p.is_chief); +const icPositions = org.positions.filter((p) => !p.is_chief); + +// Leitungen: alle besetzen bis auf ein paar Teamleitungen, damit die +// Hochrollen-Regel im Organigramm sichtbar wird. +const vakanteLeitungen = new Set( + shuffle(chiefPositions.filter((p) => unitById.get(p.org_unit_id)!.unit_type === "Team")) + .slice(0, VAKANT_LEITUNG) + .map((p) => p.id) +); +const leadEmployees: EmployeeRow[] = []; +for (const p of chiefPositions) { + if (vakanteLeitungen.has(p.id)) continue; + leadEmployees.push(occupy(p)); +} + +// Mitarbeiter-Planstellen: der Rest wird besetzt, ein Teil bleibt offen. +const shuffledIc = shuffle(icPositions); +const offeneStellen = shuffledIc.slice(0, VAKANT_IC); +const geplanteStellen = shuffledIc.slice(VAKANT_IC, VAKANT_IC + VAKANT_GEPLANT); +const besetzteIc = shuffledIc.slice(VAKANT_IC + VAKANT_GEPLANT); + +const icEmployees = besetzteIc.map((p) => occupy(p)); +void offeneStellen; // bleiben unbesetzt — genau das macht sie zu offenen Stellen + +// ── Statusverteilung (§5) ──────────────────────────────────── +const statusPool = shuffle(icEmployees); +let cursor = 0; + +// Eintritt in der Zukunft: die Person ist angelegt, die Planstelle heute noch +// vakant, die Besetzung beginnt erst. Genau der Fall, für den die Planstellen +// zeitabhängig sind. +for (const p of geplanteStellen) { + const futureEntry = addDays(TODAY, randInt(10, 90)); + const title = jobTitleById.get(p.job_id)!; + const e = finalizeEmployee(newHireBase(title), { paygrade: paygradeForIc(), entryDate: futureEntry }); + e.status = "Geplant"; + employees.push(e); + assignments.push({ position_id: p.id, employee_id: e.id, valid_from: e.entry_date, valid_to: null }); +} + +// Langzeitabwesenheit, über die Arten gestreut statt alle als Karenz — sonst +// ist die Auswertung nach Art nicht zu sehen. Zwei davon treffen bewusst eine +// Teamleitung, damit die Vertretungsregel auch mit *abwesender* (nicht nur +// unbesetzter) Leitung Daten hat. +const abwesende: EmployeeRow[] = [ + ...shuffle(leadEmployees.filter((e) => e.job_title.startsWith("Teamleitung"))).slice(0, 2), +]; +while (abwesende.length < LANGZEITABWESEND && cursor < statusPool.length) { + abwesende.push(statusPool[cursor++]); +} +for (const e of abwesende) { const entryDate = new Date(e.entry_date); const karenzStart = randomDateBetween(addDays(entryDate, 180), addDays(TODAY, -10)); const returnDate = addDays(TODAY, randInt(10, 300)); @@ -603,65 +650,89 @@ for (let i = 0; i < 12 && cursor < shuffledIcs.length; i++, cursor++) { }); } -// ~3 Geplant (future entry). A person who hasn't started yet can't already -// have a Beförderung or other history predating that future entry date — -// found during the consolidation review that reassigning an already- -// finalized IC to Geplant only patched their Eintritt row's date, leaving -// any earlier-generated history (e.g. a Beförderung) still on the record -// with a date before the (now future) entry_date. Fixed by dropping every -// history row for that employee except Eintritt, then moving Eintritt to -// the new future date. -for (let i = 0; i < 3 && cursor < shuffledIcs.length; i++, cursor++) { - const e = shuffledIcs[cursor]; - const futureEntry = addDays(TODAY, randInt(10, 90)); - e.status = "Geplant"; - e.entry_date = isoDate(futureEntry); - for (let hi = history.length - 1; hi >= 0; hi--) { - if (history[hi].employee_id === e.id && history[hi].event_type !== "Eintritt") history.splice(hi, 1); - } - const historyEntry = history.find((h) => h.employee_id === e.id && h.event_type === "Eintritt"); - if (historyEntry) historyEntry.event_date = e.entry_date; -} - -// ~3 planned future exits (still Aktiv until the exit date arrives) -for (let i = 0; i < 3 && cursor < shuffledIcs.length; i++, cursor++) { - const e = shuffledIcs[cursor]; +// Geplante Austritte: noch aktiv, die Besetzung endet an einem Datum in der +// Zukunft. +for (let i = 0; i < GEPLANTER_AUSTRITT && cursor < statusPool.length; i++, cursor++) { + const e = statusPool[cursor]; const futureExit = addDays(TODAY, randInt(10, 90)); e.exit_date = isoDate(futureExit); e.exit_reason = pick(EXIT_REASONS); + const a = assignments.find((x) => x.employee_id === e.id)!; + a.valid_to = e.exit_date; } -// ── Open positions (§4.6 / §5) ─────────────────────────────── -type PositionRow = { - title: string; - team_id: string; - is_lead: boolean; - reports_to_employee_id: string | null; - status: "open"; - created_at: string; -}; -const positions: PositionRow[] = []; +// ── Ausgetretene als Vorgänger:innen auf besetzten Planstellen ─────── +// Im Altmodell hingen Ausgetretene weiter an einem Team und liessen dessen +// Planstellen als vakant erscheinen. Im OM-Modell hat eine Planstelle eine +// Besetzungshistorie: die vorherige Besetzung ist beendet, die heutige läuft. +// Voraussetzung ist, dass der Austritt vor dem Eintritt der heutigen +// Besetzung liegt — sonst wäre die Planstelle zweimal gleichzeitig besetzt. { - const pool = shuffle([...teamRows]); - for (let i = 0; i < 8; i++) { - const team = pool[i % pool.length]; - const leadOfTeam = employees.find((e) => e.team_id === team.id && e.is_lead); - const isLeadPosition = i < 2; // first two are leadership requisitions - const reportsTo = isLeadPosition - ? (employees.find((e) => e.division_id === leadOfTeam?.division_id && e.org_level === 1)?.id ?? null) - : (leadOfTeam?.id ?? null); - positions.push({ - title: isLeadPosition ? `Teamleitung ${team.name}` : "Neue Position", - team_id: team.id, - is_lead: isLeadPosition, - reports_to_employee_id: reportsTo, - status: "open", - created_at: new Date(addDays(TODAY, -randInt(1, 45))).toISOString(), + const holderOf = new Map(icEmployees.map((e) => [e.id, e])); + const uebernehmbar = shuffle( + assignments.filter((a) => { + const holder = holderOf.get(a.employee_id); + // Genug Vorlauf, damit vor der heutigen Besetzung noch eine ganze + // Beschäftigung Platz hat. + return holder && new Date(holder.entry_date) > addDays(TODAY, -8 * 365) && holder.status === "Aktiv"; + }) + ).slice(0, AUSGETRETEN); + + for (const a of uebernehmbar) { + const nachfolgerEintritt = new Date(a.valid_from); + const exitDate = addDays(nachfolgerEintritt, -randInt(1, 60)); + const entryDate = addDays(exitDate, -randInt(400, 3000)); + const p = org.positions.find((x) => x.id === a.position_id)!; + const title = jobTitleById.get(p.job_id)!; + + const e = finalizeEmployee(newHireBase(title), { paygrade: paygradeForIc(), entryDate }); + e.status = "Ausgetreten"; + e.exit_date = isoDate(exitDate); + e.exit_reason = pick(EXIT_REASONS); + // Ein befristeter Vertrag, der nach dem Austritt endet, wäre Unsinn; und + // finalizeEmployee kann eine Beförderung bis heute gestreut haben, die + // hier nach dem Austritt läge. + e.contract_type = "unbefristet"; + e.contract_end_date = null; + for (let i = history.length - 1; i >= 0; i--) { + if (history[i].employee_id === e.id && history[i].event_date > e.exit_date) history.splice(i, 1); + } + employees.push(e); + history.push({ + employee_id: e.id, + event_date: e.exit_date, + event_type: "Austritt", + description: `Austritt (${e.exit_reason})`, + }); + assignments.push({ + position_id: p.id, + employee_id: e.id, + valid_from: e.entry_date, + valid_to: e.exit_date, }); } } // ── Insert helpers ─────────────────────────────────────────── +/** Eltern vor Kindern, damit parent_id beim Einfügen schon existiert. */ +function sortParentsFirst(units: BuiltUnit[]): BuiltUnit[] { + const byParent = new Map(); + for (const u of units) { + const list = byParent.get(u.parent_id) ?? []; + list.push(u); + byParent.set(u.parent_id, list); + } + const out: BuiltUnit[] = []; + const queue = [...(byParent.get(null) ?? [])]; + while (queue.length > 0) { + const u = queue.shift()!; + out.push(u); + queue.push(...(byParent.get(u.id) ?? [])); + } + if (out.length !== units.length) throw new Error("Org-Baum hat abgehängte Einheiten"); + return out; +} + async function insertInChunks(table: string, rows: Record[], chunkSize = 200) { for (let i = 0; i < rows.length; i += chunkSize) { const chunk = rows.slice(i, i + chunkSize); @@ -671,52 +742,198 @@ async function insertInChunks(table: string, rows: Record[], ch console.log(` inserted ${rows.length} row(s) into ${table}`); } +// Alles ausser den Anmeldekonten. profiles und auth.users bleiben stehen — +// sonst sperrt sich der Seed selbst aus der Anwendung aus. +// +// Reihenfolge: Kinder vor Eltern. org_units verweist auf sich selbst; ein +// einzelnes DELETE über alle Zeilen geht trotzdem durch, weil Postgres die +// Fremdschlüsselprüfung erst nach dem Statement auswertet. +const WIPE_ORDER = [ + "reorg_moves", + "reorg_scenarios", + "pending_org_changes", + "hire_drafts", + "employee_notes", + "employee_dependents", + "employee_history", + "position_assignments", + "audit_log", + "saved_reports", + "employees", + "om_positions", + "jobs", + "org_units", + "locations", +]; + +async function wipe() { + for (const table of WIPE_ORDER) { + // PostgREST verlangt einen Filter; "id ist nicht null" trifft alles. + const { error } = await supabase.from(table).delete().not("id", "is", null); + if (error) throw new Error(`Delete from ${table} failed: ${error.message}`); + const { count } = await supabase.from(table).select("*", { count: "exact", head: true }); + if (count) throw new Error(`${table} ist nach dem Löschen nicht leer (${count} Zeilen)`); + console.log(` geleert: ${table}`); + } +} + +/** + * Prüft die Zusagen, die der Seed der Datenbank gegenüber macht, bevor er sie + * löscht. Die Unique-Indizes fangen das Meiste ab — aber erst nach dem + * Löschen, und dann steht die Datenbank leer da. + */ +function pruefeInvarianten() { + const laufend = assignments.filter((a) => a.valid_to === null); + + const jeStelle = new Map(); + for (const a of laufend) jeStelle.set(a.position_id, (jeStelle.get(a.position_id) ?? 0) + 1); + for (const [id, n] of jeStelle) if (n > 1) throw new Error(`Planstelle ${id} ist ${n}-fach laufend besetzt`); + + const jePerson = new Map(); + for (const a of laufend) jePerson.set(a.employee_id, (jePerson.get(a.employee_id) ?? 0) + 1); + for (const [id, n] of jePerson) if (n > 1) throw new Error(`Person ${id} hat ${n} laufende Planstellen`); + + // Überlappende Besetzungen derselben Planstelle: der Unique-Index deckt nur + // die laufende ab, die Historie könnte sich also unbemerkt überschneiden. + const nachStelle = new Map(); + for (const a of assignments) { + const list = nachStelle.get(a.position_id) ?? []; + list.push(a); + nachStelle.set(a.position_id, list); + } + for (const [id, list] of nachStelle) { + const sortiert = [...list].sort((x, y) => x.valid_from.localeCompare(y.valid_from)); + for (let i = 1; i < sortiert.length; i++) { + const vorher = sortiert[i - 1]; + if (vorher.valid_to === null || vorher.valid_to > sortiert[i].valid_from) { + throw new Error(`Planstelle ${id}: Besetzungen überschneiden sich (${vorher.valid_from}–${vorher.valid_to})`); + } + } + } + + for (const a of assignments) { + if (a.valid_to !== null && a.valid_to <= a.valid_from) throw new Error(`Besetzung ${a.position_id}: valid_to <= valid_from`); + } + + const personen = new Set(employees.map((e) => e.id)); + for (const a of assignments) if (!personen.has(a.employee_id)) throw new Error("Besetzung ohne Person"); + for (const h of history) if (!personen.has(h.employee_id)) throw new Error("Historie ohne Person"); + + // Jede Person genau eine Planstelle — auch die ausgetretenen, sonst hinge + // sie ausserhalb der Organisation. + const mitStelle = new Set(assignments.map((a) => a.employee_id)); + for (const e of employees) if (!mitStelle.has(e.id)) throw new Error(`${e.first_name} ${e.last_name} hat keine Planstelle`); + + // Die SV-Nummer trägt das Geburtsdatum in sich; weichen die beiden + // voneinander ab, weist der Trigger die Zeile zurück — mitten im Einfügen, + // wenn die Datenbank bereits leergeräumt ist. + for (const e of employees) { + const fehler = validateSvnr(e.sv_nummer, e.birth_date); + if (fehler) throw new Error(`${e.email}: SV-Nummer ${e.sv_nummer} zu Geburtsdatum ${e.birth_date} — ${svnrErrorMessage(fehler)}`); + } + + for (const e of employees) { + if (e.exit_date && e.exit_date <= e.entry_date) throw new Error(`${e.email}: Austritt vor Eintritt`); + } + for (const h of history) { + const e = employees.find((x) => x.id === h.employee_id)!; + if (e.exit_date && h.event_date > e.exit_date) throw new Error(`${e.email}: Ereignis ${h.event_type} nach dem Austritt`); + } +} + async function main() { - console.log("Seeding locations..."); + pruefeInvarianten(); + + if (process.argv.includes("--dry-run")) { + console.log("Trockenlauf — es wird nichts geschrieben."); + berichte(); + return; + } + + console.log("Lösche alle Daten (Anmeldekonten bleiben)..."); + await wipe(); + + console.log("\nSeeding locations..."); await insertInChunks("locations", LOCATIONS.map((l) => ({ ...l }))); - console.log("Seeding divisions..."); - await insertInChunks("divisions", divisionRows); + console.log(`Seeding ${org.units.length} org_units...`); + // Eltern vor Kindern: der Fremdschlüssel auf parent_id wird pro Zeile + // geprüft, und insertInChunks zerlegt in mehrere Statements. buildOrg + // liefert die Einheiten bereits in dieser Reihenfolge, aber darauf soll + // sich der Seed nicht verlassen. + await insertInChunks("org_units", sortParentsFirst(org.units)); - console.log("Seeding departments..."); - await insertInChunks("departments", departmentRows); + console.log(`Seeding ${org.jobs.length} jobs...`); + await insertInChunks("jobs", org.jobs); - console.log("Seeding teams..."); - await insertInChunks("teams", teamRows); + console.log(`Seeding ${org.positions.length} Planstellen...`); + await insertInChunks("om_positions", org.positions); console.log(`Seeding ${employees.length} employees...`); await insertInChunks("employees", employees); + console.log(`Seeding ${assignments.length} Besetzungen...`); + await insertInChunks("position_assignments", assignments); + console.log(`Seeding ${history.length} employee_history rows...`); await insertInChunks("employee_history", history); - console.log(`Seeding ${positions.length} open positions...`); - await insertInChunks("positions", positions); + // Das HR-Konto wird nicht neu angelegt: die Auth-Konten überstehen den + // Seed, und ein zweites Konto auf dieselbe Adresse liesse sich gar nicht + // anlegen. Fehlt es, wird es einmalig erzeugt — das ist die eine bewusste + // Freischaltung, jede weitere profiles-Zeile startet mit is_active = false + // und muss von HR freigeschaltet werden (§2.3). + const { data: existing } = await supabase.from("profiles").select("id, email").eq("email", ADMIN_EMAIL).maybeSingle(); + if (existing) { + console.log(`\nHR-Konto ${ADMIN_EMAIL} besteht weiter — Passwort unverändert.`); + } else { + console.log("\nLege HR-Konto an..."); + const hrPassword = randomUUID().slice(0, 12) + "!Aa1"; + const { data: hrUser, error: hrErr } = await supabase.auth.admin.createUser({ + email: ADMIN_EMAIL, + password: hrPassword, + email_confirm: true, + }); + if (hrErr) throw new Error(`Creating HR user failed: ${hrErr.message}`); + await supabase.from("profiles").insert({ + id: hrUser.user.id, + email: ADMIN_EMAIL, + full_name: "Maximilian Stubhan", + role: "hr", + is_active: true, + }); + console.log(`HR login: ${ADMIN_EMAIL} / ${hrPassword}`); + console.log("(Password is shown once here only — store it somewhere safe.)"); + } - // The app is HR-only now (see docs/decisions/0001-hr-only-access.md) — no - // second "manager" role exists to seed a test account for. This is the - // one deliberate, explicit bootstrap grant of HR access (not an automatic - // one): every other new profile row defaults to is_active = false and - // must be activated by an existing HR user (§2.3). - console.log("Creating initial HR account..."); - const hrPassword = randomUUID().slice(0, 12) + "!Aa1"; - const { data: hrUser, error: hrErr } = await supabase.auth.admin.createUser({ - email: ADMIN_EMAIL, - password: hrPassword, - email_confirm: true, - }); - if (hrErr) throw new Error(`Creating HR user failed: ${hrErr.message}`); - await supabase.from("profiles").insert({ - id: hrUser.user.id, - email: ADMIN_EMAIL, - full_name: "Maximilian Stubhan", - role: "hr", - is_active: true, - }); + // Gegenprobe an der Datenbank selbst: die Berichtslinie wird nicht mehr + // gepflegt, sondern abgeleitet. Wenn der Seed den Baum falsch verdrahtet + // hat, fällt das hier auf und nicht erst im Organigramm. + const { data: linien, error: linienErr } = await supabase.rpc("om_reporting_lines", { p_as_of: isoDate(TODAY) }); + if (linienErr) throw new Error(`om_reporting_lines failed: ${linienErr.message}`); + const ohneVorgesetzte = (linien ?? []).filter( + (l: { acting_manager_id: string | null }) => l.acting_manager_id === null + ); + console.log(`\nBerichtslinie: ${linien?.length} Zeilen, ${ohneVorgesetzte.length} ohne Vorgesetzte (erwartet: 1, die Geschäftsführung)`); - console.log("\nDone."); - console.log(`HR login: ${ADMIN_EMAIL} / ${hrPassword}`); - console.log("(Password is shown once here only — store it somewhere safe.)"); + console.log("\nFertig."); + berichte(); +} + +function berichte() { + const heute = isoDate(TODAY); + const laufendHeute = assignments.filter((a) => a.valid_from <= heute && (a.valid_to === null || a.valid_to > heute)); + const zahl = (t: string) => org.units.filter((u) => u.unit_type === t).length; + + console.log(` Organisation: ${zahl("Gesellschaft")} Gesellschaft, ${zahl("Bereich")} Bereiche, ${zahl("Abteilung")} Abteilungen, ${zahl("Team")} Teams`); + console.log(` Jobkatalog: ${org.jobs.length} Tätigkeiten`); + console.log(` Planstellen: ${org.positions.length}, davon ${org.positions.length - laufendHeute.length} heute unbesetzt`); + console.log(` Personen: ${employees.length}`); + for (const s of ["Aktiv", "Karenz", "Geplant", "Ausgetreten"] as const) { + console.log(` ${s.padEnd(12)} ${employees.filter((e) => e.status === s).length}`); + } + console.log(` Besetzungen: ${assignments.length} (${assignments.filter((a) => a.valid_to !== null).length} beendet)`); + console.log(` Historie: ${history.length} Ereignisse`); } main().catch((err) => { From 27669e0359f89bb5a8f67b8142a43e026398aee7 Mon Sep 17 00:00:00 2001 From: Maximilian Stubhan Date: Mon, 27 Jul 2026 20:02:26 +0200 Subject: [PATCH 06/64] Put the whole application on the OM model, and delete what it replaced MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- actions/employees.ts | 25 +- actions/positions.ts | 28 +- actions/reorg.ts | 37 -- app/(app)/employees/[id]/page.tsx | 100 +++-- app/(app)/employees/page.tsx | 109 +++-- app/(app)/orgchart/page.tsx | 27 +- app/(app)/page.tsx | 29 +- app/(app)/positions/page.tsx | 29 +- app/api/export/employees/route.ts | 47 +- app/api/export/events/route.ts | 6 +- components/employees/EmployeeDetail.tsx | 48 +- components/employees/EmployeeFilters.tsx | 28 +- components/employees/panels/TransferPanel.tsx | 95 ++-- components/employees/tabs/OrganisationTab.tsx | 14 +- components/orgchart/OrgChartClient.tsx | 48 +- components/orgchart/PositionTree.tsx | 414 ++++++++---------- components/orgchart/ReorgWorkbench.tsx | 404 ----------------- components/orgchart/types.ts | 30 +- components/positions/CreatePositionModal.tsx | 138 +++--- components/positions/PositionsPageClient.tsx | 24 +- lib/org.ts | 125 ++++-- lib/orgchart-data.ts | 285 ++++++------ lib/placement.ts | 115 +++++ lib/positions.ts | 125 ++++-- lib/reports-data.ts | 172 +++++--- lib/reports.ts | 42 +- lib/supabase/types.ts | 163 ++----- ...0260727130000_om_cleanup_and_positions.sql | 140 ++++++ supabase/seed.ts | 3 - tests/integration/assignment-history.test.ts | 140 ------ tests/integration/authorization.test.ts | 4 +- tests/integration/data-integrity.test.ts | 24 +- tests/integration/effective-dating.test.ts | 88 ++-- tests/integration/helpers.ts | 119 +++-- .../integration/position-assignments.test.ts | 216 +++++++++ tests/integration/positions.test.ts | 237 ++++++---- tests/integration/reorg.test.ts | 147 ------- tests/integration/svnr-validation.test.ts | 26 +- tests/unit/org.test.ts | 122 ++++-- tests/unit/orgchart-data.test.ts | 276 ++++++------ tests/unit/reports.test.ts | 54 ++- 41 files changed, 2203 insertions(+), 2100 deletions(-) delete mode 100644 actions/reorg.ts delete mode 100644 components/orgchart/ReorgWorkbench.tsx create mode 100644 lib/placement.ts create mode 100644 supabase/migrations/20260727130000_om_cleanup_and_positions.sql delete mode 100644 tests/integration/assignment-history.test.ts create mode 100644 tests/integration/position-assignments.test.ts delete mode 100644 tests/integration/reorg.test.ts diff --git a/actions/employees.ts b/actions/employees.ts index 6bb9933..987e98d 100644 --- a/actions/employees.ts +++ b/actions/employees.ts @@ -1,7 +1,6 @@ "use server"; import { revalidatePath } from "next/cache"; -import { sanitizeIlikeTerm } from "@/lib/supabase/query"; import { createClient } from "@/lib/supabase/server"; import type { CollectiveAgreement, Database, NoteCategory, RelationshipType, Weekday, WorkerType } from "@/lib/supabase/types"; @@ -65,8 +64,8 @@ export async function terminateEmployee(payload: { export async function transferEmployee(payload: { employee_id: string; effective_date: string; - new_team_id: string; - new_title?: string; + /** Die Zielplanstelle; Bereich, Abteilung und Team ergeben sich aus ihrer Einheit. */ + target_position_id: string; }): Promise { return callRpc("transfer_employee", payload, [`/employees/${payload.employee_id}`, "/employees"]); } @@ -153,23 +152,3 @@ export async function addEmployeeNote(payload: { export async function completeEmployeeNote(payload: { note_id: string; employee_id: string }): Promise { return callRpc("complete_employee_note", payload, [`/employees/${payload.employee_id}`, "/"]); } - -export type EmployeeSearchResult = { id: string; first_name: string; last_name: string; job_title: string; team_id: string | null }; - -// Shared by "Position besetzen" (staff an open position) and the reorg -// workbench's "Mitarbeiter:in(nen)" multi-select — both search active/ -// on-leave employees by name or title. -export async function searchActiveEmployees(query: string): Promise { - const supabase = await createClient(); - let q = supabase - .from("employees") - .select("id, first_name, last_name, job_title, team_id") - .in("status", ["Aktiv", "Karenz"]) - .limit(20); - if (query.trim()) { - const term = sanitizeIlikeTerm(query.trim()); - q = q.or(`first_name.ilike.%${term}%,last_name.ilike.%${term}%,job_title.ilike.%${term}%`); - } - const { data } = await q; - return data ?? []; -} diff --git a/actions/positions.ts b/actions/positions.ts index d06b6ff..2e43393 100644 --- a/actions/positions.ts +++ b/actions/positions.ts @@ -1,7 +1,6 @@ "use server"; import { revalidatePath } from "next/cache"; -import { sanitizeIlikeTerm } from "@/lib/supabase/query"; import { createClient } from "@/lib/supabase/server"; type ActionResult = { success: boolean; error?: string }; @@ -21,10 +20,9 @@ async function callRpc( } export async function createPosition(payload: { - title: string; - superior_employee_id: string; - is_lead: boolean; - team_id?: string; + org_unit_id: string; + job_title: string; + is_chief: boolean; valid_from: string; }): Promise { return callRpc("create_position", payload, POSITION_PATHS); @@ -34,23 +32,3 @@ export async function deletePosition(positionId: string): Promise return callRpc("delete_position", { position_id: positionId }, POSITION_PATHS); } -export type SuperiorSearchResult = { id: string; first_name: string; last_name: string; job_title: string; division_id: string }; - -// For "Position ausschreiben": superior lookup, filtered to team-leads when -// the new position is an IC role, or to division-heads/CEO when the new -// position is itself a team lead (§2). -export async function searchSuperiors(query: string, forLeadPosition: boolean): Promise { - const supabase = await createClient(); - let q = supabase - .from("employees") - .select("id, first_name, last_name, job_title, division_id") - .eq("status", "Aktiv") - .limit(20); - q = forLeadPosition ? q.lte("org_level", 1) : q.eq("is_lead", true).eq("org_level", 2); - if (query.trim()) { - const term = sanitizeIlikeTerm(query.trim()); - q = q.or(`first_name.ilike.%${term}%,last_name.ilike.%${term}%,job_title.ilike.%${term}%`); - } - const { data } = await q; - return data ?? []; -} diff --git a/actions/reorg.ts b/actions/reorg.ts deleted file mode 100644 index cfc6b84..0000000 --- a/actions/reorg.ts +++ /dev/null @@ -1,37 +0,0 @@ -"use server"; - -import { revalidatePath } from "next/cache"; -import { createClient } from "@/lib/supabase/server"; - -type ActionResult = { success: boolean; error?: string }; - -export type ReorgMovePayload = { - kind: "emp" | "team" | "abt" | "dept"; - label: string; - employee_ids: string[]; - target_team_id: string; -}; - -export async function applyReorg(payload: { - name: string; - effective_date: string; - moves: ReorgMovePayload[]; -}): Promise { - const supabase = await createClient(); - const { data, error } = await supabase.rpc("apply_reorg", { payload }); - if (error) return { success: false, error: error.message }; - revalidatePath("/orgchart"); - revalidatePath("/employees"); - revalidatePath("/"); - return { success: true, scenarioId: data as string }; -} - -export async function undoReorg(payload: { scenario_id: string }): Promise { - const supabase = await createClient(); - const { error } = await supabase.rpc("undo_reorg", { payload }); - if (error) return { success: false, error: error.message }; - revalidatePath("/orgchart"); - revalidatePath("/employees"); - revalidatePath("/"); - return { success: true }; -} diff --git a/app/(app)/employees/[id]/page.tsx b/app/(app)/employees/[id]/page.tsx index 711f14a..7003c7a 100644 --- a/app/(app)/employees/[id]/page.tsx +++ b/app/(app)/employees/[id]/page.tsx @@ -1,40 +1,35 @@ import { notFound } from "next/navigation"; import { EmployeeDetail } from "@/components/employees/EmployeeDetail"; +import { todayIso } from "@/lib/format"; +import { breadcrumbLabel, loadOrgMaps } from "@/lib/org"; +import { loadPlacements, type ReportingLine } from "@/lib/placement"; +import { loadOpenPositions } from "@/lib/positions"; import { createClient } from "@/lib/supabase/server"; -import type { Database } from "@/lib/supabase/types"; type PageProps = { params: Promise<{ id: string }> }; -type EmployeeWithManager = Database["public"]["Tables"]["employees"]["Row"] & { - manager: { id: string; first_name: string; last_name: string; job_title: string } | null; -}; - export default async function EmployeeDetailPage({ params }: PageProps) { const { id } = await params; const supabase = await createClient(); + const today = todayIso(); - // Everything here keys off the id already in the URL, and the manager - // comes back as an embedded resource on the employee row rather than as a - // follow-up query — so the page is one round trip instead of two. Measured - // against the hosted database that halved the data time (120ms -> 62ms, - // median of five), because a round trip costs more than these queries do. - // - // The hand-written Database type carries no relationship metadata - // (NoRelationships), so the embed is typed at the destructure below. + // Vorgesetzte und direkte Berichte stehen nirgends als Spalte — sie kommen + // aus om_reporting_lines(). Beide Abfragen filtern *in* der Funktion, es + // wandern also neun Zeilen über die Leitung und nicht achthundert. const [ - { data: employeeRow }, - { data: directReports }, + { data: employee }, + { data: ownLine }, + { data: reportLines }, { data: history }, { data: dependents }, { data: notes }, - { data: divisions }, - { data: departments }, - { data: teams }, - { data: locations }, - { data: openPositions }, + orgMaps, + placements, + openPositions, ] = await Promise.all([ - supabase.from("employees").select("*, manager:manager_id(id, first_name, last_name, job_title)").eq("id", id).single(), - supabase.from("employees").select("id, first_name, last_name, job_title, status").eq("manager_id", id).order("last_name"), + supabase.from("employees").select("*").eq("id", id).single(), + supabase.rpc("om_reporting_lines", { p_as_of: today }).eq("employee_id", id).maybeSingle(), + supabase.rpc("om_reporting_lines", { p_as_of: today }).eq("acting_manager_id", id), supabase .from("employee_history") .select("*") @@ -43,32 +38,61 @@ export default async function EmployeeDetailPage({ params }: PageProps) { .order("created_at", { ascending: false }), supabase.from("employee_dependents").select("*").eq("employee_id", id).order("created_at"), supabase.from("employee_notes").select("*").eq("employee_id", id).order("created_at", { ascending: false }), - supabase.from("divisions").select("*").order("name"), - supabase.from("departments").select("*"), - supabase.from("teams").select("*"), - supabase.from("locations").select("*").order("name"), - supabase.from("positions").select("id, position_number, title, team_id, is_lead").eq("status", "open"), + loadOrgMaps(supabase), + loadPlacements(supabase, { asOf: today, employeeIds: [id] }), + loadOpenPositions(supabase), ]); - if (!employeeRow) notFound(); + if (!employee) notFound(); - // Split the embedded manager back off so EmployeeDetail keeps receiving a - // plain employees row plus a separate manager, unchanged. - const { manager, ...employee } = employeeRow as EmployeeWithManager; + const line = ownLine as ReportingLine | null; + const reports = (reportLines ?? []) as ReportingLine[]; + + // Namen für die beteiligten Personen in einem Zug: die Vertretung, die + // formal zuständige Leitung und die direkten Berichte. + const relatedIds = Array.from( + new Set( + [line?.acting_manager_id, line?.formal_manager_id, ...reports.map((r) => r.employee_id)].filter( + (x): x is string => Boolean(x) + ) + ) + ); + const { data: relatedRows } = relatedIds.length + ? await supabase.from("employees").select("id, first_name, last_name, job_title, status").in("id", relatedIds) + : { data: [] }; + const byId = new Map((relatedRows ?? []).map((e) => [e.id, e])); + + const placement = placements.get(id) ?? null; return ( { + const e = byId.get(r.employee_id); + return e ? [e] : []; + })} history={history ?? []} dependents={dependents ?? []} notes={notes ?? []} - divisions={divisions ?? []} - departments={departments ?? []} - teams={teams ?? []} - locations={locations ?? []} - openPositions={openPositions ?? []} + locations={orgMaps.locationList} + openPositions={openPositions} /> ); } diff --git a/app/(app)/employees/page.tsx b/app/(app)/employees/page.tsx index 7051d7e..eeae0ed 100644 --- a/app/(app)/employees/page.tsx +++ b/app/(app)/employees/page.tsx @@ -7,13 +7,28 @@ import { Pagination } from "@/components/ui/Pagination"; import { StatusChip } from "@/components/ui/StatusChip"; import { applyDerivedStatusFilter } from "@/lib/employee-status-filter"; import { fmtDate, todayIso } from "@/lib/format"; -import { breadcrumbFor, loadOrgMaps } from "@/lib/org"; +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 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 = { @@ -37,48 +52,73 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps 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(); - let query = supabase - .from("employees") - .select( - "id, first_name, last_name, personnel_number, job_title, team_id, division_id, location_id, entry_date, employment_type, weekly_hours, status, absence_type", - { count: "exact" } - ) - .order("last_name", { ascending: true }) - .range(from, to); + // 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; - if (params.q) { - const q = params.q.trim(); - if (/^\d+$/.test(q)) { - query = query.eq("personnel_number", Number(q)); - } else { - const term = sanitizeIlikeTerm(q); - query = query.or(`first_name.ilike.%${term}%,last_name.ilike.%${term}%,job_title.ilike.%${term}%`); - } - } - if (params.division) query = query.eq("division_id", params.division); // 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 ?? "") .split(",") .map((s) => s.trim()) .filter((s): s is EmploymentStatus => (["Aktiv", "Karenz", "Geplant", "Ausgetreten"] as const).includes(s as EmploymentStatus)); - // Derived from the dates, not read off employees.status — see - // lib/employee-status-filter.ts for why the two can disagree. - query = applyDerivedStatusFilter(query, statuses, todayIso()); - if (params.location) query = query.eq("location_id", params.location); - // The org lookup tables are needed only to label the rows, so they load - // alongside the page of employees instead of before it — one round trip - // saved on a page that is otherwise two fast queries. - const [orgMaps, { data: employeesData, count }] = await Promise.all([loadOrgMaps(supabase), query]); + // 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(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; + } + } + // 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)); + // 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) }); + return (
- +

{count ?? 0} Mitarbeiter:innen gefunden

@@ -97,7 +137,9 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps {employees.map((e) => { - const { division, team } = breadcrumbFor(orgMaps, e.division_id, e.team_id); + const placement = placements.get(e.id); + const division = divisionOf(orgMaps, placement?.orgUnitId); + const unit = unitOf(orgMaps, placement?.orgUnitId); const location = e.location_id ? orgMaps.locations.get(e.location_id) : undefined; return ( // border-subtle between rows: the full-strength border made @@ -113,7 +155,7 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps
{e.first_name} {e.last_name}
-
{e.job_title}
+
{placement?.jobTitle ?? e.job_title}
@@ -122,7 +164,12 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps {e.personnel_number}
{division?.name ?? "–"}
-
{team?.name ?? "–"}
+ {/* Die eigene Einheit, egal auf welcher Ebene sie hängt — + eine Bereichsleitung sitzt am Bereich, nicht an einem + Team, und stand vorher deshalb ohne Zuordnung da. */} +
+ {unit && unit.id !== division?.id ? unit.name : "–"} +
{location?.name ?? "–"} {fmtDate(e.entry_date)} diff --git a/app/(app)/orgchart/page.tsx b/app/(app)/orgchart/page.tsx index cccb90a..6f288de 100644 --- a/app/(app)/orgchart/page.tsx +++ b/app/(app)/orgchart/page.tsx @@ -1,8 +1,8 @@ import { Suspense } from "react"; import { OrgChartClient } from "@/components/orgchart/OrgChartClient"; +import type { OrgUnitNode } from "@/components/orgchart/types"; import { todayIso } from "@/lib/format"; import { loadOrgAsOf } from "@/lib/orgchart-data"; -import { loadOpenPositions } from "@/lib/positions"; import { parseIsoDateParam } from "@/lib/reports"; import { createClient } from "@/lib/supabase/server"; @@ -20,30 +20,17 @@ export default async function OrgChartPage({ searchParams }: { searchParams: Pro const supabase = await createClient(); - const [org, { data: divisions }, { data: departments }, { data: teams }, openPositions, { data: reorgScenarios }] = - await Promise.all([ - loadOrgAsOf(supabase, asOf), - supabase.from("divisions").select("*").order("name"), - supabase.from("departments").select("*"), - supabase.from("teams").select("*"), - loadOpenPositions(supabase), - supabase - .from("reorg_scenarios") - .select("id, name, effective_date, applied, applied_at") - .eq("applied", true) - .order("applied_at", { ascending: false }) - .limit(5), - ]); + const [org, { data: units }] = await Promise.all([ + loadOrgAsOf(supabase, asOf), + supabase.from("org_units").select("id, org_number, name, parent_id, unit_type").order("org_number"), + ]); return ( supabase .from("employees") - .select("weekly_hours, division_id, entry_date, exit_date, karenz_start_date, karenz_return_date") + .select("id, weekly_hours, entry_date, exit_date, karenz_start_date, karenz_return_date") .order("id") ), // Entries/exits count history events, which is what the linked report @@ -100,8 +104,9 @@ export default async function DashboardPage() { .eq("event_type", "Austritt") .gte("event_date", yearStart) .lte("event_date", yearEnd), - supabase.from("positions").select("id", { count: "exact", head: true }).eq("status", "open"), - supabase.from("divisions").select("id, name"), + loadOpenPositions(supabase), + loadOrgMaps(supabase), + loadPlacements(supabase, { asOf: today }), supabase .from("employees") .select("id, first_name, last_name, entry_date") @@ -143,12 +148,18 @@ export default async function DashboardPage() { const karenzCount = staffRows.filter((row) => statusOf(row) === "Karenz").length; const fte = activeStaff.reduce((sum, row) => sum + Number(row.weekly_hours), 0) / 38.5; + // Der Bereich einer Person steht nicht mehr auf ihr; er ergibt sich aus der + // Einheit ihrer Planstelle und deren Vorfahren. Die Bereichsleitung selbst + // sitzt *am* Bereich, ihre Leute darunter — beide landen über die + // Vorfahrenkette im selben Balken. const headcountByDivision = new Map(); for (const row of activeStaff) { - if (!row.division_id) continue; - headcountByDivision.set(row.division_id, (headcountByDivision.get(row.division_id) ?? 0) + 1); + const division = divisionOf(orgMaps, placements.get(row.id)?.orgUnitId); + if (!division) continue; + headcountByDivision.set(division.id, (headcountByDivision.get(division.id) ?? 0) + 1); } - const divisionBars = (divisionsRes.data ?? []) + const divisionBars = orgMaps.unitList + .filter((u) => u.unit_type === "Bereich") .map((d) => ({ name: d.name, count: headcountByDivision.get(d.id) ?? 0 })) .sort((a, b) => b.count - a.count); const maxDivisionCount = Math.max(1, ...divisionBars.map((d) => d.count)); @@ -214,7 +225,7 @@ export default async function DashboardPage() { href: `/reports?mode=events&eventType=Austritt&from=${yearStart}&to=${yearEnd}`, }, { label: "Langzeitabwesend", value: karenzCount, tone: "warning", href: "/employees?status=Karenz" }, - { label: "Offene Positionen", value: openPositionsRes.count ?? 0, tone: "brand", href: "/positions" }, + { label: "Offene Positionen", value: openPositions.length, tone: "brand", href: "/positions" }, ]; return ( diff --git a/app/(app)/positions/page.tsx b/app/(app)/positions/page.tsx index 4404a58..c942a4a 100644 --- a/app/(app)/positions/page.tsx +++ b/app/(app)/positions/page.tsx @@ -1,21 +1,32 @@ +import type { UnitOption } from "@/components/positions/CreatePositionModal"; import { PositionsPageClient } from "@/components/positions/PositionsPageClient"; -import { daysBetweenIso, toIsoDate } from "@/lib/format"; +import { daysBetweenIso } from "@/lib/format"; +import { loadOrgMaps } from "@/lib/org"; import { loadOpenPositions } from "@/lib/positions"; import { createClient } from "@/lib/supabase/server"; export default async function PositionsPage() { const supabase = await createClient(); - // Teams are only needed for the "Position ausschreiben" dialog's team - // select. The division/department/team headcount overview this page used - // to render was dropped, and with it the two employee-wide aggregation - // queries that fed it. - const [openPositions, { data: teams }] = await Promise.all([ + const [openPositions, orgMaps, { data: chiefRows }] = await Promise.all([ loadOpenPositions(supabase), - supabase.from("teams").select("*").order("name"), + loadOrgMaps(supabase), + // Wo es schon eine gültige Leitungsplanstelle gibt, lässt der + // Unique-Index keine zweite zu — das gehört in den Dialog, nicht in eine + // Fehlermeldung nach dem Absenden. + supabase.from("om_positions").select("org_unit_id").eq("is_chief", true).is("valid_to", null), ]); - const openPositionsWithDays = openPositions.map((p) => ({ ...p, daysOpen: daysBetweenIso(toIsoDate(p.created_at)) })); + const withChief = new Set((chiefRows ?? []).map((r) => r.org_unit_id)); + const units: UnitOption[] = orgMaps.unitList.map((u) => ({ + id: u.id, + name: u.name, + unit_type: u.unit_type, + depth: orgMaps.depthOf.get(u.id) ?? 0, + hasChief: withChief.has(u.id), + })); - return ; + const openPositionsWithDays = openPositions.map((p) => ({ ...p, daysOpen: daysBetweenIso(p.vacantSince) })); + + return ; } diff --git a/app/api/export/employees/route.ts b/app/api/export/employees/route.ts index ad17ecb..283f7eb 100644 --- a/app/api/export/employees/route.ts +++ b/app/api/export/employees/route.ts @@ -1,6 +1,9 @@ import { NextResponse, type NextRequest } from "next/server"; import { statusLabel } from "@/lib/absence"; import { exportFilename, exportResponseHeaders, toCsv, toXlsx, type ExportColumn } from "@/lib/export"; +import { todayIso } from "@/lib/format"; +import { subtreeOf } from "@/lib/org"; +import { loadPlacements, loadReportingLines } from "@/lib/placement"; import { deriveStatusAsOf, parseIsoDateParam, parseStatuses, type OrgLookups } from "@/lib/reports"; import { loadDependentsCounts, loadOrgLookups, type ReportFilters } from "@/lib/reports-data"; import { requireHrUser } from "@/lib/supabase/auth"; @@ -8,7 +11,14 @@ import { fetchAllRows } from "@/lib/supabase/query"; import { createClient } from "@/lib/supabase/server"; import type { Database, EmploymentType, Weekday } from "@/lib/supabase/types"; -type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"]; +// Die Rohzeile plus die Einordnung, die nicht mehr auf ihr steht: sie kommt +// über die Planstelle und die abgeleitete Berichtslinie. +type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"] & { + org_unit_id: string | null; + position_number: string | null; + is_chief: boolean; + manager_id: string | null; +}; // Full raw data dump — every column on `employees`, not just the fields a // pivot report groups by. Respects the same division/location/status/ @@ -32,24 +42,43 @@ export async function GET(request: NextRequest) { const statuses = parseStatuses(filters.status); + const stichtag = asOf ?? todayIso(); + function employeeQuery() { let query = supabase.from("employees").select("*").order("last_name").order("id"); - if (filters.division) query = query.eq("division_id", filters.division); if (filters.location) query = query.eq("location_id", filters.location); if (filters.employment) query = query.eq("employment_type", filters.employment as EmploymentType); if (!asOf) query = query.in("status", statuses); return query; } - const [employees, { lookups }, allEmployees, dependentsCounts] = await Promise.all([ + const [employees, { lookups, orgMaps }, allEmployees, dependentsCounts, placements, lines] = await Promise.all([ fetchAllRows(employeeQuery), loadOrgLookups(supabase), fetchAllRows(() => supabase.from("employees").select("id, first_name, last_name").order("id")), loadDependentsCounts(supabase), + loadPlacements(supabase, { asOf: stichtag }), + loadReportingLines(supabase, stichtag), ]); const managerName = new Map(allEmployees.map((e) => [e.id, `${e.first_name} ${e.last_name}`])); - const rows = asOf ? employees.filter((e) => statuses.includes(deriveStatusAsOf(e, asOf))) : employees; + // Der Einheitenfilter meint den ganzen Teilbaum — sonst enthielte ein + // Export für "Produktion" nur die Bereichsleitung. + const allowedUnits = filters.division ? new Set(subtreeOf(orgMaps, filters.division)) : null; + + const enriched: EmployeeRow[] = employees.flatMap((e) => { + const placement = placements.get(e.id); + const orgUnitId = placement?.current ? placement.orgUnitId : null; + if (allowedUnits && (!orgUnitId || !allowedUnits.has(orgUnitId))) return []; + return [{ + ...e, + org_unit_id: orgUnitId, + position_number: placement?.positionNumber ?? null, + is_chief: placement?.isChief ?? false, + manager_id: lines.get(e.id)?.acting_manager_id ?? null, + }]; + }); + const rows = asOf ? enriched.filter((e) => statuses.includes(deriveStatusAsOf(e, asOf))) : enriched; const columns = employeeExportColumns(lookups, managerName, dependentsCounts, asOf); const filename = exportFilename("mitarbeiter-export", format); @@ -81,14 +110,14 @@ function employeeExportColumns( { header: "Wohnsitzland", get: (e) => e.address_country }, { header: "E-Mail", get: (e) => e.email }, { header: "Telefon", get: (e) => e.phone }, - { header: "Bereich", get: (e) => lookups.divisionName.get(e.division_id) ?? "" }, - { header: "Abteilung", get: (e) => (e.team_id ? (lookups.departmentNameByTeam.get(e.team_id) ?? "") : "") }, - { header: "Team", get: (e) => (e.team_id ? (lookups.teamName.get(e.team_id) ?? "") : "") }, + { header: "Bereich", get: (e) => (e.org_unit_id ? (lookups.divisionName.get(e.org_unit_id) ?? "") : "") }, + { header: "Abteilung", get: (e) => (e.org_unit_id ? (lookups.departmentName.get(e.org_unit_id) ?? "") : "") }, + { header: "Team", get: (e) => (e.org_unit_id ? (lookups.teamName.get(e.org_unit_id) ?? "") : "") }, { header: "Standort", get: (e) => lookups.locationName.get(e.location_id) ?? "" }, { header: "Position", get: (e) => e.job_title }, { header: "Vorgesetzte:r", get: (e) => (e.manager_id ? (managerName.get(e.manager_id) ?? "") : "") }, - { header: "Führungskraft", get: (e) => e.is_lead }, - { header: "Org-Level", get: (e) => e.org_level }, + { header: "Planstelle", get: (e) => e.position_number }, + { header: "Leitungsplanstelle", get: (e) => e.is_chief }, { header: "Beschäftigungsausmaß", get: (e) => e.employment_type }, { header: "Wochenstunden", get: (e) => e.weekly_hours }, // work_days is stored in click order (see RoleEmploymentFields), not diff --git a/app/api/export/events/route.ts b/app/api/export/events/route.ts index 3e24266..8a03382 100644 --- a/app/api/export/events/route.ts +++ b/app/api/export/events/route.ts @@ -44,9 +44,9 @@ function eventExportColumns(lookups: OrgLookups): ExportColumn[] { { header: "Vorname", get: (e) => e.first_name }, { header: "Nachname", get: (e) => e.last_name }, { header: "Position", get: (e) => e.job_title }, - { header: "Bereich", get: (e) => lookups.divisionName.get(e.division_id) ?? "" }, - { header: "Abteilung", get: (e) => (e.team_id ? (lookups.departmentNameByTeam.get(e.team_id) ?? "") : "") }, - { header: "Team", get: (e) => (e.team_id ? (lookups.teamName.get(e.team_id) ?? "") : "") }, + { header: "Bereich", get: (e) => (e.org_unit_id ? (lookups.divisionName.get(e.org_unit_id) ?? "") : "") }, + { header: "Abteilung", get: (e) => (e.org_unit_id ? (lookups.departmentName.get(e.org_unit_id) ?? "") : "") }, + { header: "Team", get: (e) => (e.org_unit_id ? (lookups.teamName.get(e.org_unit_id) ?? "") : "") }, { header: "Standort", get: (e) => lookups.locationName.get(e.location_id) ?? "" }, { header: "Beschreibung", get: (e) => e.description }, ]; diff --git a/components/employees/EmployeeDetail.tsx b/components/employees/EmployeeDetail.tsx index 3101577..a846a3f 100644 --- a/components/employees/EmployeeDetail.tsx +++ b/components/employees/EmployeeDetail.tsx @@ -7,6 +7,7 @@ import { Avatar } from "@/components/ui/Avatar"; import { Button } from "@/components/ui/Button"; import { StatusChip } from "@/components/ui/StatusChip"; import { fmtFullName, tenure } from "@/lib/format"; +import type { OpenPositionResolved } from "@/lib/positions"; import type { Database } from "@/lib/supabase/types"; import { DatenAendernPanel } from "./panels/DatenAendernPanel"; import { KarenzPanel } from "./panels/KarenzPanel"; @@ -21,42 +22,37 @@ import { StammdatenTab } from "./tabs/StammdatenTab"; import { VertragTab } from "./tabs/VertragTab"; type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"]; -type Division = Database["public"]["Tables"]["divisions"]["Row"]; -type Department = Database["public"]["Tables"]["departments"]["Row"]; -type Team = Database["public"]["Tables"]["teams"]["Row"]; type Location = Database["public"]["Tables"]["locations"]["Row"]; type HistoryRow = Database["public"]["Tables"]["employee_history"]["Row"]; type Dependent = Database["public"]["Tables"]["employee_dependents"]["Row"]; type NoteRow = Database["public"]["Tables"]["employee_notes"]["Row"]; type MiniEmployee = { id: string; first_name: string; last_name: string; job_title: string; status?: string }; -type OpenPosition = { id: string; position_number: string; title: string; team_id: string; is_lead: boolean }; +/** Die Planstelle, die die Person heute innehat. */ +type PlacementInfo = { positionNumber: string; jobTitle: string; isChief: boolean; current: boolean }; type EmployeeDetailProps = { employee: EmployeeRow; + placement: PlacementInfo | null; + breadcrumb: string; manager: MiniEmployee | null; + /** Nur gesetzt, wenn die zuständige Leitung abwesend ist und vertreten wird. */ + formalManager: MiniEmployee | null; directReports: MiniEmployee[]; history: HistoryRow[]; dependents: Dependent[]; notes: NoteRow[]; - divisions: Division[]; - departments: Department[]; - teams: Team[]; locations: Location[]; - openPositions: OpenPosition[]; + openPositions: OpenPositionResolved[]; }; type PanelType = "transfer" | "promote" | "karenz" | "daten" | "terminate" | "rehire" | null; const TABS = ["Stammdaten", "Vertrag", "Organisation", "Historie", "HR-Notizen"] as const; export function EmployeeDetail(props: EmployeeDetailProps) { - const { employee, manager, directReports, history, dependents, notes, divisions, departments, teams, locations } = props; + const { employee, placement, breadcrumb, manager, formalManager, directReports, history, dependents, notes, locations, openPositions } = props; const [tab, setTab] = useState<(typeof TABS)[number]>("Stammdaten"); const [panel, setPanel] = useState(null); - const division = divisions.find((d) => d.id === employee.division_id); - const team = employee.team_id ? teams.find((t) => t.id === employee.team_id) : undefined; - const department = team ? departments.find((d) => d.id === team.department_id) : undefined; - const breadcrumb = [division?.name, department?.name, team?.name].filter(Boolean).join(" › ") || "–"; const location = locations.find((l) => l.id === employee.location_id); const isActive = employee.status === "Aktiv" || employee.status === "Karenz"; @@ -79,8 +75,17 @@ export function EmployeeDetail(props: EmployeeDetailProps) { -

{employee.job_title}

-

{breadcrumb}

+

{placement?.jobTitle ?? employee.job_title}

+

+ {breadcrumb} + {placement && ( + <> + {" · Planstelle "} + {placement.positionNumber} + {placement.isChief && " (Leitung)"} + + )} +

Pers.-Nr. {employee.personnel_number} {employee.status !== "Geplant" && <> · Zugehörigkeit: {tenure(employee.entry_date, employee.exit_date)}} @@ -142,7 +147,13 @@ export function EmployeeDetail(props: EmployeeDetailProps) { {tab === "Stammdaten" && } {tab === "Vertrag" && } {tab === "Organisation" && ( - + )} {tab === "Historie" && } {tab === "HR-Notizen" && } @@ -152,10 +163,7 @@ export function EmployeeDetail(props: EmployeeDetailProps) { open={panel === "transfer"} onClose={() => setPanel(null)} employee={employee} - divisions={divisions} - departments={departments} - teams={teams} - currentTeamId={employee.team_id} + openPositions={openPositions} /> setPanel(null)} employee={employee} /> setPanel(null)} employee={employee} /> diff --git a/components/employees/EmployeeFilters.tsx b/components/employees/EmployeeFilters.tsx index 11d22bc..d29fbc5 100644 --- a/components/employees/EmployeeFilters.tsx +++ b/components/employees/EmployeeFilters.tsx @@ -6,7 +6,9 @@ import { FILTER_SELECT_CLASS } from "@/components/ui/Field"; import { SearchInput } from "@/components/ui/SearchInput"; type EmployeeFiltersProps = { - divisions: { id: string; name: string }[]; + /** Der ganze Baum, in Tiefensuche-Reihenfolge. */ + units: { id: string; name: string; unit_type: string }[]; + depthOf: Map; locations: { id: string; name: string }[]; }; @@ -22,7 +24,7 @@ const STATUS_OPTIONS = [ { value: "Ausgetreten", label: "Ausgetreten" }, ] as const; -export function EmployeeFilters({ divisions, locations }: EmployeeFiltersProps) { +export function EmployeeFilters({ units, depthOf, locations }: EmployeeFiltersProps) { const router = useRouter(); const pathname = usePathname(); const searchParams = useSearchParams(); @@ -55,18 +57,26 @@ export function EmployeeFilters({ divisions, locations }: EmployeeFiltersProps) {/* aria-label rather than a visible label: the filter bar is a single horizontal row, and each select's first option already names it on screen. */} + {/* Der ganze Baum, nicht nur die oberste Ebene: die Auswahl greift + jeweils auf die Einheit *und alles darunter*, weshalb sich damit + auch nach einer einzelnen Abteilung oder einem Team filtern lässt. + Eingerückt statt gruppiert, weil optgroup keine Verschachtelung + kennt und die Tiefe hier beliebig ist. */} { - setIsLead(e.target.checked); - setSuperior(null); - }} - /> - Führungsposition (Teamleitung) + setIsChief(e.target.checked)} /> + Leitungsplanstelle für diese Einheit - {!superior ? ( - - {(p) => ( - - {...p} - placeholder="Name oder Titel…" - onSearch={(q) => searchSuperiors(q, isLead)} - onSelect={setSuperior} - renderResult={(r) => ( -

-
- {r.first_name} {r.last_name} -
-
{r.job_title}
-
- )} - /> - )} - - ) : ( -
-

- {isLead ? "Übergeordnete Bereichsleitung" : "Übergeordnete Teamleitung"} -

-
-
-
- {superior.first_name} {superior.last_name} -
-
{superior.job_title}
-
- -
-
- )} - {isLead && ( - ({ value: t.id, label: t.name }))} - /> + {chiefTaken && ( +

Für {unit?.name} besteht bereits eine Leitungsplanstelle.

)} diff --git a/components/positions/PositionsPageClient.tsx b/components/positions/PositionsPageClient.tsx index 71dac4c..aa59535 100644 --- a/components/positions/PositionsPageClient.tsx +++ b/components/positions/PositionsPageClient.tsx @@ -8,16 +8,16 @@ import { Button } from "@/components/ui/Button"; import { useToast } from "@/components/ui/Toast"; import { fmtDate, todayIso } from "@/lib/format"; import type { OpenPositionResolved } from "@/lib/positions"; -import { CreatePositionModal } from "./CreatePositionModal"; +import { CreatePositionModal, type UnitOption } from "./CreatePositionModal"; type OpenPositionWithDays = OpenPositionResolved & { daysOpen: number }; type PositionsPageClientProps = { openPositions: OpenPositionWithDays[]; - teams: { id: string; org_number: string; name: string; department_id: string }[]; + units: UnitOption[]; }; -export function PositionsPageClient({ openPositions, teams }: PositionsPageClientProps) { +export function PositionsPageClient({ openPositions, units }: PositionsPageClientProps) { const { showToast } = useToast(); const router = useRouter(); const [createOpen, setCreateOpen] = useState(false); @@ -29,7 +29,7 @@ export function PositionsPageClient({ openPositions, teams }: PositionsPageClien const result = await deletePosition(id); setDeletingId(null); if (result.success) { - showToast("Position gelöscht."); + showToast("Planstelle entfernt."); router.refresh(); } else { showToast(result.error ?? "Fehler beim Löschen.", "error"); @@ -40,14 +40,14 @@ export function PositionsPageClient({ openPositions, teams }: PositionsPageClien
-

Offene Positionen ({openPositions.length})

+

Unbesetzte Planstellen ({openPositions.length})

{openPositions.length === 0 ? ( -

Derzeit keine offenen Positionen.

+

Derzeit ist jede Planstelle besetzt.

) : (
{openPositions.map((p) => { @@ -60,7 +60,7 @@ export function PositionsPageClient({ openPositions, teams }: PositionsPageClien variant="icon" onClick={() => handleDelete(p.id)} pending={deletingId === p.id} - aria-label={`Position ${p.title} löschen`} + aria-label={`Planstelle ${p.position_number} (${p.title}) entfernen`} className="-mr-1 -mt-1 hover:!text-danger-solid" > @@ -69,7 +69,11 @@ export function PositionsPageClient({ openPositions, teams }: PositionsPageClien
{p.position_number} · {p.orgLabel}
-
seit {p.daysOpen} Tagen offen
+
+ {p.is_chief ? "Leitungsplanstelle · " : ""} + seit {p.daysOpen} Tagen unbesetzt +
+ {p.managerName &&
berichtet an {p.managerName}
} {notYetValid &&
Gültig ab {fmtDate(p.valid_from)}
}
); @@ -78,7 +82,7 @@ export function PositionsPageClient({ openPositions, teams }: PositionsPageClien )}
- setCreateOpen(false)} teams={teams} /> + setCreateOpen(false)} units={units} />
); } diff --git a/lib/org.ts b/lib/org.ts index 3c16e70..f5675a8 100644 --- a/lib/org.ts +++ b/lib/org.ts @@ -1,48 +1,119 @@ import type { SupabaseClient } from "@supabase/supabase-js"; import type { Database } from "./supabase/types"; -type Division = Database["public"]["Tables"]["divisions"]["Row"]; -type Department = Database["public"]["Tables"]["departments"]["Row"]; -type Team = Database["public"]["Tables"]["teams"]["Row"]; +// Die Organisation ist ein Baum, keine drei Tabellen mehr. Alles, was früher +// aus divisions/departments/teams zusammengesteckt wurde, ergibt sich jetzt +// aus org_units.parent_id — und damit funktioniert es auch für eine fünfte +// Ebene, ohne dass hier etwas zu ändern wäre. + +export type OrgUnitType = "Gesellschaft" | "Bereich" | "Abteilung" | "Team"; + +export type OrgUnit = { + id: string; + org_number: string; + name: string; + parent_id: string | null; + unit_type: OrgUnitType; +}; + type Location = Database["public"]["Tables"]["locations"]["Row"]; export type OrgMaps = { - divisions: Map; - departments: Map; - teams: Map; + units: Map; + /** Tiefensuche ab der Wurzel: eine Einheit steht immer hinter ihrem Elternteil. */ + unitList: OrgUnit[]; + /** Abstand zur Wurzel; die Wurzel selbst hat 0. */ + depthOf: Map; + childrenOf: Map; locations: Map; - divisionList: Division[]; locationList: Location[]; }; -// Org reference data is tiny (9 divisions / 16 departments / 35 teams / 5 -// locations) — fetched whole and joined client-side rather than per-row. +// Die Referenzdaten sind winzig (60 Einheiten, 5 Standorte) — sie werden +// ganz geladen und im Speicher verknüpft, statt je Zeile nachzuschlagen. export async function loadOrgMaps(supabase: SupabaseClient): Promise { - const [{ data: divisions }, { data: departments }, { data: teams }, { data: locations }] = await Promise.all([ - supabase.from("divisions").select("*").order("name"), - supabase.from("departments").select("*"), - supabase.from("teams").select("*"), + const [{ data: units }, { data: locations }] = await Promise.all([ + supabase.from("org_units").select("id, org_number, name, parent_id, unit_type").order("org_number"), supabase.from("locations").select("*").order("name"), ]); + return buildOrgMaps((units ?? []) as OrgUnit[], locations ?? []); +} + +/** Der reine Teil: aus den Zeilen den Baum bauen, ohne Datenbank. */ +export function buildOrgMaps(units: OrgUnit[], locations: Location[]): OrgMaps { + const childrenOf = new Map(); + for (const u of units) { + const list = childrenOf.get(u.parent_id) ?? []; + list.push(u); + childrenOf.set(u.parent_id, list); + } + for (const list of childrenOf.values()) list.sort((a, b) => a.name.localeCompare(b.name, "de")); + + const unitList: OrgUnit[] = []; + const depthOf = new Map(); + const walk = (parentId: string | null, depth: number) => { + for (const u of childrenOf.get(parentId) ?? []) { + unitList.push(u); + depthOf.set(u.id, depth); + walk(u.id, depth + 1); + } + }; + walk(null, 0); + return { - divisions: new Map((divisions ?? []).map((d) => [d.id, d])), - departments: new Map((departments ?? []).map((d) => [d.id, d])), - teams: new Map((teams ?? []).map((t) => [t.id, t])), - locations: new Map((locations ?? []).map((l) => [l.id, l])), - divisionList: divisions ?? [], - locationList: locations ?? [], + units: new Map(units.map((u) => [u.id, u])), + unitList, + depthOf, + childrenOf, + locations: new Map(locations.map((l) => [l.id, l])), + locationList: locations, }; } -export function breadcrumbFor(orgMaps: OrgMaps, divisionId: string | null, teamId: string | null) { - const division = divisionId ? orgMaps.divisions.get(divisionId) : undefined; - const team = teamId ? orgMaps.teams.get(teamId) : undefined; - const department = team ? orgMaps.departments.get(team.department_id) : undefined; - return { division, department, team }; +/** Wurzel zuerst, die Einheit selbst zuletzt. */ +export function ancestorsOf(maps: OrgMaps, unitId: string | null | undefined): OrgUnit[] { + const chain: OrgUnit[] = []; + const seen = new Set(); + let current = unitId ? maps.units.get(unitId) : undefined; + while (current && !seen.has(current.id)) { + seen.add(current.id); + chain.unshift(current); + current = current.parent_id ? maps.units.get(current.parent_id) : undefined; + } + return chain; } -export function breadcrumbLabel(orgMaps: OrgMaps, divisionId: string | null, teamId: string | null): string { - const { division, department, team } = breadcrumbFor(orgMaps, divisionId, teamId); - return [division?.name, department?.name, team?.name].filter(Boolean).join(" › ") || "–"; +/** Die Einheit und alles darunter — die Menge, die ein Filter „Bereich X" meint. */ +export function subtreeOf(maps: OrgMaps, unitId: string): string[] { + const out: string[] = []; + const queue = [unitId]; + const seen = new Set(); + while (queue.length > 0) { + const id = queue.shift()!; + if (seen.has(id)) continue; + seen.add(id); + out.push(id); + for (const child of maps.childrenOf.get(id) ?? []) queue.push(child.id); + } + return out; +} + +/** + * „Produktion › Fertigung › Montage". Die Gesellschaft bleibt weg: sie steht + * über allem und trägt in einer Zeile nichts bei. + */ +export function breadcrumbLabel(maps: OrgMaps, unitId: string | null | undefined): string { + const chain = ancestorsOf(maps, unitId).filter((u) => u.unit_type !== "Gesellschaft"); + return chain.map((u) => u.name).join(" › ") || "–"; +} + +/** Die oberste Einheit unterhalb der Gesellschaft — das, was früher „Bereich" hiess. */ +export function divisionOf(maps: OrgMaps, unitId: string | null | undefined): OrgUnit | undefined { + return ancestorsOf(maps, unitId).find((u) => u.unit_type !== "Gesellschaft"); +} + +/** Die Einheit selbst, wenn sie nicht die Gesellschaft ist. */ +export function unitOf(maps: OrgMaps, unitId: string | null | undefined): OrgUnit | undefined { + return unitId ? maps.units.get(unitId) : undefined; } diff --git a/lib/orgchart-data.ts b/lib/orgchart-data.ts index 3436f2c..480efde 100644 --- a/lib/orgchart-data.ts +++ b/lib/orgchart-data.ts @@ -1,32 +1,35 @@ import type { SupabaseClient } from "@supabase/supabase-js"; -import type { OrgEmployee } from "@/components/orgchart/types"; -import { resolveActingManagers } from "./acting-manager"; +import type { OrgEmployee, OrgVacancy } from "@/components/orgchart/types"; import { todayIso } from "./format"; -import { deriveStatusAsOf } from "./reports"; +import { resolveReportingLines, type OmHolder, type OmUnit } from "./om-reporting"; import { fetchAllRows } from "./supabase/query"; import type { Database } from "./supabase/types"; -// The Organigramm as it stood (or will stand) on a given date. Three sources -// have to be reconciled, because no single one covers the whole timeline: +// Das Organigramm, wie es an einem Stichtag stand oder stehen wird. // -// past/today employee_assignments — the interval covering `asOf` -// future pending_org_changes — effective-dated moves not yet applied -// membership entry/exit/karenz — who counted as staff on that date +// Im Altmodell mussten dafür drei Quellen versöhnt werden, weil keine den +// ganzen Zeitstrahl abdeckte: eine mitgeschriebene Zuordnungshistorie für die +// Vergangenheit, vorgemerkte Änderungen für die Zukunft und die +// Ein-/Austrittsdaten für die Frage, wer überhaupt dazuzählte. // -// See supabase/migrations/*_employee_assignment_history.sql for why the -// placement timeline is captured by a trigger rather than per-RPC. +// Im OM-Modell fällt das zusammen. position_assignments ist zeitabhängig, also +// beantwortet eine einzige Abfrage „wer besetzte am Stichtag welche +// Planstelle" — für Vergangenheit und Zukunft gleichermassen. Wer zu dem +// Zeitpunkt keine Planstelle innehatte, war nicht da; eine zweite +// Zugehörigkeitsregel braucht es nicht mehr. +// +// Übrig bleibt die Projektion vorgemerkter Versetzungen: die stehen noch nicht +// in position_assignments, weil sie erst am Stichtag geschrieben werden. -const ORG_COLUMNS = - "id, personnel_number, first_name, last_name, job_title, manager_id, team_id, division_id, is_lead, org_level, entry_date, exit_date, karenz_start_date, karenz_return_date, absence_type"; - -/** Change types that move someone in the org; the rest only affect status or contract. */ -const PLACEMENT_CHANGES = ["transfer", "reorg", "promotion"] as const; +/** Änderungsarten, die jemanden in der Organisation verschieben. */ +const PLACEMENT_CHANGES = ["transfer"] as const; export type OrgAsOfResult = { employees: OrgEmployee[]; - /** How many placements were projected from not-yet-applied changes. */ + vacancies: OrgVacancy[]; + /** Wie viele Platzierungen aus noch nicht angewandten Änderungen stammen. */ projectedCount: number; - /** Earliest date the assignment history actually covers. */ + /** Frühester Tag, den die Besetzungshistorie tatsächlich abdeckt. */ historyStartsAt: string | null; }; @@ -36,196 +39,172 @@ type EmployeeRow = { first_name: string; last_name: string; job_title: string; - manager_id: string | null; - team_id: string | null; - division_id: string; - is_lead: boolean; - org_level: number; - entry_date: string; - exit_date: string | null; karenz_start_date: string | null; karenz_return_date: string | null; absence_type: string | null; }; -type AssignmentRow = { - employee_id: string; - manager_id: string | null; - team_id: string | null; - division_id: string; - job_title: string; - is_lead: boolean; - org_level: number; - valid_from: string; +type PositionRow = { + id: string; + position_number: string; + org_unit_id: string; + is_chief: boolean; + jobs: { title: string }; }; +type AssignmentRow = { employee_id: string; position_id: string }; + type PendingRow = { employee_id: string; effective_date: string; payload: Record }; -type Placement = { team_id: string | null; division_id: string; job_title: string; is_lead: boolean; org_level: number }; - -// Mirrors resolve_manager_for() in supabase/migrations: an IC reports to -// their team's lead, a team lead to the division head, and anyone without a -// team to the CEO. Only used for employees a pending change actually moves — -// everyone else keeps the manager recorded on their assignment, so existing -// data that deviates from the rule is never silently "corrected". -function resolveManagerFor(placement: Placement, all: { id: string; placement: Placement }[]): string | null { - if (placement.team_id && !placement.is_lead) { - return all.find((e) => e.placement.team_id === placement.team_id && e.placement.is_lead)?.id ?? null; - } - if (placement.team_id && placement.is_lead) { - return ( - all.find((e) => e.placement.division_id === placement.division_id && !e.placement.team_id && e.placement.org_level === 1)?.id ?? - null - ); - } - return all.find((e) => e.placement.org_level === 0)?.id ?? null; -} - export async function loadOrgAsOf(supabase: SupabaseClient, asOf: string): Promise { const today = todayIso(); - const [allEmployees, assignments, teams, departments, pending] = await Promise.all([ - fetchAllRows(() => supabase.from("employees").select(ORG_COLUMNS).order("id")), + const [units, positions, assignments, employees, pending, earliest] = await Promise.all([ + fetchAllRows(() => supabase.from("org_units").select("id, parent_id").order("id")), fetchAllRows(() => supabase - .from("employee_assignments") - .select("employee_id, manager_id, team_id, division_id, job_title, is_lead, org_level, valid_from") + .from("om_positions") + .select("id, position_number, org_unit_id, is_chief, jobs!inner(title)") + .lte("valid_from", asOf) + .or(`valid_to.is.null,valid_to.gt.${asOf}`) + .order("id") + ), + fetchAllRows(() => + supabase + .from("position_assignments") + .select("employee_id, position_id") .lte("valid_from", asOf) .or(`valid_to.is.null,valid_to.gt.${asOf}`) .order("employee_id") ), - fetchAllRows(() => supabase.from("teams").select("id, department_id").order("id")), - fetchAllRows(() => supabase.from("departments").select("id, division_id").order("id")), + fetchAllRows(() => + supabase + .from("employees") + .select("id, personnel_number, first_name, last_name, job_title, karenz_start_date, karenz_return_date, absence_type") + .order("id") + ), asOf > today ? fetchAllRows(() => supabase .from("pending_org_changes") - .select("employee_id, change_type, effective_date, payload") + .select("employee_id, effective_date, payload") .eq("status", "pending") .lte("effective_date", asOf) .in("change_type", [...PLACEMENT_CHANGES]) .order("effective_date") ) : Promise.resolve([]), + supabase.from("position_assignments").select("valid_from").order("valid_from").limit(1).maybeSingle(), ]); - return resolveOrgSnapshot({ asOf, employees: allEmployees, assignments, teams, departments, pending }); + return resolveOrgSnapshot({ + asOf, + units: units.map((u) => ({ id: u.id, parentId: u.parent_id })), + positions: positions as unknown as PositionRow[], + assignments: assignments as AssignmentRow[], + employees: employees as EmployeeRow[], + pending: pending as PendingRow[], + historyStartsAt: earliest.data?.valid_from ?? null, + }); } -// The pure half of the above: everything that turns the four row sets into a -// snapshot, with no Supabase client in sight, so the reconciliation rules can -// be tested directly. +/** + * Der reine Teil: aus den Zeilen den Stand machen, ohne Datenbank, damit die + * Regeln direkt prüfbar sind. + */ export function resolveOrgSnapshot({ asOf, - employees: allEmployees, + units, + positions, assignments, - teams, - departments, + employees: allEmployees, pending, + historyStartsAt, }: { asOf: string; - employees: EmployeeRow[]; + units: OmUnit[]; + positions: PositionRow[]; assignments: AssignmentRow[]; - teams: { id: string; department_id: string }[]; - departments: { id: string; division_id: string }[]; + employees: EmployeeRow[]; pending: PendingRow[]; + historyStartsAt: string | null; }): OrgAsOfResult { - const assignmentByEmployee = new Map(assignments.map((a) => [a.employee_id, a])); - // A projected move names only the target team; its division follows from - // the team's department, the same way the DB trigger derives it. - const departmentDivision = new Map(departments.map((d) => [d.id, d.division_id])); - const teamDivision = new Map( - teams.flatMap((t) => { - const divisionId = departmentDivision.get(t.department_id); - return divisionId ? [[t.id, divisionId] as const] : []; - }) - ); + const positionById = new Map(positions.map((p) => [p.id, p])); + const employeeById = new Map(allEmployees.map((e) => [e.id, e])); - // Employed (or on leave) on that date — the same derivation the Berichte - // page uses, so the two can never disagree on who counted when. - const staff = allEmployees.filter((e) => { - const status = deriveStatusAsOf(e, asOf); - return status === "Aktiv" || status === "Karenz"; - }); + // Dieselbe Ableitung wie in deriveStatusAsOf() und in om_reporting_lines(), + // damit die drei nie auseinanderlaufen können. + const isAbsent = (e: EmployeeRow) => + e.karenz_start_date !== null && + e.karenz_start_date <= asOf && + (e.karenz_return_date === null || asOf < e.karenz_return_date); - const resolved = staff.map((e) => { - const a = assignmentByEmployee.get(e.id); - return { - employee: e, - managerId: a ? a.manager_id : e.manager_id, - placement: { - team_id: a ? a.team_id : e.team_id, - division_id: a ? a.division_id : e.division_id, - job_title: a ? a.job_title : e.job_title, - is_lead: a ? a.is_lead : e.is_lead, - org_level: a ? a.org_level : e.org_level, - } satisfies Placement, - }; - }); + const positionOf = new Map(); + for (const a of assignments) { + if (positionById.has(a.position_id) && employeeById.has(a.employee_id)) positionOf.set(a.employee_id, a.position_id); + } - // Project the future. Ordered by effective_date, so a later move wins. - const byId = new Map(resolved.map((r) => [r.employee.id, r])); + // Die Zukunft projizieren: nach effective_date sortiert, eine spätere + // Versetzung gewinnt. const moved = new Set(); for (const change of pending) { - const target = byId.get(change.employee_id); - if (!target) continue; - const payload = change.payload as { new_team_id?: string; target_team_id?: string; new_title?: string }; - const newTeamId = payload.new_team_id ?? payload.target_team_id ?? null; - if (newTeamId) { - target.placement.team_id = newTeamId; - const divisionId = teamDivision.get(newTeamId); - if (divisionId) target.placement.division_id = divisionId; - moved.add(target.employee.id); - } - if (payload.new_title) target.placement.job_title = payload.new_title; + if (!positionOf.has(change.employee_id)) continue; + const targetId = (change.payload as { target_position_id?: string }).target_position_id; + if (!targetId || !positionById.has(targetId)) continue; + positionOf.set(change.employee_id, targetId); + moved.add(change.employee_id); } - // Second pass: a moved employee's manager follows from the *projected* - // org, not the one they left — and the lead of their new team may itself - // have moved in this same batch. - for (const r of resolved) { - if (moved.has(r.employee.id)) r.managerId = resolveManagerFor(r.placement, resolved.map((x) => ({ id: x.employee.id, placement: x.placement }))); + const holders: OmHolder[] = []; + for (const [employeeId, positionId] of positionOf) { + const position = positionById.get(positionId)!; + holders.push({ + employeeId, + positionId, + orgUnitId: position.org_unit_id, + isChief: position.is_chief, + absent: isAbsent(employeeById.get(employeeId)!), + }); } - // A manager who had not joined yet, or had already left, is not in this - // set — without re-rooting, their whole reporting line would silently - // vanish from the chart rather than showing up one level higher. - const presentIds = new Set(resolved.map((r) => r.employee.id)); - const recordedManagerOf = (r: (typeof resolved)[number]) => - r.managerId && presentIds.has(r.managerId) ? r.managerId : null; + const lines = resolveReportingLines(units, holders); - // While somebody is on a long-term absence their reports roll up to the - // next present level. Derived here rather than written to the database: - // the absent person stays formally in charge, and the stand-in is only a - // stand-in — which is why both ids travel to the UI. - const absentIds = new Set(resolved.filter((r) => deriveStatusAsOf(r.employee, asOf) === "Karenz").map((r) => r.employee.id)); - const acting = resolveActingManagers( - resolved.map((r) => ({ id: r.employee.id, managerId: recordedManagerOf(r), absent: absentIds.has(r.employee.id) })) - ); - - const employees: OrgEmployee[] = resolved.map((r) => { - const { actingManagerId, coveredForId } = acting.get(r.employee.id) ?? { actingManagerId: null, coveredForId: null }; + const employees: OrgEmployee[] = lines.map((l) => { + const e = employeeById.get(l.employeeId)!; + const position = positionById.get(l.positionId)!; return { - id: r.employee.id, - personnel_number: r.employee.personnel_number, - first_name: r.employee.first_name, - last_name: r.employee.last_name, - job_title: r.placement.job_title, - manager_id: actingManagerId, - formal_manager_id: coveredForId, - absent: absentIds.has(r.employee.id), - absence_type: r.employee.absence_type, - team_id: r.placement.team_id, - division_id: r.placement.division_id, - is_lead: r.placement.is_lead, - org_level: r.placement.org_level, + id: e.id, + personnel_number: e.personnel_number, + first_name: e.first_name, + last_name: e.last_name, + // Die Tätigkeit der Planstelle, nicht das Freitextfeld auf der Person: + // bei einer projizierten Versetzung ist nur die erste schon richtig. + job_title: position.jobs.title, + manager_id: l.actingManagerId, + // Nur setzen, wenn eine Vertretung im Spiel ist — sonst zeigt die + // Oberfläche zweimal dieselbe Person an. + formal_manager_id: l.formalManagerId === l.actingManagerId ? null : l.formalManagerId, + absent: isAbsent(e), + absence_type: e.absence_type, + org_unit_id: l.orgUnitId, + is_chief: l.isChief, + position_id: l.positionId, + position_number: position.position_number, }; }); - const historyStartsAt = assignments.reduce( - (min, a) => (min === null || a.valid_from < min ? a.valid_from : min), - null - ); + // Unbesetzte Planstellen. Im Altmodell waren offene Stellen eine eigene + // Tabelle neben der Organisation; hier sind sie schlicht das Komplement. + const besetzt = new Set(positionOf.values()); + const vacancies: OrgVacancy[] = positions + .filter((p) => !besetzt.has(p.id)) + .map((p) => ({ + position_id: p.id, + position_number: p.position_number, + job_title: p.jobs.title, + org_unit_id: p.org_unit_id, + is_chief: p.is_chief, + })); - return { employees, projectedCount: moved.size, historyStartsAt }; + return { employees, vacancies, projectedCount: moved.size, historyStartsAt }; } diff --git a/lib/placement.ts b/lib/placement.ts new file mode 100644 index 0000000..4dd7672 --- /dev/null +++ b/lib/placement.ts @@ -0,0 +1,115 @@ +import type { SupabaseClient } from "@supabase/supabase-js"; +import { fetchAllRows } from "./supabase/query"; +import type { Database } from "./supabase/types"; + +// Wo jemand in der Organisation steht, steht nicht mehr auf der Person. Es +// ergibt sich aus der Planstelle, die sie zum Stichtag innehat: +// +// employees ──A008──> position_assignments ──> om_positions ──> org_units +// └────────> jobs +// +// Das ist der Grund, warum es diese Datei gibt: die Verkettung braucht es an +// einem Dutzend Stellen, und sie zeitrichtig aufzulösen ist die Arbeit. + +export type Placement = { + employeeId: string; + positionId: string; + positionNumber: string; + orgUnitId: string; + isChief: boolean; + jobTitle: string; + validFrom: string; + validTo: string | null; + /** Die Besetzung läuft am Stichtag; sonst ist es die zuletzt beendete. */ + current: boolean; +}; + +const SELECT = + "employee_id, valid_from, valid_to, om_positions!inner(id, position_number, org_unit_id, is_chief, jobs!inner(title))"; + +type Row = { + employee_id: string; + valid_from: string; + valid_to: string | null; + om_positions: { + id: string; + position_number: string; + org_unit_id: string; + is_chief: boolean; + jobs: { title: string }; + }; +}; + +function toPlacement(row: Row, asOf: string): Placement { + return { + employeeId: row.employee_id, + positionId: row.om_positions.id, + positionNumber: row.om_positions.position_number, + orgUnitId: row.om_positions.org_unit_id, + isChief: row.om_positions.is_chief, + jobTitle: row.om_positions.jobs.title, + validFrom: row.valid_from, + validTo: row.valid_to, + current: row.valid_from <= asOf && (row.valid_to === null || row.valid_to > asOf), + }; +} + +/** + * Die am Stichtag laufende Besetzung je Person — und für alle, die zu dem + * Zeitpunkt keine hatten, die zuletzt beendete. Ohne diesen Rückfall stünde + * bei jeder ausgetretenen Person „–" statt der Stelle, die sie innehatte. + */ +export function pickPlacements(rows: Row[], asOf: string): Map { + const byEmployee = new Map(); + for (const row of rows) { + const p = toPlacement(row, asOf); + const best = byEmployee.get(p.employeeId); + if (!best) { + byEmployee.set(p.employeeId, p); + continue; + } + // Laufend schlägt beendet; unter beendeten gewinnt die jüngste. + if (p.current && !best.current) byEmployee.set(p.employeeId, p); + else if (p.current === best.current && p.validFrom > best.validFrom) byEmployee.set(p.employeeId, p); + } + return byEmployee; +} + +export async function loadPlacements( + supabase: SupabaseClient, + { asOf, employeeIds }: { asOf: string; employeeIds?: string[] } +): Promise> { + if (employeeIds?.length === 0) return new Map(); + + const rows = await fetchAllRows(() => { + const q = supabase.from("position_assignments").select(SELECT).order("employee_id"); + return employeeIds ? q.in("employee_id", employeeIds) : q; + }); + + return pickPlacements(rows as unknown as Row[], asOf); +} + +// ── Abgeleitete Berichtslinie ────────────────────────────────────── +// Sie steht nirgends als Spalte; om_reporting_lines() rechnet sie aus dem +// Baum aus. formal_manager_id ist die zuständige Leitung, acting_manager_id +// die nächste besetzte und anwesende darüber — beides, damit sich in der +// Oberfläche zeigen lässt, dass eine Vertretung im Spiel ist, statt sie +// stillschweigend als die echte Führungskraft auszugeben. + +export type ReportingLine = { + employee_id: string; + position_id: string; + org_unit_id: string; + is_chief: boolean; + formal_manager_id: string | null; + acting_manager_id: string | null; +}; + +export async function loadReportingLines( + supabase: SupabaseClient, + asOf: string +): Promise> { + const { data, error } = await supabase.rpc("om_reporting_lines", { p_as_of: asOf }); + if (error) throw new Error(`Berichtslinie konnte nicht geladen werden: ${error.message}`); + return new Map(((data ?? []) as ReportingLine[]).map((l) => [l.employee_id, l])); +} diff --git a/lib/positions.ts b/lib/positions.ts index c2ea943..064097a 100644 --- a/lib/positions.ts +++ b/lib/positions.ts @@ -1,41 +1,112 @@ import type { SupabaseClient } from "@supabase/supabase-js"; -import { breadcrumbLabel, loadOrgMaps } from "./org"; +import { todayIso } from "./format"; +import { breadcrumbLabel, loadOrgMaps, type OrgMaps } from "./org"; +import { fetchAllRows } from "./supabase/query"; import type { Database } from "./supabase/types"; +// Eine offene Stelle ist keine eigene Sache mehr. Sie ist eine Planstelle +// ohne laufende Besetzung — Vakanz ist eine Eigenschaft der Planstelle, kein +// zweites Objekt daneben, das mit der Organisation synchron gehalten werden +// müsste. + export type OpenPositionResolved = { id: string; position_number: string; title: string; - team_id: string; - division_id: string; - is_lead: boolean; - reports_to_employee_id: string | null; + org_unit_id: string; + is_chief: boolean; valid_from: string; - created_at: string; + /** Wer die Stelle nach der Berichtslinie führen wird. */ managerName: string | null; orgLabel: string; + /** Seit wann die Stelle unbesetzt ist: Ende der letzten Besetzung, sonst ihr Beginn. */ + vacantSince: string; }; -// Shared by the Hire Wizard (position lookup) and the Positions & Bereiche page. -export async function loadOpenPositions(supabase: SupabaseClient): Promise { - const orgMaps = await loadOrgMaps(supabase); - const { data: positions } = await supabase - .from("positions") - .select("id, position_number, title, team_id, division_id, is_lead, reports_to_employee_id, valid_from, created_at") - .eq("status", "open") - .order("created_at", { ascending: false }); +type PositionRow = { + id: string; + position_number: string; + org_unit_id: string; + is_chief: boolean; + valid_from: string; + jobs: { title: string }; + position_assignments: { employee_id: string; valid_from: string; valid_to: string | null }[]; +}; - const managerIds = Array.from( - new Set((positions ?? []).map((p) => p.reports_to_employee_id).filter((id): id is string => Boolean(id))) - ); - const { data: managers } = managerIds.length - ? await supabase.from("employees").select("id, first_name, last_name").in("id", managerIds) - : { data: [] as { id: string; first_name: string; last_name: string }[] }; - const managerNameById = new Map((managers ?? []).map((m) => [m.id, `${m.first_name} ${m.last_name}`])); - - return (positions ?? []).map((p) => ({ - ...p, - managerName: p.reports_to_employee_id ? (managerNameById.get(p.reports_to_employee_id) ?? null) : null, - orgLabel: breadcrumbLabel(orgMaps, p.division_id, p.team_id), - })); +/** + * Wer eine unbesetzte Planstelle führen würde: die Leitung der eigenen + * Einheit, für eine Leitungsplanstelle die der übergeordneten — dieselbe + * Regel wie in om_reporting_lines(), nur ohne Inhaber:in, für die sie gälte. + */ +function managerUnitFor(maps: OrgMaps, orgUnitId: string, isChief: boolean): string | null { + if (!isChief) return orgUnitId; + return maps.units.get(orgUnitId)?.parent_id ?? null; +} + +export async function loadOpenPositions(supabase: SupabaseClient): Promise { + const asOf = todayIso(); + + const [orgMaps, positions] = await Promise.all([ + loadOrgMaps(supabase), + fetchAllRows(() => + supabase + .from("om_positions") + .select( + "id, position_number, org_unit_id, is_chief, valid_from, jobs!inner(title), position_assignments(employee_id, valid_from, valid_to)" + ) + .lte("valid_from", asOf) + .or(`valid_to.is.null,valid_to.gt.${asOf}`) + .order("position_number") + ), + ]); + + const open = (positions as unknown as PositionRow[]).filter( + (p) => !p.position_assignments.some((a) => a.valid_from <= asOf && (a.valid_to === null || a.valid_to > asOf)) + ); + if (open.length === 0) return []; + + // Die Leitung der zuständigen Einheit — genau die Planstellen, die als + // Leitung markiert und laufend besetzt sind. + const chiefUnitIds = Array.from( + new Set(open.map((p) => managerUnitFor(orgMaps, p.org_unit_id, p.is_chief)).filter((id): id is string => Boolean(id))) + ); + const chiefs = chiefUnitIds.length + ? ((await fetchAllRows(() => + supabase + .from("om_positions") + .select("org_unit_id, position_assignments!inner(employees!inner(first_name, last_name), valid_to)") + .eq("is_chief", true) + .in("org_unit_id", chiefUnitIds) + .is("position_assignments.valid_to", null) + )) as unknown as { + org_unit_id: string; + position_assignments: { employees: { first_name: string; last_name: string } }[]; + }[]) + : []; + + const chiefNameByUnit = new Map( + chiefs.flatMap((c) => { + const holder = c.position_assignments[0]?.employees; + return holder ? [[c.org_unit_id, `${holder.first_name} ${holder.last_name}`] as const] : []; + }) + ); + + return open.map((p) => { + const ended = p.position_assignments + .map((a) => a.valid_to) + .filter((d): d is string => d !== null) + .sort(); + const managerUnit = managerUnitFor(orgMaps, p.org_unit_id, p.is_chief); + return { + id: p.id, + position_number: p.position_number, + title: p.jobs.title, + org_unit_id: p.org_unit_id, + is_chief: p.is_chief, + valid_from: p.valid_from, + managerName: managerUnit ? (chiefNameByUnit.get(managerUnit) ?? null) : null, + orgLabel: breadcrumbLabel(orgMaps, p.org_unit_id), + vacantSince: ended.at(-1) ?? p.valid_from, + }; + }); } diff --git a/lib/reports-data.ts b/lib/reports-data.ts index e73ad8f..dbbf432 100644 --- a/lib/reports-data.ts +++ b/lib/reports-data.ts @@ -1,4 +1,6 @@ import type { SupabaseClient } from "@supabase/supabase-js"; +import { ancestorsOf, loadOrgMaps, subtreeOf, type OrgMaps } from "./org"; +import { loadPlacements } from "./placement"; import { deriveStatusAsOf, EVENT_DATE_OPEN, parseStatuses, todayIso, type OrgLookups, type ReportEmployee, type ReportEvent } from "./reports"; import { fetchAllRows } from "./supabase/query"; import type { Database, EmploymentType, HistoryEventType } from "./supabase/types"; @@ -7,6 +9,7 @@ import type { Database, EmploymentType, HistoryEventType } from "./supabase/type // what "the current view" means — same filters, same stichtag/event-window // rules. export type ReportFilters = { + /** Id einer Organisationseinheit; wirkt auf die Einheit *und alles darunter*. */ division?: string; location?: string; status?: string; @@ -16,33 +19,49 @@ export type ReportFilters = { export type SnapshotFilters = ReportFilters & { asOf?: string }; export type EventFilters = { eventType?: HistoryEventType; division?: string; location?: string; from?: string; to?: string }; +/** + * Für jede Einheit vorberechnen, welcher Bereich, welche Abteilung und + * welches Team über ihr liegen. Ein Bericht gruppiert dann über einen + * Kartenzugriff statt über einen Aufstieg im Baum je Zeile. + */ +export function lookupsFromOrgMaps(orgMaps: OrgMaps, locations: { id: string; name: string }[]): OrgLookups { + const divisionName = new Map(); + const departmentName = new Map(); + const teamName = new Map(); + + for (const unit of orgMaps.unitList) { + for (const a of ancestorsOf(orgMaps, unit.id)) { + if (a.unit_type === "Bereich") divisionName.set(unit.id, a.name); + else if (a.unit_type === "Abteilung") departmentName.set(unit.id, a.name); + else if (a.unit_type === "Team") teamName.set(unit.id, a.name); + } + } + + return { divisionName, departmentName, teamName, locationName: new Map(locations.map((l) => [l.id, l.name])) }; +} + export async function loadOrgLookups(supabase: SupabaseClient): Promise<{ lookups: OrgLookups; + orgMaps: OrgMaps; divisions: { id: string; name: string }[]; locations: { id: string; name: string }[]; }> { - const [{ data: divisions }, { data: departments }, { data: teams }, { data: locations }] = await Promise.all([ - supabase.from("divisions").select("id, name").order("name"), - supabase.from("departments").select("id, name"), - supabase.from("teams").select("id, name, department_id"), - supabase.from("locations").select("id, name").order("name"), - ]); + const orgMaps = await loadOrgMaps(supabase); + const locations = orgMaps.locationList.map((l) => ({ id: l.id, name: l.name })); - const departmentNameById = new Map((departments ?? []).map((d) => [d.id, d.name])); return { - lookups: { - divisionName: new Map((divisions ?? []).map((d) => [d.id, d.name])), - departmentNameByTeam: new Map((teams ?? []).map((t) => [t.id, departmentNameById.get(t.department_id) ?? "Unbekannt"])), - teamName: new Map((teams ?? []).map((t) => [t.id, t.name])), - locationName: new Map((locations ?? []).map((l) => [l.id, l.name])), - }, - divisions: divisions ?? [], - locations: locations ?? [], + lookups: lookupsFromOrgMaps(orgMaps, locations), + orgMaps, + // Als Filter angeboten wird die oberste Ebene unter der Gesellschaft — + // das, was im Altmodell „Bereich" hiess. Der Filter greift auf den + // ganzen Teilbaum. + divisions: orgMaps.unitList.filter((u) => u.unit_type === "Bereich").map((u) => ({ id: u.id, name: u.name })), + locations, }; } const SNAPSHOT_EMPLOYEE_COLUMNS = - "id, first_name, last_name, job_title, division_id, team_id, location_id, employment_type, contract_type, entry_date, exit_date, weekly_hours, source, paygrade, birth_date, gender, karenz_start_date, karenz_return_date, worker_type, collective_agreement, work_days, is_betriebsrat, has_dienstwagen, is_laterale_fuehrung, is_c_level"; + "id, first_name, last_name, job_title, location_id, employment_type, contract_type, entry_date, exit_date, weekly_hours, source, paygrade, birth_date, gender, karenz_start_date, karenz_return_date, worker_type, collective_agreement, work_days, is_betriebsrat, has_dienstwagen, is_laterale_fuehrung, is_c_level"; // employee_id -> number of employee_dependents rows. Selects only the FK // column (no dependent PII needed) since only per-employee counts feed the @@ -56,58 +75,73 @@ export async function loadDependentsCounts(supabase: SupabaseClient): return counts; } -// Bestand zum Stichtag: reconstructs each employee's status as of `asOf` -// (defaults to today) from entry/exit/Karenz dates — see deriveStatusAsOf. -// division/team/location still reflect the employee's *current* assignment. +// Bestand zum Stichtag: Status *und* Einordnung werden auf `asOf` aufgelöst. export async function loadSnapshotEmployees(supabase: SupabaseClient, filters: SnapshotFilters): Promise { const asOf = filters.asOf || todayIso(); function snapshotQuery() { let query = supabase.from("employees").select(SNAPSHOT_EMPLOYEE_COLUMNS).order("id"); - if (filters.division) query = query.eq("division_id", filters.division); if (filters.location) query = query.eq("location_id", filters.location); if (filters.employment) query = query.eq("employment_type", filters.employment as EmploymentType); return query; } - const [data, dependentsCounts] = await Promise.all([fetchAllRows(snapshotQuery), loadDependentsCounts(supabase)]); + const [data, dependentsCounts, placements, orgMaps] = await Promise.all([ + fetchAllRows(snapshotQuery), + loadDependentsCounts(supabase), + loadPlacements(supabase, { asOf }), + filters.division ? loadOrgMaps(supabase) : Promise.resolve(null), + ]); - const withDerivedStatus: ReportEmployee[] = data.map((e) => ({ - id: e.id, - first_name: e.first_name, - last_name: e.last_name, - job_title: e.job_title, - division_id: e.division_id, - team_id: e.team_id, - location_id: e.location_id, - status: deriveStatusAsOf(e, asOf), - employment_type: e.employment_type, - contract_type: e.contract_type, - entry_date: e.entry_date, - exit_date: e.exit_date, - weekly_hours: e.weekly_hours, - source: e.source, - paygrade: e.paygrade, - birth_date: e.birth_date, - gender: e.gender, - worker_type: e.worker_type, - collective_agreement: e.collective_agreement, - work_days: e.work_days, - is_betriebsrat: e.is_betriebsrat, - has_dienstwagen: e.has_dienstwagen, - is_laterale_fuehrung: e.is_laterale_fuehrung, - is_c_level: e.is_c_level, - dependents_count: dependentsCounts.get(e.id) ?? 0, - })); + // Der Bereichsfilter meint den ganzen Teilbaum: „Produktion" schliesst + // deren Abteilungen und Teams ein, sonst käme null heraus, weil unter dem + // Bereich selbst nur die Bereichsleitung sitzt. + const allowedUnits = orgMaps && filters.division ? new Set(subtreeOf(orgMaps, filters.division)) : null; + + const withDerivedStatus: ReportEmployee[] = []; + for (const e of data) { + const placement = placements.get(e.id); + // Zum Stichtag laufend? Sonst zählt die Person zwar noch im Bestand, + // sitzt aber auf keiner Planstelle mehr. + const orgUnitId = placement?.current ? placement.orgUnitId : null; + if (allowedUnits && (!orgUnitId || !allowedUnits.has(orgUnitId))) continue; + + withDerivedStatus.push({ + id: e.id, + first_name: e.first_name, + last_name: e.last_name, + job_title: placement?.jobTitle ?? e.job_title, + org_unit_id: orgUnitId, + location_id: e.location_id, + status: deriveStatusAsOf(e, asOf), + employment_type: e.employment_type, + contract_type: e.contract_type, + entry_date: e.entry_date, + exit_date: e.exit_date, + weekly_hours: e.weekly_hours, + source: e.source, + paygrade: e.paygrade, + birth_date: e.birth_date, + gender: e.gender, + worker_type: e.worker_type, + collective_agreement: e.collective_agreement, + work_days: e.work_days, + is_betriebsrat: e.is_betriebsrat, + has_dienstwagen: e.has_dienstwagen, + is_laterale_fuehrung: e.is_laterale_fuehrung, + is_c_level: e.is_c_level, + dependents_count: dependentsCounts.get(e.id) ?? 0, + }); + } const statuses = parseStatuses(filters.status); return withDerivedStatus.filter((e) => statuses.includes(e.status as (typeof statuses)[number])); } -// Ereignisse: employee_history has no division_id/team_id of its own, so -// this joins in the affected employee's *current* org placement (two plain -// queries, merged in JS — the hand-written Database type has no relational -// embedding metadata for a single nested-select query). +// Ereignisse: employee_history trägt selbst keine Organisationszuordnung, sie +// kommt über die Planstelle, die die Person *am Tag des Ereignisses* innehatte. +// Vorher war es die heutige — womit ein Austritt von vor zwei Jahren unter dem +// Team stand, in das die Person nie versetzt worden war. // // from/to: "" (unset) falls back to the current calendar year; the literal // sentinel EVENT_DATE_OPEN means that side of the interval is intentionally @@ -125,25 +159,49 @@ export async function loadEventHistory(supabase: SupabaseClient, filte return query; } - const [history, employees] = await Promise.all([ + const [history, employees, assignments, orgMaps] = await Promise.all([ fetchAllRows(historyQuery), - fetchAllRows(() => supabase.from("employees").select("id, first_name, last_name, job_title, division_id, team_id, location_id").order("id")), + fetchAllRows(() => supabase.from("employees").select("id, first_name, last_name, job_title, location_id").order("id")), + fetchAllRows(() => + supabase + .from("position_assignments") + .select("employee_id, valid_from, valid_to, om_positions!inner(org_unit_id)") + .order("employee_id") + ), + filters.division ? loadOrgMaps(supabase) : Promise.resolve(null), ]); + const spans = new Map(); + for (const a of assignments as unknown as { + employee_id: string; + valid_from: string; + valid_to: string | null; + om_positions: { org_unit_id: string }; + }[]) { + const list = spans.get(a.employee_id) ?? []; + list.push({ from: a.valid_from, to: a.valid_to, unitId: a.om_positions.org_unit_id }); + spans.set(a.employee_id, list); + } + + const allowedUnits = orgMaps && filters.division ? new Set(subtreeOf(orgMaps, filters.division)) : null; const employeeById = new Map(employees.map((e) => [e.id, e])); + const events: ReportEvent[] = []; for (const h of history) { const emp = employeeById.get(h.employee_id); if (!emp) continue; - if (filters.division && emp.division_id !== filters.division) continue; if (filters.location && emp.location_id !== filters.location) continue; + + const unitId = + spans.get(h.employee_id)?.find((s) => s.from <= h.event_date && (s.to === null || s.to > h.event_date))?.unitId ?? null; + if (allowedUnits && (!unitId || !allowedUnits.has(unitId))) continue; + events.push({ employee_id: emp.id, first_name: emp.first_name, last_name: emp.last_name, job_title: emp.job_title, - division_id: emp.division_id, - team_id: emp.team_id, + org_unit_id: unitId, location_id: emp.location_id, event_date: h.event_date, event_type: h.event_type, diff --git a/lib/reports.ts b/lib/reports.ts index 954f25e..24d70a3 100644 --- a/lib/reports.ts +++ b/lib/reports.ts @@ -81,8 +81,8 @@ export type ReportEmployee = { first_name: string; last_name: string; job_title: string; - division_id: string; - team_id: string | null; + /** Die Einheit der Planstelle; null, wenn zum Stichtag keine besetzt war. */ + org_unit_id: string | null; location_id: string; status: string; employment_type: string; @@ -104,20 +104,26 @@ export type ReportEmployee = { dependents_count: number; }; +// Alle drei sind über die *Einheit* der Planstelle geschlüsselt, nicht über +// drei verschiedene Fremdschlüssel: welcher Bereich, welche Abteilung und +// welches Team zu einer Einheit gehören, ergibt sich aus ihrer Vorfahrenkette +// und wird einmal vorberechnet. export type OrgLookups = { divisionName: Map; - departmentNameByTeam: Map; + departmentName: Map; teamName: Map; locationName: Map; }; // Reconstructs status as of any date from the columns that actually carry a // timeline (entry/exit/Karenz), rather than trusting `employees.status`, -// which only ever reflects *today*. Division/team/location still reflect the -// employee's *current* assignment — the schema has no history of org-unit -// changes over time, only free-text employee_history descriptions — so a -// stichtag report groups by today's org placement, not the placement as of -// that date. Documented in the UI rather than silently wrong. +// which only ever reflects *today*. +// +// Die Einordnung in die Organisation wird zum selben Stichtag aufgelöst: seit +// dem OM-Modell ist position_assignments zeitabhängig, eine Auswertung +// gruppiert also nach der Einheit von damals. Vorher gab es diese Historie +// nicht, und ein Stichtagsbericht gruppierte nach der heutigen Zuordnung — +// was in der Oberfläche vermerkt werden musste, statt still falsch zu sein. export function deriveStatusAsOf( e: { entry_date: string; exit_date: string | null; karenz_start_date: string | null; karenz_return_date: string | null }, asOf: string @@ -137,11 +143,11 @@ function tenureYearsAsOf(entryDate: string, exitDate: string | null, asOf: strin export function groupKeyFor(e: ReportEmployee, dim: GroupDimension, lookups: OrgLookups): string { switch (dim) { case "division": - return lookups.divisionName.get(e.division_id) ?? "Unbekannt"; + return e.org_unit_id ? (lookups.divisionName.get(e.org_unit_id) ?? "Unbekannt") : "–"; case "department": - return e.team_id ? (lookups.departmentNameByTeam.get(e.team_id) ?? "Unbekannt") : "–"; + return e.org_unit_id ? (lookups.departmentName.get(e.org_unit_id) ?? "–") : "–"; case "team": - return e.team_id ? (lookups.teamName.get(e.team_id) ?? "Unbekannt") : "–"; + return e.org_unit_id ? (lookups.teamName.get(e.org_unit_id) ?? "–") : "–"; case "location": return lookups.locationName.get(e.location_id) ?? "Unbekannt"; case "status": @@ -256,7 +262,7 @@ export function aggregateReport( id: e.id, name: `${e.first_name} ${e.last_name}`, title: e.job_title, - team: e.team_id ? (lookups.teamName.get(e.team_id) ?? "–") : "–", + team: e.org_unit_id ? (lookups.teamName.get(e.org_unit_id) ?? "–") : "–", entry_date: e.entry_date, })); const row: ReportRow = { key, value, count: rowsForGroup.length, people }; @@ -345,8 +351,8 @@ export type ReportEvent = { first_name: string; last_name: string; job_title: string; - division_id: string; - team_id: string | null; + /** Die Einheit der Planstelle; null, wenn zum Stichtag keine besetzt war. */ + org_unit_id: string | null; location_id: string; event_date: string; event_type: HistoryEventType; @@ -358,11 +364,11 @@ function eventGroupKeyFor(e: ReportEvent, dim: EventGroupDimension, lookups: Org case "event_type": return EVENT_TYPE_LABELS[e.event_type] ?? e.event_type; case "division": - return lookups.divisionName.get(e.division_id) ?? "Unbekannt"; + return e.org_unit_id ? (lookups.divisionName.get(e.org_unit_id) ?? "Unbekannt") : "–"; case "department": - return e.team_id ? (lookups.departmentNameByTeam.get(e.team_id) ?? "Unbekannt") : "–"; + return e.org_unit_id ? (lookups.departmentName.get(e.org_unit_id) ?? "–") : "–"; case "team": - return e.team_id ? (lookups.teamName.get(e.team_id) ?? "Unbekannt") : "–"; + return e.org_unit_id ? (lookups.teamName.get(e.org_unit_id) ?? "–") : "–"; case "location": return lookups.locationName.get(e.location_id) ?? "Unbekannt"; case "event_year": @@ -394,7 +400,7 @@ export function aggregateEvents( id: e.employee_id, name: `${e.first_name} ${e.last_name}`, title: e.description, - team: e.team_id ? (lookups.teamName.get(e.team_id) ?? "–") : "–", + team: e.org_unit_id ? (lookups.teamName.get(e.org_unit_id) ?? "–") : "–", entry_date: e.event_date, })); const row: ReportRow = { key, value: rowsForGroup.length, count: rowsForGroup.length, people }; diff --git a/lib/supabase/types.ts b/lib/supabase/types.ts index 724f655..2c80a9c 100644 --- a/lib/supabase/types.ts +++ b/lib/supabase/types.ts @@ -32,8 +32,6 @@ export type HistoryEventType = | "Reorganisation" | "Gehaltsanpassung" | "Rückkehr"; -export type PositionStatus = "open" | "filled"; -export type ReorgMoveKind = "emp" | "team" | "abt" | "dept"; export type PendingChangeType = | "transfer" | "promotion" @@ -51,21 +49,6 @@ type NoRelationships = { Relationships: [] }; export type Database = { public: { Tables: { - divisions: NoRelationships & { - Row: { id: string; org_number: string; name: string }; - Insert: { id?: string; org_number: string; name: string }; - Update: Partial<{ id: string; org_number: string; name: string }>; - }; - departments: NoRelationships & { - Row: { id: string; org_number: string; name: string; division_id: string }; - Insert: { id?: string; org_number: string; name: string; division_id: string }; - Update: Partial<{ id: string; org_number: string; name: string; division_id: string }>; - }; - teams: NoRelationships & { - Row: { id: string; org_number: string; name: string; department_id: string }; - Insert: { id?: string; org_number: string; name: string; department_id: string }; - Update: Partial<{ id: string; org_number: string; name: string; department_id: string }>; - }; locations: NoRelationships & { Row: { id: string; name: string; country: string }; Insert: { id?: string; name: string; country: string }; @@ -119,13 +102,8 @@ export type Database = { address_country: string | null; email: string; phone: string | null; - team_id: string | null; - division_id: string; job_title: string; location_id: string; - manager_id: string | null; - org_level: number; - is_lead: boolean; employment_type: EmploymentType; weekly_hours: number; /** @deprecated Salary is out of MVP scope; column kept only for pre-existing data. */ @@ -168,13 +146,8 @@ export type Database = { address_country?: string | null; email: string; phone?: string | null; - team_id?: string | null; - division_id?: string; job_title: string; location_id: string; - manager_id?: string | null; - org_level?: number; - is_lead?: boolean; employment_type?: EmploymentType; weekly_hours?: number; contract_type?: ContractType; @@ -210,7 +183,6 @@ export type Database = { event_date: string; event_type: HistoryEventType; description: string; - reorg_scenario_id: string | null; created_at: string; }; Insert: { @@ -219,7 +191,6 @@ export type Database = { event_date: string; event_type: HistoryEventType; description: string; - reorg_scenario_id?: string | null; created_at?: string; }; Update: Partial; @@ -276,37 +247,6 @@ export type Database = { }; Update: Partial; }; - positions: NoRelationships & { - Row: { - id: string; - position_number: string; - title: string; - team_id: string; - division_id: string; - is_lead: boolean; - reports_to_employee_id: string | null; - status: PositionStatus; - valid_from: string; - created_at: string; - filled_at: string | null; - filled_by_employee_id: string | null; - }; - Insert: { - id?: string; - position_number?: string; - title: string; - team_id: string; - division_id?: string; - is_lead?: boolean; - reports_to_employee_id?: string | null; - status?: PositionStatus; - valid_from?: string; - created_at?: string; - filled_at?: string | null; - filled_by_employee_id?: string | null; - }; - Update: Partial; - }; hire_drafts: NoRelationships & { Row: { id: string; created_by: string | null; step: number; payload: Record; updated_at: string }; Insert: { id?: string; created_by?: string | null; step?: number; payload: Record; updated_at?: string }; @@ -340,34 +280,6 @@ export type Database = { }; Update: Partial; }; - reorg_scenarios: NoRelationships & { - Row: { - id: string; - name: string; - effective_date: string; - created_by: string | null; - applied: boolean; - applied_at: string | null; - undo_snapshot: Record | null; - created_at: string; - }; - Insert: { - id?: string; - name: string; - effective_date: string; - created_by?: string | null; - applied?: boolean; - applied_at?: string | null; - undo_snapshot?: Record | null; - created_at?: string; - }; - Update: Partial; - }; - reorg_moves: NoRelationships & { - Row: { id: string; scenario_id: string; kind: ReorgMoveKind; payload: Record }; - Insert: { id?: string; scenario_id: string; kind: ReorgMoveKind; payload: Record }; - Update: Partial; - }; pending_org_changes: NoRelationships & { Row: { id: string; @@ -375,7 +287,6 @@ export type Database = { change_type: PendingChangeType; effective_date: string; payload: Record; - reorg_scenario_id: string | null; status: PendingChangeStatus; created_by: string | null; created_at: string; @@ -387,7 +298,6 @@ export type Database = { change_type: PendingChangeType; effective_date: string; payload: Record; - reorg_scenario_id?: string | null; status?: PendingChangeStatus; created_by?: string | null; created_at?: string; @@ -395,11 +305,17 @@ export type Database = { }; Update: Partial; }; - // Written exclusively by trg_track_employee_assignment; RLS grants HR - // read access only, hence no Insert/Update shapes worth modelling. // ── SAP-OM-Modell ────────────────────────────────────────── // O: rekursiv über parent_id, unit_type ist nur ein Etikett. - org_units: NoRelationships & { + org_units: { + Relationships: [ + { + foreignKeyName: "org_units_parent_id_fkey"; + columns: ["parent_id"]; + referencedRelation: "org_units"; + referencedColumns: ["id"]; + }, + ]; Row: { id: string; org_number: string; @@ -428,9 +344,23 @@ export type Database = { Insert: { id?: string; code: string; title: string; created_at?: string }; Update: Partial; }; - // S: Planstelle. Heisst om_positions, weil `positions` noch die alte - // Tabelle für offene Stellen ist, bis der Umstieg abgeschlossen ist. + // S: Planstelle. Der Name om_positions stammt aus der Zeit, in der die + // alte positions-Tabelle noch danebenstand; sie ist inzwischen weg. om_positions: { + Relationships: [ + { + foreignKeyName: "om_positions_org_unit_id_fkey"; + columns: ["org_unit_id"]; + referencedRelation: "org_units"; + referencedColumns: ["id"]; + }, + { + foreignKeyName: "om_positions_job_id_fkey"; + columns: ["job_id"]; + referencedRelation: "jobs"; + referencedColumns: ["id"]; + }, + ]; Row: { id: string; position_number: string; @@ -452,7 +382,6 @@ export type Database = { created_at?: string; }; Update: Partial; - Relationships: []; }; // A008: Person besetzt Planstelle, zeitabhängig. position_assignments: { @@ -473,26 +402,23 @@ export type Database = { created_at?: string; }; Update: Partial; - // Einbettung auf die Planstelle, damit die Berichtslinie in einer - // Abfrage geladen werden kann. - Relationships: [{ foreignKeyName: "position_assignments_position_id_fkey"; columns: ["position_id"]; referencedRelation: "om_positions"; referencedColumns: ["id"] }]; - }; - employee_assignments: NoRelationships & { - Row: { - id: string; - employee_id: string; - manager_id: string | null; - team_id: string | null; - division_id: string; - job_title: string; - is_lead: boolean; - org_level: number; - valid_from: string; - valid_to: string | null; - created_at: string; - }; - Insert: never; - Update: never; + // Beide Richtungen: über die Planstelle hängt die Verortung in der + // Organisation, über die Person die Verortung in der Akte. Die + // Einbettung erspart an einem Dutzend Stellen eine zweite Abfrage. + Relationships: [ + { + foreignKeyName: "position_assignments_position_id_fkey"; + columns: ["position_id"]; + referencedRelation: "om_positions"; + referencedColumns: ["id"]; + }, + { + foreignKeyName: "position_assignments_employee_id_fkey"; + columns: ["employee_id"]; + referencedRelation: "employees"; + referencedColumns: ["id"]; + }, + ]; }; }; Views: Record; @@ -510,12 +436,11 @@ export type Database = { delete_employee_dependent: { Args: { payload: Record }; Returns: void }; add_employee_note: { Args: { payload: Record }; Returns: string }; complete_employee_note: { Args: { payload: Record }; Returns: void }; + // Planstelle anlegen bzw. schliessen — im OM-Modell Operationen auf + // om_positions, nicht mehr auf einer eigenen Ausschreibungstabelle. create_position: { Args: { payload: Record }; Returns: string }; delete_position: { Args: { payload: Record }; Returns: void }; - staff_position_internally: { Args: { payload: Record }; Returns: void }; is_valid_svnr: { Args: { p_svnr: string; p_birth_date?: string | null }; Returns: boolean }; - apply_reorg: { Args: { payload: Record }; Returns: string }; - undo_reorg: { Args: { payload: Record }; Returns: void }; apply_due_pending_changes: { Args: Record; Returns: number }; om_reporting_lines: { Args: { p_as_of?: string }; diff --git a/supabase/migrations/20260727130000_om_cleanup_and_positions.sql b/supabase/migrations/20260727130000_om_cleanup_and_positions.sql new file mode 100644 index 0000000..27398bf --- /dev/null +++ b/supabase/migrations/20260727130000_om_cleanup_and_positions.sql @@ -0,0 +1,140 @@ +-- Reste des Altmodells entfernen und die Planstellenpflege im OM-Modell +-- nachziehen. +-- +-- Die Cut-over-Migration hat die Funktionen des Altmodells mit ihren damals +-- bekannten Signaturen entfernt. Ein Teil davon existierte zusätzlich in +-- einer jsonb-Variante und ist deshalb stehen geblieben — sichtbar daran, +-- dass delete_position und undo_reorg weiterhin in der PostgREST-Schnittstelle +-- auftauchen, obwohl die Tabellen, auf denen sie arbeiten, weg sind. Ein +-- Aufruf würde erst zur Laufzeit scheitern. + +-- ═══ 1. Übriggebliebene Funktionen des Altmodells ════════════════ +drop function if exists create_position(jsonb); +drop function if exists delete_position(jsonb); +drop function if exists delete_position(uuid); +drop function if exists staff_position_internally(jsonb); +drop function if exists apply_reorg(jsonb); +drop function if exists undo_reorg(jsonb); +drop function if exists undo_reorg(uuid); + +-- ═══ 2. Reorganisations-Werkbank ═════════════════════════════════ +-- Sie hat Teams und Abteilungen zwischen Bereichen verschoben — Objekte, die +-- es nicht mehr gibt. Im OM-Modell ist eine Reorganisation das Umhängen von +-- org_units.parent_id und braucht kein eigenes Szenario-Modell mehr. +alter table employee_history drop column if exists reorg_scenario_id; +alter table pending_org_changes drop column if exists reorg_scenario_id; +drop table if exists reorg_moves; +drop table if exists reorg_scenarios; + +-- ═══ 3. Planstellen pflegen ══════════════════════════════════════ +-- Die alte positions-Tabelle führte nur *offene* Stellen und war damit ein +-- eigenes Objekt neben der Person. Im OM-Modell hat jede Person eine +-- Planstelle, und eine offene Stelle ist schlicht eine unbesetzte. Anlegen +-- und Schliessen sind deshalb Operationen auf om_positions. + +create or replace function next_position_number() +returns text language sql stable as $$ + select '6' || lpad((coalesce(max(substring(position_number from 2)::bigint), 0) + 1)::text, 7, '0') + from om_positions + where position_number ~ '^6[0-9]{7}$'; +$$; + +comment on function next_position_number() is + 'Nächste freie Planstellennummer im Nummernkreis 6xxxxxxx.'; + +create or replace function create_position(payload jsonb) +returns uuid language plpgsql as $$ +declare + v_org_unit_id uuid := (payload->>'org_unit_id')::uuid; + v_job_title text := nullif(trim(payload->>'job_title'), ''); + v_is_chief boolean := coalesce((payload->>'is_chief')::boolean, false); + v_valid_from date := coalesce(nullif(payload->>'valid_from','')::date, current_date); + v_job_id uuid; + v_position_id uuid; + v_unit_name text; +begin + perform require_hr_admin(); + + select name into v_unit_name from org_units where id = v_org_unit_id; + if v_unit_name is null then + raise exception 'Die Organisationseinheit existiert nicht.'; + end if; + if v_job_title is null then + raise exception 'Es muss eine Tätigkeit angegeben werden.'; + end if; + + -- Der Unique-Index würde das ebenfalls abfangen, aber mit einer Meldung, + -- die in der Oberfläche nichts erklärt. + if v_is_chief and exists ( + select 1 from om_positions + where org_unit_id = v_org_unit_id and is_chief and valid_to is null + ) then + raise exception 'Für % besteht bereits eine Leitungsplanstelle.', v_unit_name; + end if; + + -- Gleiche Tätigkeit, ein Katalogeintrag: sonst stehen "Schlosser:in" und + -- "Schlosser" nebeneinander und jede Auswertung nach Tätigkeit ist wertlos. + select id into v_job_id from jobs where lower(title) = lower(v_job_title); + if v_job_id is null then + insert into jobs (code, title) + values ('J' || lpad((select count(*) + 1 from jobs)::text, 4, '0'), v_job_title) + returning id into v_job_id; + end if; + + insert into om_positions (position_number, org_unit_id, job_id, is_chief, valid_from) + values (next_position_number(), v_org_unit_id, v_job_id, v_is_chief, v_valid_from) + returning id into v_position_id; + + insert into audit_log (actor_user_id, actor_name, action, target_label, details) + values (auth.uid(), current_actor_name(), 'Planstelle angelegt', + v_job_title || ' (' || v_unit_name || ')', + 'Gültig ab ' || v_valid_from || case when v_is_chief then ', Leitung' else '' end); + + return v_position_id; +end; +$$; + +create or replace function delete_position(payload jsonb) +returns void language plpgsql as $$ +declare + v_position_id uuid := (payload->>'position_id')::uuid; + v_label text; + v_hat_historie boolean; +begin + perform require_hr_admin(); + + select j.title || ' (' || u.name || ')' into v_label + from om_positions p + join jobs j on j.id = p.job_id + join org_units u on u.id = p.org_unit_id + where p.id = v_position_id; + if v_label is null then + raise exception 'Die Planstelle existiert nicht.'; + end if; + + if exists (select 1 from position_assignments where position_id = v_position_id and valid_to is null) then + raise exception 'Die Planstelle ist besetzt und kann nicht entfernt werden.'; + end if; + + select exists (select 1 from position_assignments where position_id = v_position_id) + into v_hat_historie; + + -- Eine Planstelle, auf der einmal jemand sass, wird geschlossen statt + -- gelöscht: sonst verschwindet mit ihr die Besetzungshistorie, und in der + -- Personalakte klafft eine Lücke. + if v_hat_historie then + update om_positions set valid_to = current_date where id = v_position_id; + else + delete from om_positions where id = v_position_id; + end if; + + insert into audit_log (actor_user_id, actor_name, action, target_label, details) + values (auth.uid(), current_actor_name(), + case when v_hat_historie then 'Planstelle geschlossen' else 'Planstelle gelöscht' end, + v_label, null); +end; +$$; + +grant execute on function next_position_number() to anon, authenticated, service_role; +grant execute on function create_position(jsonb) to anon, authenticated, service_role; +grant execute on function delete_position(jsonb) to anon, authenticated, service_role; diff --git a/supabase/seed.ts b/supabase/seed.ts index 80f0ee7..8588172 100644 --- a/supabase/seed.ts +++ b/supabase/seed.ts @@ -451,7 +451,6 @@ function newHireBase(jobTitle: string) { const employees: EmployeeRow[] = []; const history: HistoryRow[] = []; -const icPoolForStatusAssignment: EmployeeRow[] = []; function finalizeEmployee( base: ReturnType, @@ -749,8 +748,6 @@ async function insertInChunks(table: string, rows: Record[], ch // einzelnes DELETE über alle Zeilen geht trotzdem durch, weil Postgres die // Fremdschlüsselprüfung erst nach dem Statement auswertet. const WIPE_ORDER = [ - "reorg_moves", - "reorg_scenarios", "pending_org_changes", "hire_drafts", "employee_notes", diff --git a/tests/integration/assignment-history.test.ts b/tests/integration/assignment-history.test.ts deleted file mode 100644 index c344eb1..0000000 --- a/tests/integration/assignment-history.test.ts +++ /dev/null @@ -1,140 +0,0 @@ -import type { SupabaseClient } from "@supabase/supabase-js"; -import { afterAll, beforeAll, describe, expect, it } from "vitest"; -import type { Database } from "@/lib/supabase/types"; -import { - adminClient, - createHrUser, - deleteTestEmployee, - deleteTestUser, - hireTestEmployee, - isoDateOffset, - pickSeededTeam, - signInAs, - type TestUser, -} from "./helpers"; - -// Org-assignment history (supabase/migrations/20260724120000_employee_ -// assignment_history.sql). The point of capturing this with a trigger rather -// than inside each RPC is that it holds for *every* write path — so these -// tests drive the real RPCs and assert on the timeline they leave behind. -describe("employee_assignments history", () => { - let hrUser: TestUser; - let hrClient: SupabaseClient; - let teamA: { id: string }; - let teamB: { id: string }; - const employeeIds: string[] = []; - - beforeAll(async () => { - hrUser = await createHrUser({ active: true }); - hrClient = await signInAs(hrUser); - teamA = await pickSeededTeam(); - teamB = await pickSeededTeam(teamA.id); - }); - - afterAll(async () => { - for (const id of employeeIds) await deleteTestEmployee(id); - await deleteTestUser(hrUser); - }); - - async function freshEmployee(teamId: string): Promise { - const id = await hireTestEmployee(hrClient, teamId); - employeeIds.push(id); - return id; - } - - async function assignmentsFor(employeeId: string) { - const { data } = await adminClient - .from("employee_assignments") - .select("team_id, job_title, valid_from, valid_to") - .eq("employee_id", employeeId) - .order("valid_from"); - return data ?? []; - } - - it("opens an interval when an employee is hired", async () => { - const employeeId = await freshEmployee(teamA.id); - const rows = await assignmentsFor(employeeId); - expect(rows).toHaveLength(1); - expect(rows[0].team_id).toBe(teamA.id); - expect(rows[0].valid_to).toBeNull(); - }); - - it("closes the old interval and opens a new one on transfer", async () => { - const employeeId = await freshEmployee(teamA.id); - const { error } = await hrClient.rpc("transfer_employee", { - payload: { employee_id: employeeId, effective_date: isoDateOffset(0), new_team_id: teamB.id }, - }); - expect(error).toBeNull(); - - const rows = await assignmentsFor(employeeId); - expect(rows).toHaveLength(2); - expect(rows[0].team_id).toBe(teamA.id); - expect(rows[0].valid_to).toBe(isoDateOffset(0)); - expect(rows[1].team_id).toBe(teamB.id); - expect(rows[1].valid_to).toBeNull(); - // Intervals must abut exactly, or an as-of query lands in a gap. - expect(rows[1].valid_from).toBe(rows[0].valid_to); - }); - - it("rewrites in place rather than leaving a zero-length interval for a same-day second move", async () => { - const employeeId = await freshEmployee(teamA.id); - await hrClient.rpc("transfer_employee", { - payload: { employee_id: employeeId, effective_date: isoDateOffset(0), new_team_id: teamB.id }, - }); - await hrClient.rpc("transfer_employee", { - payload: { employee_id: employeeId, effective_date: isoDateOffset(0), new_team_id: teamA.id }, - }); - - const rows = await assignmentsFor(employeeId); - expect(rows.every((r) => r.valid_to === null || r.valid_to > r.valid_from)).toBe(true); - expect(rows.filter((r) => r.valid_to === null)).toHaveLength(1); - expect(rows.at(-1)?.team_id).toBe(teamA.id); - }); - - it("records a promotion's new title as its own interval", async () => { - const employeeId = await freshEmployee(teamA.id); - const { error } = await hrClient.rpc("promote_employee", { - payload: { employee_id: employeeId, effective_date: isoDateOffset(0), new_title: "Senior Testtitel" }, - }); - expect(error).toBeNull(); - - const rows = await assignmentsFor(employeeId); - expect(rows.at(-1)?.job_title).toBe("Senior Testtitel"); - expect(rows.at(-1)?.valid_to).toBeNull(); - }); - - it("writes no new interval when nothing about the placement changed", async () => { - const employeeId = await freshEmployee(teamA.id); - const before = await assignmentsFor(employeeId); - - const { error } = await hrClient.rpc("change_employee_data", { - payload: { - employee_id: employeeId, - effective_date: isoDateOffset(0), - person: { phone: "+43 1 2345678" }, - contract: {}, - role: {}, - }, - }); - expect(error).toBeNull(); - - expect(await assignmentsFor(employeeId)).toHaveLength(before.length); - }); - - it("keeps exactly one open interval per employee", async () => { - const employeeId = await freshEmployee(teamA.id); - await hrClient.rpc("transfer_employee", { - payload: { employee_id: employeeId, effective_date: isoDateOffset(0), new_team_id: teamB.id }, - }); - const rows = await assignmentsFor(employeeId); - expect(rows.filter((r) => r.valid_to === null)).toHaveLength(1); - }); - - it("is not readable without an active HR session", async () => { - const outsider = await createHrUser({ active: false }); - const outsiderClient = await signInAs(outsider); - const { data } = await outsiderClient.from("employee_assignments").select("id").limit(1); - expect(data ?? []).toHaveLength(0); - await deleteTestUser(outsider); - }); -}); diff --git a/tests/integration/authorization.test.ts b/tests/integration/authorization.test.ts index 8fca2dc..7dcc038 100644 --- a/tests/integration/authorization.test.ts +++ b/tests/integration/authorization.test.ts @@ -36,7 +36,9 @@ describe("HR-only access (is_hr_user gate)", () => { createdUsers.push(user); const client = await signInAs(user); - const { error } = await client.from("divisions").insert({ org_number: "20999999", name: `Test-${user.id}` }); + const { error } = await client + .from("org_units") + .insert({ org_number: "20999999", name: `Test-${user.id}`, unit_type: "Bereich" }); expect(error).not.toBeNull(); }); diff --git a/tests/integration/data-integrity.test.ts b/tests/integration/data-integrity.test.ts index 88fecc6..e2e44bd 100644 --- a/tests/integration/data-integrity.test.ts +++ b/tests/integration/data-integrity.test.ts @@ -2,11 +2,13 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { adminClient, createHrUser, + createTestPosition, deleteTestEmployee, + deleteTestPosition, deleteTestUser, hireTestEmployee, isoDateOffset, - pickSeededTeam, + pickSeededUnit, signInAs, type TestUser, } from "./helpers"; @@ -20,22 +22,32 @@ import type { Database } from "@/lib/supabase/types"; describe("data integrity guards", () => { let hrUser: TestUser; let hrClient: SupabaseClient; - let teamA: { id: string }; + let unitA: { id: string }; const employeeIds: string[] = []; + const positionIds: string[] = []; beforeAll(async () => { hrUser = await createHrUser({ active: true }); hrClient = await signInAs(hrUser); - teamA = await pickSeededTeam(); + unitA = await pickSeededUnit(); }); afterAll(async () => { for (const id of employeeIds) await deleteTestEmployee(id); + for (const id of positionIds) await deleteTestPosition(id); await deleteTestUser(hrUser); }); + // Jede Einstellung braucht im OM-Modell eine freie Zielplanstelle; eine + // geteilte wäre nach der ersten besetzt. + async function freshPosition(): Promise { + const id = await createTestPosition(hrClient, unitA.id, { valid_from: isoDateOffset(-40) }); + positionIds.push(id); + return id; + } + async function freshEmployeeOnKarenz(): Promise<{ employeeId: string; karenzStartDate: string }> { - const employeeId = await hireTestEmployee(hrClient, teamA.id); + const employeeId = await hireTestEmployee(hrClient, await freshPosition()); employeeIds.push(employeeId); const karenzStartDate = isoDateOffset(-5); const { error } = await hrClient.rpc("start_karenz", { @@ -95,7 +107,7 @@ describe("data integrity guards", () => { }); it("rejects an employee_history row dated before the employee's entry_date", async () => { - const employeeId = await hireTestEmployee(hrClient, teamA.id, { entry_date: isoDateOffset(-10) }); + const employeeId = await hireTestEmployee(hrClient, await freshPosition(), { entry_date: isoDateOffset(-10) }); employeeIds.push(employeeId); const { error } = await adminClient.from("employee_history").insert({ @@ -109,7 +121,7 @@ describe("data integrity guards", () => { it("accepts an employee_history row dated exactly on the entry_date", async () => { const entryDate = isoDateOffset(-10); - const employeeId = await hireTestEmployee(hrClient, teamA.id, { entry_date: entryDate }); + const employeeId = await hireTestEmployee(hrClient, await freshPosition(), { entry_date: entryDate }); employeeIds.push(employeeId); const { error } = await adminClient.from("employee_history").insert({ diff --git a/tests/integration/effective-dating.test.ts b/tests/integration/effective-dating.test.ts index 0558b95..9263240 100644 --- a/tests/integration/effective-dating.test.ts +++ b/tests/integration/effective-dating.test.ts @@ -1,14 +1,16 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { adminClient, + chiefOfUnit, createHrUser, + createTestPosition, deleteTestEmployee, + deleteTestPosition, deleteTestUser, hireTestEmployee, isoDateOffset, - pickSeededTeam, + pickSeededUnit, signInAs, - teamLeadId, type TestUser, } from "./helpers"; import type { SupabaseClient } from "@supabase/supabase-js"; @@ -16,60 +18,89 @@ import type { Database } from "@/lib/supabase/types"; // Deferred/effective-dated changes (supabase/migrations/20260714120200_ // effective_dating_rpcs.sql): a future "wirksam ab" date must queue a -// pending_org_changes row instead of writing to `employees` immediately; +// pending_org_changes row instead of writing immediately; // apply_due_pending_changes() applies it once due. +// +// Im OM-Modell ist eine Versetzung der Wechsel auf eine Zielplanstelle, und +// die Berichtslinie wird nicht mehr mitgeschrieben. Geprüft wird deshalb die +// laufende Besetzung und was om_reporting_lines daraus ableitet — nicht mehr +// employees.team_id/manager_id, die es nicht mehr gibt. describe("effective-dated mutations", () => { let hrUser: TestUser; let hrClient: SupabaseClient; - let teamA: { id: string }; - let teamB: { id: string }; + let unitA: { id: string }; + let unitB: { id: string }; const employeeIds: string[] = []; + const positionIds: string[] = []; beforeAll(async () => { hrUser = await createHrUser({ active: true }); hrClient = await signInAs(hrUser); - teamA = await pickSeededTeam(); - teamB = await pickSeededTeam(teamA.id); + unitA = await pickSeededUnit(); + unitB = await pickSeededUnit(unitA.id); }); afterAll(async () => { for (const id of employeeIds) await deleteTestEmployee(id); + for (const id of positionIds) await deleteTestPosition(id); await deleteTestUser(hrUser); }); - async function freshEmployee(teamId: string): Promise { - const id = await hireTestEmployee(hrClient, teamId); + /** Eine Wegwerf-Planstelle in `unitId`, alt genug für einen Eintritt vor 30 Tagen. */ + async function freshPosition(unitId: string): Promise { + const positionId = await createTestPosition(hrClient, unitId, { valid_from: isoDateOffset(-40) }); + positionIds.push(positionId); + return positionId; + } + + async function freshEmployee(unitId: string): Promise { + const id = await hireTestEmployee(hrClient, await freshPosition(unitId)); employeeIds.push(id); return id; } + async function lineOf(employeeId: string) { + const { data } = await adminClient + .rpc("om_reporting_lines", { p_as_of: isoDateOffset(0) }) + .eq("employee_id", employeeId) + .single(); + return data as unknown as { org_unit_id: string; formal_manager_id: string | null }; + } + it("transfer_employee with today's date writes immediately", async () => { - const employeeId = await freshEmployee(teamA.id); - const newLead = await teamLeadId(teamB.id); + const employeeId = await freshEmployee(unitA.id); + const target = await freshPosition(unitB.id); const { error } = await hrClient.rpc("transfer_employee", { - payload: { employee_id: employeeId, effective_date: isoDateOffset(0), new_team_id: teamB.id }, + payload: { employee_id: employeeId, effective_date: isoDateOffset(0), target_position_id: target }, }); expect(error).toBeNull(); - const { data: employee } = await adminClient.from("employees").select("team_id, manager_id").eq("id", employeeId).single(); - expect(employee?.team_id).toBe(teamB.id); - expect(employee?.manager_id).toBe(newLead); + const { data: assignment } = await adminClient + .from("position_assignments") + .select("position_id") + .eq("employee_id", employeeId) + .is("valid_to", null) + .single(); + expect(assignment?.position_id).toBe(target); + + const line = await lineOf(employeeId); + expect(line.org_unit_id).toBe(unitB.id); + expect(line.formal_manager_id).toBe(await chiefOfUnit(unitB.id)); }); it("transfer_employee with a future date defers the write and applies it once due", async () => { - const employeeId = await freshEmployee(teamA.id); - const newLead = await teamLeadId(teamB.id); + const employeeId = await freshEmployee(unitA.id); + const target = await freshPosition(unitB.id); const { error } = await hrClient.rpc("transfer_employee", { - payload: { employee_id: employeeId, effective_date: isoDateOffset(30), new_team_id: teamB.id }, + payload: { employee_id: employeeId, effective_date: isoDateOffset(30), target_position_id: target }, }); expect(error).toBeNull(); // Not written yet — this is the exact bug the migration fixes: a // future-dated transfer must not overwrite the live record today. - const { data: unchanged } = await adminClient.from("employees").select("team_id").eq("id", employeeId).single(); - expect(unchanged?.team_id).toBe(teamA.id); + expect((await lineOf(employeeId)).org_unit_id).toBe(unitA.id); const { data: pending } = await adminClient .from("pending_org_changes") @@ -78,7 +109,7 @@ describe("effective-dated mutations", () => { .eq("change_type", "transfer") .single(); expect(pending?.status).toBe("pending"); - expect(pending?.payload.new_team_id).toBe(teamB.id); + expect(pending?.payload.target_position_id).toBe(target); // Fast-forward: simulate the effective date having arrived, then run // the same function the daily cron route calls. @@ -87,9 +118,14 @@ describe("effective-dated mutations", () => { expect(applyError).toBeNull(); expect(appliedCount).toBeGreaterThanOrEqual(1); - const { data: employee } = await adminClient.from("employees").select("team_id, manager_id").eq("id", employeeId).single(); - expect(employee?.team_id).toBe(teamB.id); - expect(employee?.manager_id).toBe(newLead); + const { data: assignment } = await adminClient + .from("position_assignments") + .select("position_id") + .eq("employee_id", employeeId) + .is("valid_to", null) + .single(); + expect(assignment?.position_id).toBe(target); + expect((await lineOf(employeeId)).org_unit_id).toBe(unitB.id); const { data: appliedRow } = await adminClient .from("pending_org_changes") @@ -101,7 +137,7 @@ describe("effective-dated mutations", () => { }); it("promote_employee with a future date does not change job_title/paygrade until applied", async () => { - const employeeId = await freshEmployee(teamA.id); + const employeeId = await freshEmployee(unitA.id); const { error } = await hrClient.rpc("promote_employee", { payload: { employee_id: employeeId, effective_date: isoDateOffset(14), new_title: "Senior Testperson", new_paygrade: "D" }, @@ -126,7 +162,7 @@ describe("effective-dated mutations", () => { }); it("start_karenz with a future date sets karenz_start_date immediately but keeps status Aktiv", async () => { - const employeeId = await freshEmployee(teamA.id); + const employeeId = await freshEmployee(unitA.id); const startDate = isoDateOffset(20); const returnDate = isoDateOffset(200); diff --git a/tests/integration/helpers.ts b/tests/integration/helpers.ts index 3b5accb..4c5f0eb 100644 --- a/tests/integration/helpers.ts +++ b/tests/integration/helpers.ts @@ -55,31 +55,57 @@ export async function signInAs(user: TestUser): Promise return client; } -// Pulled from the seeded dataset (supabase/seed.ts) — any active, non-lead -// employee works for read/mutation tests that don't care which one. -export async function pickSeededEmployee( - filter: Partial<{ status: EmploymentStatus; is_lead: boolean }> = {} -): Promise<{ +// 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; - team_id: string | null; - division_id: string; - manager_id: string | null; status: string; + position_id: string; + org_unit_id: string; + is_chief: boolean; }> { - let q = adminClient.from("employees").select("id, team_id, division_id, manager_id, status").limit(1); - if (filter.status) q = q.eq("status", filter.status); - if (filter.is_lead !== undefined) q = q.eq("is_lead", filter.is_lead); + 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"}`); - return data; + 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, + }; } -export async function pickSeededTeam(excludeTeamId?: string): Promise<{ id: string }> { - const q = adminClient.from("teams").select("id").limit(2); - const { data, error } = await q; - if (error || !data?.length) throw new Error(`pickSeededTeam failed: ${error?.message}`); - const match = data.find((t) => t.id !== excludeTeamId) ?? data[0]; - return match; +/** 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 }> { @@ -88,18 +114,19 @@ export async function pickSeededLocation(): Promise<{ id: string }> { return data; } -// The seeded org guarantees exactly one active team lead per team (§2's -// "reports-to" rule) — resolve_manager_for() relies on the same query. -export async function teamLeadId(teamId: string): Promise { +/** Wer die Leitungsplanstelle einer Einheit laufend innehat, falls jemand. */ +export async function chiefOfUnit(orgUnitId: string): Promise { const { data, error } = await adminClient - .from("employees") - .select("id") - .eq("team_id", teamId) - .eq("is_lead", true) - .neq("status", "Ausgetreten") + .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(`teamLeadId(${teamId}) failed: ${error.message}`); - return data?.id ?? null; + 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 @@ -110,12 +137,13 @@ export function isoDateOffset(days: number): string { return d.toISOString().slice(0, 10); } -// Hires a throwaway employee into `teamId` via the real hire_employee RPC -// (not a raw insert) so every mutation test starts from a state the app -// itself can produce. Caller must clean up with deleteTestEmployee. +// 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, - teamId: string, + positionId: string, overrides: Partial> = {} ): Promise { const location = await pickSeededLocation(); @@ -125,8 +153,9 @@ export async function hireTestEmployee( gender: "w", birth_date: "1990-01-01", location_id: location.id, - team_id: teamId, - job_title: "Integrationstest-Rolle", + // 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, @@ -145,21 +174,19 @@ export async function deleteTestEmployee(employeeId: string): Promise { await adminClient.from("employees").delete().eq("id", employeeId); } -// Creates a throwaway open position via the real create_position RPC. -// Defaults to a non-lead position reporting to `superiorEmployeeId` (its -// team is derived from that employee's own team, same as the app does). -// Caller must clean up with deleteTestPosition — and, since positions. -// reports_to_employee_id / filled_by_employee_id reference employees(id) -// with no cascade, delete positions before the employees they point to. +// 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, - superiorEmployeeId: string, + orgUnitId: string, overrides: Partial> = {} ): Promise { const payload = { - title: `Integrationstest-Position-${randomUUID().slice(0, 8)}`, - superior_employee_id: superiorEmployeeId, - is_lead: false, + 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 }); @@ -168,5 +195,7 @@ export async function createTestPosition( } export async function deleteTestPosition(positionId: string): Promise { - await adminClient.from("positions").delete().eq("id", positionId); + // 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); } diff --git a/tests/integration/position-assignments.test.ts b/tests/integration/position-assignments.test.ts new file mode 100644 index 0000000..c7e4142 --- /dev/null +++ b/tests/integration/position-assignments.test.ts @@ -0,0 +1,216 @@ +import type { SupabaseClient } from "@supabase/supabase-js"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import type { Database } from "@/lib/supabase/types"; +import { + adminClient, + createHrUser, + createTestPosition, + deleteTestEmployee, + deleteTestPosition, + deleteTestUser, + hireTestEmployee, + isoDateOffset, + pickSeededUnit, + signInAs, + type TestUser, +} from "./helpers"; + +// Die Besetzungshistorie (A008). Im Altmodell wurde sie von einem Trigger in +// eine eigene Tabelle mitgeschrieben; jetzt *ist* position_assignments die +// Historie — dieselben Zeilen, aus denen auch der heutige Stand kommt. Damit +// gibt es nichts mehr, was auseinanderlaufen könnte, aber die Invarianten +// müssen umso mehr halten: keine Lücke, keine Überschneidung, höchstens eine +// laufende Besetzung. +// +// Die Tests treiben die echten RPCs, nicht Inserts. +describe("position_assignments als Besetzungshistorie", () => { + let hrUser: TestUser; + let hrClient: SupabaseClient; + let unitA: { id: string }; + let unitB: { id: string }; + const employeeIds: string[] = []; + const positionIds: string[] = []; + + beforeAll(async () => { + hrUser = await createHrUser({ active: true }); + hrClient = await signInAs(hrUser); + unitA = await pickSeededUnit(); + unitB = await pickSeededUnit(unitA.id); + }); + + afterAll(async () => { + // Planstellen vor den Personen: die Besetzungen hängen an beiden. + for (const id of positionIds) await deleteTestPosition(id); + for (const id of employeeIds) await deleteTestEmployee(id); + await deleteTestUser(hrUser); + }); + + async function freshPosition(orgUnitId: string): Promise { + const id = await createTestPosition(hrClient, orgUnitId); + positionIds.push(id); + return id; + } + + async function freshEmployee(positionId: string): Promise { + const id = await hireTestEmployee(hrClient, positionId); + employeeIds.push(id); + return id; + } + + async function assignmentsFor(employeeId: string) { + const { data } = await adminClient + .from("position_assignments") + .select("position_id, valid_from, valid_to") + .eq("employee_id", employeeId) + .order("valid_from"); + return data ?? []; + } + + it("öffnet eine Besetzung bei der Einstellung", async () => { + const positionId = await freshPosition(unitA.id); + const employeeId = await freshEmployee(positionId); + + const rows = await assignmentsFor(employeeId); + expect(rows).toHaveLength(1); + expect(rows[0].position_id).toBe(positionId); + expect(rows[0].valid_to).toBeNull(); + }); + + it("übernimmt die Tätigkeit aus dem Job der Planstelle", async () => { + // Sie wird bei der Einstellung nicht mitgegeben — sonst könnten die + // Tätigkeit auf der Person und die der Planstelle auseinanderlaufen. + const positionId = await freshPosition(unitA.id); + const employeeId = await freshEmployee(positionId); + + const { data: position } = await adminClient + .from("om_positions") + .select("jobs!inner(title)") + .eq("id", positionId) + .single(); + const { data: employee } = await adminClient.from("employees").select("job_title").eq("id", employeeId).single(); + + expect(employee?.job_title).toBe((position as unknown as { jobs: { title: string } }).jobs.title); + }); + + it("weist eine Einstellung auf eine bereits besetzte Planstelle zurück", async () => { + // Der Unique-Index fängt das ebenfalls ab; die RPC soll es vorher mit + // einer Meldung tun, die in der Oberfläche etwas erklärt. + const positionId = await freshPosition(unitA.id); + await freshEmployee(positionId); + + await expect(hireTestEmployee(hrClient, positionId)).rejects.toThrow(/bereits besetzt/i); + }); + + it("schliesst die alte Besetzung und öffnet die neue bei einer Versetzung", async () => { + const from = await freshPosition(unitA.id); + const to = await freshPosition(unitB.id); + const employeeId = await freshEmployee(from); + + const { error } = await hrClient.rpc("transfer_employee", { + payload: { employee_id: employeeId, effective_date: isoDateOffset(0), target_position_id: to }, + }); + expect(error).toBeNull(); + + const rows = await assignmentsFor(employeeId); + expect(rows).toHaveLength(2); + expect(rows[0].position_id).toBe(from); + expect(rows[0].valid_to).toBe(isoDateOffset(0)); + expect(rows[1].position_id).toBe(to); + expect(rows[1].valid_to).toBeNull(); + // Die Intervalle müssen exakt aneinanderstossen, sonst landet eine + // Stichtagsabfrage in einer Lücke. + expect(rows[1].valid_from).toBe(rows[0].valid_to); + }); + + it("hält höchstens eine laufende Besetzung je Person", async () => { + const from = await freshPosition(unitA.id); + const to = await freshPosition(unitB.id); + const employeeId = await freshEmployee(from); + + await hrClient.rpc("transfer_employee", { + payload: { employee_id: employeeId, effective_date: isoDateOffset(0), target_position_id: to }, + }); + + const rows = await assignmentsFor(employeeId); + expect(rows.filter((r) => r.valid_to === null)).toHaveLength(1); + }); + + it("weist eine Versetzung auf eine besetzte Zielplanstelle zurück", async () => { + const besetzt = await freshPosition(unitA.id); + await freshEmployee(besetzt); + const andere = await freshPosition(unitB.id); + const employeeId = await freshEmployee(andere); + + const { error } = await hrClient.rpc("transfer_employee", { + payload: { employee_id: employeeId, effective_date: isoDateOffset(0), target_position_id: besetzt }, + }); + expect(error?.message).toMatch(/bereits besetzt/i); + }); + + it("merkt eine Versetzung in der Zukunft vor, statt sie sofort zu schreiben", async () => { + const from = await freshPosition(unitA.id); + const to = await freshPosition(unitB.id); + const employeeId = await freshEmployee(from); + + await hrClient.rpc("transfer_employee", { + payload: { employee_id: employeeId, effective_date: isoDateOffset(30), target_position_id: to }, + }); + + const rows = await assignmentsFor(employeeId); + expect(rows).toHaveLength(1); + expect(rows[0].position_id).toBe(from); + + const { data: pending } = await adminClient + .from("pending_org_changes") + .select("change_type, effective_date, payload, status") + .eq("employee_id", employeeId); + expect(pending).toHaveLength(1); + expect(pending?.[0].status).toBe("pending"); + expect((pending?.[0].payload as { target_position_id: string }).target_position_id).toBe(to); + }); + + it("gibt die Planstelle beim Austritt frei", async () => { + const positionId = await freshPosition(unitA.id); + const employeeId = await freshEmployee(positionId); + + const { error } = await hrClient.rpc("terminate_employee", { + payload: { employee_id: employeeId, exit_date: isoDateOffset(0), exit_reason: "Kündigung AN" }, + }); + expect(error).toBeNull(); + + const rows = await assignmentsFor(employeeId); + expect(rows.filter((r) => r.valid_to === null)).toHaveLength(0); + expect(rows.at(-1)?.valid_to).toBe(isoDateOffset(0)); + }); + + it("hinterlässt keine überschneidenden Besetzungen auf einer Planstelle", async () => { + // Der Unique-Index deckt nur die *laufende* Besetzung ab; die Historie + // könnte sich unbemerkt überschneiden. + const positionId = await freshPosition(unitA.id); + const ersteR = await freshEmployee(positionId); + await hrClient.rpc("terminate_employee", { + payload: { employee_id: ersteR, exit_date: isoDateOffset(0), exit_reason: "Kündigung AN" }, + }); + await freshEmployee(positionId, ); + + const { data } = await adminClient + .from("position_assignments") + .select("valid_from, valid_to") + .eq("position_id", positionId) + .order("valid_from"); + + const rows = data ?? []; + for (let i = 1; i < rows.length; i++) { + const vorher = rows[i - 1]; + expect(vorher.valid_to === null || vorher.valid_to <= rows[i].valid_from, `Besetzung ${i} überschneidet`).toBe(true); + } + }); + + it("ist ohne aktive HR-Sitzung nicht lesbar", async () => { + const outsider = await createHrUser({ active: false }); + const outsiderClient = await signInAs(outsider); + const { data } = await outsiderClient.from("position_assignments").select("id").limit(1); + expect(data ?? []).toHaveLength(0); + await deleteTestUser(outsider); + }); +}); diff --git a/tests/integration/positions.test.ts b/tests/integration/positions.test.ts index 2c50543..4cdd49c 100644 --- a/tests/integration/positions.test.ts +++ b/tests/integration/positions.test.ts @@ -1,4 +1,3 @@ -import { randomUUID } from "node:crypto"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { adminClient, @@ -9,148 +8,202 @@ import { deleteTestUser, hireTestEmployee, isoDateOffset, - pickSeededLocation, - pickSeededTeam, + pickSeededUnit, signInAs, - teamLeadId, type TestUser, } from "./helpers"; import type { SupabaseClient } from "@supabase/supabase-js"; import type { Database } from "@/lib/supabase/types"; -// Position validity window + delete (supabase/migrations/20260716120000_position_validity_and_delete.sql): -// positions now carry a required valid_from ("gültig ab") date, an open -// position can be deleted again, and neither internal staffing nor an -// external hire may assign an employee to a position before that date. -describe("position validity and delete", () => { +// Planstellenpflege im OM-Modell +// (supabase/migrations/20260727130000_om_cleanup_and_positions.sql). +// +// Eine Planstelle gehört zu einer Organisationseinheit, trägt eine Tätigkeit +// aus dem Job-Katalog und ist entweder Leitung oder nicht. Was früher an der +// Ausschreibung hing — vorgesetzte Person, Team, is_lead — ergibt sich jetzt +// aus der Einheit und wird deshalb hier nicht mehr geprüft: es kann gar nicht +// mehr abweichen. +describe("Planstellen anlegen und schliessen", () => { let hrUser: TestUser; let hrClient: SupabaseClient; - let teamA: { id: string }; - let superiorId: string; + let unit: { id: string }; const positionIds: string[] = []; const employeeIds: string[] = []; beforeAll(async () => { hrUser = await createHrUser({ active: true }); hrClient = await signInAs(hrUser); - teamA = await pickSeededTeam(); - superiorId = (await teamLeadId(teamA.id))!; + unit = await pickSeededUnit(); }); afterAll(async () => { - // Positions first: reports_to_employee_id / filled_by_employee_id - // reference employees(id) with no cascade. - for (const id of positionIds) await deleteTestPosition(id); + // Personen zuerst: die Besetzung hängt mit on delete cascade an der + // Planstelle, die Person selbst nicht. for (const id of employeeIds) await deleteTestEmployee(id); + for (const id of positionIds) await deleteTestPosition(id); await deleteTestUser(hrUser); }); - it("create_position records the given valid_from", async () => { + it("übernimmt das angegebene Gültig-ab", async () => { const validFrom = isoDateOffset(10); - const positionId = await createTestPosition(hrClient, superiorId, { valid_from: validFrom }); + const positionId = await createTestPosition(hrClient, unit.id, { valid_from: validFrom }); positionIds.push(positionId); - const { data } = await adminClient.from("positions").select("valid_from").eq("id", positionId).single(); + const { data } = await adminClient.from("om_positions").select("valid_from").eq("id", positionId).single(); expect(data?.valid_from).toBe(validFrom); }); - it("create_position defaults valid_from to today when omitted", async () => { - const positionId = await createTestPosition(hrClient, superiorId); + it("setzt Gültig-ab ohne Angabe auf heute", async () => { + const positionId = await createTestPosition(hrClient, unit.id); positionIds.push(positionId); - const { data } = await adminClient.from("positions").select("valid_from").eq("id", positionId).single(); + const { data } = await adminClient.from("om_positions").select("valid_from").eq("id", positionId).single(); expect(data?.valid_from).toBe(isoDateOffset(0)); }); - it("delete_position removes an open position", async () => { - const positionId = await createTestPosition(hrClient, superiorId); + it("hängt die Planstelle an die angegebene Einheit", async () => { + const positionId = await createTestPosition(hrClient, unit.id); + positionIds.push(positionId); + + const { data } = await adminClient.from("om_positions").select("org_unit_id, is_chief").eq("id", positionId).single(); + expect(data?.org_unit_id).toBe(unit.id); + expect(data?.is_chief).toBe(false); + }); + + it("teilt sich denselben Job-Katalogeintrag, statt ihn zu verdoppeln", async () => { + // Sonst stünden „Schlosser:in" und „Schlosser" nebeneinander und jede + // Auswertung nach Tätigkeit wäre wertlos. + const title = `Geteilte Tätigkeit ${Date.now()}`; + const first = await createTestPosition(hrClient, unit.id, { job_title: title }); + const second = await createTestPosition(hrClient, unit.id, { job_title: title }); + positionIds.push(first, second); + + const { data } = await adminClient.from("om_positions").select("job_id").in("id", [first, second]); + expect(new Set((data ?? []).map((p) => p.job_id)).size).toBe(1); + }); + + it("weist eine Planstelle ohne Tätigkeit zurück", async () => { + const { error } = await hrClient.rpc("create_position", { + payload: { org_unit_id: unit.id, job_title: " " }, + }); + expect(error?.message).toMatch(/Tätigkeit/); + }); + + it("lässt keine zweite Leitungsplanstelle für dieselbe Einheit zu", async () => { + // Der Unique-Index erzwingt das ohnehin; die RPC soll es mit einer + // Meldung abfangen, die in der Oberfläche etwas erklärt. + const { data: existing } = await adminClient + .from("om_positions") + .select("org_unit_id") + .eq("is_chief", true) + .is("valid_to", null) + .limit(1) + .single(); + + const { error } = await hrClient.rpc("create_position", { + payload: { org_unit_id: existing!.org_unit_id, job_title: "Zweite Leitung", is_chief: true }, + }); + expect(error?.message).toMatch(/Leitungsplanstelle/); + }); + + it("löscht eine nie besetzte Planstelle vollständig", async () => { + const positionId = await createTestPosition(hrClient, unit.id); const { error } = await hrClient.rpc("delete_position", { payload: { position_id: positionId } }); expect(error).toBeNull(); - const { data } = await adminClient.from("positions").select("id").eq("id", positionId).maybeSingle(); + const { data } = await adminClient.from("om_positions").select("id").eq("id", positionId).maybeSingle(); expect(data).toBeNull(); }); - it("delete_position rejects a filled position", async () => { - const positionId = await createTestPosition(hrClient, superiorId, { valid_from: isoDateOffset(-10) }); + it("weigert sich, eine besetzte Planstelle zu entfernen", async () => { + const positionId = await createTestPosition(hrClient, unit.id); positionIds.push(positionId); - const employeeId = await hireTestEmployee(hrClient, teamA.id); - employeeIds.push(employeeId); - - const { error: staffError } = await hrClient.rpc("staff_position_internally", { - payload: { position_id: positionId, employee_id: employeeId }, - }); - expect(staffError).toBeNull(); + employeeIds.push(await hireTestEmployee(hrClient, positionId)); const { error } = await hrClient.rpc("delete_position", { payload: { position_id: positionId } }); - expect(error?.message).toMatch(/Nur offene Positionen können gelöscht werden/); + expect(error?.message).toMatch(/besetzt/); }); - it("staff_position_internally rejects assigning to a position before its valid_from", async () => { - const positionId = await createTestPosition(hrClient, superiorId, { valid_from: isoDateOffset(10) }); + it("schliesst eine früher besetzte Planstelle, statt die Historie zu löschen", async () => { + // Sonst verschwände mit der Planstelle die Besetzungshistorie, und in der + // Personalakte klaffte eine Lücke. + const positionId = await createTestPosition(hrClient, unit.id, { valid_from: isoDateOffset(-40) }); positionIds.push(positionId); - const employeeId = await hireTestEmployee(hrClient, teamA.id); + const employeeId = await hireTestEmployee(hrClient, positionId); employeeIds.push(employeeId); - const { error } = await hrClient.rpc("staff_position_internally", { - payload: { position_id: positionId, employee_id: employeeId }, + await hrClient.rpc("terminate_employee", { + payload: { employee_id: employeeId, exit_date: isoDateOffset(-1), exit_reason: "Integrationstest" }, }); - expect(error?.message).toMatch(/erst ab .* gültig/); - }); - it("staff_position_internally accepts assigning to a position on/after its valid_from", async () => { - const positionId = await createTestPosition(hrClient, superiorId, { valid_from: isoDateOffset(-1) }); - positionIds.push(positionId); - const employeeId = await hireTestEmployee(hrClient, teamA.id); - employeeIds.push(employeeId); - - const { error } = await hrClient.rpc("staff_position_internally", { - payload: { position_id: positionId, employee_id: employeeId }, - }); + const { error } = await hrClient.rpc("delete_position", { payload: { position_id: positionId } }); expect(error).toBeNull(); - }); - it("hire_employee rejects an entry_date before the position's valid_from", async () => { - const validFrom = isoDateOffset(10); - const positionId = await createTestPosition(hrClient, superiorId, { valid_from: validFrom }); - positionIds.push(positionId); - const location = await pickSeededLocation(); + const { data } = await adminClient.from("om_positions").select("valid_to").eq("id", positionId).maybeSingle(); + expect(data?.valid_to).toBe(isoDateOffset(0)); - const { error } = await hrClient.rpc("hire_employee", { - payload: { - first_name: "Integrationstest", - last_name: `Person-${randomUUID().slice(0, 8)}`, - gender: "w", - birth_date: "1990-01-01", - location_id: location.id, - position_id: positionId, - entry_date: isoDateOffset(5), - source: "Extern", - }, - }); - expect(error?.message).toMatch(/Eintrittsdatum darf nicht vor dem Gültigkeitsbeginn/); - }); - - it("hire_employee accepts an entry_date on/after the position's valid_from", async () => { - const validFrom = isoDateOffset(10); - const positionId = await createTestPosition(hrClient, superiorId, { valid_from: validFrom }); - positionIds.push(positionId); - const location = await pickSeededLocation(); - - const { data, error } = await hrClient.rpc("hire_employee", { - payload: { - first_name: "Integrationstest", - last_name: `Person-${randomUUID().slice(0, 8)}`, - gender: "w", - birth_date: "1990-01-01", - location_id: location.id, - position_id: positionId, - entry_date: validFrom, - source: "Extern", - }, - }); - expect(error).toBeNull(); - if (data) employeeIds.push(data); + const { data: history } = await adminClient.from("position_assignments").select("id").eq("position_id", positionId); + expect(history?.length).toBeGreaterThan(0); + }); +}); + +describe("Besetzung", () => { + let hrUser: TestUser; + let hrClient: SupabaseClient; + let unit: { id: string }; + const positionIds: string[] = []; + const employeeIds: string[] = []; + + beforeAll(async () => { + hrUser = await createHrUser({ active: true }); + hrClient = await signInAs(hrUser); + unit = await pickSeededUnit(); + }); + + afterAll(async () => { + for (const id of employeeIds) await deleteTestEmployee(id); + for (const id of positionIds) await deleteTestPosition(id); + await deleteTestUser(hrUser); + }); + + it("lässt eine Planstelle nicht zweimal laufend besetzen", async () => { + const positionId = await createTestPosition(hrClient, unit.id, { valid_from: isoDateOffset(-40) }); + positionIds.push(positionId); + employeeIds.push(await hireTestEmployee(hrClient, positionId)); + + await expect(hireTestEmployee(hrClient, positionId)).rejects.toThrow(/bereits besetzt/); + }); + + it("übernimmt die Tätigkeit aus dem Job der Planstelle", async () => { + // Der Titel wird bei der Einstellung nicht mitgegeben; sonst könnten + // Planstelle und Person unterschiedliche Tätigkeiten führen. + const title = `Tätigkeit aus dem Katalog ${Date.now()}`; + const positionId = await createTestPosition(hrClient, unit.id, { job_title: title, valid_from: isoDateOffset(-40) }); + positionIds.push(positionId); + const employeeId = await hireTestEmployee(hrClient, positionId); + employeeIds.push(employeeId); + + const { data } = await adminClient.from("employees").select("job_title").eq("id", employeeId).single(); + expect(data?.job_title).toBe(title); + }); + + it("beendet die Besetzung beim Austritt und macht die Planstelle frei", async () => { + const positionId = await createTestPosition(hrClient, unit.id, { valid_from: isoDateOffset(-40) }); + positionIds.push(positionId); + const employeeId = await hireTestEmployee(hrClient, positionId); + employeeIds.push(employeeId); + + await hrClient.rpc("terminate_employee", { + payload: { employee_id: employeeId, exit_date: isoDateOffset(-1), exit_reason: "Integrationstest" }, + }); + + const { data } = await adminClient + .from("position_assignments") + .select("valid_to") + .eq("position_id", positionId) + .eq("employee_id", employeeId) + .single(); + expect(data?.valid_to).toBe(isoDateOffset(-1)); }); }); diff --git a/tests/integration/reorg.test.ts b/tests/integration/reorg.test.ts deleted file mode 100644 index c0ee192..0000000 --- a/tests/integration/reorg.test.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { afterAll, beforeAll, describe, expect, it } from "vitest"; -import { - adminClient, - createHrUser, - deleteTestEmployee, - deleteTestUser, - hireTestEmployee, - isoDateOffset, - pickSeededTeam, - signInAs, - teamLeadId, - type TestUser, -} from "./helpers"; -import type { SupabaseClient } from "@supabase/supabase-js"; -import type { Database } from "@/lib/supabase/types"; - -// Reorg scenarios: immediate apply/undo must respect employee_history's -// append-only contract (20260714120300_reorg_undo_append_only.sql), and a -// future-dated scenario must defer every move via pending_org_changes until -// its effective date, flipping reorg_scenarios.applied only once every move -// has landed (20260714120200_effective_dating_rpcs.sql). -describe("reorg scenarios", () => { - let hrUser: TestUser; - let hrClient: SupabaseClient; - let teamA: { id: string }; - let teamB: { id: string }; - const employeeIds: string[] = []; - - beforeAll(async () => { - hrUser = await createHrUser({ active: true }); - hrClient = await signInAs(hrUser); - teamA = await pickSeededTeam(); - teamB = await pickSeededTeam(teamA.id); - }); - - afterAll(async () => { - for (const id of employeeIds) await deleteTestEmployee(id); - await deleteTestUser(hrUser); - }); - - async function freshEmployee(teamId: string): Promise { - const id = await hireTestEmployee(hrClient, teamId); - employeeIds.push(id); - return id; - } - - it("applies an immediate reorg now, and undo appends a compensating history row instead of deleting", async () => { - const employeeId = await freshEmployee(teamA.id); - const teamBLead = await teamLeadId(teamB.id); - - const { data: scenarioId, error: applyError } = await hrClient.rpc("apply_reorg", { - payload: { - name: `Integrationstest Reorg ${employeeId.slice(0, 8)}`, - effective_date: isoDateOffset(0), - moves: [{ kind: "emp", label: "Test", employee_ids: [employeeId], target_team_id: teamB.id }], - }, - }); - expect(applyError).toBeNull(); - expect(scenarioId).toBeTruthy(); - - const { data: movedEmployee } = await adminClient.from("employees").select("team_id, manager_id").eq("id", employeeId).single(); - expect(movedEmployee?.team_id).toBe(teamB.id); - expect(movedEmployee?.manager_id).toBe(teamBLead); - - const { data: scenario } = await adminClient - .from("reorg_scenarios") - .select("applied, applied_at") - .eq("id", scenarioId as string) - .single(); - expect(scenario?.applied).toBe(true); - expect(scenario?.applied_at).not.toBeNull(); - - const { count: historyBeforeUndo } = await adminClient - .from("employee_history") - .select("id", { count: "exact", head: true }) - .eq("employee_id", employeeId) - .eq("event_type", "Reorganisation"); - expect(historyBeforeUndo).toBe(1); - - const { error: undoError } = await hrClient.rpc("undo_reorg", { payload: { scenario_id: scenarioId } }); - expect(undoError).toBeNull(); - - const { data: revertedEmployee } = await adminClient.from("employees").select("team_id").eq("id", employeeId).single(); - expect(revertedEmployee?.team_id).toBe(teamA.id); - - const { data: undoneScenario } = await adminClient.from("reorg_scenarios").select("applied").eq("id", scenarioId as string).single(); - expect(undoneScenario?.applied).toBe(false); - - // The original "Reorganisation" row must still be there — undo appends - // a compensating entry, it never deletes (the bug the migration fixed). - const { count: historyAfterUndo } = await adminClient - .from("employee_history") - .select("id", { count: "exact", head: true }) - .eq("employee_id", employeeId) - .eq("event_type", "Reorganisation"); - expect(historyAfterUndo).toBe(2); - }); - - it("defers a future-dated reorg and only flips reorg_scenarios.applied once its pending change lands", async () => { - const employeeId = await freshEmployee(teamA.id); - const teamBLead = await teamLeadId(teamB.id); - - const { data: scenarioId, error: applyError } = await hrClient.rpc("apply_reorg", { - payload: { - name: `Integrationstest Reorg (zukünftig) ${employeeId.slice(0, 8)}`, - effective_date: isoDateOffset(30), - moves: [{ kind: "emp", label: "Test", employee_ids: [employeeId], target_team_id: teamB.id }], - }, - }); - expect(applyError).toBeNull(); - - const { data: unchangedEmployee } = await adminClient.from("employees").select("team_id").eq("id", employeeId).single(); - expect(unchangedEmployee?.team_id).toBe(teamA.id); - - const { data: scenarioBefore } = await adminClient - .from("reorg_scenarios") - .select("applied") - .eq("id", scenarioId as string) - .single(); - expect(scenarioBefore?.applied).toBe(false); - - const { data: pending } = await adminClient - .from("pending_org_changes") - .select("id") - .eq("employee_id", employeeId) - .eq("reorg_scenario_id", scenarioId as string) - .eq("status", "pending") - .single(); - expect(pending).not.toBeNull(); - - await adminClient.from("pending_org_changes").update({ effective_date: isoDateOffset(0) }).eq("id", pending!.id); - const { error: cronError } = await adminClient.rpc("apply_due_pending_changes"); - expect(cronError).toBeNull(); - - const { data: movedEmployee } = await adminClient.from("employees").select("team_id, manager_id").eq("id", employeeId).single(); - expect(movedEmployee?.team_id).toBe(teamB.id); - expect(movedEmployee?.manager_id).toBe(teamBLead); - - const { data: scenarioAfter } = await adminClient - .from("reorg_scenarios") - .select("applied, applied_at") - .eq("id", scenarioId as string) - .single(); - expect(scenarioAfter?.applied).toBe(true); - expect(scenarioAfter?.applied_at).not.toBeNull(); - }); -}); diff --git a/tests/integration/svnr-validation.test.ts b/tests/integration/svnr-validation.test.ts index 6be5e0d..1a4189e 100644 --- a/tests/integration/svnr-validation.test.ts +++ b/tests/integration/svnr-validation.test.ts @@ -1,7 +1,19 @@ import type { SupabaseClient } from "@supabase/supabase-js"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import type { Database } from "@/lib/supabase/types"; -import { adminClient, createHrUser, deleteTestEmployee, deleteTestUser, hireTestEmployee, pickSeededTeam, signInAs, type TestUser } from "./helpers"; +import { + adminClient, + createHrUser, + createTestPosition, + deleteTestEmployee, + deleteTestPosition, + deleteTestUser, + hireTestEmployee, + isoDateOffset, + pickSeededUnit, + signInAs, + type TestUser, +} from "./helpers"; // SVNR validation (supabase/migrations/20260725120000_svnr_validation.sql). // Enforced by a trigger, so these drive it through the real write paths and @@ -9,8 +21,9 @@ import { adminClient, createHrUser, deleteTestEmployee, deleteTestUser, hireTest describe("SVNR validation", () => { let hrUser: TestUser; let hrClient: SupabaseClient; - let team: { id: string }; + let unit: { id: string }; const employeeIds: string[] = []; + const positionIds: string[] = []; // 3·1 + 7·2 + 9·3 = 44; 010180 contributes 18; 62 mod 11 = 7 const VALID = "1237 010180"; @@ -25,16 +38,21 @@ describe("SVNR validation", () => { beforeAll(async () => { hrUser = await createHrUser({ active: true }); hrClient = await signInAs(hrUser); - team = await pickSeededTeam(); + unit = await pickSeededUnit(); }); afterAll(async () => { for (const id of employeeIds) await deleteTestEmployee(id); + for (const id of positionIds) await deleteTestPosition(id); await deleteTestUser(hrUser); }); async function hireAt(country: string, overrides: Record = {}): Promise { - const id = await hireTestEmployee(hrClient, team.id, { + // Eine eigene Planstelle je Einstellung: eine geteilte wäre nach der + // ersten besetzt. + const positionId = await createTestPosition(hrClient, unit.id, { valid_from: isoDateOffset(-40) }); + positionIds.push(positionId); + const id = await hireTestEmployee(hrClient, positionId, { location_id: await locationIn(country), birth_date: BIRTH_DATE, ...overrides, diff --git a/tests/unit/org.test.ts b/tests/unit/org.test.ts index 343ed69..a62d250 100644 --- a/tests/unit/org.test.ts +++ b/tests/unit/org.test.ts @@ -1,52 +1,102 @@ import { describe, expect, it } from "vitest"; -import { breadcrumbFor, breadcrumbLabel, type OrgMaps } from "@/lib/org"; -import type { Database } from "@/lib/supabase/types"; +import { ancestorsOf, breadcrumbLabel, buildOrgMaps, divisionOf, subtreeOf, type OrgUnit } from "@/lib/org"; -type Division = Database["public"]["Tables"]["divisions"]["Row"]; -type Department = Database["public"]["Tables"]["departments"]["Row"]; -type Team = Database["public"]["Tables"]["teams"]["Row"]; -type Location = Database["public"]["Tables"]["locations"]["Row"]; +// Der Baum ersetzt die drei festen Ebenen des Altmodells. Damit hängt an +// dieser Datei mehr als vorher: eine Einheit falsch verkettet, und ein Filter +// „Bereich Produktion" liefert stillschweigend zu wenig — nicht gar nichts, +// was auffallen würde. -const division: Division = { id: "div-1", org_number: "20100000", name: "Produktion" }; -const department: Department = { id: "dept-1", org_number: "21100000", name: "Fertigung", division_id: "div-1" }; -const team: Team = { id: "team-1", org_number: "22010000", name: "Montage", department_id: "dept-1" }; -const location: Location = { id: "loc-1", name: "Wien-Hernals", country: "Österreich" }; +const units: OrgUnit[] = [ + { id: "gmbh", org_number: "10000000", name: "Alpenwerk", parent_id: null, unit_type: "Gesellschaft" }, + { id: "prod", org_number: "20100000", name: "Produktion", parent_id: "gmbh", unit_type: "Bereich" }, + { id: "fert", org_number: "21100000", name: "Fertigung", parent_id: "prod", unit_type: "Abteilung" }, + { id: "mont", org_number: "22001000", name: "Montage", parent_id: "fert", unit_type: "Team" }, + { id: "cnc", org_number: "22002000", name: "CNC", parent_id: "fert", unit_type: "Team" }, + { id: "it", org_number: "20200000", name: "IT", parent_id: "gmbh", unit_type: "Bereich" }, +]; +const locations = [{ id: "loc-1", name: "Wien-Hernals", country: "Österreich" }]; +const maps = buildOrgMaps(units, locations); -const orgMaps: OrgMaps = { - divisions: new Map([[division.id, division]]), - departments: new Map([[department.id, department]]), - teams: new Map([[team.id, team]]), - locations: new Map([[location.id, location]]), - divisionList: [division], - locationList: [location], -}; - -describe("breadcrumbFor", () => { - it("resolves division, department (via team), and team from ids", () => { - const result = breadcrumbFor(orgMaps, division.id, team.id); - expect(result.division?.name).toBe("Produktion"); - expect(result.department?.name).toBe("Fertigung"); - expect(result.team?.name).toBe("Montage"); +describe("buildOrgMaps", () => { + it("listet Eltern vor ihren Kindern", () => { + // Die Reihenfolge ist eine Zusage: der Filter im Mitarbeiterlisten-Select + // rückt danach ein, und ein Einfügen in die Datenbank verlässt sich darauf. + const position = new Map(maps.unitList.map((u, i) => [u.id, i])); + for (const u of maps.unitList) { + if (!u.parent_id) continue; + expect(position.get(u.parent_id)!, `${u.name} steht vor ${u.parent_id}`).toBeLessThan(position.get(u.id)!); + } }); - it("has no team/department for a division head with no team (team_id null)", () => { - const result = breadcrumbFor(orgMaps, division.id, null); - expect(result.division?.name).toBe("Produktion"); - expect(result.team).toBeUndefined(); - expect(result.department).toBeUndefined(); + it("misst die Tiefe ab der Wurzel", () => { + expect(maps.depthOf.get("gmbh")).toBe(0); + expect(maps.depthOf.get("prod")).toBe(1); + expect(maps.depthOf.get("mont")).toBe(3); + }); +}); + +describe("ancestorsOf", () => { + it("gibt die Kette von der Wurzel bis zur Einheit selbst", () => { + expect(ancestorsOf(maps, "mont").map((u) => u.name)).toEqual(["Alpenwerk", "Produktion", "Fertigung", "Montage"]); + }); + + it("bricht bei einem Ring ab, statt endlos zu laufen", () => { + // parent_id ist eine gewöhnliche Spalte; ein fehlerhafter Import kann + // einen Ring erzeugen, und jede Auswertung hier ist rekursiv. + const ringMaps = buildOrgMaps( + [ + { id: "a", org_number: "1", name: "A", parent_id: "b", unit_type: "Bereich" }, + { id: "b", org_number: "2", name: "B", parent_id: "a", unit_type: "Bereich" }, + ], + [] + ); + expect(ancestorsOf(ringMaps, "a")).toHaveLength(2); + }); + + it("liefert nichts für eine unbekannte Einheit", () => { + expect(ancestorsOf(maps, "gibtesnicht")).toEqual([]); + expect(ancestorsOf(maps, null)).toEqual([]); + }); +}); + +describe("subtreeOf", () => { + it("schliesst die Einheit selbst und alles darunter ein", () => { + // Genau das meint der Filter „Bereich Produktion": im Bereich selbst + // sitzt nur die Bereichsleitung, alle anderen hängen tiefer. + expect(new Set(subtreeOf(maps, "prod"))).toEqual(new Set(["prod", "fert", "mont", "cnc"])); + }); + + it("ist für ein Blatt die Einheit allein", () => { + expect(subtreeOf(maps, "mont")).toEqual(["mont"]); + }); +}); + +describe("divisionOf", () => { + it("findet den Bereich einer tief hängenden Einheit", () => { + expect(divisionOf(maps, "mont")?.name).toBe("Produktion"); + }); + + it("gibt für eine Bereichsleitung ihren eigenen Bereich", () => { + expect(divisionOf(maps, "prod")?.name).toBe("Produktion"); + }); + + it("hat für die Gesellschaft selbst keinen Bereich", () => { + // Die Geschäftsführung sitzt über allen Bereichen, nicht in einem. + expect(divisionOf(maps, "gmbh")).toBeUndefined(); }); }); describe("breadcrumbLabel", () => { - it("joins division › department › team with the SAP-OM breadcrumb separator", () => { - expect(breadcrumbLabel(orgMaps, division.id, team.id)).toBe("Produktion › Fertigung › Montage"); + it("lässt die Gesellschaft weg, die in jeder Zeile gleich wäre", () => { + expect(breadcrumbLabel(maps, "mont")).toBe("Produktion › Fertigung › Montage"); }); - it("omits missing segments instead of producing empty separators", () => { - expect(breadcrumbLabel(orgMaps, division.id, null)).toBe("Produktion"); + it("endet bei der Einheit, an der die Person tatsächlich hängt", () => { + expect(breadcrumbLabel(maps, "prod")).toBe("Produktion"); }); - it("falls back to a dash when nothing resolves", () => { - expect(breadcrumbLabel(orgMaps, null, null)).toBe("–"); + it("fällt auf einen Strich zurück, wenn nichts auflösbar ist", () => { + expect(breadcrumbLabel(maps, null)).toBe("–"); + expect(breadcrumbLabel(maps, "gmbh")).toBe("–"); }); }); diff --git a/tests/unit/orgchart-data.test.ts b/tests/unit/orgchart-data.test.ts index d019b3e..610505e 100644 --- a/tests/unit/orgchart-data.test.ts +++ b/tests/unit/orgchart-data.test.ts @@ -1,27 +1,38 @@ import { describe, expect, it } from "vitest"; import { resolveOrgSnapshot } from "@/lib/orgchart-data"; -const DIV = "div-1"; -const TEAM_A = "team-a"; -const TEAM_B = "team-b"; +// Der Stand zu einem Stichtag. Im Altmodell mussten dafür drei Quellen +// versöhnt werden; im OM-Modell beantwortet die zeitabhängige Besetzung fast +// alles allein — was diese Datei deutlich kürzer macht, aber nicht +// überflüssig: die Projektion vorgemerkter Versetzungen und die Frage, wer am +// Stichtag überhaupt dazuzählte, entscheiden sich weiterhin hier. -type EmployeeInput = Parameters[0]["employees"][number]; -type AssignmentInput = Parameters[0]["assignments"][number]; +type Args = Parameters[0]; -function emp(id: string, overrides: Partial = {}): EmployeeInput { +const UNITS: Args["units"] = [ + { id: "gmbh", parentId: null }, + { id: "prod", parentId: "gmbh" }, + { id: "team-a", parentId: "prod" }, + { id: "team-b", parentId: "prod" }, +]; + +// Planstellen: je Einheit eine Leitung, dazu Mitarbeiterstellen. +const POSITIONS: Args["positions"] = [ + { id: "p-gf", position_number: "60000001", org_unit_id: "gmbh", is_chief: true, jobs: { title: "Geschäftsführung" } }, + { id: "p-bl", position_number: "60000002", org_unit_id: "prod", is_chief: true, jobs: { title: "Bereichsleitung" } }, + { id: "p-tl-a", position_number: "60000003", org_unit_id: "team-a", is_chief: true, jobs: { title: "Teamleitung A" } }, + { id: "p-tl-b", position_number: "60000004", org_unit_id: "team-b", is_chief: true, jobs: { title: "Teamleitung B" } }, + { id: "p-a1", position_number: "60000005", org_unit_id: "team-a", is_chief: false, jobs: { title: "Monteur:in" } }, + { id: "p-b1", position_number: "60000006", org_unit_id: "team-b", is_chief: false, jobs: { title: "Fräser:in" } }, +]; + +function emp(id: string, overrides: Partial = {}): Args["employees"][number] { return { id, personnel_number: 1000, first_name: "Test", last_name: id, - job_title: "Mitarbeiter:in", - manager_id: null, - team_id: TEAM_A, - division_id: DIV, - is_lead: false, - org_level: 3, - entry_date: "2020-01-01", - exit_date: null, + job_title: "Freitext auf der Person", karenz_start_date: null, karenz_return_date: null, absence_type: null, @@ -29,141 +40,158 @@ function emp(id: string, overrides: Partial = {}): EmployeeInput }; } -function assignment(employeeId: string, overrides: Partial = {}): AssignmentInput { - return { - employee_id: employeeId, - manager_id: null, - team_id: TEAM_A, - division_id: DIV, - job_title: "Mitarbeiter:in", - is_lead: false, - org_level: 3, - valid_from: "2020-01-01", - ...overrides, - }; +function snapshot(args: Partial & { asOf: string }) { + return resolveOrgSnapshot({ + units: UNITS, + positions: POSITIONS, + assignments: [], + employees: [], + pending: [], + historyStartsAt: null, + ...args, + }); } -const TEAMS = [ - { id: TEAM_A, department_id: "dept-1" }, - { id: TEAM_B, department_id: "dept-2" }, -]; -const DEPARTMENTS = [ - { id: "dept-1", division_id: DIV }, - { id: "dept-2", division_id: "div-2" }, -]; - -function snapshot(args: Partial[0]> & { asOf: string }) { - return resolveOrgSnapshot({ employees: [], assignments: [], teams: TEAMS, departments: DEPARTMENTS, pending: [], ...args }); -} - -describe("membership as of a date", () => { - it("excludes someone who had not started yet and includes them once they have", () => { - const employees = [emp("a", { entry_date: "2026-06-01" })]; - expect(snapshot({ asOf: "2026-05-31", employees }).employees).toHaveLength(0); - expect(snapshot({ asOf: "2026-06-01", employees }).employees).toHaveLength(1); +describe("Zugehörigkeit zum Stichtag", () => { + it("zeigt nur, wer am Stichtag eine Planstelle innehatte", () => { + // Die Zugehörigkeit ist keine eigene Regel mehr: wer keine Planstelle + // hat, steht nicht in der Organisation. Ein-, Austritt und geplanter + // Eintritt stecken alle in der Gültigkeit der Besetzung. + const employees = [emp("a"), emp("b")]; + const result = snapshot({ + asOf: "2026-06-01", + employees, + assignments: [{ employee_id: "a", position_id: "p-a1" }], + }); + expect(result.employees.map((e) => e.id)).toEqual(["a"]); }); - it("excludes someone from their exit date onwards", () => { - const employees = [emp("a", { exit_date: "2026-06-30" })]; - expect(snapshot({ asOf: "2026-06-29", employees }).employees).toHaveLength(1); - expect(snapshot({ asOf: "2026-06-30", employees }).employees).toHaveLength(0); - }); - - it("keeps someone on Karenz in the chart", () => { - const employees = [emp("a", { karenz_start_date: "2026-01-01", karenz_return_date: "2026-12-01" })]; - expect(snapshot({ asOf: "2026-06-01", employees }).employees).toHaveLength(1); + it("übergeht eine Besetzung auf einer am Stichtag ungültigen Planstelle", () => { + // loadOrgAsOf filtert Planstellen bereits nach Gültigkeit; kommt eine + // Besetzung ohne passende Planstelle an, wäre sie im Baum nicht + // verortbar. + const result = snapshot({ + asOf: "2026-06-01", + employees: [emp("a")], + assignments: [{ employee_id: "a", position_id: "gibtesnicht" }], + }); + expect(result.employees).toHaveLength(0); }); }); -describe("placement as of a date", () => { - it("uses the assignment interval covering the date, not today's row on employees", () => { - const employees = [emp("a", { team_id: TEAM_B, job_title: "Heutiger Titel" })]; - const assignments = [assignment("a", { team_id: TEAM_A, job_title: "Damaliger Titel" })]; - const [result] = snapshot({ asOf: "2024-03-01", employees, assignments }).employees; - expect(result.team_id).toBe(TEAM_A); - expect(result.job_title).toBe("Damaliger Titel"); +describe("Berichtslinie", () => { + const employees = [emp("gf"), emp("bl"), emp("tl-a"), emp("a1")]; + const assignments = [ + { employee_id: "gf", position_id: "p-gf" }, + { employee_id: "bl", position_id: "p-bl" }, + { employee_id: "tl-a", position_id: "p-tl-a" }, + { employee_id: "a1", position_id: "p-a1" }, + ]; + + it("führt eine Mitarbeiterin an die Leitung ihrer Einheit", () => { + const result = snapshot({ asOf: "2026-06-01", employees, assignments }); + expect(result.employees.find((e) => e.id === "a1")!.manager_id).toBe("tl-a"); }); - it("falls back to the employee row when no assignment covers the date", () => { - const employees = [emp("a", { team_id: TEAM_B })]; - const [result] = snapshot({ asOf: "2024-03-01", employees, assignments: [] }).employees; - expect(result.team_id).toBe(TEAM_B); + it("führt eine Leitung an die Leitung darüber, nicht an sich selbst", () => { + const result = snapshot({ asOf: "2026-06-01", employees, assignments }); + expect(result.employees.find((e) => e.id === "tl-a")!.manager_id).toBe("bl"); + expect(result.employees.find((e) => e.id === "gf")!.manager_id).toBeNull(); + }); + + it("rollt bei unbesetzter Leitung eine Ebene hoch", () => { + const ohneTeamleitung = assignments.filter((a) => a.employee_id !== "tl-a"); + const result = snapshot({ asOf: "2026-06-01", employees, assignments: ohneTeamleitung }); + expect(result.employees.find((e) => e.id === "a1")!.manager_id).toBe("bl"); + }); + + it("nennt bei Abwesenheit beide: die zuständige und die tatsächliche Leitung", () => { + // Sonst gäbe die Oberfläche die Vertretung stillschweigend als die echte + // Führungskraft aus. + const abwesend = employees.map((e) => + e.id === "tl-a" ? emp("tl-a", { karenz_start_date: "2026-01-01", karenz_return_date: "2026-12-01" }) : e + ); + const [a1] = snapshot({ asOf: "2026-06-01", employees: abwesend, assignments }).employees.filter((e) => e.id === "a1"); + expect(a1.manager_id).toBe("bl"); + expect(a1.formal_manager_id).toBe("tl-a"); + }); + + it("lässt formal_manager_id leer, solange niemand vertritt", () => { + const [a1] = snapshot({ asOf: "2026-06-01", employees, assignments }).employees.filter((e) => e.id === "a1"); + expect(a1.formal_manager_id).toBeNull(); }); }); -describe("orphan re-rooting", () => { - // Without this the whole reporting line below an absent manager silently - // disappears from the chart instead of moving up a level. - it("drops a manager reference to somebody not employed on that date", () => { - const employees = [ - emp("boss", { exit_date: "2026-01-01", org_level: 2, is_lead: true }), - emp("report", { manager_id: "boss" }), - ]; - const assignments = [assignment("report", { manager_id: "boss" })]; - const result = snapshot({ asOf: "2026-06-01", employees, assignments }).employees; - expect(result).toHaveLength(1); - expect(result[0].id).toBe("report"); - expect(result[0].manager_id).toBeNull(); +describe("Tätigkeit", () => { + it("nimmt die Tätigkeit der Planstelle, nicht das Freitextfeld der Person", () => { + // Bei einer projizierten Versetzung ist nur die erste schon richtig. + const [result] = snapshot({ + asOf: "2026-06-01", + employees: [emp("a", { job_title: "Veraltet" })], + assignments: [{ employee_id: "a", position_id: "p-a1" }], + }).employees; + expect(result.job_title).toBe("Monteur:in"); + expect(result.position_number).toBe("60000005"); }); }); -describe("future projection from pending changes", () => { - const leadB = emp("lead-b", { id: "lead-b", team_id: TEAM_B, division_id: "div-2", is_lead: true, org_level: 2 }); +describe("Projektion vorgemerkter Versetzungen", () => { + const employees = [emp("bl"), emp("tl-b"), emp("a")]; + const assignments = [ + { employee_id: "bl", position_id: "p-bl" }, + { employee_id: "tl-b", position_id: "p-tl-b" }, + { employee_id: "a", position_id: "p-a1" }, + ]; - it("moves an employee into the target team and under that team's lead", () => { - const employees = [emp("a", { manager_id: "lead-a" }), leadB]; - const assignments = [assignment("a", { manager_id: "lead-a" }), assignment("lead-b", { team_id: TEAM_B, division_id: "div-2", is_lead: true, org_level: 2 })]; - const pending = [{ employee_id: "a", effective_date: "2026-08-01", payload: { new_team_id: TEAM_B } }]; - - const result = snapshot({ asOf: "2026-09-01", employees, assignments, pending }); + it("setzt die Person auf die Zielplanstelle und damit unter deren Leitung", () => { + const result = snapshot({ + asOf: "2026-09-01", + employees, + assignments, + pending: [{ employee_id: "a", effective_date: "2026-08-01", payload: { target_position_id: "p-b1" } }], + }); const moved = result.employees.find((e) => e.id === "a")!; - expect(moved.team_id).toBe(TEAM_B); - expect(moved.division_id).toBe("div-2"); - expect(moved.manager_id).toBe("lead-b"); + expect(moved.org_unit_id).toBe("team-b"); + expect(moved.job_title).toBe("Fräser:in"); + expect(moved.manager_id).toBe("tl-b"); expect(result.projectedCount).toBe(1); }); - it("lets a later change win over an earlier one", () => { - const employees = [emp("a")]; - const assignments = [assignment("a")]; - const pending = [ - { employee_id: "a", effective_date: "2026-08-01", payload: { new_team_id: TEAM_B, new_title: "Zwischenstand" } }, - { employee_id: "a", effective_date: "2026-09-01", payload: { new_team_id: TEAM_A, new_title: "Endstand" } }, - ]; - const [result] = snapshot({ asOf: "2026-10-01", employees, assignments, pending }).employees; - expect(result.team_id).toBe(TEAM_A); - expect(result.job_title).toBe("Endstand"); + it("lässt eine spätere Versetzung über eine frühere gewinnen", () => { + const [result] = snapshot({ + asOf: "2026-10-01", + employees, + assignments, + pending: [ + { employee_id: "a", effective_date: "2026-08-01", payload: { target_position_id: "p-b1" } }, + { employee_id: "a", effective_date: "2026-09-01", payload: { target_position_id: "p-a1" } }, + ], + }).employees.filter((e) => e.id === "a"); + expect(result.org_unit_id).toBe("team-a"); }); - it("leaves an untouched employee's manager exactly as recorded", () => { - // Deliberately deviating from the resolve rule: real data drifts, and a - // snapshot must not silently "repair" reporting lines it was not asked - // to change. - const employees = [emp("a", { manager_id: "someone-else" }), emp("someone-else", { id: "someone-else" }), leadB]; - const assignments = [assignment("a", { manager_id: "someone-else" })]; - const [result] = snapshot({ asOf: "2026-09-01", employees, assignments }).employees; - expect(result.manager_id).toBe("someone-else"); - }); - - it("ignores pending changes for a date the caller did not ask about", () => { - // loadOrgAsOf only fetches pending rows for a future date, so an empty - // list here must simply mean "no projection", not "drop the employee". - const employees = [emp("a")]; - const assignments = [assignment("a")]; - const result = snapshot({ asOf: "2026-09-01", employees, assignments, pending: [] }); + it("übergeht eine Versetzung auf eine unbekannte Planstelle, statt die Person zu verlieren", () => { + const result = snapshot({ + asOf: "2026-09-01", + employees, + assignments, + pending: [{ employee_id: "a", effective_date: "2026-08-01", payload: { target_position_id: "weg" } }], + }); + expect(result.employees.find((e) => e.id === "a")!.org_unit_id).toBe("team-a"); expect(result.projectedCount).toBe(0); - expect(result.employees).toHaveLength(1); }); }); -describe("historyStartsAt", () => { - it("reports the earliest recorded assignment so the UI can flag older dates", () => { - const employees = [emp("a"), emp("b", { id: "b" })]; - const assignments = [assignment("a", { valid_from: "2023-05-01" }), assignment("b", { valid_from: "2021-02-01" })]; - expect(snapshot({ asOf: "2026-01-01", employees, assignments }).historyStartsAt).toBe("2021-02-01"); - }); - - it("is null when nothing is recorded yet", () => { - expect(snapshot({ asOf: "2026-01-01", employees: [emp("a")] }).historyStartsAt).toBeNull(); +describe("Vakanzen", () => { + it("meldet jede am Stichtag unbesetzte Planstelle", () => { + // Vakanz ist im OM-Modell kein eigenes Objekt, sondern das Komplement der + // Besetzungen — sie kann deshalb gar nicht mehr aus dem Tritt geraten. + const result = snapshot({ + asOf: "2026-06-01", + employees: [emp("a")], + assignments: [{ employee_id: "a", position_id: "p-a1" }], + }); + expect(result.vacancies.map((v) => v.position_id).sort()).toEqual(["p-b1", "p-bl", "p-gf", "p-tl-a", "p-tl-b"]); + expect(result.vacancies.find((v) => v.position_id === "p-tl-a")!.is_chief).toBe(true); }); }); diff --git a/tests/unit/reports.test.ts b/tests/unit/reports.test.ts index f7c4793..04f7342 100644 --- a/tests/unit/reports.test.ts +++ b/tests/unit/reports.test.ts @@ -18,8 +18,7 @@ function emp(overrides: Partial = {}): ReportEmployee { first_name: "Maria", last_name: "Gruber", job_title: "Maschinenbediener:in", - division_id: "div-1", - team_id: "team-1", + org_unit_id: "team-1", location_id: "loc-1", status: "Aktiv", employment_type: "Vollzeit", @@ -43,9 +42,16 @@ function emp(overrides: Partial = {}): ReportEmployee { }; } +// Alle drei sind über die Einheit geschlüsselt, in der die Person sitzt: +// "team-1" hängt unter der Abteilung Fertigung im Bereich Produktion. +// "div-1" ist der Bereich selbst — dort sitzt nur die Bereichsleitung, die +// weder Abteilung noch Team hat. const lookups: OrgLookups = { - divisionName: new Map([["div-1", "Produktion"]]), - departmentNameByTeam: new Map([["team-1", "Fertigung"]]), + divisionName: new Map([ + ["team-1", "Produktion"], + ["div-1", "Produktion"], + ]), + departmentName: new Map([["team-1", "Fertigung"]]), teamName: new Map([["team-1", "Montage"]]), locationName: new Map([["loc-1", "Wien-Hernals"]]), }; @@ -65,14 +71,23 @@ describe("groupKeyFor", () => { expect(groupKeyFor(e, "location", lookups)).toBe("Wien-Hernals"); }); - it("falls back to a dash for department/team when the employee has no team", () => { - const e = emp({ team_id: null }); + it("falls back to a dash for department/team for somebody sitting at the division itself", () => { + // Eine Bereichsleitung hängt am Bereich, nicht an einem Team — vorher + // liess sich das nur über team_id = null ausdrücken. + const e = emp({ org_unit_id: "div-1" }); + expect(groupKeyFor(e, "division", lookups)).toBe("Produktion"); expect(groupKeyFor(e, "department", lookups)).toBe("–"); expect(groupKeyFor(e, "team", lookups)).toBe("–"); }); + it("falls back to a dash when somebody held no position at all", () => { + const e = emp({ org_unit_id: null }); + expect(groupKeyFor(e, "division", lookups)).toBe("–"); + expect(groupKeyFor(e, "team", lookups)).toBe("–"); + }); + it("falls back to 'Unbekannt' for an unresolvable division/location id", () => { - const e = emp({ division_id: "ghost", location_id: "ghost" }); + const e = emp({ org_unit_id: "ghost", location_id: "ghost" }); expect(groupKeyFor(e, "division", lookups)).toBe("Unbekannt"); expect(groupKeyFor(e, "location", lookups)).toBe("Unbekannt"); }); @@ -166,15 +181,17 @@ describe("measureValue", () => { describe("aggregateReport", () => { it("groups rows, sorts groups descending by value, and never includes a salary field", () => { const employees = [ - emp({ id: "1", division_id: "div-1" }), - emp({ id: "2", division_id: "div-1" }), - emp({ id: "3", division_id: "div-2" }), + emp({ id: "1", org_unit_id: "team-1" }), + emp({ id: "2", org_unit_id: "team-1" }), + emp({ id: "3", org_unit_id: "team-2" }), ]; + // Geschlüsselt über die Einheit, in der die Person sitzt: team-1 hängt + // unter Produktion, team-2 unter IT. const multiDivisionLookups: OrgLookups = { ...lookups, divisionName: new Map([ - ["div-1", "Produktion"], - ["div-2", "IT"], + ["team-1", "Produktion"], + ["team-2", "IT"], ]), }; const rows = aggregateReport(employees, "headcount", "division", null, multiDivisionLookups); @@ -229,7 +246,7 @@ describe("aggregateReport", () => { }); it("sorts a weekday split chronologically within each group", () => { - const employees = [emp({ id: "1", division_id: "div-1", work_days: ["Fr", "Mo"] })]; + const employees = [emp({ id: "1", org_unit_id: "team-1", work_days: ["Fr", "Mo"] })]; const rows = aggregateReport(employees, "headcount", "division", "weekday", lookups); expect(rows[0].split?.map((s) => s.key)).toEqual(["Mo", "Fr"]); }); @@ -259,8 +276,8 @@ describe("aggregateEvents", () => { const eventLookups: OrgLookups = { ...lookups, divisionName: new Map([ - ["div-1", "Produktion"], - ["div-2", "IT"], + ["team-1", "Produktion"], + ["team-2", "IT"], ]), }; @@ -270,8 +287,7 @@ describe("aggregateEvents", () => { first_name: "Maria", last_name: "Gruber", job_title: "Maschinenbediener:in", - division_id: "div-1", - team_id: "team-1", + org_unit_id: "team-1", location_id: "loc-1", event_date: "2026-03-01", event_type: "Eintritt", @@ -281,14 +297,14 @@ describe("aggregateEvents", () => { } it("counts every event, unlike a Bestand headcount which only sees the current entry_date", () => { - const events = [ev({ employee_id: "1" }), ev({ employee_id: "1", event_date: "2026-05-01", event_type: "Beförderung" }), ev({ employee_id: "2", division_id: "div-2" })]; + const events = [ev({ employee_id: "1" }), ev({ employee_id: "1", event_date: "2026-05-01", event_type: "Beförderung" }), ev({ employee_id: "2", org_unit_id: "team-2" })]; const rows = aggregateEvents(events, "event_type", null, eventLookups); expect(rows.find((r) => r.key === "Eintritt")).toMatchObject({ value: 2, count: 2 }); expect(rows.find((r) => r.key === "Beförderung")).toMatchObject({ value: 1, count: 1 }); }); it("groups by the affected employee's org unit and preserves the event description on drill-down", () => { - const events = [ev({ division_id: "div-1" }), ev({ division_id: "div-2", employee_id: "2" })]; + const events = [ev({ org_unit_id: "team-1" }), ev({ org_unit_id: "team-2", employee_id: "2" })]; const rows = aggregateEvents(events, "division", null, eventLookups); const produktion = rows.find((r) => r.key === "Produktion")!; expect(produktion.people[0]).toMatchObject({ id: "1", title: "Eintritt als Maschinenbediener:in", entry_date: "2026-03-01" }); From 2cce101c4b98a2c469c36bc1e300720ee97682eb Mon Sep 17 00:00:00 2001 From: Maximilian Stubhan Date: Mon, 27 Jul 2026 20:34:00 +0200 Subject: [PATCH 07/64] Sign in with Entra ID, and give the login screen something to look at MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die Anmeldung läuft über das Firmenkonto. Supabase Auth bleibt dabei die Sitzungsverwaltung — Entra ist der Anbieter, nicht der Ersatz. Genau deshalb ist der Eingriff klein: auth.uid() liefert weiterhin eine UUID, profiles.id trägt weiterhin role und is_active, und damit bleiben is_hr_user() und alle 58 RLS-Policies unverändert gültig. Die Sicherheitsgrenze wandert nicht in den Anwendungscode. Der Passwort-Pfad ist weg, nicht deaktiviert. Ein zweiter Anmeldeweg neben dem Firmenkonto hebelt jede Vorgabe des Mandanten aus — Mehrfaktor, bedingten Zugriff, Sperrung beim Austritt. Dazu die Rückweg-Route /auth/callback, die den PKCE-Code gegen eine Sitzung tauscht, und eine Ausnahme im Proxy: ohne sie leitet der Gate den Code nach /login um, weil es die Sitzung ja erst danach gibt, und die Anmeldung kommt nie zustande. Ob jemand HR-Zugriff hat, entscheidet weiterhin nicht die Route, sondern profiles.role/is_active und darunter die Policies. Zwei Werkzeuge für die Umstellung: - relink-profile.ts hängt eine bestehende profiles-Zeile auf die Entra-Identität um. Ein Passwort-Konto und das Entra-Konto derselben Person sind für Supabase zwei Benutzer mit verschiedenen IDs; ohne das zeigt die profiles-Zeile nach der ersten SSO-Anmeldung ins Leere und man sperrt sich aus. Die Fremdschlüssel auf auth.users wandern mit, sonst stünde in der Historie eine Kennung ohne Konto dahinter. - entra-claims.ts zeigt, was der Anbieter tatsächlich mitgeschickt hat. Die geplante Freischaltung über eine Entra-Gruppe hängt daran, wie der Anspruch heisst und aussieht, und das unterscheidet sich je nach Tokenkonfiguration des Mandanten. Der Trigger wird erst danach gebaut, sonst wäre er geraten. Beim Auswerten der Gruppe später gilt: die Quelle ist auth.identities. identity_data, nie raw_user_meta_data. Letzteres beschreibt die angemeldete Person über updateUser() selbst — läse die Freischaltung von dort, könnte sich jede:r Angemeldete HR-Rechte eintragen. Steht so in docs/entra-sso.md. Die Anmeldeseite war eine Box im leeren Rosa. Jetzt zweispaltig: links eine Markenfläche, rechts die Anmeldung; unter 1024px fällt die Fläche weg und die Wortmarke rückt über die Karte. Die Microsoft-Schaltfläche ist bewusst nicht mehr in der Hausfarbe — magenta las sich als Aktion *innerhalb* dieser Anwendung, während sie auf eine fremde Anmeldeseite springt. Weiss mit grauem Rand ist Microsofts eigene Vorgabe und das Muster, das man wiedererkennt. Dazu ein Wartezustand für den Sprung und eine Fehlermeldung, die erklärt, was zu tun ist, statt nur "Kein HR-Zugriff" zu behaupten. Nachgemessen im laufenden Server statt geschätzt: 656/624 auf 1280px, Markenfläche in brand-700, Schaltfläche 45px hoch, kein Querlauf auf 375px. Typecheck, Lint, Build und 182 Tests sind grün. --- actions/auth.ts | 35 ++++-- app/(auth)/login/page.tsx | 161 +++++++++++++++++--------- app/auth/callback/route.ts | 30 +++++ components/auth/EntraSignInButton.tsx | 32 +++++ docs/entra-sso.md | 117 +++++++++++++++++++ proxy.ts | 5 + supabase/entra-claims.ts | 70 +++++++++++ supabase/relink-profile.ts | 110 ++++++++++++++++++ 8 files changed, 499 insertions(+), 61 deletions(-) create mode 100644 app/auth/callback/route.ts create mode 100644 components/auth/EntraSignInButton.tsx create mode 100644 docs/entra-sso.md create mode 100644 supabase/entra-claims.ts create mode 100644 supabase/relink-profile.ts diff --git a/actions/auth.ts b/actions/auth.ts index 09f3ade..9ce36c3 100644 --- a/actions/auth.ts +++ b/actions/auth.ts @@ -1,20 +1,39 @@ "use server"; +import { headers } from "next/headers"; import { redirect } from "next/navigation"; import { createClient } from "@/lib/supabase/server"; -export async function login(formData: FormData) { - const email = String(formData.get("email") ?? ""); - const password = String(formData.get("password") ?? ""); +// Anmeldung ausschliesslich über Entra ID (in Supabase heisst der Anbieter +// „Azure"). Es gibt bewusst keinen Passwort-Pfad mehr: ein zweiter Anmeldeweg +// neben dem Firmenkonto hebelt jede Vorgabe des Mandanten aus — Mehrfaktor, +// bedingten Zugriff, Sperrung beim Austritt. +// +// Für die Datenbank ändert sich dadurch nichts. auth.uid() liefert weiterhin +// eine UUID, profiles.id trägt weiterhin role und is_active, und damit bleiben +// is_hr_user() und alle darauf gebauten RLS-Policies unverändert gültig. +export async function signInWithEntra() { const supabase = await createClient(); - const { error } = await supabase.auth.signInWithPassword({ email, password }); - if (error) { - redirect("/login?error=invalid_credentials"); - } + // Die Herkunft kommt aus dem Request statt aus einer Umgebungsvariablen, + // damit lokal, Vorschau und Produktion denselben Code benutzen. Supabase + // nimmt das Ziel nur an, wenn es in der Redirect-Allowlist des Projekts + // steht — ein untergeschobener Host läuft also ins Leere. + const origin = (await headers()).get("origin") ?? "http://localhost:3000"; - redirect("/"); + const { data, error } = await supabase.auth.signInWithOAuth({ + provider: "azure", + options: { + // openid/profile/email sind das Minimum für Anmeldung und Anzeigename. + // Weitere Berechtigungen holt sich die Anwendung bewusst nicht. + scopes: "openid profile email", + redirectTo: `${origin}/auth/callback`, + }, + }); + + if (error || !data.url) redirect("/login?error=sso_failed"); + redirect(data.url); } export async function logout() { diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx index 58e84b7..189da87 100644 --- a/app/(auth)/login/page.tsx +++ b/app/(auth)/login/page.tsx @@ -1,14 +1,20 @@ -import { login, logout } from "@/actions/auth"; -import { Button } from "@/components/ui/Button"; -import { CONTROL_CLASS } from "@/components/ui/Field"; +import { EntraSignInButton } from "@/components/auth/EntraSignInButton"; +import { logout, signInWithEntra } from "@/actions/auth"; // The query string is attacker-controlled, so the login page renders a message // looked up by code rather than whatever text ?error= carries. Reflecting the // raw parameter let anyone put arbitrary wording ("Ihr Konto wurde gesperrt, -// rufen Sie …") on the real, correctly-branded sign-in screen. +// rufen Sie …") on the real, correctly-branded sign-in screen. Dasselbe gilt +// für die Fehlertexte, die Entra im Rückweg mitschickt. const ERROR_MESSAGES = { - no_hr_access: "Kein HR-Zugriff. Bitte wenden Sie sich an eine:n bestehende:n HR-Benutzer:in.", - invalid_credentials: "E-Mail oder Passwort ist falsch.", + no_hr_access: { + title: "Kein HR-Zugriff", + body: "Ihr Firmenkonto ist bekannt, aber nicht für die Personalverwaltung freigeschaltet. Bitte wenden Sie sich an eine:n bestehende:n HR-Benutzer:in.", + }, + sso_failed: { + title: "Anmeldung fehlgeschlagen", + body: "Die Anmeldung über das Firmenkonto konnte nicht abgeschlossen werden. Bitte versuchen Sie es erneut.", + }, } as const; type ErrorCode = keyof typeof ERROR_MESSAGES; @@ -23,56 +29,105 @@ export default async function LoginPage({ searchParams }: LoginPageProps) { const error = code ? ERROR_MESSAGES[code] : null; return ( -
-
-

Alpenwerk HR

-

Melden Sie sich mit Ihrem Firmenkonto an.

+ // dvh statt vh: auf iOS zählt vh die Adressleiste mit, wodurch die Karte + // im ersten Moment unter dem Faltenrand sitzt. +
+ - {error && ( -
- {error} - {code === "no_hr_access" && ( -
- -
- )} +
+
+
+
- )} -
-
- - -
-
- - -
- -
-
+

Anmelden

+

+ Der Zugang läuft über Ihr Firmenkonto. Ein eigenes Passwort gibt es nicht. +

+ + {error && ( +
+

{error.title}

+

{error.body}

+ {code === "no_hr_access" && ( +
+ +
+ )} +
+ )} + +
+ + + +

+ Die Anmeldung allein erteilt keinen Zugriff. HR-Rechte vergibt die Personalabteilung — bis dahin bleiben alle + Personaldaten verschlossen. +

+
+ +
+ ); +} + +function BrandPanel() { + return ( + + ); +} + +function Wordmark({ className = "" }: { className?: string }) { + return ( +
+ {/* Vier Quadrate wie ein Organigramm-Ausschnitt: eine Wurzel, darunter + drei Einheiten. */} + + Alpenwerk HR
); } diff --git a/app/auth/callback/route.ts b/app/auth/callback/route.ts new file mode 100644 index 0000000..2772fd0 --- /dev/null +++ b/app/auth/callback/route.ts @@ -0,0 +1,30 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { createClient } from "@/lib/supabase/server"; + +// Rückweg aus Entra ID. @supabase/ssr benutzt PKCE, das heisst der Anbieter +// liefert einen einmaligen Code, der hier gegen eine Sitzung getauscht wird. +// Ohne diese Route landet die Anmeldung in einer Schleife: der Code steht in +// der URL, aber es entsteht nie ein Sitzungscookie, und der Proxy schickt +// zurück auf /login. +export async function GET(request: NextRequest) { + const { searchParams, origin } = request.nextUrl; + + // Entra meldet abgelehnte Zustimmung oder gesperrte Konten als Fehler + // zurück. Der Text daraus wird nicht angezeigt — er ist fremdbestimmt und + // stünde sonst auf der echten, korrekt gebrandeten Anmeldeseite. + if (searchParams.get("error")) { + return NextResponse.redirect(`${origin}/login?error=sso_failed`); + } + + const code = searchParams.get("code"); + if (!code) return NextResponse.redirect(`${origin}/login?error=sso_failed`); + + const supabase = await createClient(); + const { error } = await supabase.auth.exchangeCodeForSession(code); + if (error) return NextResponse.redirect(`${origin}/login?error=sso_failed`); + + // Ob die Person HR-Zugriff hat, entscheidet nicht diese Route, sondern + // proxy.ts anhand von profiles.role/is_active — und darunter, unabhängig + // davon, die RLS-Policies. Hier wird nur die Sitzung hergestellt. + return NextResponse.redirect(`${origin}/`); +} diff --git a/components/auth/EntraSignInButton.tsx b/components/auth/EntraSignInButton.tsx new file mode 100644 index 0000000..a228256 --- /dev/null +++ b/components/auth/EntraSignInButton.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { useFormStatus } from "react-dom"; + +// Eigene Schaltfläche statt der Button-Komponente: Microsoft gibt für „Sign in +// with Microsoft" Fläche, Schrift und Logo vor, und eine magentafarbene +// Variante wäre nicht nur regelwidrig, sondern auch irreführend — sie sähe aus +// wie eine Aktion *in* dieser Anwendung, während sie in Wirklichkeit auf eine +// fremde Anmeldeseite springt. +export function EntraSignInButton() { + const { pending } = useFormStatus(); + + return ( + + ); +} diff --git a/docs/entra-sso.md b/docs/entra-sso.md new file mode 100644 index 0000000..f33960e --- /dev/null +++ b/docs/entra-sso.md @@ -0,0 +1,117 @@ +# Anmeldung über Entra ID + +Die Anwendung meldet ausschliesslich über Microsoft Entra ID an. Supabase Auth +bleibt dabei die Sitzungsverwaltung — Entra ist der Anbieter, nicht der Ersatz. + +**Das ist der Grund, warum der Umstieg klein ist:** `auth.uid()` liefert +weiterhin eine UUID, `profiles.id` trägt weiterhin `role` und `is_active`, und +damit bleiben `is_hr_user()` und alle 58 RLS-Policies unverändert gültig. Die +Sicherheitsgrenze wandert nicht in den Anwendungscode. + +## Einrichtung im Entra-Mandanten + +App-Registrierung, einmalig — angelegt im Mandanten *loudspring management GmbH*: + +| | | +|---|---| +| Name | Alpenwerk HR | +| Kontotypen | Nur ein Mandant | +| Umleitungs-URI (Web) | `https://wcqzamhttnzhionftyoe.supabase.co/auth/v1/callback` | +| Anwendungs-ID (Client) | `88037b8a-54f6-488f-8fcd-67d72b311a88` | +| Verzeichnis-ID (Mandant) | `b002d91e-4569-4911-8756-3dcdc47152bb` | + +Die beiden IDs sind Kennungen, keine Geheimnisse — sie stehen deshalb hier. +Der *Wert* des Client-Geheimnisses gehört ausschliesslich ins Supabase-Feld +„Secret Value" und in keine Datei im Projekt. + +Die Umleitungs-URI ist **Supabases** Callback, nicht der der Anwendung. Der +eigene Callback (`/auth/callback`) steht nur in der Redirect-Allowlist des +Supabase-Projekts. + +Danach: + +1. **Zertifikate & Geheimnisse** → neues Client-Geheimnis. Der *Wert* wird + gebraucht, nicht die Geheimnis-ID, und er ist nur einmal sichtbar. +2. **API-Berechtigungen** → `openid`, `profile`, `email` (Microsoft Graph, + delegiert), Administratorzustimmung erteilen. +3. **Tokenkonfiguration** → Gruppenanspruch, siehe unten. + +## Einrichtung in Supabase + +Authentication → Providers → Azure: + +| Feld | Wert | +|---|---| +| Application (Client) ID | `88037b8a-54f6-488f-8fcd-67d72b311a88` | +| Secret Value | der Wert aus „Zertifikate & Geheimnisse" | +| Azure Tenant URL | `https://login.microsoftonline.com/b002d91e-4569-4911-8756-3dcdc47152bb` | + +Die Tenant URL ist bei „Nur ein Mandant" nicht optional. Bleibt sie leer, +benutzt Supabase `common`, und Entra weist die Anmeldung ab, weil die +Registrierung nur den eigenen Mandanten akzeptiert. + +Authentication → URL Configuration: + +- Site URL: die Produktions-URL +- Redirect URLs: `http://localhost:3000/auth/callback` und + `https:///auth/callback` + +## Freischaltung über die Entra-Gruppe + +Wer sich anmeldet, hat damit **noch keinen Zugriff**. Zugriff hat, wer eine +`profiles`-Zeile mit `role = 'hr'` und `is_active = true` besitzt. Diese Zeile +entsteht aus der Mitgliedschaft in einer Entra-Gruppe. + +### Woher der Gruppen-Anspruch kommt + +Entra schickt Gruppen nur mit, wenn es in der Tokenkonfiguration eingestellt +ist. Zwei Varianten: + +| Variante | Lizenz | Haken | +|---|---|---| +| Sicherheitsgruppen | frei | Schickt *alle* Sicherheitsgruppen mit. Ab etwa 200 Gruppen liefert Entra statt der Liste einen Verweis, und die Auswertung greift ins Leere. | +| Der Anwendung zugewiesene Gruppen | Entra ID P1 | Nur die zugewiesene Gruppe steht im Token. | + +### Warum die Auswertung aus `auth.identities` liest, nicht aus `auth.users` + +Das ist kein Detail, sondern der Kern der Absicherung. + +`auth.users.raw_user_meta_data` ist **von der angemeldeten Person selbst +beschreibbar** — `supabase.auth.updateUser({ data: … })` schreibt genau dorthin. +Läse die Freischaltung von dort, könnte sich jede:r Angemeldete den HR-Anspruch +selbst eintragen und hätte damit Zugriff auf sämtliche Personaldaten. + +`auth.identities.identity_data` schreibt ausschliesslich GoTrue aus der Antwort +des Anbieters. Nur das ist eine belastbare Quelle. + +### Reihenfolge + +Der Trigger wird erst gebaut, wenn feststeht, wie der Anspruch tatsächlich +ankommt — das hängt an der gewählten Variante und an der Konfiguration des +Mandanten. Ablauf: + +1. SSO in Betrieb nehmen, einmal anmelden. +2. `node --env-file=.env.local supabase/entra-claims.ts ` zeigt, was in + `identity_data` gelandet ist. +3. Erst dann die Migration mit der konkreten Gruppen-ID schreiben. + +Ohne Schritt 2 wäre die Migration geraten. + +### Was die Gruppe nicht kann + +Die Mitgliedschaft steht im Token. Wer aus der Gruppe entfernt wird, verliert +den Zugriff deshalb **bei der nächsten Anmeldung**, nicht sofort. Für den +sofortigen Entzug bleibt `profiles.is_active = false` das Mittel — das wirkt +beim nächsten Datenbankzugriff, weil `is_hr_user()` die Spalte je Abfrage liest. + +## Bestehende Zugänge + +Ein bestehendes Konto mit Passwort-Anmeldung und ein Entra-Konto derselben +Person sind für Supabase **zwei verschiedene Benutzer** mit verschiedenen IDs. +Die `profiles`-Zeile hängt an der alten ID; nach der ersten Entra-Anmeldung +zeigt sie ins Leere und die Person ist ausgesperrt. + +`supabase/relink-profile.ts` hängt sie um. Es überträgt auch die +Fremdschlüssel, die auf die alte Benutzer-ID zeigen (`audit_log.actor_user_id`, +`employee_notes.author_user_id`, …), sonst stünde in der Historie eine Kennung, +zu der es kein Konto mehr gibt. diff --git a/proxy.ts b/proxy.ts index 2d2c1ef..e0335d1 100644 --- a/proxy.ts +++ b/proxy.ts @@ -38,6 +38,11 @@ export async function proxy(request: NextRequest) { const isLoginRoute = request.nextUrl.pathname.startsWith("/login"); + // Der Rückweg aus Entra muss durch, bevor es eine Sitzung gibt — dort wird + // sie ja erst hergestellt. Ohne diese Ausnahme leitet der Gate den Code + // nach /login um und die Anmeldung kommt nie zustande. + if (request.nextUrl.pathname.startsWith("/auth/callback")) return response; + if (!user) { if (isLoginRoute) return response; const url = request.nextUrl.clone(); diff --git a/supabase/entra-claims.ts b/supabase/entra-claims.ts new file mode 100644 index 0000000..049712c --- /dev/null +++ b/supabase/entra-claims.ts @@ -0,0 +1,70 @@ +// Zeigt, was Entra ID beim Anmelden tatsächlich mitgeschickt hat. +// +// Run with: node --env-file=.env.local supabase/entra-claims.ts +// +// Die Freischaltung über eine Entra-Gruppe hängt daran, wie der Anspruch im +// Token heisst und wie er aussieht — das unterscheidet sich je nachdem, ob im +// Mandanten „Sicherheitsgruppen" oder „der Anwendung zugewiesene Gruppen" +// eingestellt ist. Diese Ausgabe ist die Grundlage für den Trigger; ohne sie +// wäre er geraten. + +import { createClient } from "@supabase/supabase-js"; + +const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL; +const SERVICE_ROLE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY; +if (!SUPABASE_URL || !SERVICE_ROLE_KEY) { + throw new Error("Missing NEXT_PUBLIC_SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY in the environment"); +} + +const email = process.argv[2]; +if (!email) { + console.error("Aufruf: node --env-file=.env.local supabase/entra-claims.ts "); + process.exit(1); +} + +const supabase = createClient(SUPABASE_URL, SERVICE_ROLE_KEY, { + auth: { autoRefreshToken: false, persistSession: false }, +}); + +const { data, error } = await supabase.auth.admin.listUsers({ perPage: 1000 }); +if (error) throw new Error(error.message); + +const matches = data.users.filter((u) => u.email?.toLowerCase() === email.toLowerCase()); +if (matches.length === 0) { + console.error(`Kein Konto zu ${email}. Vorhanden:`); + for (const u of data.users) console.error(` ${u.email}`); + process.exit(1); +} + +// Mehrere Treffer sind der Normalfall in der Umstellungsphase: das alte Konto +// mit Passwort und das neue über Entra sind für Supabase zwei Benutzer. +for (const user of matches) { + console.log(`\n── ${user.email} ──`); + console.log(` id: ${user.id}`); + console.log(` erstellt: ${user.created_at}`); + console.log(` Anbieter: ${user.identities?.map((i) => i.provider).join(", ") || "keiner"}`); + + for (const identity of user.identities ?? []) { + console.log(`\n identity_data (${identity.provider}) — von GoTrue aus der Antwort des Anbieters:`); + console.log( + Object.entries(identity.identity_data ?? {}) + .map(([k, v]) => ` ${k}: ${JSON.stringify(v)}`) + .join("\n") || " (leer)" + ); + } + + // Zum Vergleich, und als Warnung: hierher schreibt auch updateUser(), also + // die angemeldete Person selbst. Als Grundlage für eine Freischaltung ist + // das unbrauchbar. + console.log("\n raw_user_meta_data — auch von der Person selbst beschreibbar, NICHT als Quelle verwenden:"); + console.log( + Object.entries(user.user_metadata ?? {}) + .map(([k, v]) => ` ${k}: ${JSON.stringify(v)}`) + .join("\n") || " (leer)" + ); +} + +const { data: profiles } = await supabase.from("profiles").select("id, email, role, is_active").eq("email", email); +console.log(`\n── profiles zu ${email} ──`); +for (const p of profiles ?? []) console.log(` ${p.id} role=${p.role} is_active=${p.is_active}`); +if (!profiles?.length) console.log(" (keine Zeile — damit besteht kein Zugriff)"); diff --git a/supabase/relink-profile.ts b/supabase/relink-profile.ts new file mode 100644 index 0000000..f37d6b9 --- /dev/null +++ b/supabase/relink-profile.ts @@ -0,0 +1,110 @@ +// Hängt eine bestehende profiles-Zeile auf die Entra-Identität derselben +// Person um. +// +// Run with: node --env-file=.env.local supabase/relink-profile.ts [--apply] +// +// Ein Konto mit Passwort-Anmeldung und das Entra-Konto derselben Person sind +// für Supabase zwei Benutzer mit verschiedenen IDs. Die profiles-Zeile hängt an +// der alten; nach der ersten Anmeldung über Entra zeigt sie ins Leere und die +// Person ist ausgesperrt — mit „Kein HR-Zugriff", obwohl sie HR ist. +// +// Ohne --apply wird nur angezeigt, was passieren würde. + +import { createClient } from "@supabase/supabase-js"; + +const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL; +const SERVICE_ROLE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY; +if (!SUPABASE_URL || !SERVICE_ROLE_KEY) { + throw new Error("Missing NEXT_PUBLIC_SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY in the environment"); +} + +const email = process.argv[2]; +const apply = process.argv.includes("--apply"); +if (!email) { + console.error("Aufruf: node --env-file=.env.local supabase/relink-profile.ts [--apply]"); + process.exit(1); +} + +const supabase = createClient(SUPABASE_URL, SERVICE_ROLE_KEY, { + auth: { autoRefreshToken: false, persistSession: false }, +}); + +// Die Fremdschlüssel auf auth.users(id). Sie zeigen sonst weiter auf das alte +// Konto, und in der Historie stünde eine Kennung ohne Konto dahinter. +const REFERENCES: { table: string; column: string }[] = [ + { table: "audit_log", column: "actor_user_id" }, + { table: "employee_notes", column: "author_user_id" }, + { table: "employee_notes", column: "done_by" }, + { table: "hire_drafts", column: "created_by" }, + { table: "saved_reports", column: "created_by" }, + { table: "pending_org_changes", column: "created_by" }, + { table: "profiles", column: "created_by" }, +]; + +const { data: userList, error } = await supabase.auth.admin.listUsers({ perPage: 1000 }); +if (error) throw new Error(error.message); + +const accounts = userList.users.filter((u) => u.email?.toLowerCase() === email.toLowerCase()); +const entra = accounts.find((u) => u.identities?.some((i) => i.provider === "azure")); +const alt = accounts.find((u) => u.id !== entra?.id); + +if (!entra) { + console.error(`Kein Entra-Konto zu ${email}. Bitte zuerst einmal über „Mit Firmenkonto anmelden" anmelden.`); + process.exit(1); +} +if (!alt) { + console.log(`Zu ${email} gibt es nur das Entra-Konto (${entra.id}) — nichts umzuhängen.`); + process.exit(0); +} + +const { data: profile } = await supabase.from("profiles").select("*").eq("id", alt.id).maybeSingle(); +if (!profile) { + console.error(`Das alte Konto ${alt.id} hat keine profiles-Zeile. Nichts umzuhängen.`); + process.exit(1); +} + +console.log(`alt: ${alt.id} (${alt.identities?.map((i) => i.provider).join(", ")})`); +console.log(`neu: ${entra.id} (azure)`); +console.log(`Rolle: ${profile.role}, aktiv: ${profile.is_active}`); + +if (!apply) { + console.log("\nTrockenlauf. Mit --apply ausführen."); + process.exit(0); +} + +// Neue Zeile zuerst: profiles.id verweist auf auth.users(id), und die alte +// Zeile fällt erst, wenn die neue steht — sonst gibt es einen Moment ohne +// HR-Konto, und niemand könnte eines mehr freischalten. +const { error: insertError } = await supabase.from("profiles").insert({ + ...profile, + id: entra.id, + email: entra.email ?? profile.email, +}); +if (insertError) throw new Error(`profiles-Zeile anlegen fehlgeschlagen: ${insertError.message}`); +console.log("profiles-Zeile für das Entra-Konto angelegt."); + +for (const ref of REFERENCES) { + const { error: updateError, count } = await supabase + .from(ref.table) + .update({ [ref.column]: entra.id }, { count: "exact" }) + .eq(ref.column, alt.id); + if (updateError) { + console.warn(` ${ref.table}.${ref.column}: ${updateError.message}`); + continue; + } + console.log(` ${ref.table}.${ref.column}: ${count ?? 0} Zeile(n) umgehängt`); +} + +const { error: deleteProfileError } = await supabase.from("profiles").delete().eq("id", alt.id); +if (deleteProfileError) throw new Error(`alte profiles-Zeile löschen fehlgeschlagen: ${deleteProfileError.message}`); + +const { error: deleteUserError } = await supabase.auth.admin.deleteUser(alt.id); +if (deleteUserError) { + // Kein Abbruch: der Zugriff hängt an profiles, und die ist bereits + // umgehängt. Das alte Konto ist damit wirkungslos, nur nicht aufgeräumt. + console.warn(`Altes Konto konnte nicht gelöscht werden: ${deleteUserError.message}`); +} else { + console.log("Altes Konto gelöscht."); +} + +console.log("\nFertig. Die Anmeldung läuft jetzt über das Firmenkonto."); From ee1a38492c6170712442ffa68ef1ed636b9be179 Mon Sep 17 00:00:00 2001 From: Maximilian Stubhan Date: Tue, 28 Jul 2026 11:54:02 +0200 Subject: [PATCH 08/64] Pin search_path, and close a write path that needed no login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der Security Advisor meldete 34 Warnungen; nach dem Festnageln des search_path sind es zehn. Von diesen zehn ist eine einzige ein echter Befund — aber die hätte man in den 34 nicht gesehen. search_path (34 Warnungen) Alle betroffenen Funktionen sind SECURITY INVOKER, laufen also mit den Rechten der aufrufenden Person; ein manipulierter Pfad bringt dort nichts zu holen. Die vier DEFINER-Funktionen setzen ihn längst. Festgenagelt wird es trotzdem, für den Tag, an dem jemand eine davon auf SECURITY DEFINER umstellt, weil eine Mutation an RLS vorbei schreiben muss — dann wäre es eine Rechteausweitung, und an den search_path denkt in dem Moment niemand. Als Schleife statt als Liste von 34 Signaturen: die würde beim nächsten Umbau veralten. Sie lässt Erweiterungen in Ruhe (pg_trgm legt show_trgm und show_limit ebenfalls in public ab) und prüft am Ende selbst nach. pg_temp steht ausdrücklich am Pfadende — ohne die Angabe durchsucht Postgres das temporäre Schema zuerst, und dort darf jede Sitzung anlegen, was sie will. Ausführungsrechte (8 Warnungen) Hier trennt sich der Befund vom Rauschen, und zwar durch Messen mit dem anon-Schlüssel gegen die laufende Datenbank: anon.rpc(is_hr_user) -> false anon.rpc(current_hr_user_id) -> null anon.rpc(apply_due_pending_changes) -> 0 Die ersten beiden bleiben offen, und das ist keine Nachlässigkeit: sie werden aus den RLS-Policies heraus aufgerufen, und ein Policy-Ausdruck wird mit den Rechten der abfragenden Rolle ausgewertet. Ohne EXECUTE scheitert jede Abfrage auf jeder Tabelle. Preisgegeben wird nichts — beide nehmen keine Argumente und beantworten nur eine Frage über die aufrufende Person selbst. Der dritte ist der Befund. apply_due_pending_changes() wendet vorgemerkte Versetzungen, Beförderungen und Abwesenheiten an, ist SECURITY DEFINER, umgeht damit RLS — und war ohne Anmeldung aufrufbar. Der anon-Schlüssel steht im ausgelieferten Browser-Bündel. Der Schaden wäre begrenzt, weil nur ohnehin fällige Änderungen angewandt werden, aber es ist ein Schreibpfad für Fremde und macht das Geheimnis der Cron-Route wirkungslos. Entzogen für anon und authenticated; die Route benutzt die service_role und läuft weiter. rls_auto_enable() stammt nicht aus diesen Migrationen und wird nirgends aufgerufen. Der Entzug ist risikolos und beantwortet die Frage, was sie tut, notfalls mit einer klaren Fehlermeldung. Zwei Warnungen bleiben bewusst stehen pg_trgm in public trägt die Operatorklasse gin_trgm_ops, auf der zwei GIN-Indizes auf employees liegen. Ein Schemawechsel müsste Indizes und jeden search_path mitziehen — Risiko für eine Konvention, keine Rechteausweitung. „Leaked Password Protection" ist gegenstandslos: die Passwort-Anmeldung ist abgeschaltet, eine Anmeldung gegen die API antwortet mit email_provider_disabled. Es gibt kein Passwort, das kompromittiert sein könnte. --- ...0260727130000_om_cleanup_and_positions.sql | 16 +++- ...0260727140000_pin_function_search_path.sql | 77 +++++++++++++++++++ .../20260727150000_revoke_definer_execute.sql | 72 +++++++++++++++++ 3 files changed, 162 insertions(+), 3 deletions(-) create mode 100644 supabase/migrations/20260727140000_pin_function_search_path.sql create mode 100644 supabase/migrations/20260727150000_revoke_definer_execute.sql diff --git a/supabase/migrations/20260727130000_om_cleanup_and_positions.sql b/supabase/migrations/20260727130000_om_cleanup_and_positions.sql index 27398bf..496d196 100644 --- a/supabase/migrations/20260727130000_om_cleanup_and_positions.sql +++ b/supabase/migrations/20260727130000_om_cleanup_and_positions.sql @@ -32,8 +32,14 @@ drop table if exists reorg_scenarios; -- Planstelle, und eine offene Stelle ist schlicht eine unbesetzte. Anlegen -- und Schliessen sind deshalb Operationen auf om_positions. +-- set search_path bei jeder Funktion: siehe die folgende Migration, dort steht +-- warum. Kurz: heute sind das INVOKER-Funktionen und der Pfad ist harmlos, +-- aber sobald eine davon einmal SECURITY DEFINER wird, wäre er es nicht mehr — +-- und daran denkt dann niemand. create or replace function next_position_number() -returns text language sql stable as $$ +returns text language sql stable +set search_path = public, pg_temp +as $$ select '6' || lpad((coalesce(max(substring(position_number from 2)::bigint), 0) + 1)::text, 7, '0') from om_positions where position_number ~ '^6[0-9]{7}$'; @@ -43,7 +49,9 @@ comment on function next_position_number() is 'Nächste freie Planstellennummer im Nummernkreis 6xxxxxxx.'; create or replace function create_position(payload jsonb) -returns uuid language plpgsql as $$ +returns uuid language plpgsql +set search_path = public, pg_temp +as $$ declare v_org_unit_id uuid := (payload->>'org_unit_id')::uuid; v_job_title text := nullif(trim(payload->>'job_title'), ''); @@ -95,7 +103,9 @@ end; $$; create or replace function delete_position(payload jsonb) -returns void language plpgsql as $$ +returns void language plpgsql +set search_path = public, pg_temp +as $$ declare v_position_id uuid := (payload->>'position_id')::uuid; v_label text; diff --git a/supabase/migrations/20260727140000_pin_function_search_path.sql b/supabase/migrations/20260727140000_pin_function_search_path.sql new file mode 100644 index 0000000..41016f5 --- /dev/null +++ b/supabase/migrations/20260727140000_pin_function_search_path.sql @@ -0,0 +1,77 @@ +-- search_path für alle eigenen Funktionen festnageln. +-- +-- Der Supabase-Linter meldet 34 Funktionen mit „Function Search Path Mutable". +-- Nachgezählt sind das alles SECURITY-INVOKER-Funktionen; die vier +-- SECURITY-DEFINER-Funktionen (is_hr_user, current_hr_user_id, +-- apply_due_pending_changes, fn_track_employee_assignment) setzen den Pfad +-- längst. Deshalb steht im Advisor auch 0 errors. +-- +-- Warum das trotzdem behoben wird: +-- +-- Der Angriff braucht SECURITY DEFINER. Wer in einem Schema, das im +-- search_path früher liegt, eine eigene Tabelle `employees` anlegt, bringt +-- eine unqualifiziert schreibende Funktion dazu, auf die untergeschobene +-- zuzugreifen — mit den Rechten der Eigentümerin der Funktion. Bei INVOKER +-- läuft alles mit den Rechten der aufrufenden Person, es gibt also nichts zu +-- gewinnen, und die RLS-Policies greifen unverändert. +-- +-- Zur Lücke wird die Warnung erst, wenn eine dieser Funktionen später auf +-- SECURITY DEFINER umgestellt wird, etwa weil eine Mutation an RLS vorbei +-- schreiben muss. In dem Moment denkt niemand mehr an den search_path. +-- Einmal festnageln räumt die Falle weg und ändert kein Verhalten. +-- +-- `pg_temp` steht ausdrücklich am Ende: ohne die Angabe durchsucht Postgres +-- das temporäre Schema *zuerst*, und dort darf jede Sitzung anlegen, was sie +-- will. + +do $$ +declare + v_func record; + v_count int := 0; +begin + for v_func in + select p.oid::regprocedure as signature + from pg_proc p + join pg_namespace n on n.oid = p.pronamespace + where n.nspname = 'public' + -- Nur Funktionen, keine Prozeduren oder Aggregate. + and p.prokind = 'f' + -- Erweiterungen gehören uns nicht: pg_trgm legt show_trgm und show_limit + -- in public ab. Daran zu drehen bricht bei der nächsten Aktualisierung + -- der Erweiterung oder wird stillschweigend zurückgesetzt. + and not exists ( + select 1 from pg_depend d where d.objid = p.oid and d.deptype = 'e' + ) + -- Bereits gesetzte nicht anfassen: die vier DEFINER-Funktionen stehen + -- auf `search_path = public` und sollen so bleiben. + and not exists ( + select 1 from unnest(coalesce(p.proconfig, '{}')) c where c like 'search_path=%' + ) + loop + execute format('alter function %s set search_path = public, pg_temp', v_func.signature); + v_count := v_count + 1; + end loop; + + raise notice 'search_path festgenagelt für % Funktion(en)', v_count; +end; +$$; + +-- Gegenprobe: danach darf in public keine eigene Funktion ohne search_path +-- mehr stehen. Schlägt das an, hat die Schleife oben etwas übersehen — besser +-- hier, als es im Advisor stehen zu lassen. +do $$ +declare v_offen int; +begin + select count(*) into v_offen + from pg_proc p + join pg_namespace n on n.oid = p.pronamespace + where n.nspname = 'public' + and p.prokind = 'f' + and not exists (select 1 from pg_depend d where d.objid = p.oid and d.deptype = 'e') + and not exists (select 1 from unnest(coalesce(p.proconfig, '{}')) c where c like 'search_path=%'); + + if v_offen > 0 then + raise exception 'Es stehen noch % Funktion(en) ohne search_path in public.', v_offen; + end if; +end; +$$; diff --git a/supabase/migrations/20260727150000_revoke_definer_execute.sql b/supabase/migrations/20260727150000_revoke_definer_execute.sql new file mode 100644 index 0000000..408fbdd --- /dev/null +++ b/supabase/migrations/20260727150000_revoke_definer_execute.sql @@ -0,0 +1,72 @@ +-- Ausführungsrechte auf den SECURITY-DEFINER-Funktionen zurechtrücken. +-- +-- Der Advisor meldet alle vier als „Public Can Execute" und „Signed-In Users +-- Can Execute". Das ist nicht bei allen vieren dasselbe Problem — nachgemessen +-- mit dem anon-Schlüssel gegen die laufende Datenbank: +-- +-- anon.rpc(is_hr_user) -> false +-- anon.rpc(current_hr_user_id) -> null +-- anon.rpc(apply_due_pending_changes) -> 0 ← das ist der Befund +-- +-- Nur der dritte ist einer. + +-- ── Bleibt offen, und zwar mit Absicht ─────────────────────────── +-- +-- is_hr_user() und current_hr_user_id() werden *aus den RLS-Policies heraus* +-- aufgerufen. Ein Policy-Ausdruck wird mit den Rechten der abfragenden Rolle +-- ausgewertet; ohne EXECUTE für anon und authenticated scheitert damit jede +-- Abfrage auf jeder Tabelle mit „permission denied for function". Der Entzug +-- würde die Anwendung vollständig lahmlegen. +-- +-- Preisgegeben wird dabei nichts: beide nehmen keine Argumente und beantworten +-- ausschliesslich eine Frage über die aufrufende Person selbst. Wer nicht +-- angemeldet ist, bekommt false beziehungsweise null — siehe Messung oben. + +-- ── Wird entzogen ──────────────────────────────────────────────── +-- +-- apply_due_pending_changes() wendet vorgemerkte Versetzungen, Beförderungen +-- und Abwesenheiten an, sobald ihr Datum erreicht ist. Es ist SECURITY +-- DEFINER, umgeht also RLS, und war bis hierher ohne Anmeldung aufrufbar — der +-- anon-Schlüssel steht im ausgelieferten Browser-Bündel. +-- +-- Der Schaden wäre begrenzt, weil nur ohnehin fällige Änderungen angewandt +-- werden. Aber es ist ein Schreibpfad, den Fremde auslösen können, und er +-- macht das Geheimnis der Cron-Route (app/api/cron/apply-pending-changes) +-- wirkungslos. +-- +-- Diese Route ist der einzige Aufrufer und benutzt createAdminClient(), also +-- die service_role — der Entzug für anon und authenticated bricht sie nicht. +revoke execute on function apply_due_pending_changes() from anon, authenticated; + +-- rls_auto_enable() stammt nicht aus diesen Migrationen und wird von der +-- Anwendung nirgends aufgerufen. Was sie tut, ist von hier aus nicht +-- feststellbar; eine Funktion, die RLS umschaltet und ohne Anmeldung +-- aufrufbar ist, wäre allerdings ernst. Der Entzug ist risikolos, weil kein +-- Aufrufer existiert — und falls doch jemand sie braucht, meldet er sich mit +-- einer klaren Fehlermeldung statt still etwas zu verstellen. +do $$ +begin + if exists ( + select 1 from pg_proc p + join pg_namespace n on n.oid = p.pronamespace + where n.nspname = 'public' and p.proname = 'rls_auto_enable' + ) then + execute 'revoke execute on function public.rls_auto_enable() from anon, authenticated'; + end if; +end; +$$; + +-- ── Was bewusst *nicht* passiert ───────────────────────────────── +-- +-- „Extension in Public" (pg_trgm) bleibt stehen. Die Erweiterung trägt die +-- Operatorklasse gin_trgm_ops, auf der zwei GIN-Indizes auf employees liegen +-- (20260714120400_performance_indexes.sql). Ein Schemawechsel müsste die +-- Indizes und jeden search_path mitziehen, der sie erreichen soll — gerade +-- jetzt, wo jede Funktion auf `public, pg_temp` festgenagelt ist. Das ist +-- Aufwand und Risiko für einen Hinweis, der keine Rechteausweitung beschreibt, +-- sondern eine Konvention. +-- +-- „Leaked Password Protection Disabled" ist gegenstandslos: die +-- Passwort-Anmeldung ist abgeschaltet. Eine Anmeldung mit E-Mail und Passwort +-- gegen die API antwortet mit `email_provider_disabled` (422). Es gibt kein +-- Passwort, dessen Kompromittierung geprüft werden könnte. From acbb03c4f5db8d5b977ed28139bb892350e990b5 Mon Sep 17 00:00:00 2001 From: Maximilian Stubhan Date: Thu, 30 Jul 2026 18:37:44 +0200 Subject: [PATCH 09/64] Record the supported Node range in the lockfile package.json declares engines: node >=22 <25; npm writes that into the lockfile on the next install. Committing it keeps a fresh clone from producing a diff on the first npm ci. Co-Authored-By: Claude Opus 5 --- package-lock.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/package-lock.json b/package-lock.json index bde5e90..4f7b62e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,6 +36,9 @@ "tailwindcss": "^4.3.3", "typescript": "^5.9.3", "vitest": "^4.1.10" + }, + "engines": { + "node": ">=22 <25" } }, "node_modules/@adobe/css-tools": { From 730521ee792a0ca10472c6ae872b2b75a47098ce Mon Sep 17 00:00:00 2001 From: Maximilian Stubhan Date: Thu, 30 Jul 2026 18:39:20 +0200 Subject: [PATCH 10/64] Keep the environment's identifiers out of the repository MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Projekt-Ref, Entra-Client- und Tenant-ID standen im Klartext in der SSO-Anleitung. Geheimnisse sind das nicht — ohne Schlüssel gibt eine Projekt-URL nichts her, und RLS greift unabhängig davon. Sie zeigen aber auf die laufende Umgebung, und dieses Repository wandert weiter als sie: es geht gleich auf einen eigenen Git-Server und später an den Kunden. Jetzt Platzhalter; die Werte gehören in die Übergabedokumentation. In der Historie stehen sie weiterhin — das sauber zu entfernen hiesse, die Historie neu zu schreiben, und das passiert nicht nebenbei. Co-Authored-By: Claude Opus 5 --- docs/entra-sso.md | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/docs/entra-sso.md b/docs/entra-sso.md index f33960e..74ea932 100644 --- a/docs/entra-sso.md +++ b/docs/entra-sso.md @@ -16,11 +16,15 @@ App-Registrierung, einmalig — angelegt im Mandanten *loudspring management Gmb |---|---| | Name | Alpenwerk HR | | Kontotypen | Nur ein Mandant | -| Umleitungs-URI (Web) | `https://wcqzamhttnzhionftyoe.supabase.co/auth/v1/callback` | -| Anwendungs-ID (Client) | `88037b8a-54f6-488f-8fcd-67d72b311a88` | -| Verzeichnis-ID (Mandant) | `b002d91e-4569-4911-8756-3dcdc47152bb` | +| Umleitungs-URI (Web) | `https://.supabase.co/auth/v1/callback` | +| Anwendungs-ID (Client) | `` | +| Verzeichnis-ID (Mandant) | `` | + +Die konkreten Werte stehen bewusst nicht hier, sondern in der +Übergabedokumentation. Sie sind zwar keine Geheimnisse — ohne Schlüssel gibt +eine Projekt-URL nichts her, und RLS greift ohnehin —, aber sie zeigen auf die +echte Umgebung, und dieses Repository wandert weiter als sie. -Die beiden IDs sind Kennungen, keine Geheimnisse — sie stehen deshalb hier. Der *Wert* des Client-Geheimnisses gehört ausschliesslich ins Supabase-Feld „Secret Value" und in keine Datei im Projekt. @@ -42,9 +46,9 @@ Authentication → Providers → Azure: | Feld | Wert | |---|---| -| Application (Client) ID | `88037b8a-54f6-488f-8fcd-67d72b311a88` | +| Application (Client) ID | `` | | Secret Value | der Wert aus „Zertifikate & Geheimnisse" | -| Azure Tenant URL | `https://login.microsoftonline.com/b002d91e-4569-4911-8756-3dcdc47152bb` | +| Azure Tenant URL | `https://login.microsoftonline.com/` | Die Tenant URL ist bei „Nur ein Mandant" nicht optional. Bleibt sie leer, benutzt Supabase `common`, und Entra weist die Anmeldung ab, weil die From a66263a96e4257f3c2f10b3a1356866b113b1a22 Mon Sep 17 00:00:00 2001 From: Maximilian Stubhan Date: Thu, 30 Jul 2026 19:01:39 +0200 Subject: [PATCH 11/64] Put the session context under the app's own control MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Erster Schritt weg von Supabase hin zu "läuft auf jedem PostgreSQL". Gemessen sitzt die Kopplung nicht dort, wo der Begriff "Supabase-Projekt" sie vermuten lässt: das Schema ist reines PostgreSQL, und von 58 RLS-Policies rufen nur fünf auth.uid() direkt auf. Die übrigen 53 gehen über is_hr_user(). Diese eine Funktion ist die Brücke — wird sie umgelegt, folgt der Rest. Die Migration legt sie um. app_current_user_id() liest jetzt zuerst current_setting('app.user_id') und fällt nur ersatzweise auf auth.uid() zurück. Deshalb plpgsql statt language sql: eine SQL-Funktion wird beim Anlegen geparst, und auth.uid() gibt es auf einem gewöhnlichen PostgreSQL nicht — die Migration liesse sich dort gar nicht erst anwenden. Der Ausnahmeblock fängt das ab, und damit läuft dieselbe Migration auf beiden Systemen. Der Rückfall verschwindet mit der Abschlussmigration. Dazu app_users als Nachfolger von auth.users, external_id ist die oid des Anbieters statt der E-Mail: eine Namensänderung darf kein zweites Konto erzeugen. Die neue Zugriffsschicht ist Kysely auf einem pg-Pool. Was daran zählt, ist nicht der Query-Builder, sondern was er verhindert: - Die Kysely-Instanz wird nicht exportiert. Wer abfragen will, geht durch withUser() — und das öffnet immer eine Transaktion. - set_config(..., true) ist transaktionslokal. Ohne das dritte Argument bliebe die Kennung an der gepoolten Verbindung kleben und die nächste Anfrage liefe im Namen der vorherigen Person. In einer Personaldatenbank. - Eine ESLint-Regel verbietet den Import von pg und von lib/db/pool ausserhalb von lib/db. Nachgewiesen: eine Testdatei mit beiden Importen erzeugt zwei Fehler. - Einen privilegierten Zugang gibt es nicht mehr. asSystem() benutzt dieselbe Rolle ohne BYPASSRLS; was ohne angemeldete Person laufen darf, muss als SECURITY-DEFINER-Funktion in der Datenbank stehen. tests/integration/session-context.test.ts läuft gegen einen Pool mit genau einer Verbindung — sonst träfe er die Lücke mal und mal nicht. Er prüft, dass nach Commit *und* nach Rollback nichts an der Verbindung zurückbleibt, und belegt in einer Gegenprobe, dass eine Einstellung ohne Transaktion tatsächlich hängen bleibt. Ein Sicherheitstest, der sich mangels DATABASE_URL selbst überspringt, wäre schlimmer als keiner: in der CI schlägt schon das Fehlen des Verbindungsstrings fehl. Beim Schreiben der Migration stellte sich heraus, dass die Policies hire_drafts_owner und saved_reports_owner heissen, nicht _own. Mit dem geratenen Namen hätte drop policy nichts getroffen und create policy wäre mit "already exists" abgebrochen. Typecheck, Lint und 182 Tests sind grün. Die Anwendung läuft unverändert weiter — sie benutzt die neue Schicht noch nicht. --- .env.example | 12 ++ .github/workflows/ci.yml | 13 +- eslint.config.mjs | 42 +++++ lib/db/index.ts | 74 ++++++++ lib/db/pool.ts | 39 ++++ lib/db/schema.ts | 49 +++++ package-lock.json | 170 ++++++++++++++++++ package.json | 3 + ...30120000_app_users_and_session_context.sql | 167 +++++++++++++++++ tests/integration/session-context.test.ts | 118 ++++++++++++ 10 files changed, 684 insertions(+), 3 deletions(-) create mode 100644 lib/db/index.ts create mode 100644 lib/db/pool.ts create mode 100644 lib/db/schema.ts create mode 100644 supabase/migrations/20260730120000_app_users_and_session_context.sql create mode 100644 tests/integration/session-context.test.ts diff --git a/.env.example b/.env.example index cb52814..c516bf8 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,15 @@ +# Direkter PostgreSQL-Zugang. Die Anwendung spricht künftig unmittelbar mit +# der Datenbank statt über eine API-Schicht — damit läuft sie auf jedem +# PostgreSQL ab 15 (Azure Flexible Server, RDS, Cloud SQL, eigenes Blech). +# +# Die Rolle in diesem String darf KEIN BYPASSRLS haben: fehlt der +# Sitzungskontext, sollen die Policies nichts zurückgeben statt alles. +DATABASE_URL= +# Auf "false" setzen, wenn die Datenbank ohne TLS läuft (lokal, CI). +DATABASE_SSL= +# Verbindungen im Pool; Vorgabe 10. +DATABASE_POOL_MAX= + # Public: safe to expose to the browser (inlined into the client bundle at # build time). Anon-key access is still fully gated by RLS server-side. NEXT_PUBLIC_SUPABASE_URL= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3a09cd8..1d0041e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,16 +68,23 @@ jobs: - name: Supabase starten run: supabase start - # `-o env` emits API_URL / ANON_KEY / SERVICE_ROLE_KEY; the app expects - # them under its own names. + # `-o env` emits API_URL / ANON_KEY / SERVICE_ROLE_KEY / DB_URL; the app + # expects them under its own names. DATABASE_URL ist der direkte + # Postgres-Zugang — den braucht die neue Zugriffsschicht (lib/db) und + # vor allem der Nachweis zum Sitzungskontext. - name: Testumgebung schreiben run: | supabase status -o env \ --override-name api.url=NEXT_PUBLIC_SUPABASE_URL \ --override-name auth.anon_key=NEXT_PUBLIC_SUPABASE_ANON_KEY \ --override-name auth.service_role_key=SUPABASE_SERVICE_ROLE_KEY \ - | grep -E '^(NEXT_PUBLIC_SUPABASE_URL|NEXT_PUBLIC_SUPABASE_ANON_KEY|SUPABASE_SERVICE_ROLE_KEY)=' \ + --override-name db.url=DATABASE_URL \ + | grep -E '^(NEXT_PUBLIC_SUPABASE_URL|NEXT_PUBLIC_SUPABASE_ANON_KEY|SUPABASE_SERVICE_ROLE_KEY|DATABASE_URL)=' \ | tr -d '"' > .env.test.local + # Lokal läuft Postgres ohne TLS; ohne das versucht `pg` es trotzdem. + echo "DATABASE_SSL=false" >> .env.test.local + grep -q '^DATABASE_URL=' .env.test.local \ + || { echo "DATABASE_URL wurde nicht geschrieben — der Sitzungskontext-Nachweis liefe ins Leere."; exit 1; } - name: Seed run: node --env-file=.env.test.local supabase/seed.ts diff --git a/eslint.config.mjs b/eslint.config.mjs index 05e726d..3a67424 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -13,6 +13,48 @@ const eslintConfig = defineConfig([ "build/**", "next-env.d.ts", ]), + + // ── Der Datenbankzugriff bleibt in einer Hand ─────────────────── + // + // Die Zugriffsrechte hängen an einer Sitzungsvariablen, die nur innerhalb + // einer Transaktion gesetzt werden darf (siehe lib/db/index.ts). Wer den + // Pool direkt benutzt, umgeht das — und die Abfrage läuft dann mit dem + // Kontext, den die vorherige Anfrage auf derselben gepoolten Verbindung + // hinterlassen hat. + // + // Das muss strukturell unmöglich sein, nicht per Konvention: eine + // Vereinbarung überlebt den nächsten Termindruck nicht. + { + files: ["**/*.ts", "**/*.tsx"], + ignores: ["lib/db/**", "tests/integration/**", "supabase/**", "scripts/**"], + rules: { + "no-restricted-imports": [ + "error", + { + paths: [ + { + name: "pg", + message: + "Kein direkter Pool-Zugriff. Abfragen laufen über withUser() aus lib/db — nur dort wird der Sitzungskontext transaktionslokal gesetzt.", + }, + { + name: "kysely", + importNames: ["Kysely"], + message: + "Keine zweite Kysely-Instanz. lib/db exportiert withUser(); die Instanz selbst bleibt privat, damit keine Abfrage ohne Kontext möglich ist.", + }, + ], + patterns: [ + { + group: ["**/lib/db/pool", "**/db/pool"], + message: + "Der Pool ist absichtlich nicht exportiert. Über lib/db gehen — withUser() erzwingt die Transaktion.", + }, + ], + }, + ], + }, + }, ]); export default eslintConfig; diff --git a/lib/db/index.ts b/lib/db/index.ts new file mode 100644 index 0000000..1c21838 --- /dev/null +++ b/lib/db/index.ts @@ -0,0 +1,74 @@ +import "server-only"; +import { Kysely, PostgresDialect, sql, type Transaction } from "kysely"; +import { pool } from "./pool"; +import type { Schema } from "./schema"; + +// Der einzige Weg an die Datenbank. +// +// ═══ Warum das keine gewöhnliche Datenbankschicht ist ═══ +// +// Die Zugriffsrechte liegen in der Datenbank: 58 RLS-Policies rufen +// is_hr_user() auf, und das fragt seit der Umstellung nicht mehr Supabase, +// sondern `current_setting('app.user_id')` — eine Sitzungsvariable. +// +// Sitzungsvariablen hängen an der *Verbindung*, nicht an der Anfrage. Und +// Verbindungen kommen aus einem Pool. Wird die Variable ohne Transaktion +// gesetzt, bleibt sie an der Verbindung kleben, und die nächste Anfrage, die +// dieselbe Verbindung zieht, läuft mit der Kennung der vorherigen Person — +// quer über Benutzer hinweg, in einer Personaldatenbank. +// +// Das ist die Art Fehler, die in keinem Test auffällt, den man nicht +// absichtlich dafür schreibt (tests/integration/session-context.test.ts tut +// genau das). Deshalb: +// +// 1. Die Kysely-Instanz wird **nicht exportiert**. Wer abfragen will, muss +// durch withUser() — und das öffnet immer eine Transaktion. +// 2. `set_config(..., true)` — das dritte Argument bedeutet +// transaktionslokal. Mit `false` wäre die ganze Vorsichtsmassnahme +// wirkungslos. +// 3. Eine ESLint-Regel verbietet den Import von `pg` und `./pool` +// ausserhalb dieses Verzeichnisses. +// +// Zusätzlich verbindet sich die Anwendung mit einer Datenbankrolle **ohne** +// BYPASSRLS. Fehlt der Kontext trotz allem, liefern die Policies nichts +// zurück — nicht alles. + +const db = new Kysely({ + dialect: new PostgresDialect({ pool }), +}); + +export type Tx = Transaction; + +/** + * Führt `fn` im Namen der angegebenen Person aus. + * + * `userId` ist die app_users.id. Für nicht angemeldete Zugriffe null — dann + * greift keine Policy und es kommt nichts zurück, was auch richtig ist. + */ +export async function withUser(userId: string | null, fn: (tx: Tx) => Promise): Promise { + return db.transaction().execute(async (tx) => { + // Erste Anweisung der Transaktion, vor allem anderen. + await sql`select set_config('app.user_id', ${userId ?? ""}, true)`.execute(tx); + return fn(tx); + }); +} + +/** + * Für Abläufe ohne angemeldete Person — heute nur der nächtliche Lauf für + * fällige Änderungen. + * + * Bewusst kein privilegierter Zugang: die Verbindung benutzt dieselbe Rolle + * ohne BYPASSRLS. Was hier laufen darf, muss als SECURITY-DEFINER-Funktion + * in der Datenbank stehen und dort selbst prüfen, was es tut. Ein + * Dienstschlüssel, der RLS aushebelt, existiert nicht mehr. + */ +export async function asSystem(fn: (tx: Tx) => Promise): Promise { + return withUser(null, fn); +} + +/** Für Migrations- und Wartungsskripte, die ausserhalb einer Anfrage laufen. */ +export async function closeDb(): Promise { + await db.destroy(); +} + +export { sql }; diff --git a/lib/db/pool.ts b/lib/db/pool.ts new file mode 100644 index 0000000..583b05a --- /dev/null +++ b/lib/db/pool.ts @@ -0,0 +1,39 @@ +import "server-only"; +import { Pool } from "pg"; + +// Die einzige Stelle im Projekt, die `pg` importieren darf. +// +// Der Grund steht in lib/db/index.ts: eine Abfrage ausserhalb von withUser() +// läuft ohne Sitzungskontext und damit — je nachdem, was die vorherige +// Anfrage auf derselben gepoolten Verbindung hinterlassen hat — im Namen +// einer fremden Person. Deshalb wird der Pool nicht exportiert, sondern nur +// die Kysely-Instanz, die ihn benutzt, und eine ESLint-Regel verbietet den +// Import von `pg` und von dieser Datei überall sonst. + +const connectionString = process.env.DATABASE_URL; +if (!connectionString) { + throw new Error( + "DATABASE_URL fehlt. Erwartet wird ein PostgreSQL-Verbindungsstring — " + + "die Anwendung spricht direkt mit der Datenbank, nicht über eine API-Schicht." + ); +} + +export const pool = new Pool({ + connectionString, + // Der Standard sind 10; bei serverseitigem Rendering hängt an jeder + // Anfrage genau eine Transaktion, und mehr Verbindungen als die Datenbank + // zulässt bringen nur Wartezeit an einer anderen Stelle. + max: Number(process.env.DATABASE_POOL_MAX ?? 10), + // Eine Anfrage, die länger braucht, ist kaputt und soll das melden statt + // eine Verbindung zu belegen. + statement_timeout: 20_000, + idle_in_transaction_session_timeout: 20_000, + connectionTimeoutMillis: 10_000, + // Verwaltete Anbieter (Azure, RDS, Supabase) verlangen TLS; lokal nicht. + ssl: process.env.DATABASE_SSL === "false" ? undefined : { rejectUnauthorized: false }, +}); + +// Ein Fehler auf einer Leerlaufverbindung beendet sonst den Prozess. +pool.on("error", (err) => { + console.error("Unerwarteter Fehler auf einer Leerlaufverbindung:", err); +}); diff --git a/lib/db/schema.ts b/lib/db/schema.ts new file mode 100644 index 0000000..51a5374 --- /dev/null +++ b/lib/db/schema.ts @@ -0,0 +1,49 @@ +import type { ColumnType } from "kysely"; +import type { Database } from "@/lib/supabase/types"; + +// Die Tabellenform für Kysely, abgeleitet aus der bestehenden +// Schemabeschreibung — nicht daneben gestellt. +// +// Zwei Beschreibungen desselben Schemas driften auseinander, und die eine +// hier ist bereits gegen die Migrationen abgesichert: `npm run types:check` +// vergleicht lib/supabase/types.ts Spalte für Spalte mit +// supabase/migrations/*.sql und schlägt in der CI fehl, wenn etwas fehlt. +// Diese Ableitung erbt diese Absicherung. +// +// Die Datei heisst noch lib/supabase/types.ts, weil sie aus der Zeit stammt, +// als PostgREST der Zugriffsweg war. Sie beschreibt reines PostgreSQL und +// wird beim Entfernen der Supabase-Pakete lediglich umbenannt. + +type Tables = Database["public"]["Tables"]; + +/** + * Row/Insert/Update einer Tabelle in Kyselys ColumnType-Form. + * + * Kysely braucht die drei Richtungen getrennt: was beim Lesen herauskommt, + * was beim Einfügen erlaubt ist (Spalten mit Vorgabewert dürfen fehlen) und + * was beim Aktualisieren erlaubt ist. + */ +type Columns = { + [K in keyof Tables[T]["Row"]]: K extends keyof Tables[T]["Insert"] + ? ColumnType< + Tables[T]["Row"][K], + Tables[T]["Insert"][K], + K extends keyof Tables[T]["Update"] ? Tables[T]["Update"][K] : never + > + : // Spalten, die es nur beim Lesen gibt (von Triggern gesetzt). + ColumnType; +}; + +export type DB = { [T in keyof Tables]: Columns }; + +/** Die Tabelle, die auth.users ablöst. Noch nicht in der Alt-Beschreibung. */ +export type AppUsersTable = { + id: ColumnType; + external_id: string; + email: string; + full_name: ColumnType; + created_at: ColumnType; + last_seen_at: ColumnType; +}; + +export type Schema = DB & { app_users: AppUsersTable }; diff --git a/package-lock.json b/package-lock.json index 4f7b62e..f80ee66 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,8 +13,10 @@ "@supabase/supabase-js": "^2.110.8", "@xyflow/react": "^12.11.2", "exceljs": "^4.4.0", + "kysely": "^0.29.4", "lucide-react": "^1.26.0", "next": "^16.2.11", + "pg": "^8.22.0", "react": "^19.2.8", "react-dom": "^19.2.8", "server-only": "^0.0.1" @@ -25,6 +27,7 @@ "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", "@types/node": "^24.13.3", + "@types/pg": "^8.20.0", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@vitest/coverage-v8": "^4.1.10", @@ -2834,6 +2837,18 @@ "undici-types": "~7.18.0" } }, + "node_modules/@types/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, "node_modules/@types/react": { "version": "19.2.17", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", @@ -7116,6 +7131,15 @@ "json-buffer": "3.0.1" } }, + "node_modules/kysely": { + "version": "0.29.4", + "resolved": "https://registry.npmjs.org/kysely/-/kysely-0.29.4.tgz", + "integrity": "sha512-y5mVgQNkMbs1eK9Xyc0pmNdabN2wHhRYY/5r4W5HrUT1rYCEPeVNSj1RUJeSDKT3U0p+mXCvLgkrFuIafYI6BA==", + "license": "MIT", + "engines": { + "node": ">=22.0.0" + } + }, "node_modules/language-subtag-registry": { "version": "0.3.23", "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", @@ -8198,6 +8222,95 @@ "dev": true, "license": "MIT" }, + "node_modules/pg": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz", + "integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.14.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.15.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz", + "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz", + "integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -8255,6 +8368,45 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -8961,6 +9113,15 @@ "node": ">=0.10.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/stable-hash": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", @@ -10188,6 +10349,15 @@ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", "license": "MIT" }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/package.json b/package.json index f98fd4f..4542d7a 100644 --- a/package.json +++ b/package.json @@ -24,8 +24,10 @@ "@supabase/supabase-js": "^2.110.8", "@xyflow/react": "^12.11.2", "exceljs": "^4.4.0", + "kysely": "^0.29.4", "lucide-react": "^1.26.0", "next": "^16.2.11", + "pg": "^8.22.0", "react": "^19.2.8", "react-dom": "^19.2.8", "server-only": "^0.0.1" @@ -36,6 +38,7 @@ "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", "@types/node": "^24.13.3", + "@types/pg": "^8.20.0", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@vitest/coverage-v8": "^4.1.10", diff --git a/supabase/migrations/20260730120000_app_users_and_session_context.sql b/supabase/migrations/20260730120000_app_users_and_session_context.sql new file mode 100644 index 0000000..ef673bd --- /dev/null +++ b/supabase/migrations/20260730120000_app_users_and_session_context.sql @@ -0,0 +1,167 @@ +-- Schritt 1 auf dem Weg weg von Supabase: eigene Benutzertabelle und ein +-- eigener Sitzungskontext. +-- +-- Ziel ist ein Schema, das auf jedem PostgreSQL ab 15 läuft — Azure Flexible +-- Server, RDS, Cloud SQL, eigenes Blech. Heute hängt genau eine Sache an +-- Supabase: `auth.uid()`, die Kennung der angemeldeten Person. Sie steckt in +-- 72 Zeilen SQL, aber für die Absicherung zählt nur eine Stelle — +-- is_hr_user(), das alle 58 RLS-Policies aufrufen. +-- +-- Diese Migration ist bewusst **additiv und beidseitig lauffähig**: die +-- Anwendung läuft danach unverändert auf Supabase weiter, während die neue +-- Zugriffsschicht daneben entsteht. Ein Umbau, der beide Enden gleichzeitig +-- bewegt, lässt sich nicht testen. + +-- ═══ 1. Sitzungskontext ══════════════════════════════════════════ +-- Wer gerade angemeldet ist, kommt künftig aus einer Sitzungsvariablen, die +-- die Zugriffsschicht **transaktionslokal** setzt (siehe lib/db). +-- +-- Warum plpgsql und nicht `language sql`: eine SQL-Funktion wird beim Anlegen +-- geparst, und `auth.uid()` existiert auf einem gewöhnlichen PostgreSQL +-- nicht — die Funktion liesse sich dort gar nicht erst erzeugen. plpgsql löst +-- den Aufruf erst zur Laufzeit auf, und der Ausnahmeblock fängt die fehlende +-- Funktion ab. Genau das macht diese Migration auf beiden Systemen anwendbar. +create or replace function app_current_user_id() +returns uuid +language plpgsql +stable +security definer +set search_path = public, pg_temp +as $$ +declare + v_id uuid; +begin + -- Vorrang hat der eigene Kontext. `true` als zweites Argument heisst: + -- fehlt die Variable, kommt null statt eines Fehlers. + v_id := nullif(current_setting('app.user_id', true), '')::uuid; + if v_id is not null then + return v_id; + end if; + + -- Übergangsweise: solange die Anmeldung noch über GoTrue läuft. Fällt in + -- der Abschlussmigration weg, zusammen mit den Fremdschlüsseln auf + -- auth.users. + begin + execute 'select auth.uid()' into v_id; + exception + when undefined_function or invalid_schema_name or undefined_table then + v_id := null; + end; + return v_id; +end; +$$; + +comment on function app_current_user_id() is + 'Kennung der angemeldeten Person: erst app.user_id aus der Sitzung, ersatzweise auth.uid(). Der zweite Zweig ist Übergang.'; + +grant execute on function app_current_user_id() to anon, authenticated, service_role; + +-- ═══ 2. Benutzertabelle ══════════════════════════════════════════ +-- Tritt an die Stelle von auth.users. Die neun Fremdschlüssel, die heute +-- dorthin zeigen, wandern in der Abschlussmigration hierher. +create table if not exists app_users ( + id uuid primary key default gen_random_uuid(), + -- Die `oid` aus dem Entra-Token. Unveränderlich, anders als die E-Mail: + -- eine Namensänderung darf nicht zu einem neuen Konto führen. + external_id text not null unique, + email text not null, + full_name text, + created_at timestamptz not null default now(), + last_seen_at timestamptz +); + +comment on table app_users is + 'Ersetzt auth.users. external_id ist die oid des Identitätsanbieters, nicht die E-Mail.'; + +create index if not exists app_users_email_idx on app_users (lower(email)); + +alter table app_users enable row level security; + +-- Sich selbst sehen darf jede:r Angemeldete; alles andere ist HR-Sache. +-- Ohne diese Policy käme die Anmeldung nicht an die eigene Zeile. +drop policy if exists "app_users_select_own" on app_users; +create policy "app_users_select_own" on app_users + for select using (id = app_current_user_id() or is_hr_user()); + +grant select on table app_users to anon, authenticated; +grant all on table app_users to service_role; + +-- ═══ 3. Die eine Brücke umlegen ══════════════════════════════════ +-- Ab hier fragt die Absicherung nicht mehr Supabase, sondern den eigenen +-- Kontext. Die 58 Policies bleiben Wort für Wort unverändert — sie rufen +-- weiterhin is_hr_user() auf und merken davon nichts. +create or replace function is_hr_user() +returns boolean +language sql +security definer +set search_path = public, pg_temp +stable +as $$ + select exists ( + select 1 from profiles p + where p.id = app_current_user_id() and p.role = 'hr' and p.is_active = true + ); +$$; + +create or replace function current_hr_user_id() +returns uuid +language sql +security definer +set search_path = public, pg_temp +stable +as $$ + select p.id from profiles p + where p.id = app_current_user_id() and p.role = 'hr' and p.is_active = true; +$$; + +create or replace function current_actor_name() +returns text +language sql +stable +set search_path = public, pg_temp +as $$ + select coalesce(p.full_name, p.email, 'Unbekannt') + from profiles p where p.id = app_current_user_id(); +$$; + +-- ═══ 4. Die fünf Policies mit direktem auth.uid() ════════════════ +-- Die übrigen 53 laufen über is_hr_user() und brauchen nichts. +drop policy if exists "profiles_select_own" on profiles; +create policy "profiles_select_own" on profiles + for select using (app_current_user_id() = id); + +-- Die Namen stammen aus 20260714120000_hr_only_access.sql: „_owner", nicht +-- „_own". Mit dem falschen Namen bricht die Migration bei create policy ab. +drop policy if exists "hire_drafts_owner" on hire_drafts; +create policy "hire_drafts_owner" on hire_drafts + for all + using (created_by = app_current_user_id() and is_hr_user()) + with check (created_by = app_current_user_id() and is_hr_user()); + +drop policy if exists "saved_reports_owner" on saved_reports; +create policy "saved_reports_owner" on saved_reports + for all + using (created_by = app_current_user_id() and is_hr_user()) + with check (created_by = app_current_user_id() and is_hr_user()); + +-- ═══ 5. Gegenprobe ═══════════════════════════════════════════════ +-- Ohne Kontext und ohne Anmeldung darf is_hr_user() nicht wahr sein. Das +-- klingt selbstverständlich und ist genau der Fehler, der eine ganze +-- Datenbank öffnet. +do $$ +begin + perform set_config('app.user_id', '', true); + if is_hr_user() then + raise exception 'is_hr_user() liefert ohne Sitzungskontext true — Abbruch.'; + end if; + + perform set_config('app.user_id', gen_random_uuid()::text, true); + if is_hr_user() then + raise exception 'is_hr_user() liefert für eine unbekannte Kennung true — Abbruch.'; + end if; + + -- Aufräumen: die Einstellung gilt bis zum Ende dieser Transaktion, und + -- was danach in derselben Sitzung läuft, soll sie nicht erben. + perform set_config('app.user_id', '', true); +end; +$$; diff --git a/tests/integration/session-context.test.ts b/tests/integration/session-context.test.ts new file mode 100644 index 0000000..a80e48a --- /dev/null +++ b/tests/integration/session-context.test.ts @@ -0,0 +1,118 @@ +import { randomUUID } from "node:crypto"; +import { Kysely, PostgresDialect, sql } from "kysely"; +import { Pool } from "pg"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +// Der Test, ohne den die Zugriffsschicht eine Behauptung wäre. +// +// Die Zugriffsrechte hängen an `current_setting('app.user_id')`. Diese +// Variable gehört der *Verbindung*, nicht der Anfrage — und Verbindungen +// kommen aus einem Pool. Wird sie ohne Transaktion gesetzt, sieht die +// nächste Anfrage auf derselben Verbindung die Kennung der vorherigen +// Person. +// +// Deshalb läuft hier ein Pool mit **genau einer** Verbindung: so ist +// garantiert, dass zwei aufeinanderfolgende Zugriffe dieselbe physische +// Verbindung benutzen. Mit mehreren Verbindungen würde der Test die Lücke +// zufällig mal treffen und mal nicht — und wäre damit wertlos. + +const CONNECTION = process.env.DATABASE_URL; + +// Ohne Datenbank lässt sich lokal nichts prüfen — in der CI wäre ein +// stillschweigend übersprungener Sicherheitstest allerdings schlimmer als +// gar keiner. Deshalb schlägt dort schon das Fehlen des Verbindungsstrings +// fehl, statt eine grüne Anzeige vorzutäuschen. +describe("Voraussetzung", () => { + it.runIf(process.env.CI)("DATABASE_URL ist in der CI gesetzt", () => { + expect( + CONNECTION, + "DATABASE_URL fehlt. Ohne sie wird der Nachweis zum Sitzungskontext übersprungen — " + + "und genau dieser Nachweis trägt die Zugriffsabsicherung." + ).toBeTruthy(); + }); +}); + +const pool = new Pool({ connectionString: CONNECTION, max: 1 }); +const db = new Kysely>({ dialect: new PostgresDialect({ pool }) }); + +/** Die Fassung aus lib/db: transaktionslokal. */ +async function withUser(userId: string | null, fn: () => Promise): Promise { + return db.transaction().execute(async (tx) => { + await sql`select set_config('app.user_id', ${userId ?? ""}, true)`.execute(tx); + return fn(); + }); +} + +async function currentUserOnConnection(): Promise { + const r = await sql<{ v: string | null }>`select nullif(current_setting('app.user_id', true), '') as v`.execute(db); + return r.rows[0]?.v ?? null; +} + +describe.skipIf(!CONNECTION)("Sitzungskontext über eine gepoolte Verbindung", () => { + const alice = randomUUID(); + const bob = randomUUID(); + + beforeAll(async () => { + // Ein Pool mit einer Verbindung: der Test ist nur dann aussagekräftig, + // wenn beide Zugriffe garantiert dieselbe benutzen. + await sql`select 1`.execute(db); + }); + + afterAll(async () => { + await db.destroy(); + }); + + it("sieht innerhalb der Transaktion die eigene Kennung", async () => { + const seen = await withUser(alice, async () => { + const r = await sql<{ v: string }>`select current_setting('app.user_id', true) as v`.execute(db); + return r.rows[0].v; + }); + expect(seen).toBe(alice); + }); + + it("lässt nach der Transaktion nichts an der Verbindung zurück", async () => { + // Das ist der eigentliche Befund: nach dem Commit muss die Variable weg + // sein. Bliebe sie stehen, liefe die nächste Anfrage im Namen von Alice. + await withUser(alice, async () => { + await sql`select 1`.execute(db); + }); + expect(await currentUserOnConnection()).toBeNull(); + }); + + it("trägt die Kennung nicht von einer Anfrage in die nächste", async () => { + await withUser(alice, async () => { + await sql`select 1`.execute(db); + }); + + const seenByBob = await withUser(bob, async () => { + const r = await sql<{ v: string }>`select current_setting('app.user_id', true) as v`.execute(db); + return r.rows[0].v; + }); + expect(seenByBob).toBe(bob); + expect(seenByBob).not.toBe(alice); + }); + + it("hinterlässt auch nach einem Fehler keinen Kontext", async () => { + // Ein Rollback muss die Variable genauso zurücknehmen wie ein Commit — + // sonst wird ausgerechnet der Fehlerfall zur Lücke. + await expect( + withUser(alice, async () => { + await sql`select 1 / 0`.execute(db); + }) + ).rejects.toThrow(); + + expect(await currentUserOnConnection()).toBeNull(); + }); + + it("zeigt, dass eine Einstellung ohne Transaktion tatsächlich hängen bleibt", async () => { + // Die Gegenprobe. Sie belegt, dass die Vorsichtsmassnahme kein Ritual + // ist: ohne das dritte Argument `true` überlebt die Kennung die Anfrage + // und die nächste Person erbt sie. + await sql`select set_config('app.user_id', ${alice}, false)`.execute(db); + expect(await currentUserOnConnection()).toBe(alice); + + // Aufräumen, sonst verfälscht dieser Test die anderen. + await sql`select set_config('app.user_id', '', false)`.execute(db); + expect(await currentUserOnConnection()).toBeNull(); + }); +}); From b3a0af2b8f7841341f8782943b6fbf8c8949b6b4 Mon Sep 17 00:00:00 2001 From: Maximilian Stubhan Date: Fri, 31 Jul 2026 08:45:26 +0200 Subject: [PATCH 12/64] Talk to PostgreSQL directly, and let the pooled connection forget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- actions/employees.ts | 36 +-- actions/hireDrafts.ts | 65 +++--- actions/positions.ts | 12 +- actions/reports.ts | 38 ++-- app/(app)/audit/page.tsx | 59 +++-- app/(app)/employees/[id]/page.tsx | 108 ++++----- app/(app)/employees/page.tsx | 167 ++++++++------ app/(app)/layout.tsx | 57 +++-- app/(app)/orgchart/page.tsx | 18 +- app/(app)/page.tsx | 207 ++++++++++-------- app/(app)/positions/page.tsx | 26 ++- app/(app)/reports/page.tsx | 72 +++--- app/api/cron/apply-pending-changes/route.ts | 19 +- app/api/export/employees/route.ts | 57 +++-- app/api/export/events/route.ts | 32 +-- app/api/export/report/route.ts | 83 +++---- lib/auth/require-hr.ts | 26 +++ lib/auth/session.ts | 34 +++ lib/db/rpc.ts | 56 +++++ lib/employee-status-filter.ts | 63 +++--- lib/notes.ts | 40 ++-- lib/org.ts | 16 +- lib/orgchart-data.ts | 97 ++++---- lib/placement.ts | 88 +++++--- lib/positions.ts | 123 ++++++----- lib/reports-data.ts | 119 +++++----- lib/supabase/admin.ts | 23 -- lib/supabase/auth.ts | 21 -- lib/supabase/query.ts | 33 --- .../employee-status-filter.test.ts | 55 +++-- tests/unit/security.test.ts | 39 ++-- 31 files changed, 1086 insertions(+), 803 deletions(-) create mode 100644 lib/auth/require-hr.ts create mode 100644 lib/auth/session.ts create mode 100644 lib/db/rpc.ts delete mode 100644 lib/supabase/admin.ts delete mode 100644 lib/supabase/auth.ts delete mode 100644 lib/supabase/query.ts diff --git a/actions/employees.ts b/actions/employees.ts index 987e98d..3034921 100644 --- a/actions/employees.ts +++ b/actions/employees.ts @@ -1,18 +1,16 @@ "use server"; import { revalidatePath } from "next/cache"; -import { createClient } from "@/lib/supabase/server"; -import type { CollectiveAgreement, Database, NoteCategory, RelationshipType, Weekday, WorkerType } from "@/lib/supabase/types"; - -type ActionResult = { success: boolean; error?: string }; -type MutationFn = keyof Database["public"]["Functions"]; +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, revalidate: string[]): Promise { - const supabase = await createClient(); - const { error } = await supabase.rpc(fn, { payload }); - if (error) return { success: false, error: error.message }; + const result = await runMutation(await currentUserId(), fn, payload); + if (!result.success) return result; for (const path of revalidate) revalidatePath(path); - return { success: true }; + return result; } export async function hireEmployee(payload: { @@ -43,13 +41,19 @@ export async function hireEmployee(payload: { is_laterale_fuehrung?: boolean; is_c_level?: boolean; }): Promise { - const supabase = await createClient(); - const { data, error } = await supabase.rpc("hire_employee", { payload }); - if (error) return { success: false, error: error.message }; - revalidatePath("/employees"); - revalidatePath("/"); - revalidatePath("/positions"); - return { success: true, employeeId: data as 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) + ); + 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: { diff --git a/actions/hireDrafts.ts b/actions/hireDrafts.ts index 66552de..a60a9c0 100644 --- a/actions/hireDrafts.ts +++ b/actions/hireDrafts.ts @@ -1,45 +1,52 @@ "use server"; import { revalidatePath } from "next/cache"; -import { createClient } from "@/lib/supabase/server"; - -type ActionResult = { success: boolean; error?: string }; +import { currentUserId } from "@/lib/auth/session"; +import { withUser } from "@/lib/db"; +import type { ActionResult } from "@/lib/db/rpc"; export async function saveHireDraft(payload: { id?: string; step: number; data: Record; }): Promise { - const supabase = await createClient(); - const { - data: { user }, - } = await supabase.auth.getUser(); - if (!user) return { success: false, error: "Nicht angemeldet." }; + const userId = await currentUserId(); + if (!userId) return { success: false, error: "Nicht angemeldet." }; + + try { + const id = await withUser(userId, async (tx) => { + if (payload.id) { + // Ob die Zeile der aufrufenden Person gehört, entscheidet die + // Policy hire_drafts_owner — nicht eine Prüfung hier. + await tx + .updateTable("hire_drafts") + .set({ step: payload.step, payload: payload.data, updated_at: new Date().toISOString() }) + .where("id", "=", payload.id) + .execute(); + return payload.id; + } + + const row = await tx + .insertInto("hire_drafts") + .values({ created_by: userId, step: payload.step, payload: payload.data }) + .returning("id") + .executeTakeFirstOrThrow(); + return row.id; + }); - if (payload.id) { - const { error } = await supabase - .from("hire_drafts") - .update({ step: payload.step, payload: payload.data, updated_at: new Date().toISOString() }) - .eq("id", payload.id); - if (error) return { success: false, error: error.message }; revalidatePath("/"); - return { success: true, id: payload.id }; + return { success: true, id }; + } catch (err) { + return { success: false, error: err instanceof Error ? err.message : "Unbekannter Fehler." }; } - - const { data, error } = await supabase - .from("hire_drafts") - .insert({ created_by: user.id, step: payload.step, payload: payload.data }) - .select("id") - .single(); - if (error) return { success: false, error: error.message }; - revalidatePath("/"); - return { success: true, id: data.id }; } export async function deleteHireDraft(id: string): Promise { - const supabase = await createClient(); - const { error } = await supabase.from("hire_drafts").delete().eq("id", id); - if (error) return { success: false, error: error.message }; - revalidatePath("/"); - return { success: true }; + try { + await withUser(await currentUserId(), (tx) => tx.deleteFrom("hire_drafts").where("id", "=", id).execute()); + revalidatePath("/"); + return { success: true }; + } catch (err) { + return { success: false, error: err instanceof Error ? err.message : "Unbekannter Fehler." }; + } } diff --git a/actions/positions.ts b/actions/positions.ts index 2e43393..35df7fb 100644 --- a/actions/positions.ts +++ b/actions/positions.ts @@ -1,9 +1,8 @@ "use server"; import { revalidatePath } from "next/cache"; -import { createClient } from "@/lib/supabase/server"; - -type ActionResult = { success: boolean; error?: string }; +import { currentUserId } from "@/lib/auth/session"; +import { runMutation, type ActionResult } from "@/lib/db/rpc"; const POSITION_PATHS = ["/positions", "/orgchart", "/"]; @@ -12,11 +11,10 @@ async function callRpc( payload: Record, revalidate: string[] ): Promise { - const supabase = await createClient(); - const { error } = await supabase.rpc(fn, { payload }); - if (error) return { success: false, error: error.message }; + const result = await runMutation(await currentUserId(), fn, payload); + if (!result.success) return result; for (const path of revalidate) revalidatePath(path); - return { success: true }; + return result; } export async function createPosition(payload: { diff --git a/actions/reports.ts b/actions/reports.ts index 0558ff1..b0d21e3 100644 --- a/actions/reports.ts +++ b/actions/reports.ts @@ -1,27 +1,31 @@ "use server"; import { revalidatePath } from "next/cache"; -import { createClient } from "@/lib/supabase/server"; - -type ActionResult = { success: boolean; error?: string }; +import { currentUserId } from "@/lib/auth/session"; +import { withUser } from "@/lib/db"; +import type { ActionResult } from "@/lib/db/rpc"; export async function saveReport(payload: { name: string; config: Record }): Promise { - const supabase = await createClient(); - const { - data: { user }, - } = await supabase.auth.getUser(); - if (!user) return { success: false, error: "Nicht angemeldet." }; + const userId = await currentUserId(); + if (!userId) return { success: false, error: "Nicht angemeldet." }; - const { error } = await supabase.from("saved_reports").insert({ created_by: user.id, name: payload.name, config: payload.config }); - if (error) return { success: false, error: error.message }; - revalidatePath("/reports"); - return { success: true }; + try { + await withUser(userId, (tx) => + tx.insertInto("saved_reports").values({ created_by: userId, name: payload.name, config: payload.config }).execute() + ); + revalidatePath("/reports"); + return { success: true }; + } catch (err) { + return { success: false, error: err instanceof Error ? err.message : "Unbekannter Fehler." }; + } } export async function deleteReport(id: string): Promise { - const supabase = await createClient(); - const { error } = await supabase.from("saved_reports").delete().eq("id", id); - if (error) return { success: false, error: error.message }; - revalidatePath("/reports"); - return { success: true }; + try { + await withUser(await currentUserId(), (tx) => tx.deleteFrom("saved_reports").where("id", "=", id).execute()); + revalidatePath("/reports"); + return { success: true }; + } catch (err) { + return { success: false, error: err instanceof Error ? err.message : "Unbekannter Fehler." }; + } } diff --git a/app/(app)/audit/page.tsx b/app/(app)/audit/page.tsx index 704f248..585dbeb 100644 --- a/app/(app)/audit/page.tsx +++ b/app/(app)/audit/page.tsx @@ -4,8 +4,8 @@ import { AuditFilters } from "@/components/audit/AuditFilters"; import { CARD_CLASS } from "@/components/ui/Card"; import { Pagination } from "@/components/ui/Pagination"; import { actionBadgeStyle } from "@/lib/colors"; -import { sanitizeIlikeTerm } from "@/lib/supabase/query"; -import { createClient } from "@/lib/supabase/server"; +import { currentUserId } from "@/lib/auth/session"; +import { withUser } from "@/lib/db"; const PAGE_SIZE = 25; @@ -34,33 +34,50 @@ const dateTimeFormatter = new Intl.DateTimeFormat("de-AT", { export default async function AuditPage({ searchParams }: { searchParams: Promise }) { 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; - let query = supabase - .from("audit_log") - .select("id, occurred_at, actor_name, action, target_label, target_employee_id, details", { count: "exact" }) - .order("occurred_at", { ascending: false }) - .range(from, to); + const { entries, count } = await withUser(await currentUserId(), async (tx) => { + const base = () => { + let q = tx.selectFrom("audit_log"); + if (params.action) q = q.where("action", "=", params.action); + if (params.q) { + // Als Parameter gebunden statt in die Abfrage geschrieben: die + // Zeichen, die in der alten Filtersyntax ausbrechen konnten, haben + // hier keine Bedeutung mehr. + const like = `%${params.q.trim()}%`; + q = q.where((eb) => + eb.or([eb("target_label", "ilike", like), eb("details", "ilike", like), eb("actor_name", "ilike", like)]) + ); + } + return q; + }; - if (params.action) query = query.eq("action", params.action); - if (params.q) { - const q = sanitizeIlikeTerm(params.q.trim()); - query = query.or(`target_label.ilike.%${q}%,details.ilike.%${q}%,actor_name.ilike.%${q}%`); - } + const [entries, total] = await Promise.all([ + base() + .select(["id", "occurred_at", "actor_name", "action", "target_label", "target_employee_id", "details"]) + // Nach id als zweitem Kriterium: bei gleichem Zeitstempel wäre die + // Reihenfolge sonst unbestimmt und ein Eintrag könnte auf zwei Seiten + // erscheinen oder auf keiner. + .orderBy("occurred_at", "desc") + .orderBy("id", "desc") + .limit(PAGE_SIZE) + .offset((page - 1) * PAGE_SIZE) + .execute(), + base() + .select(({ fn }) => fn.countAll().as("anzahl")) + .executeTakeFirst(), + ]); + return { entries, count: Number(total?.anzahl ?? 0) }; + }); - const { data: entries, count } = await query; - const totalPages = Math.max(1, Math.ceil((count ?? 0) / PAGE_SIZE)); + const totalPages = Math.max(1, Math.ceil(count / PAGE_SIZE)); return (
-

{count ?? 0} Einträge

+

{count} Einträge

@@ -74,7 +91,7 @@ export default async function AuditPage({ searchParams }: { searchParams: Promis - {(entries ?? []).map((entry) => { + {entries.map((entry) => { return ( ); })} - {(entries ?? []).length === 0 && ( + {entries.length === 0 && (
@@ -102,7 +119,7 @@ export default async function AuditPage({ searchParams }: { searchParams: Promis
Keine Einträge gefunden. diff --git a/app/(app)/employees/[id]/page.tsx b/app/(app)/employees/[id]/page.tsx index 7003c7a..467d28f 100644 --- a/app/(app)/employees/[id]/page.tsx +++ b/app/(app)/employees/[id]/page.tsx @@ -1,68 +1,78 @@ import { notFound } from "next/navigation"; import { EmployeeDetail } from "@/components/employees/EmployeeDetail"; +import { currentUserId } from "@/lib/auth/session"; +import { withUser } from "@/lib/db"; import { todayIso } from "@/lib/format"; import { breadcrumbLabel, loadOrgMaps } from "@/lib/org"; -import { loadPlacements, type ReportingLine } from "@/lib/placement"; +import { loadPlacements, loadReportingLines } from "@/lib/placement"; import { loadOpenPositions } from "@/lib/positions"; -import { createClient } from "@/lib/supabase/server"; type PageProps = { params: Promise<{ id: string }> }; export default async function EmployeeDetailPage({ params }: PageProps) { const { id } = await params; - const supabase = await createClient(); const today = todayIso(); - // Vorgesetzte und direkte Berichte stehen nirgends als Spalte — sie kommen - // aus om_reporting_lines(). Beide Abfragen filtern *in* der Funktion, es - // wandern also neun Zeilen über die Leitung und nicht achthundert. - const [ - { data: employee }, - { data: ownLine }, - { data: reportLines }, - { data: history }, - { data: dependents }, - { data: notes }, - orgMaps, - placements, - openPositions, - ] = await Promise.all([ - supabase.from("employees").select("*").eq("id", id).single(), - supabase.rpc("om_reporting_lines", { p_as_of: today }).eq("employee_id", id).maybeSingle(), - supabase.rpc("om_reporting_lines", { p_as_of: today }).eq("acting_manager_id", id), - supabase - .from("employee_history") - .select("*") - .eq("employee_id", id) - .order("event_date", { ascending: false }) - .order("created_at", { ascending: false }), - supabase.from("employee_dependents").select("*").eq("employee_id", id).order("created_at"), - supabase.from("employee_notes").select("*").eq("employee_id", id).order("created_at", { ascending: false }), - loadOrgMaps(supabase), - loadPlacements(supabase, { asOf: today, employeeIds: [id] }), - loadOpenPositions(supabase), - ]); + const data = await withUser(await currentUserId(), async (tx) => { + // Vorgesetzte und direkte Berichte stehen nirgends als Spalte — sie + // kommen aus om_reporting_lines(). Beide Abfragen schränken *in* der + // Funktion ein, es wandern also neun Zeilen über die Leitung und nicht + // achthundert. + const [employee, ownLines, reports, history, dependents, notes, orgMaps, placements, openPositions] = + await Promise.all([ + tx.selectFrom("employees").selectAll().where("id", "=", id).executeTakeFirst(), + loadReportingLines(tx, today, { employeeId: id }), + loadReportingLines(tx, today, { actingManagerId: id }), + tx + .selectFrom("employee_history") + .selectAll() + .where("employee_id", "=", id) + .orderBy("event_date", "desc") + .orderBy("created_at", "desc") + .execute(), + tx.selectFrom("employee_dependents").selectAll().where("employee_id", "=", id).orderBy("created_at").execute(), + tx.selectFrom("employee_notes").selectAll().where("employee_id", "=", id).orderBy("created_at", "desc").execute(), + loadOrgMaps(tx), + loadPlacements(tx, { asOf: today, employeeIds: [id] }), + loadOpenPositions(tx), + ]); - if (!employee) notFound(); + if (!employee) return null; + const line = ownLines[0] ?? null; - const line = ownLine as ReportingLine | null; - const reports = (reportLines ?? []) as ReportingLine[]; - - // Namen für die beteiligten Personen in einem Zug: die Vertretung, die - // formal zuständige Leitung und die direkten Berichte. - const relatedIds = Array.from( - new Set( - [line?.acting_manager_id, line?.formal_manager_id, ...reports.map((r) => r.employee_id)].filter( - (x): x is string => Boolean(x) + // Namen für die beteiligten Personen in einem Zug: die Vertretung, die + // formal zuständige Leitung und die direkten Berichte. + const relatedIds = Array.from( + new Set( + [line?.acting_manager_id, line?.formal_manager_id, ...reports.map((r) => r.employee_id)].filter( + (x): x is string => Boolean(x) + ) ) - ) - ); - const { data: relatedRows } = relatedIds.length - ? await supabase.from("employees").select("id, first_name, last_name, job_title, status").in("id", relatedIds) - : { data: [] }; - const byId = new Map((relatedRows ?? []).map((e) => [e.id, e])); + ); + const relatedRows = relatedIds.length + ? await tx + .selectFrom("employees") + .select(["id", "first_name", "last_name", "job_title", "status"]) + .where("id", "in", relatedIds) + .execute() + : []; - const placement = placements.get(id) ?? null; + return { + employee, + line, + reports, + history, + dependents, + notes, + orgMaps, + placement: placements.get(id) ?? null, + openPositions, + byId: new Map(relatedRows.map((e) => [e.id, e])), + }; + }); + + if (!data) notFound(); + const { employee, line, reports, history, dependents, notes, orgMaps, placement, openPositions, byId } = data; return ( 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(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().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 (
diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx index f189f25..f7c1275 100644 --- a/app/(app)/layout.tsx +++ b/app/(app)/layout.tsx @@ -2,36 +2,51 @@ import { redirect } from "next/navigation"; import type { ReactNode } from "react"; import { HireWizardProvider } from "@/components/hire/HireWizardContext"; import { AppShell } from "@/components/shell/AppShell"; +import { currentUserId } from "@/lib/auth/session"; +import { withUser } from "@/lib/db"; import { loadOpenNotes } from "@/lib/notes"; import { loadOpenPositions } from "@/lib/positions"; -import { createClient } from "@/lib/supabase/server"; export default async function AppLayout({ children }: { children: ReactNode }) { - const supabase = await createClient(); - const { - data: { user }, - } = await supabase.auth.getUser(); - if (!user) redirect("/login"); + const userId = await currentUserId(); + if (!userId) redirect("/login"); - // Defense in depth: proxy.ts already redirects any non-active-HR session - // away before this layout ever renders. Re-checking here means a gap in - // the proxy matcher (or a future route added outside it) still fails - // closed instead of silently granting access — see docs/security.md. - const { data: profile } = await supabase.from("profiles").select("full_name, email, role, is_active").eq("id", user.id).maybeSingle(); - if (profile?.role !== "hr" || profile?.is_active !== true) redirect("/login"); + // Alles in *einer* Transaktion, weil nur dort der Sitzungskontext gilt — + // und damit nebenbei auf einem einheitlichen Lesestand. + const data = await withUser(userId, async (tx) => { + // Defense in depth: proxy.ts already redirects any non-active-HR session + // away before this layout ever renders. Re-checking here means a gap in + // the proxy matcher (or a future route added outside it) still fails + // closed instead of silently granting access — see docs/security.md. + const profile = await tx + .selectFrom("profiles") + .select(["full_name", "email", "role", "is_active"]) + .where("id", "=", userId) + .executeTakeFirst(); + if (profile?.role !== "hr" || profile?.is_active !== true) return null; - const userLabel = profile.full_name || profile.email || user.email || ""; + const [openPositions, locations, drafts, openNotes] = await Promise.all([ + loadOpenPositions(tx), + tx.selectFrom("locations").select(["id", "name", "country"]).orderBy("name").execute(), + tx + .selectFrom("hire_drafts") + .select(["id", "step", "payload", "updated_at"]) + .where("created_by", "=", userId) + .orderBy("updated_at", "desc") + .execute(), + loadOpenNotes(tx), + ]); - const [openPositions, locationsRes, draftsRes, openNotes] = await Promise.all([ - loadOpenPositions(supabase), - supabase.from("locations").select("id, name, country").order("name"), - supabase.from("hire_drafts").select("id, step, payload, updated_at").eq("created_by", user.id).order("updated_at", { ascending: false }), - loadOpenNotes(supabase), - ]); + return { profile, openPositions, locations, drafts, openNotes }; + }); + + if (!data) redirect("/login"); + + const userLabel = data.profile.full_name || data.profile.email || ""; return ( - - + + {children} diff --git a/app/(app)/orgchart/page.tsx b/app/(app)/orgchart/page.tsx index 6f288de..aa2d7bb 100644 --- a/app/(app)/orgchart/page.tsx +++ b/app/(app)/orgchart/page.tsx @@ -4,7 +4,8 @@ import type { OrgUnitNode } from "@/components/orgchart/types"; import { todayIso } from "@/lib/format"; import { loadOrgAsOf } from "@/lib/orgchart-data"; import { parseIsoDateParam } from "@/lib/reports"; -import { createClient } from "@/lib/supabase/server"; +import { currentUserId } from "@/lib/auth/session"; +import { withUser } from "@/lib/db"; type SearchParams = { asOf?: string; focus?: string }; @@ -18,18 +19,19 @@ export default async function OrgChartPage({ searchParams }: { searchParams: Pro // so a junk value can't reach the client as an arbitrary string. const focusId = params.focus && UUID.test(params.focus) ? params.focus : null; - const supabase = await createClient(); - - const [org, { data: units }] = await Promise.all([ - loadOrgAsOf(supabase, asOf), - supabase.from("org_units").select("id, org_number, name, parent_id, unit_type").order("org_number"), - ]); + const { org, units } = await withUser(await currentUserId(), async (tx) => { + const [org, units] = await Promise.all([ + loadOrgAsOf(tx, asOf), + tx.selectFrom("org_units").select(["id", "org_number", "name", "parent_id", "unit_type"]).orderBy("org_number").execute(), + ]); + return { org, units }; + }); return ( = { const KIND_LABEL = { hire: "Eintritt", exit: "Austritt", return: "Rückkehr aus Abwesenheit" } as const; export default async function DashboardPage() { - const supabase = await createClient(); - const { - data: { user }, - } = await supabase.auth.getUser(); - const { data: drafts } = user - ? await supabase - .from("hire_drafts") - .select("id, step, payload, updated_at") - .eq("created_by", user.id) - .order("updated_at", { ascending: false }) - : { data: [] }; - // Built as strings, not by round-tripping a local Date through // toISOString(): in any positive-offset zone new Date(year, 0, 1) is still // the previous year in UTC, which shifted the whole YTD window a day early @@ -61,6 +50,8 @@ export default async function DashboardPage() { const yearEnd = `${year}-12-31`; const in60Iso = addDaysIso(today, 60); + const userId = await currentUserId(); + // Headcount, FTE, Karenz and the division bars all come from one full read // and the *derived* status, not from the `employees.status` column. // @@ -69,70 +60,116 @@ export default async function DashboardPage() { // planned hire whose start date has passed, or a Karenz that ended without // anyone recording the return, made the dashboard and the Berichte page // disagree about the same headcount. Same derivation, same numbers. - // It also replaces four separate count queries with one. - const [ + const { + drafts, staffRows, - hiresYtdRes, - exitsYtdRes, + hiresYtd, + exitsYtd, openPositions, orgMaps, placements, - upcomingHiresRes, - upcomingExitsRes, - upcomingReturnsRes, - historyRes, - ] = await Promise.all([ - fetchAllRows(() => - supabase - .from("employees") - .select("id, weekly_hours, entry_date, exit_date, karenz_start_date, karenz_return_date") - .order("id") - ), - // Entries/exits count history events, which is what the linked report - // counts too. `entry_date` would also sweep up rehires, whose event is - // logged as 'Wiedereintritt' — the tile and its destination then showed - // different numbers for the same year. - supabase - .from("employee_history") - .select("id", { count: "exact", head: true }) - .in("event_type", ["Eintritt", "Wiedereintritt"]) - .gte("event_date", yearStart) - .lte("event_date", yearEnd), - supabase - .from("employee_history") - .select("id", { count: "exact", head: true }) - .eq("event_type", "Austritt") - .gte("event_date", yearStart) - .lte("event_date", yearEnd), - loadOpenPositions(supabase), - loadOrgMaps(supabase), - loadPlacements(supabase, { asOf: today }), - supabase - .from("employees") - .select("id, first_name, last_name, entry_date") - .eq("status", "Geplant") - .gte("entry_date", today) - .lte("entry_date", in60Iso), - supabase - .from("employees") - .select("id, first_name, last_name, exit_date") - .not("exit_date", "is", null) - .gte("exit_date", today) - .lte("exit_date", in60Iso), - supabase - .from("employees") - .select("id, first_name, last_name, karenz_return_date") - .eq("status", "Karenz") - .not("karenz_return_date", "is", null) - .gte("karenz_return_date", today) - .lte("karenz_return_date", in60Iso), - supabase - .from("employee_history") - .select("id, employee_id, event_date, event_type, description") - .order("event_date", { ascending: false }) - .order("created_at", { ascending: false }) - .limit(10), - ]); + upcomingHires, + upcomingExits, + upcomingReturns, + history, + } = await withUser(userId, async (tx) => { + const countIn = (types: readonly HistoryEventType[]) => + tx + .selectFrom("employee_history") + .select(({ fn }) => fn.countAll().as("anzahl")) + .where("event_type", "in", [...types]) + .where("event_date", ">=", yearStart) + .where("event_date", "<=", yearEnd) + .executeTakeFirst(); + + const [ + drafts, + staffRows, + hiresYtd, + exitsYtd, + openPositions, + orgMaps, + placements, + upcomingHires, + upcomingExits, + upcomingReturns, + history, + ] = await Promise.all([ + userId + ? tx + .selectFrom("hire_drafts") + .select(["id", "step", "payload", "updated_at"]) + .where("created_by", "=", userId) + .orderBy("updated_at", "desc") + .execute() + : Promise.resolve([]), + + tx + .selectFrom("employees") + .select(["id", "weekly_hours", "entry_date", "exit_date", "karenz_start_date", "karenz_return_date"]) + .orderBy("id") + .execute(), + + // Entries/exits count history events, which is what the linked report + // counts too. `entry_date` would also sweep up rehires, whose event is + // logged as 'Wiedereintritt' — the tile and its destination then showed + // different numbers for the same year. + countIn(["Eintritt", "Wiedereintritt"]), + countIn(["Austritt"]), + + loadOpenPositions(tx), + loadOrgMaps(tx), + loadPlacements(tx, { asOf: today }), + + tx + .selectFrom("employees") + .select(["id", "first_name", "last_name", "entry_date"]) + .where("status", "=", "Geplant") + .where("entry_date", ">=", today) + .where("entry_date", "<=", in60Iso) + .execute(), + + tx + .selectFrom("employees") + .select(["id", "first_name", "last_name", "exit_date"]) + .where("exit_date", "is not", null) + .where("exit_date", ">=", today) + .where("exit_date", "<=", in60Iso) + .execute(), + + tx + .selectFrom("employees") + .select(["id", "first_name", "last_name", "karenz_return_date"]) + .where("status", "=", "Karenz") + .where("karenz_return_date", "is not", null) + .where("karenz_return_date", ">=", today) + .where("karenz_return_date", "<=", in60Iso) + .execute(), + + tx + .selectFrom("employee_history as h") + .leftJoin("employees as e", "e.id", "h.employee_id") + .select(["h.id", "h.employee_id", "h.event_date", "h.event_type", "h.description", "e.first_name", "e.last_name"]) + .orderBy("h.event_date", "desc") + .orderBy("h.created_at", "desc") + .limit(10) + .execute(), + ]); + + return { + drafts, + staffRows, + hiresYtd: Number(hiresYtd?.anzahl ?? 0), + exitsYtd: Number(exitsYtd?.anzahl ?? 0), + openPositions, + orgMaps, + placements, + upcomingHires, + upcomingExits, + upcomingReturns, + history, + }; + }); // "Aktiv" means status Aktiv — somebody on Karenz is employed but not // active, and is counted by its own tile instead. FTE follows the same @@ -166,19 +203,19 @@ export default async function DashboardPage() { type UpcomingItem = { id: string; label: string; date: string; kind: keyof typeof KIND_LABEL }; const upcoming: UpcomingItem[] = [ - ...(upcomingHiresRes.data ?? []).map((e) => ({ + ...(upcomingHires).map((e) => ({ id: e.id, label: `${e.first_name} ${e.last_name}`, date: e.entry_date, kind: "hire" as const, })), - ...(upcomingExitsRes.data ?? []).map((e) => ({ + ...(upcomingExits).map((e) => ({ id: e.id, label: `${e.first_name} ${e.last_name}`, date: e.exit_date!, kind: "exit" as const, })), - ...(upcomingReturnsRes.data ?? []).map((e) => ({ + ...(upcomingReturns).map((e) => ({ id: e.id, label: `${e.first_name} ${e.last_name}`, date: e.karenz_return_date!, @@ -188,12 +225,6 @@ export default async function DashboardPage() { .sort((a, b) => a.date.localeCompare(b.date)) .slice(0, 8); - const historyEmployeeIds = Array.from(new Set((historyRes.data ?? []).map((h) => h.employee_id))); - const historyEmployeesRes = historyEmployeeIds.length - ? await supabase.from("employees").select("id, first_name, last_name").in("id", historyEmployeeIds) - : { data: [] as { id: string; first_name: string; last_name: string }[] }; - const employeeNameById = new Map((historyEmployeesRes.data ?? []).map((e) => [e.id, `${e.first_name} ${e.last_name}`])); - // Each tile links to the view that shows what it counts, with the filters // pre-applied. // @@ -214,13 +245,13 @@ export default async function DashboardPage() { { label: "FTE", value: fte.toFixed(1), tone: "default", href: "/reports?mode=snapshot&measure=fte&status=Aktiv" }, { label: "Eintritte (Jahr)", - value: hiresYtdRes.count ?? 0, + value: hiresYtd, tone: "success", href: `/reports?mode=events&eventType=Eintritt&from=${yearStart}&to=${yearEnd}`, }, { label: "Austritte (Jahr)", - value: exitsYtdRes.count ?? 0, + value: exitsYtd, tone: "danger", href: `/reports?mode=events&eventType=Austritt&from=${yearStart}&to=${yearEnd}`, }, @@ -300,14 +331,14 @@ export default async function DashboardPage() { Letzte Aktivitäten
    - {(historyRes.data ?? []).map((h) => ( + {(history).map((h) => (
  • {/* Dot aligned to the first line of text, not centred on the whole row, so it stays put as descriptions wrap. */}
    - {employeeNameById.get(h.employee_id) ?? "Unbekannt"} + {h.first_name && h.last_name ? `${h.first_name} ${h.last_name}` : "Unbekannt"} {h.event_type} @@ -316,7 +347,7 @@ export default async function DashboardPage() {
  • ))} - {(historyRes.data ?? []).length === 0 &&

    Keine Aktivitäten vorhanden.

    } + {(history).length === 0 &&

    Keine Aktivitäten vorhanden.

    }
diff --git a/app/(app)/positions/page.tsx b/app/(app)/positions/page.tsx index c942a4a..12bb401 100644 --- a/app/(app)/positions/page.tsx +++ b/app/(app)/positions/page.tsx @@ -3,21 +3,23 @@ import { PositionsPageClient } from "@/components/positions/PositionsPageClient" import { daysBetweenIso } from "@/lib/format"; import { loadOrgMaps } from "@/lib/org"; import { loadOpenPositions } from "@/lib/positions"; -import { createClient } from "@/lib/supabase/server"; +import { currentUserId } from "@/lib/auth/session"; +import { withUser } from "@/lib/db"; export default async function PositionsPage() { - const supabase = await createClient(); + const { openPositions, orgMaps, chiefRows } = await withUser(await currentUserId(), async (tx) => { + const [openPositions, orgMaps, chiefRows] = await Promise.all([ + loadOpenPositions(tx), + loadOrgMaps(tx), + // Wo es schon eine gültige Leitungsplanstelle gibt, lässt der + // Unique-Index keine zweite zu — das gehört in den Dialog, nicht in eine + // Fehlermeldung nach dem Absenden. + tx.selectFrom("om_positions").select("org_unit_id").where("is_chief", "=", true).where("valid_to", "is", null).execute(), + ]); + return { openPositions, orgMaps, chiefRows }; + }); - const [openPositions, orgMaps, { data: chiefRows }] = await Promise.all([ - loadOpenPositions(supabase), - loadOrgMaps(supabase), - // Wo es schon eine gültige Leitungsplanstelle gibt, lässt der - // Unique-Index keine zweite zu — das gehört in den Dialog, nicht in eine - // Fehlermeldung nach dem Absenden. - supabase.from("om_positions").select("org_unit_id").eq("is_chief", true).is("valid_to", null), - ]); - - const withChief = new Set((chiefRows ?? []).map((r) => r.org_unit_id)); + const withChief = new Set(chiefRows.map((r) => r.org_unit_id)); const units: UnitOption[] = orgMaps.unitList.map((u) => ({ id: u.id, name: u.name, diff --git a/app/(app)/reports/page.tsx b/app/(app)/reports/page.tsx index d30ef89..5cc1949 100644 --- a/app/(app)/reports/page.tsx +++ b/app/(app)/reports/page.tsx @@ -16,7 +16,8 @@ import { totalForRows, } from "@/lib/reports"; import { loadEventHistory, loadOrgLookups, loadSnapshotEmployees } from "@/lib/reports-data"; -import { createClient } from "@/lib/supabase/server"; +import { currentUserId } from "@/lib/auth/session"; +import { withUser } from "@/lib/db"; type SearchParams = { mode?: string; @@ -35,7 +36,6 @@ type SearchParams = { export default async function ReportsPage({ searchParams }: { searchParams: Promise }) { const params = await searchParams; - const supabase = await createClient(); const mode = parseMode(params.mode); // Both modes are parsed up front so the data load can start before @@ -55,34 +55,44 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom // in, so all three go out together. Against a hosted database a round trip // costs about as much as the query itself, which made this page's three // sequential waves its dominant cost. - const [{ lookups, divisions, locations }, { data: userRes }, events, employees] = await Promise.all([ - loadOrgLookups(supabase), - supabase.auth.getUser(), - mode === "events" - ? loadEventHistory(supabase, { - eventType: eventType ?? undefined, - division: params.division, - location: params.location, - from, - to, - }) - : Promise.resolve([]), - mode === "snapshot" - ? loadSnapshotEmployees(supabase, { - division: params.division, - location: params.location, - status: params.status, - employment: params.employment, - asOf, - }) - : Promise.resolve([]), - ]); + const userId = await currentUserId(); - const user = userRes.user; - // Still a wave of its own: it needs the user id the call above resolves. - const { data: savedReports } = user - ? await supabase.from("saved_reports").select("id, name, config").eq("created_by", user.id).order("created_at", { ascending: false }) - : { data: [] }; + // Alles in einer Transaktion — dort gilt der Sitzungskontext, und der + // Lesestand ist über alle Abfragen hinweg derselbe. Vorher waren es drei + // Wellen nacheinander, was gegen eine entfernte Datenbank der teuerste + // Teil dieser Seite war. + const { lookups, divisions, locations, events, employees, savedReports } = await withUser(userId, async (tx) => { + const [{ lookups, divisions, locations }, events, employees, savedReports] = await Promise.all([ + loadOrgLookups(tx), + mode === "events" + ? loadEventHistory(tx, { + eventType: eventType ?? undefined, + division: params.division, + location: params.location, + from, + to, + }) + : Promise.resolve([]), + mode === "snapshot" + ? loadSnapshotEmployees(tx, { + division: params.division, + location: params.location, + status: params.status, + employment: params.employment, + asOf, + }) + : Promise.resolve([]), + userId + ? tx + .selectFrom("saved_reports") + .select(["id", "name", "config"]) + .where("created_by", "=", userId) + .orderBy("created_at", "desc") + .execute() + : Promise.resolve([]), + ]); + return { lookups, divisions, locations, events, employees, savedReports }; + }); if (mode === "events") { const rows = aggregateEvents(events, eventGroup, eventSplit, lookups); @@ -100,7 +110,7 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom recordCount={events.length} divisions={divisions} locations={locations} - savedReports={savedReports ?? []} + savedReports={savedReports} /> ); @@ -127,7 +137,7 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom recordCount={employees.length} divisions={divisions} locations={locations} - savedReports={savedReports ?? []} + savedReports={savedReports} /> ); diff --git a/app/api/cron/apply-pending-changes/route.ts b/app/api/cron/apply-pending-changes/route.ts index 2c32824..e3bc0fd 100644 --- a/app/api/cron/apply-pending-changes/route.ts +++ b/app/api/cron/apply-pending-changes/route.ts @@ -1,5 +1,6 @@ import { NextResponse, type NextRequest } from "next/server"; -import { createAdminClient } from "@/lib/supabase/admin"; +import { asSystem } from "@/lib/db"; +import { callFunction } from "@/lib/db/rpc"; // Applies effective-dated changes (Versetzung/Beförderung/Karenz/Reorg/Daten // ändern with a future "Wirksam ab" date) once their date has arrived — see @@ -13,13 +14,15 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: "Nicht autorisiert." }, { status: 401 }); } - const supabase = createAdminClient(); - const { data, error } = await supabase.rpc("apply_due_pending_changes"); - - if (error) { - console.error("apply_due_pending_changes failed:", error); + // Kein privilegierter Zugang mehr: derselbe Datenbankbenutzer ohne + // BYPASSRLS wie überall. apply_due_pending_changes ist SECURITY DEFINER + // und prüft selbst, was sie tut — der Dienstschlüssel, der RLS aushebelte, + // ist damit entfallen. + try { + const applied = await asSystem((tx) => callFunction(tx, "apply_due_pending_changes")); + return NextResponse.json({ applied }); + } catch (err) { + console.error("apply_due_pending_changes failed:", err); return NextResponse.json({ error: "Interner Fehler." }, { status: 500 }); } - - return NextResponse.json({ applied: data }); } diff --git a/app/api/export/employees/route.ts b/app/api/export/employees/route.ts index 283f7eb..d295868 100644 --- a/app/api/export/employees/route.ts +++ b/app/api/export/employees/route.ts @@ -3,12 +3,11 @@ import { statusLabel } from "@/lib/absence"; import { exportFilename, exportResponseHeaders, toCsv, toXlsx, type ExportColumn } from "@/lib/export"; import { todayIso } from "@/lib/format"; import { subtreeOf } from "@/lib/org"; -import { loadPlacements, loadReportingLines } from "@/lib/placement"; +import { loadPlacements, loadReportingLineMap } from "@/lib/placement"; import { deriveStatusAsOf, parseIsoDateParam, parseStatuses, type OrgLookups } from "@/lib/reports"; import { loadDependentsCounts, loadOrgLookups, type ReportFilters } from "@/lib/reports-data"; -import { requireHrUser } from "@/lib/supabase/auth"; -import { fetchAllRows } from "@/lib/supabase/query"; -import { createClient } from "@/lib/supabase/server"; +import { requireHrUser } from "@/lib/auth/require-hr"; +import { withUser } from "@/lib/db"; import type { Database, EmploymentType, Weekday } from "@/lib/supabase/types"; // Die Rohzeile plus die Einordnung, die nicht mehr auf ihr steht: sie kommt @@ -26,9 +25,8 @@ type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"] & { // filtering happens against the *derived* status as of that date rather // than the live `status` column — see deriveStatusAsOf. export async function GET(request: NextRequest) { - const supabase = await createClient(); - const denied = await requireHrUser(supabase); - if (denied) return denied; + const gate = await requireHrUser(); + if ("denied" in gate) return gate.denied; const params = request.nextUrl.searchParams; const format = params.get("format") === "xlsx" ? "xlsx" : "csv"; @@ -44,22 +42,37 @@ export async function GET(request: NextRequest) { const stichtag = asOf ?? todayIso(); - function employeeQuery() { - let query = supabase.from("employees").select("*").order("last_name").order("id"); - if (filters.location) query = query.eq("location_id", filters.location); - if (filters.employment) query = query.eq("employment_type", filters.employment as EmploymentType); - if (!asOf) query = query.in("status", statuses); - return query; - } + const { employees, lookups, orgMaps, allEmployees, dependentsCounts, placements, lines } = await withUser( + gate.userId, + async (tx) => { + function employeeQuery() { + let q = tx.selectFrom("employees").selectAll().orderBy("last_name").orderBy("id"); + if (filters.location) q = q.where("location_id", "=", filters.location); + if (filters.employment) q = q.where("employment_type", "=", filters.employment as EmploymentType); + if (!asOf) q = q.where("status", "in", statuses); + return q; + } - const [employees, { lookups, orgMaps }, allEmployees, dependentsCounts, placements, lines] = await Promise.all([ - fetchAllRows(employeeQuery), - loadOrgLookups(supabase), - fetchAllRows(() => supabase.from("employees").select("id, first_name, last_name").order("id")), - loadDependentsCounts(supabase), - loadPlacements(supabase, { asOf: stichtag }), - loadReportingLines(supabase, stichtag), - ]); + const [employees, lookupResult, allEmployees, dependentsCounts, placements, lines] = await Promise.all([ + employeeQuery().execute(), + loadOrgLookups(tx), + tx.selectFrom("employees").select(["id", "first_name", "last_name"]).orderBy("id").execute(), + loadDependentsCounts(tx), + loadPlacements(tx, { asOf: stichtag }), + loadReportingLineMap(tx, stichtag), + ]); + + return { + employees, + lookups: lookupResult.lookups, + orgMaps: lookupResult.orgMaps, + allEmployees, + dependentsCounts, + placements, + lines, + }; + } + ); const managerName = new Map(allEmployees.map((e) => [e.id, `${e.first_name} ${e.last_name}`])); // Der Einheitenfilter meint den ganzen Teilbaum — sonst enthielte ein diff --git a/app/api/export/events/route.ts b/app/api/export/events/route.ts index 8a03382..2459670 100644 --- a/app/api/export/events/route.ts +++ b/app/api/export/events/route.ts @@ -2,32 +2,34 @@ import { NextResponse, type NextRequest } from "next/server"; import { exportFilename, exportResponseHeaders, toCsv, toXlsx, type ExportColumn } from "@/lib/export"; import { EVENT_TYPE_LABELS, parseEventDateParam, parseEventType, type OrgLookups, type ReportEvent } from "@/lib/reports"; import { loadEventHistory, loadOrgLookups } from "@/lib/reports-data"; -import { requireHrUser } from "@/lib/supabase/auth"; -import { createClient } from "@/lib/supabase/server"; +import { requireHrUser } from "@/lib/auth/require-hr"; +import { withUser } from "@/lib/db"; // Full raw event-log dump — one row per employee_history entry in the // selected period (default: current year), every event type unless one is // picked, org columns resolved from each affected employee's current // placement (see loadEventHistory). export async function GET(request: NextRequest) { - const supabase = await createClient(); - const denied = await requireHrUser(supabase); - if (denied) return denied; + const gate = await requireHrUser(); + if ("denied" in gate) return gate.denied; const params = request.nextUrl.searchParams; const format = params.get("format") === "xlsx" ? "xlsx" : "csv"; const eventType = parseEventType(params.get("eventType")); - const [{ lookups }, events] = await Promise.all([ - loadOrgLookups(supabase), - loadEventHistory(supabase, { - eventType: eventType ?? undefined, - division: params.get("division") ?? undefined, - location: params.get("location") ?? undefined, - from: parseEventDateParam(params.get("from")), - to: parseEventDateParam(params.get("to")), - }), - ]); + const { lookups, events } = await withUser(gate.userId, async (tx) => { + const [{ lookups }, events] = await Promise.all([ + loadOrgLookups(tx), + loadEventHistory(tx, { + eventType: eventType ?? undefined, + division: params.get("division") ?? undefined, + location: params.get("location") ?? undefined, + from: parseEventDateParam(params.get("from")), + to: parseEventDateParam(params.get("to")), + }), + ]); + return { lookups, events }; + }); const columns = eventExportColumns(lookups); const filename = exportFilename(`ereignisse-${eventType ?? "alle"}`, format); diff --git a/app/api/export/report/route.ts b/app/api/export/report/route.ts index 5e306a6..233e5b0 100644 --- a/app/api/export/report/route.ts +++ b/app/api/export/report/route.ts @@ -24,59 +24,64 @@ import { type ReportRow, } from "@/lib/reports"; import { loadEventHistory, loadOrgLookups, loadSnapshotEmployees } from "@/lib/reports-data"; -import { requireHrUser } from "@/lib/supabase/auth"; -import { createClient } from "@/lib/supabase/server"; +import { requireHrUser } from "@/lib/auth/require-hr"; +import { withUser } from "@/lib/db"; // Exports exactly the pivot table currently on screen (same mode/measure or // event-type/group/split/filters, read from the query string the client // already keeps in the URL) as a flat table — one row per group, one column // per split value if a split is active. export async function GET(request: NextRequest) { - const supabase = await createClient(); - const denied = await requireHrUser(supabase); - if (denied) return denied; + const gate = await requireHrUser(); + if ("denied" in gate) return gate.denied; const params = request.nextUrl.searchParams; const format = params.get("format") === "xlsx" ? "xlsx" : "csv"; const mode = parseMode(params.get("mode")); - const { lookups } = await loadOrgLookups(supabase); + // Eine Transaktion für Nachschlagewerte und Daten: dort gilt der + // Sitzungskontext, und beide sehen denselben Lesestand. + const { rows, columns, filenameBase } = await withUser(gate.userId, async (tx) => { + const { lookups } = await loadOrgLookups(tx); + let rows: ReportRow[]; + let columns: ExportColumn[]; + let filenameBase: string; - let rows: ReportRow[]; - let columns: ExportColumn[]; - let filenameBase: string; + if (mode === "events") { + const group = parseEventGroupDimension(params.get("group")); + const split = parseEventSplitDimension(params.get("split")); + const eventType = parseEventType(params.get("eventType")); + const events = await loadEventHistory(tx, { + eventType: eventType ?? undefined, + division: params.get("division") ?? undefined, + location: params.get("location") ?? undefined, + from: parseEventDateParam(params.get("from")), + to: parseEventDateParam(params.get("to")), + }); + rows = aggregateEvents(events, group, split, lookups); + columns = eventReportColumns(rows, group, split, sumValues(rows)); + filenameBase = `ereignisse-${eventType ?? "alle"}-${group}`; + } else { + const measure = parseMeasure(params.get("measure")); + const group = parseGroupDimension(params.get("group")); + const split = parseSplitDimension(params.get("split")); + const asOf = parseIsoDateParam(params.get("asOf")); + const employees = await loadSnapshotEmployees(tx, { + division: params.get("division") ?? undefined, + location: params.get("location") ?? undefined, + status: params.get("status") ?? undefined, + employment: params.get("employment") ?? undefined, + asOf, + }); + rows = aggregateReport(employees, measure, group, split, lookups, asOf); + columns = snapshotReportColumns(rows, measure, group, split, totalForRows(rows, measure)); + filenameBase = `bericht-${measure}-${group}`; + } - if (mode === "events") { - const group = parseEventGroupDimension(params.get("group")); - const split = parseEventSplitDimension(params.get("split")); - const eventType = parseEventType(params.get("eventType")); - const events = await loadEventHistory(supabase, { - eventType: eventType ?? undefined, - division: params.get("division") ?? undefined, - location: params.get("location") ?? undefined, - from: parseEventDateParam(params.get("from")), - to: parseEventDateParam(params.get("to")), - }); - rows = aggregateEvents(events, group, split, lookups); - columns = eventReportColumns(rows, group, split, sumValues(rows)); - filenameBase = `ereignisse-${eventType ?? "alle"}-${group}`; - } else { - const measure = parseMeasure(params.get("measure")); - const group = parseGroupDimension(params.get("group")); - const split = parseSplitDimension(params.get("split")); - const asOf = parseIsoDateParam(params.get("asOf")); - const employees = await loadSnapshotEmployees(supabase, { - division: params.get("division") ?? undefined, - location: params.get("location") ?? undefined, - status: params.get("status") ?? undefined, - employment: params.get("employment") ?? undefined, - asOf, - }); - rows = aggregateReport(employees, measure, group, split, lookups, asOf); - columns = snapshotReportColumns(rows, measure, group, split, totalForRows(rows, measure)); - filenameBase = `bericht-${measure}-${group}`; - } + return { rows, columns, filenameBase }; + }); const filename = exportFilename(filenameBase, format); + const body = format === "xlsx" ? await toXlsx(rows, columns, "Bericht") : toCsv(rows, columns); // TS 5.9's Uint8Array vs DOM's BlobPart/ArrayBuffer<> generic // mismatch (microsoft/TypeScript#59417) — a real Uint8Array works fine here. diff --git a/lib/auth/require-hr.ts b/lib/auth/require-hr.ts new file mode 100644 index 0000000..2497e11 --- /dev/null +++ b/lib/auth/require-hr.ts @@ -0,0 +1,26 @@ +import "server-only"; +import { NextResponse } from "next/server"; +import { withUser } from "@/lib/db"; +import { currentUserId } from "./session"; + +// Route Handlers under /api/export/* are outside the App Router layout tree, +// so app/(app)/layout.tsx's HR gate never runs for them — each one has to +// re-establish that the caller is an active HR user itself. RLS is still the +// real boundary (an unauthorized session simply reads nothing); this exists +// so those routes answer 401/403 instead of handing back an empty workbook. + +export type HrGate = { denied: NextResponse } | { userId: string }; + +export async function requireHrUser(): Promise { + const userId = await currentUserId(); + if (!userId) return { denied: NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 }) }; + + const profile = await withUser(userId, (tx) => + tx.selectFrom("profiles").select(["role", "is_active"]).where("id", "=", userId).executeTakeFirst() + ); + + if (profile?.role !== "hr" || profile.is_active !== true) { + return { denied: NextResponse.json({ error: "Nicht berechtigt." }, { status: 403 }) }; + } + return { userId }; +} diff --git a/lib/auth/session.ts b/lib/auth/session.ts new file mode 100644 index 0000000..6c348bb --- /dev/null +++ b/lib/auth/session.ts @@ -0,0 +1,34 @@ +import "server-only"; +import { createClient } from "@/lib/supabase/server"; + +// Der einzige Ort, an dem die Kennung der angemeldeten Person herkommt. +// +// Heute liefert sie GoTrue, morgen Auth.js mit Entra ID. Weil alles andere +// nur noch `currentUserId()` aufruft und den Wert an withUser() weiterreicht, +// ist der Wechsel des Anmeldeverfahrens eine Änderung an dieser Datei — nicht +// an fünfzig Aufrufstellen. +// +// Dass das aufgeht, liegt an einer Eigenschaft des Übergangs: profiles.id ist +// heute die auth.users.id. Die Kennung, die hier herauskommt, passt also +// bereits auf das, was app_current_user_id() in der Datenbank erwartet. + +export async function currentUserId(): Promise { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + return user?.id ?? null; +} + +/** + * Wie currentUserId(), bricht aber ab, statt null zu liefern. + * + * Für Stellen, die ohne angemeldete Person keinen Sinn ergeben. Die + * Absicherung hängt trotzdem nicht daran: ohne Kontext geben die + * RLS-Policies nichts zurück, unabhängig davon, was der Anwendungscode tut. + */ +export async function requireUserId(): Promise { + const id = await currentUserId(); + if (!id) throw new Error("Nicht angemeldet."); + return id; +} diff --git a/lib/db/rpc.ts b/lib/db/rpc.ts new file mode 100644 index 0000000..fade4bb --- /dev/null +++ b/lib/db/rpc.ts @@ -0,0 +1,56 @@ +import "server-only"; +import { sql, withUser, type Tx } from "./index"; +import type { Database } from "@/lib/supabase/types"; + +// Aufruf einer Datenbankfunktion. +// +// Die Geschäftslogik liegt in PL/pgSQL — Eintritt, Versetzung, Austritt und +// die übrigen zehn Mutationen. Daran ändert der Wechsel des Zugriffswegs +// nichts: es fällt nur die API-Schicht dazwischen weg. Aufgerufen wird die +// Funktion jetzt unmittelbar, innerhalb der Transaktion, in der auch der +// Sitzungskontext gilt — ohne den würde require_hr_admin() darin abweisen. + +export type MutationFn = keyof Database["public"]["Functions"]; + +/** + * Ruft `fn(payload)` innerhalb der laufenden Transaktion auf. + * + * `payload` weglassen für die Funktionen ohne Argument — + * apply_due_pending_changes() ist die einzige. Mit einem jsonb-Argument + * aufgerufen fände Postgres keine passende Signatur. + */ +export async function callFunction(tx: Tx, fn: MutationFn, payload?: Record): Promise { + // Der Funktionsname stammt aus einer geschlossenen Aufzählung, nie aus + // einer Eingabe — sonst wäre die Verkettung hier eine Einladung. + const name = sql.raw(`"${fn}"`); + const query = + payload === undefined + ? sql<{ result: unknown }>`select ${name}() as result` + : sql<{ result: unknown }>`select ${name}(${sql.val(JSON.stringify(payload))}::jsonb) as result`; + const result = await query.execute(tx); + return result.rows[0]?.result; +} + +export type ActionResult = { success: boolean; error?: string }; + +/** + * Eine Mutation im Namen der angemeldeten Person, mit der üblichen + * Fehlerbehandlung für Server Actions. + * + * Die Prüfung der Berechtigung passiert in der Funktion selbst + * (require_hr_admin) und unabhängig davon in den RLS-Policies — nicht hier. + */ +export async function runMutation( + userId: string | null, + fn: MutationFn, + payload: Record +): Promise { + try { + await withUser(userId, (tx) => callFunction(tx, fn, payload)); + return { success: true }; + } catch (err) { + // Die Meldungen der Funktionen sind für die Oberfläche geschrieben + // („Diese Planstelle ist bereits besetzt.") und werden durchgereicht. + return { success: false, error: err instanceof Error ? err.message : "Unbekannter Fehler." }; + } +} diff --git a/lib/employee-status-filter.ts b/lib/employee-status-filter.ts index cdc811b..5dd722f 100644 --- a/lib/employee-status-filter.ts +++ b/lib/employee-status-filter.ts @@ -1,3 +1,5 @@ +import type { Expression, ExpressionBuilder, SqlBool } from "kysely"; +import type { Schema } from "./db/schema"; import type { EmploymentStatus } from "./supabase/types"; // The SQL counterpart of deriveStatusAsOf() in lib/reports.ts. @@ -19,58 +21,59 @@ import type { EmploymentStatus } from "./supabase/types"; // tests/integration/employee-status-filter.test.ts asserts the two agree // against a real database, which is the only place that can prove it. -type Filterable = { - gt: (column: string, value: string) => Filterable; - lte: (column: string, value: string) => Filterable; - gte: (column: string, value: string) => Filterable; - or: (filters: string) => Filterable; - is: (column: string, value: null) => Filterable; - not: (column: string, operator: string, value: null) => Filterable; -}; +type Eb = ExpressionBuilder; /** True once the person has started and has not left yet. */ -function employed(query: Q, asOf: string): Q { - return query.lte("entry_date", asOf).or(`exit_date.is.null,exit_date.gt.${asOf}`) as Q; +function employed(eb: Eb, asOf: string): Expression { + return eb.and([eb("entry_date", "<=", asOf), eb.or([eb("exit_date", "is", null), eb("exit_date", ">", asOf)])]); } /** - * Narrows a PostgREST query to the employees whose *derived* status on - * `asOf` is one of `statuses`. Only the combinations the UI offers are - * supported; anything else is left unfiltered rather than silently applying - * a wrong one. + * Die Bedingung für die Menge, deren *abgeleiteter* Status am Stichtag einer + * der genannten ist — oder null, wenn nicht eingeschränkt werden soll. + * + * Nur die Kombinationen, die die Oberfläche anbietet, sind abgedeckt. Für + * alles andere kommt null zurück: lieber nicht filtern als falsch filtern. */ -export function applyDerivedStatusFilter(query: Q, statuses: EmploymentStatus[], asOf: string): Q { +export function derivedStatusFilter(eb: Eb, statuses: EmploymentStatus[], asOf: string): Expression | null { const wanted = new Set(statuses); - if (wanted.size === 0) return query; + if (wanted.size === 0) return null; // A single non-employed status is a straight date comparison. - if (wanted.size === 1 && wanted.has("Geplant")) return query.gt("entry_date", asOf) as Q; - if (wanted.size === 1 && wanted.has("Ausgetreten")) return query.not("exit_date", "is", null).lte("exit_date", asOf) as Q; + if (wanted.size === 1 && wanted.has("Geplant")) return eb("entry_date", ">", asOf); + if (wanted.size === 1 && wanted.has("Ausgetreten")) { + return eb.and([eb("exit_date", "is not", null), eb("exit_date", "<=", asOf)]); + } const wantsAktiv = wanted.has("Aktiv"); const wantsKarenz = wanted.has("Karenz"); - if (wantsAktiv && wantsKarenz && wanted.size === 2) { - // Everyone employed today, whether or not they are on leave. - return employed(query, asOf); - } + // Everyone employed today, whether or not they are on leave. + if (wantsAktiv && wantsKarenz && wanted.size === 2) return employed(eb, asOf); if (wantsKarenz && !wantsAktiv && wanted.size === 1) { - return employed(query, asOf) - .not("karenz_start_date", "is", null) - .lte("karenz_start_date", asOf) - .or(`karenz_return_date.is.null,karenz_return_date.gt.${asOf}`) as Q; + return eb.and([ + employed(eb, asOf), + eb("karenz_start_date", "is not", null), + eb("karenz_start_date", "<=", asOf), + eb.or([eb("karenz_return_date", "is", null), eb("karenz_return_date", ">", asOf)]), + ]); } if (wantsAktiv && !wantsKarenz && wanted.size === 1) { // Employed but *not* inside a karenz window: either no start date, a // start still ahead, or a return that has already happened. - return employed(query, asOf).or( - `karenz_start_date.is.null,karenz_start_date.gt.${asOf},karenz_return_date.lte.${asOf}` - ) as Q; + return eb.and([ + employed(eb, asOf), + eb.or([ + eb("karenz_start_date", "is", null), + eb("karenz_start_date", ">", asOf), + eb("karenz_return_date", "<=", asOf), + ]), + ]); } // Mixed selections spanning employed and non-employed states have no UI // path today; filtering on a guess would be worse than not filtering. - return query; + return null; } diff --git a/lib/notes.ts b/lib/notes.ts index 72aaebf..794124a 100644 --- a/lib/notes.ts +++ b/lib/notes.ts @@ -1,26 +1,32 @@ -import type { SupabaseClient } from "@supabase/supabase-js"; -import { fetchAllRows } from "./supabase/query"; +import type { Tx } from "./db"; import type { Database } from "./supabase/types"; export type OpenNote = Database["public"]["Tables"]["employee_notes"]["Row"] & { employeeName: string; }; -// "Meine Notizen" (Topbar-Glocke): das geteilte, mitarbeiterübergreifende -// Postfach aller noch nicht erledigten HR-Notizen — unabhängig davon wer -// sie verfasst hat oder zu wem sie gehören (mit Nutzer abgestimmt). Zwei -// einfache Queries, in JS gemerged — gleiches Muster wie loadEventHistory -// in lib/reports-data.ts, da der handgeschriebene Database-Typ keine -// relationalen Embeddings für eine einzelne verschachtelte Query kennt. -export async function loadOpenNotes(supabase: SupabaseClient): Promise { - const [{ data: notes }, employees] = await Promise.all([ - supabase.from("employee_notes").select("*").eq("done", false).order("created_at", { ascending: false }), - fetchAllRows(() => supabase.from("employees").select("id, first_name, last_name").order("id")), - ]); +// „Meine Notizen" (Topbar-Glocke): das geteilte, mitarbeiterübergreifende +// Postfach aller noch nicht erledigten HR-Notizen — unabhängig davon, wer sie +// verfasst hat oder zu wem sie gehören (mit Nutzer abgestimmt). +// +// Früher zwei Abfragen, in JavaScript zusammengeführt, weil die API-Schicht +// für eine einzelne verschachtelte Abfrage keine Verknüpfung anbot. Am +// direkten Zugang ist es schlicht ein Join. +export async function loadOpenNotes(tx: Tx): Promise { + const rows = await tx + .selectFrom("employee_notes as n") + .leftJoin("employees as e", "e.id", "n.employee_id") + .selectAll("n") + .select(["e.first_name", "e.last_name"]) + .where("n.done", "=", false) + .orderBy("n.created_at", "desc") + .execute(); - const employeeById = new Map(employees.map((e) => [e.id, e])); - return (notes ?? []).map((n) => { - const emp = employeeById.get(n.employee_id); - return { ...n, employeeName: emp ? `${emp.first_name} ${emp.last_name}` : "Unbekannt" }; + return rows.map((row) => { + const { first_name, last_name, ...note } = row; + return { + ...(note as Database["public"]["Tables"]["employee_notes"]["Row"]), + employeeName: first_name && last_name ? `${first_name} ${last_name}` : "Unbekannt", + }; }); } diff --git a/lib/org.ts b/lib/org.ts index f5675a8..9296189 100644 --- a/lib/org.ts +++ b/lib/org.ts @@ -1,4 +1,4 @@ -import type { SupabaseClient } from "@supabase/supabase-js"; +import type { Tx } from "./db"; import type { Database } from "./supabase/types"; // Die Organisation ist ein Baum, keine drei Tabellen mehr. Alles, was früher @@ -31,13 +31,17 @@ export type OrgMaps = { // Die Referenzdaten sind winzig (60 Einheiten, 5 Standorte) — sie werden // ganz geladen und im Speicher verknüpft, statt je Zeile nachzuschlagen. -export async function loadOrgMaps(supabase: SupabaseClient): Promise { - const [{ data: units }, { data: locations }] = await Promise.all([ - supabase.from("org_units").select("id, org_number, name, parent_id, unit_type").order("org_number"), - supabase.from("locations").select("*").order("name"), +export async function loadOrgMaps(tx: Tx): Promise { + const [units, locations] = await Promise.all([ + tx + .selectFrom("org_units") + .select(["id", "org_number", "name", "parent_id", "unit_type"]) + .orderBy("org_number") + .execute(), + tx.selectFrom("locations").selectAll().orderBy("name").execute(), ]); - return buildOrgMaps((units ?? []) as OrgUnit[], locations ?? []); + return buildOrgMaps(units as OrgUnit[], locations as Location[]); } /** Der reine Teil: aus den Zeilen den Baum bauen, ohne Datenbank. */ diff --git a/lib/orgchart-data.ts b/lib/orgchart-data.ts index 480efde..eed1adc 100644 --- a/lib/orgchart-data.ts +++ b/lib/orgchart-data.ts @@ -1,9 +1,7 @@ -import type { SupabaseClient } from "@supabase/supabase-js"; import type { OrgEmployee, OrgVacancy } from "@/components/orgchart/types"; +import type { Tx } from "./db"; import { todayIso } from "./format"; import { resolveReportingLines, type OmHolder, type OmUnit } from "./om-reporting"; -import { fetchAllRows } from "./supabase/query"; -import type { Database } from "./supabase/types"; // Das Organigramm, wie es an einem Stichtag stand oder stehen wird. // @@ -56,55 +54,76 @@ type AssignmentRow = { employee_id: string; position_id: string }; type PendingRow = { employee_id: string; effective_date: string; payload: Record }; -export async function loadOrgAsOf(supabase: SupabaseClient, asOf: string): Promise { +export async function loadOrgAsOf(tx: Tx, asOf: string): Promise { const today = todayIso(); + // Ohne die 1000-Zeilen-Grenze der API-Schicht fällt das seitenweise + // Nachladen weg: sechs Abfragen, jede vollständig. const [units, positions, assignments, employees, pending, earliest] = await Promise.all([ - fetchAllRows(() => supabase.from("org_units").select("id, parent_id").order("id")), - fetchAllRows(() => - supabase - .from("om_positions") - .select("id, position_number, org_unit_id, is_chief, jobs!inner(title)") - .lte("valid_from", asOf) - .or(`valid_to.is.null,valid_to.gt.${asOf}`) - .order("id") - ), - fetchAllRows(() => - supabase - .from("position_assignments") - .select("employee_id, position_id") - .lte("valid_from", asOf) - .or(`valid_to.is.null,valid_to.gt.${asOf}`) - .order("employee_id") - ), - fetchAllRows(() => - supabase - .from("employees") - .select("id, personnel_number, first_name, last_name, job_title, karenz_start_date, karenz_return_date, absence_type") - .order("id") - ), + tx.selectFrom("org_units").select(["id", "parent_id"]).orderBy("id").execute(), + + tx + .selectFrom("om_positions as p") + .innerJoin("jobs as j", "j.id", "p.job_id") + .select(["p.id", "p.position_number", "p.org_unit_id", "p.is_chief", "j.title"]) + .where("p.valid_from", "<=", asOf) + .where((eb) => eb.or([eb("p.valid_to", "is", null), eb("p.valid_to", ">", asOf)])) + .orderBy("p.id") + .execute(), + + tx + .selectFrom("position_assignments") + .select(["employee_id", "position_id"]) + .where("valid_from", "<=", asOf) + .where((eb) => eb.or([eb("valid_to", "is", null), eb("valid_to", ">", asOf)])) + .orderBy("employee_id") + .execute(), + + tx + .selectFrom("employees") + .select([ + "id", + "personnel_number", + "first_name", + "last_name", + "job_title", + "karenz_start_date", + "karenz_return_date", + "absence_type", + ]) + .orderBy("id") + .execute(), + asOf > today - ? fetchAllRows(() => - supabase - .from("pending_org_changes") - .select("employee_id, effective_date, payload") - .eq("status", "pending") - .lte("effective_date", asOf) - .in("change_type", [...PLACEMENT_CHANGES]) - .order("effective_date") - ) + ? tx + .selectFrom("pending_org_changes") + .select(["employee_id", "effective_date", "payload"]) + .where("status", "=", "pending") + .where("effective_date", "<=", asOf) + .where("change_type", "in", [...PLACEMENT_CHANGES]) + .orderBy("effective_date") + .execute() : Promise.resolve([]), - supabase.from("position_assignments").select("valid_from").order("valid_from").limit(1).maybeSingle(), + + tx.selectFrom("position_assignments").select("valid_from").orderBy("valid_from").limit(1).executeTakeFirst(), ]); return resolveOrgSnapshot({ asOf, units: units.map((u) => ({ id: u.id, parentId: u.parent_id })), - positions: positions as unknown as PositionRow[], + // Der Join liefert den Jobtitel flach; die reine Funktion erwartet ihn + // verschachtelt, weil sie so auch aus einem Testbestand gefüttert wird. + positions: positions.map((p) => ({ + id: p.id, + position_number: p.position_number, + org_unit_id: p.org_unit_id, + is_chief: p.is_chief, + jobs: { title: p.title }, + })), assignments: assignments as AssignmentRow[], employees: employees as EmployeeRow[], pending: pending as PendingRow[], - historyStartsAt: earliest.data?.valid_from ?? null, + historyStartsAt: earliest?.valid_from ?? null, }); } diff --git a/lib/placement.ts b/lib/placement.ts index 4dd7672..4534e6f 100644 --- a/lib/placement.ts +++ b/lib/placement.ts @@ -1,6 +1,4 @@ -import type { SupabaseClient } from "@supabase/supabase-js"; -import { fetchAllRows } from "./supabase/query"; -import type { Database } from "./supabase/types"; +import { sql, type Tx } from "./db"; // Wo jemand in der Organisation steht, steht nicht mehr auf der Person. Es // ergibt sich aus der Planstelle, die sie zum Stichtag innehat: @@ -24,30 +22,25 @@ export type Placement = { current: boolean; }; -const SELECT = - "employee_id, valid_from, valid_to, om_positions!inner(id, position_number, org_unit_id, is_chief, jobs!inner(title))"; - type Row = { employee_id: string; valid_from: string; valid_to: string | null; - om_positions: { - id: string; - position_number: string; - org_unit_id: string; - is_chief: boolean; - jobs: { title: string }; - }; + position_id: string; + position_number: string; + org_unit_id: string; + is_chief: boolean; + job_title: string; }; function toPlacement(row: Row, asOf: string): Placement { return { employeeId: row.employee_id, - positionId: row.om_positions.id, - positionNumber: row.om_positions.position_number, - orgUnitId: row.om_positions.org_unit_id, - isChief: row.om_positions.is_chief, - jobTitle: row.om_positions.jobs.title, + positionId: row.position_id, + positionNumber: row.position_number, + orgUnitId: row.org_unit_id, + isChief: row.is_chief, + jobTitle: row.job_title, validFrom: row.valid_from, validTo: row.valid_to, current: row.valid_from <= asOf && (row.valid_to === null || row.valid_to > asOf), @@ -76,17 +69,33 @@ export function pickPlacements(rows: Row[], asOf: string): Map, + tx: Tx, { asOf, employeeIds }: { asOf: string; employeeIds?: string[] } ): Promise> { if (employeeIds?.length === 0) return new Map(); - const rows = await fetchAllRows(() => { - const q = supabase.from("position_assignments").select(SELECT).order("employee_id"); - return employeeIds ? q.in("employee_id", employeeIds) : q; - }); + // Ein Join statt einer eingebetteten Ressource. Und ohne die + // 1000-Zeilen-Grenze von PostgREST fällt das seitenweise Nachladen weg, + // das es dafür brauchte. + let q = tx + .selectFrom("position_assignments as pa") + .innerJoin("om_positions as p", "p.id", "pa.position_id") + .innerJoin("jobs as j", "j.id", "p.job_id") + .select([ + "pa.employee_id", + "pa.valid_from", + "pa.valid_to", + "p.id as position_id", + "p.position_number", + "p.org_unit_id", + "p.is_chief", + "j.title as job_title", + ]) + .orderBy("pa.employee_id"); - return pickPlacements(rows as unknown as Row[], asOf); + if (employeeIds) q = q.where("pa.employee_id", "in", employeeIds); + + return pickPlacements((await q.execute()) as Row[], asOf); } // ── Abgeleitete Berichtslinie ────────────────────────────────────── @@ -105,11 +114,30 @@ export type ReportingLine = { acting_manager_id: string | null; }; +/** + * `filter` schränkt die Funktion selbst ein, nicht das Ergebnis im Speicher — + * bei der Detailseite wandern damit neun Zeilen über die Leitung statt + * achthundert. + */ export async function loadReportingLines( - supabase: SupabaseClient, - asOf: string -): Promise> { - const { data, error } = await supabase.rpc("om_reporting_lines", { p_as_of: asOf }); - if (error) throw new Error(`Berichtslinie konnte nicht geladen werden: ${error.message}`); - return new Map(((data ?? []) as ReportingLine[]).map((l) => [l.employee_id, l])); + tx: Tx, + asOf: string, + filter?: { employeeId?: string; actingManagerId?: string } +): Promise { + const conditions = [sql`true`]; + if (filter?.employeeId) conditions.push(sql`employee_id = ${filter.employeeId}::uuid`); + if (filter?.actingManagerId) conditions.push(sql`acting_manager_id = ${filter.actingManagerId}::uuid`); + + const result = await sql` + select * from om_reporting_lines(${asOf}::date) + where ${sql.join(conditions, sql` and `)} + `.execute(tx); + + return result.rows; +} + +/** Wie loadReportingLines, aber als Karte über die Personen-Kennung. */ +export async function loadReportingLineMap(tx: Tx, asOf: string): Promise> { + const lines = await loadReportingLines(tx, asOf); + return new Map(lines.map((l) => [l.employee_id, l])); } diff --git a/lib/positions.ts b/lib/positions.ts index 064097a..7ec90b4 100644 --- a/lib/positions.ts +++ b/lib/positions.ts @@ -1,8 +1,6 @@ -import type { SupabaseClient } from "@supabase/supabase-js"; +import type { Tx } from "./db"; import { todayIso } from "./format"; import { breadcrumbLabel, loadOrgMaps, type OrgMaps } from "./org"; -import { fetchAllRows } from "./supabase/query"; -import type { Database } from "./supabase/types"; // Eine offene Stelle ist keine eigene Sache mehr. Sie ist eine Planstelle // ohne laufende Besetzung — Vakanz ist eine Eigenschaft der Planstelle, kein @@ -23,16 +21,6 @@ export type OpenPositionResolved = { vacantSince: string; }; -type PositionRow = { - id: string; - position_number: string; - org_unit_id: string; - is_chief: boolean; - valid_from: string; - jobs: { title: string }; - position_assignments: { employee_id: string; valid_from: string; valid_to: string | null }[]; -}; - /** * Wer eine unbesetzte Planstelle führen würde: die Leitung der eigenen * Einheit, für eine Leitungsplanstelle die der übergeordneten — dieselbe @@ -43,70 +31,89 @@ function managerUnitFor(maps: OrgMaps, orgUnitId: string, isChief: boolean): str return maps.units.get(orgUnitId)?.parent_id ?? null; } -export async function loadOpenPositions(supabase: SupabaseClient): Promise { +export async function loadOpenPositions(tx: Tx): Promise { const asOf = todayIso(); - const [orgMaps, positions] = await Promise.all([ - loadOrgMaps(supabase), - fetchAllRows(() => - supabase - .from("om_positions") - .select( - "id, position_number, org_unit_id, is_chief, valid_from, jobs!inner(title), position_assignments(employee_id, valid_from, valid_to)" + const [orgMaps, open] = await Promise.all([ + loadOrgMaps(tx), + // Unbesetzt heisst: keine am Stichtag laufende Zuordnung. Als NOT EXISTS + // in der Datenbank statt als Filter über alle Planstellen im Speicher. + tx + .selectFrom("om_positions as p") + .innerJoin("jobs as j", "j.id", "p.job_id") + .select(["p.id", "p.position_number", "p.org_unit_id", "p.is_chief", "p.valid_from", "j.title"]) + .where("p.valid_from", "<=", asOf) + .where((eb) => eb.or([eb("p.valid_to", "is", null), eb("p.valid_to", ">", asOf)])) + .where((eb) => + eb.not( + eb.exists( + eb + .selectFrom("position_assignments as a") + .select("a.id") + .whereRef("a.position_id", "=", "p.id") + .where("a.valid_from", "<=", asOf) + .where((e2) => e2.or([e2("a.valid_to", "is", null), e2("a.valid_to", ">", asOf)])) + ) ) - .lte("valid_from", asOf) - .or(`valid_to.is.null,valid_to.gt.${asOf}`) - .order("position_number") - ), + ) + .orderBy("p.position_number") + .execute(), ]); - const open = (positions as unknown as PositionRow[]).filter( - (p) => !p.position_assignments.some((a) => a.valid_from <= asOf && (a.valid_to === null || a.valid_to > asOf)) - ); if (open.length === 0) return []; - // Die Leitung der zuständigen Einheit — genau die Planstellen, die als - // Leitung markiert und laufend besetzt sind. - const chiefUnitIds = Array.from( - new Set(open.map((p) => managerUnitFor(orgMaps, p.org_unit_id, p.is_chief)).filter((id): id is string => Boolean(id))) - ); - const chiefs = chiefUnitIds.length - ? ((await fetchAllRows(() => - supabase - .from("om_positions") - .select("org_unit_id, position_assignments!inner(employees!inner(first_name, last_name), valid_to)") - .eq("is_chief", true) - .in("org_unit_id", chiefUnitIds) - .is("position_assignments.valid_to", null) - )) as unknown as { - org_unit_id: string; - position_assignments: { employees: { first_name: string; last_name: string } }[]; - }[]) - : []; + const positionIds = open.map((p) => p.id); - const chiefNameByUnit = new Map( - chiefs.flatMap((c) => { - const holder = c.position_assignments[0]?.employees; - return holder ? [[c.org_unit_id, `${holder.first_name} ${holder.last_name}`] as const] : []; - }) - ); + // Zwei Nachschläge: seit wann die Stelle leer steht, und wer sie führen + // würde. + const [ended, chiefs] = await Promise.all([ + tx + .selectFrom("position_assignments") + .select(["position_id", "valid_to"]) + .where("position_id", "in", positionIds) + .where("valid_to", "is not", null) + .execute(), + (async () => { + const chiefUnitIds = Array.from( + new Set( + open + .map((p) => managerUnitFor(orgMaps, p.org_unit_id, p.is_chief)) + .filter((id): id is string => Boolean(id)) + ) + ); + if (chiefUnitIds.length === 0) return []; + return tx + .selectFrom("om_positions as p") + .innerJoin("position_assignments as a", "a.position_id", "p.id") + .innerJoin("employees as e", "e.id", "a.employee_id") + .select(["p.org_unit_id", "e.first_name", "e.last_name"]) + .where("p.is_chief", "=", true) + .where("p.valid_to", "is", null) + .where("a.valid_to", "is", null) + .where("p.org_unit_id", "in", chiefUnitIds) + .execute(); + })(), + ]); + + const lastEndByPosition = new Map(); + for (const e of ended) { + const prev = lastEndByPosition.get(e.position_id); + if (e.valid_to && (!prev || e.valid_to > prev)) lastEndByPosition.set(e.position_id, e.valid_to); + } + const chiefNameByUnit = new Map(chiefs.map((c) => [c.org_unit_id, `${c.first_name} ${c.last_name}`])); return open.map((p) => { - const ended = p.position_assignments - .map((a) => a.valid_to) - .filter((d): d is string => d !== null) - .sort(); const managerUnit = managerUnitFor(orgMaps, p.org_unit_id, p.is_chief); return { id: p.id, position_number: p.position_number, - title: p.jobs.title, + title: p.title, org_unit_id: p.org_unit_id, is_chief: p.is_chief, valid_from: p.valid_from, managerName: managerUnit ? (chiefNameByUnit.get(managerUnit) ?? null) : null, orgLabel: breadcrumbLabel(orgMaps, p.org_unit_id), - vacantSince: ended.at(-1) ?? p.valid_from, + vacantSince: lastEndByPosition.get(p.id) ?? p.valid_from, }; }); } diff --git a/lib/reports-data.ts b/lib/reports-data.ts index dbbf432..b948971 100644 --- a/lib/reports-data.ts +++ b/lib/reports-data.ts @@ -1,9 +1,8 @@ -import type { SupabaseClient } from "@supabase/supabase-js"; +import type { Tx } from "./db"; import { ancestorsOf, loadOrgMaps, subtreeOf, type OrgMaps } from "./org"; import { loadPlacements } from "./placement"; import { deriveStatusAsOf, EVENT_DATE_OPEN, parseStatuses, todayIso, type OrgLookups, type ReportEmployee, type ReportEvent } from "./reports"; -import { fetchAllRows } from "./supabase/query"; -import type { Database, EmploymentType, HistoryEventType } from "./supabase/types"; +import type { EmploymentType, HistoryEventType } from "./supabase/types"; // Shared by the Berichte page and /api/export/* so they can never drift on // what "the current view" means — same filters, same stichtag/event-window @@ -40,13 +39,13 @@ export function lookupsFromOrgMaps(orgMaps: OrgMaps, locations: { id: string; na return { divisionName, departmentName, teamName, locationName: new Map(locations.map((l) => [l.id, l.name])) }; } -export async function loadOrgLookups(supabase: SupabaseClient): Promise<{ +export async function loadOrgLookups(tx: Tx): Promise<{ lookups: OrgLookups; orgMaps: OrgMaps; divisions: { id: string; name: string }[]; locations: { id: string; name: string }[]; }> { - const orgMaps = await loadOrgMaps(supabase); + const orgMaps = await loadOrgMaps(tx); const locations = orgMaps.locationList.map((l) => ({ id: l.id, name: l.name })); return { @@ -60,37 +59,61 @@ export async function loadOrgLookups(supabase: SupabaseClient): Promis }; } -const SNAPSHOT_EMPLOYEE_COLUMNS = - "id, first_name, last_name, job_title, location_id, employment_type, contract_type, entry_date, exit_date, weekly_hours, source, paygrade, birth_date, gender, karenz_start_date, karenz_return_date, worker_type, collective_agreement, work_days, is_betriebsrat, has_dienstwagen, is_laterale_fuehrung, is_c_level"; +const SNAPSHOT_EMPLOYEE_COLUMNS = [ + "id", + "first_name", + "last_name", + "job_title", + "location_id", + "employment_type", + "contract_type", + "entry_date", + "exit_date", + "weekly_hours", + "source", + "paygrade", + "birth_date", + "gender", + "karenz_start_date", + "karenz_return_date", + "worker_type", + "collective_agreement", + "work_days", + "is_betriebsrat", + "has_dienstwagen", + "is_laterale_fuehrung", + "is_c_level", +] as const; -// employee_id -> number of employee_dependents rows. Selects only the FK -// column (no dependent PII needed) since only per-employee counts feed the -// has_dependents/avg_dependents report dimensions; counted client-side -// since the Supabase JS client has no `count(*) group by employee_id` -// shorthand. Shared by the Bestand pivot and the full employees export. -export async function loadDependentsCounts(supabase: SupabaseClient): Promise> { - const rows = await fetchAllRows(() => supabase.from("employee_dependents").select("employee_id").order("employee_id")); - const counts = new Map(); - for (const d of rows) counts.set(d.employee_id, (counts.get(d.employee_id) ?? 0) + 1); - return counts; +// Anzahl der Angehörigen je Person. Nur der Fremdschlüssel wird gelesen — +// für die Berichtsdimensionen zählt die Anzahl, nicht wer es ist. +export async function loadDependentsCounts(tx: Tx): Promise> { + // Am direkten Zugang zählt die Datenbank, statt dass die Anwendung alle + // Zeilen holt und sie selbst durchgeht. + const rows = await tx + .selectFrom("employee_dependents") + .select(({ fn }) => ["employee_id", fn.countAll().as("anzahl")]) + .groupBy("employee_id") + .execute(); + return new Map(rows.map((r) => [r.employee_id, Number(r.anzahl)])); } // Bestand zum Stichtag: Status *und* Einordnung werden auf `asOf` aufgelöst. -export async function loadSnapshotEmployees(supabase: SupabaseClient, filters: SnapshotFilters): Promise { +export async function loadSnapshotEmployees(tx: Tx, filters: SnapshotFilters): Promise { const asOf = filters.asOf || todayIso(); function snapshotQuery() { - let query = supabase.from("employees").select(SNAPSHOT_EMPLOYEE_COLUMNS).order("id"); - if (filters.location) query = query.eq("location_id", filters.location); - if (filters.employment) query = query.eq("employment_type", filters.employment as EmploymentType); - return query; + let q = tx.selectFrom("employees").select([...SNAPSHOT_EMPLOYEE_COLUMNS]).orderBy("id"); + if (filters.location) q = q.where("location_id", "=", filters.location); + if (filters.employment) q = q.where("employment_type", "=", filters.employment as EmploymentType); + return q; } const [data, dependentsCounts, placements, orgMaps] = await Promise.all([ - fetchAllRows(snapshotQuery), - loadDependentsCounts(supabase), - loadPlacements(supabase, { asOf }), - filters.division ? loadOrgMaps(supabase) : Promise.resolve(null), + snapshotQuery().execute(), + loadDependentsCounts(tx), + loadPlacements(tx, { asOf }), + filters.division ? loadOrgMaps(tx) : Promise.resolve(null), ]); // Der Bereichsfilter meint den ganzen Teilbaum: „Produktion" schliesst @@ -146,40 +169,38 @@ export async function loadSnapshotEmployees(supabase: SupabaseClient, // from/to: "" (unset) falls back to the current calendar year; the literal // sentinel EVENT_DATE_OPEN means that side of the interval is intentionally // unbounded (e.g. "alle Ereignisse bis heute", no start date). -export async function loadEventHistory(supabase: SupabaseClient, filters: EventFilters): Promise { +export async function loadEventHistory(tx: Tx, filters: EventFilters): Promise { const currentYear = new Date().getFullYear(); const from = filters.from === EVENT_DATE_OPEN ? undefined : filters.from || `${currentYear}-01-01`; const to = filters.to === EVENT_DATE_OPEN ? undefined : filters.to || `${currentYear}-12-31`; function historyQuery() { - let query = supabase.from("employee_history").select("employee_id, event_date, event_type, description").order("id"); - if (from) query = query.gte("event_date", from); - if (to) query = query.lte("event_date", to); - if (filters.eventType) query = query.eq("event_type", filters.eventType); - return query; + let q = tx + .selectFrom("employee_history") + .select(["employee_id", "event_date", "event_type", "description"]) + .orderBy("id"); + if (from) q = q.where("event_date", ">=", from); + if (to) q = q.where("event_date", "<=", to); + if (filters.eventType) q = q.where("event_type", "=", filters.eventType); + return q; } const [history, employees, assignments, orgMaps] = await Promise.all([ - fetchAllRows(historyQuery), - fetchAllRows(() => supabase.from("employees").select("id, first_name, last_name, job_title, location_id").order("id")), - fetchAllRows(() => - supabase - .from("position_assignments") - .select("employee_id, valid_from, valid_to, om_positions!inner(org_unit_id)") - .order("employee_id") - ), - filters.division ? loadOrgMaps(supabase) : Promise.resolve(null), + historyQuery().execute(), + tx.selectFrom("employees").select(["id", "first_name", "last_name", "job_title", "location_id"]).orderBy("id").execute(), + tx + .selectFrom("position_assignments as a") + .innerJoin("om_positions as p", "p.id", "a.position_id") + .select(["a.employee_id", "a.valid_from", "a.valid_to", "p.org_unit_id"]) + .orderBy("a.employee_id") + .execute(), + filters.division ? loadOrgMaps(tx) : Promise.resolve(null), ]); const spans = new Map(); - for (const a of assignments as unknown as { - employee_id: string; - valid_from: string; - valid_to: string | null; - om_positions: { org_unit_id: string }; - }[]) { + for (const a of assignments) { const list = spans.get(a.employee_id) ?? []; - list.push({ from: a.valid_from, to: a.valid_to, unitId: a.om_positions.org_unit_id }); + list.push({ from: a.valid_from, to: a.valid_to, unitId: a.org_unit_id }); spans.set(a.employee_id, list); } @@ -193,7 +214,7 @@ export async function loadEventHistory(supabase: SupabaseClient, filte if (filters.location && emp.location_id !== filters.location) continue; const unitId = - spans.get(h.employee_id)?.find((s) => s.from <= h.event_date && (s.to === null || s.to > h.event_date))?.unitId ?? null; + spans.get(h.employee_id)?.find((s2) => s2.from <= h.event_date && (s2.to === null || s2.to > h.event_date))?.unitId ?? null; if (allowedUnits && (!unitId || !allowedUnits.has(unitId))) continue; events.push({ diff --git a/lib/supabase/admin.ts b/lib/supabase/admin.ts deleted file mode 100644 index 575fdd1..0000000 --- a/lib/supabase/admin.ts +++ /dev/null @@ -1,23 +0,0 @@ -import "server-only"; -import { createClient as createSupabaseClient } from "@supabase/supabase-js"; -import type { Database } from "./types"; - -// Service-role client: bypasses RLS entirely. Server-only — never import this -// from a Client Component or anything bundled for the browser. The -// "server-only" import makes an accidental client-side import a build error -// instead of a runtime one. -export function createAdminClient() { - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; - const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY; - - if (!supabaseUrl) { - throw new Error("Missing NEXT_PUBLIC_SUPABASE_URL"); - } - if (!serviceRoleKey) { - throw new Error("Missing SUPABASE_SERVICE_ROLE_KEY"); - } - - return createSupabaseClient(supabaseUrl, serviceRoleKey, { - auth: { autoRefreshToken: false, persistSession: false }, - }); -} diff --git a/lib/supabase/auth.ts b/lib/supabase/auth.ts deleted file mode 100644 index 5e142e6..0000000 --- a/lib/supabase/auth.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { SupabaseClient } from "@supabase/supabase-js"; -import { NextResponse } from "next/server"; -import type { Database } from "./types"; - -// Route Handlers under /api/export/* are outside the App Router layout tree, -// so app/(app)/layout.tsx's HR gate never runs for them — each one has to -// re-establish that the caller is an active HR user itself. RLS is still the -// real boundary (an unauthorized session simply reads nothing); this exists -// so those routes answer 401/403 instead of handing back an empty workbook. -export async function requireHrUser(supabase: SupabaseClient): Promise { - const { - data: { user }, - } = await supabase.auth.getUser(); - if (!user) return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 }); - - const { data: profile } = await supabase.from("profiles").select("role, is_active").eq("id", user.id).maybeSingle(); - if (profile?.role !== "hr" || profile.is_active !== true) { - return NextResponse.json({ error: "Nicht berechtigt." }, { status: 403 }); - } - return null; -} diff --git a/lib/supabase/query.ts b/lib/supabase/query.ts deleted file mode 100644 index 3d6d2a8..0000000 --- a/lib/supabase/query.ts +++ /dev/null @@ -1,33 +0,0 @@ -// PostgREST's .or() filter syntax treats "," "(" and ")" as structural -// delimiters between conditions. A raw user-supplied search term containing -// them (e.g. from a search box or ?q= param) can break out of the intended -// column conditions and append arbitrary extra filters to the query. Strip -// them before interpolating — harmless for real name/title searches, which -// never legitimately contain them. -export function sanitizeIlikeTerm(term: string): string { - return term.replace(/[,()]/g, ""); -} - -// PostgREST caps every response at db.max_rows (1000, see -// supabase/config.toml) and does so *silently* — a query over ~800 employees -// or the employee_history log just stops returning rows, and a report or -// export built from it is quietly wrong rather than failing. Anything that -// aggregates a whole table has to page explicitly; anything that renders a -// bounded list (an employee page, the audit log) uses .range() directly and -// does not need this. -const PAGE_SIZE = 1000; - -type PagedQuery = { - range: (from: number, to: number) => PromiseLike<{ data: Row[] | null; error: unknown }>; -}; - -export async function fetchAllRows(buildQuery: () => PagedQuery): Promise { - const rows: Row[] = []; - for (let page = 0; ; page++) { - const { data, error } = await buildQuery().range(page * PAGE_SIZE, (page + 1) * PAGE_SIZE - 1); - if (error || !data) break; - rows.push(...data); - if (data.length < PAGE_SIZE) break; - } - return rows; -} diff --git a/tests/integration/employee-status-filter.test.ts b/tests/integration/employee-status-filter.test.ts index 9bca6e0..3675526 100644 --- a/tests/integration/employee-status-filter.test.ts +++ b/tests/integration/employee-status-filter.test.ts @@ -1,9 +1,11 @@ -import { describe, expect, it } from "vitest"; -import { applyDerivedStatusFilter } from "@/lib/employee-status-filter"; +import { Kysely, PostgresDialect } from "kysely"; +import { Pool } from "pg"; +import { afterAll, describe, expect, it } from "vitest"; +import { derivedStatusFilter } from "@/lib/employee-status-filter"; +import type { Schema } from "@/lib/db/schema"; import { todayIso } from "@/lib/format"; import { deriveStatusAsOf } from "@/lib/reports"; import type { EmploymentStatus } from "@/lib/supabase/types"; -import { adminClient } from "./helpers"; // lib/employee-status-filter.ts is a SQL restatement of deriveStatusAsOf(): // the employee list pages in the database and cannot derive status in JS, so @@ -13,22 +15,36 @@ import { adminClient } from "./helpers"; // // Only a real database can settle it, so this runs both over the whole // seeded roster and demands the same set of ids. -describe("derived status filter matches deriveStatusAsOf", () => { +// +// Eigene Verbindung statt der Zugriffsschicht: geprüft wird die Bedingung, +// nicht die Berechtigung. Mit RLS dazwischen liefe der Test gegen eine +// gefilterte Teilmenge und bewiese nichts über die Regel. +const db = new Kysely({ + dialect: new PostgresDialect({ pool: new Pool({ connectionString: process.env.DATABASE_URL, max: 2 }) }), +}); + +describe.skipIf(!process.env.DATABASE_URL)("derived status filter matches deriveStatusAsOf", () => { const asOf = todayIso(); + afterAll(async () => { + await db.destroy(); + }); + async function idsFromDatabase(statuses: EmploymentStatus[]): Promise> { - const query = adminClient.from("employees").select("id"); - const { data, error } = await applyDerivedStatusFilter(query, statuses, asOf); - if (error) throw new Error(error.message); - return new Set((data ?? []).map((r) => r.id)); + const rows = await db + .selectFrom("employees") + .select("id") + .where((eb) => derivedStatusFilter(eb, statuses, asOf) ?? eb.val(true)) + .execute(); + return new Set(rows.map((r) => r.id)); } async function idsFromDerivation(statuses: EmploymentStatus[]): Promise> { - const { data, error } = await adminClient - .from("employees") - .select("id, entry_date, exit_date, karenz_start_date, karenz_return_date"); - if (error) throw new Error(error.message); - return new Set((data ?? []).filter((e) => statuses.includes(deriveStatusAsOf(e, asOf))).map((e) => e.id)); + const rows = await db + .selectFrom("employees") + .select(["id", "entry_date", "exit_date", "karenz_start_date", "karenz_return_date"]) + .execute(); + return new Set(rows.filter((e) => statuses.includes(deriveStatusAsOf(e, asOf))).map((e) => e.id)); } async function expectSameSet(statuses: EmploymentStatus[]) { @@ -68,12 +84,11 @@ describe("derived status filter matches deriveStatusAsOf", () => { }); it("returns everyone when no status is selected", async () => { - const { count: total } = await adminClient.from("employees").select("id", { count: "exact", head: true }); - const { count: filtered } = await applyDerivedStatusFilter( - adminClient.from("employees").select("id", { count: "exact", head: true }), - [], - asOf - ); - expect(filtered).toBe(total); + const total = await db + .selectFrom("employees") + .select(({ fn }) => fn.countAll().as("anzahl")) + .executeTakeFirstOrThrow(); + const filtered = await idsFromDatabase([]); + expect(filtered.size).toBe(Number(total.anzahl)); }); }); diff --git a/tests/unit/security.test.ts b/tests/unit/security.test.ts index 000b89f..5717b4f 100644 --- a/tests/unit/security.test.ts +++ b/tests/unit/security.test.ts @@ -9,14 +9,18 @@ import { parseMeasure, parseSplitDimension, } from "@/lib/reports"; -import { sanitizeIlikeTerm } from "@/lib/supabase/query"; -// The route under test imports lib/supabase/admin.ts, which is guarded by -// `import "server-only"` — that throws when loaded outside Next's own -// server compilation (e.g. here, under plain Vitest/Node). Mock it out: -// these tests only exercise the auth guard, which returns before the real -// admin client is ever created. -vi.mock("@/lib/supabase/admin", () => ({ createAdminClient: vi.fn() })); +// Die Cron-Route lädt lib/db, das über `import "server-only"` abgesichert +// ist und beim Laden eine DATABASE_URL verlangt — beides gibt es unter +// reinem Vitest nicht. Weggemockt: geprüft wird hier nur der +// Geheimnis-Abgleich, der zurückkehrt, bevor irgendeine Verbindung +// entsteht. +// `import "server-only"` wirft ausserhalb der Server-Übersetzung von Next — +// also auch hier. Der Riegel ist im Betrieb richtig; für den Test wird das +// Modul zu einer leeren Hülle. +vi.mock("server-only", () => ({})); +vi.mock("@/lib/db", () => ({ asSystem: vi.fn(), withUser: vi.fn() })); +vi.mock("@/lib/db/rpc", () => ({ callFunction: vi.fn() })); describe("sanitizeForSpreadsheetCell", () => { it("prefixes values that would be read as a formula by Excel/Sheets", () => { @@ -37,20 +41,11 @@ describe("sanitizeForSpreadsheetCell", () => { }); }); -describe("sanitizeIlikeTerm", () => { - it("strips PostgREST or-filter delimiter characters", () => { - expect(sanitizeIlikeTerm("a,b")).toBe("ab"); - expect(sanitizeIlikeTerm("a(b)c")).toBe("abc"); - // An attempt to close the current ilike condition and append another - // column filter is neutralized by removing the delimiters, not escaped - // into a differently-structured (but still injected) query. - expect(sanitizeIlikeTerm("x),sv_nummer.ilike.%")).toBe("xsv_nummer.ilike.%"); - }); - - it("leaves a normal search term untouched", () => { - expect(sanitizeIlikeTerm("Gruber")).toBe("Gruber"); - }); -}); +// Der Test zu sanitizeIlikeTerm ist entfallen, weil die Funktion es ist. +// Sie entschärfte Zeichen, die in der Filtersyntax der alten API-Schicht +// strukturelle Bedeutung hatten. Am direkten Datenbankzugang wird der +// Suchbegriff als Parameter gebunden — ein Komma oder eine Klammer darin +// ist schlicht ein Zeichen. Die Lücke ist nicht abgesichert, sondern weg. describe("report query-string parsing", () => { it("falls back to a known dimension instead of passing an unknown one through", () => { @@ -152,6 +147,6 @@ describe("protected export route without a session (/api/export/employees)", () const request = new NextRequest("http://localhost/api/export/employees"); const res = await GET(request); expect(res.status).toBe(401); - vi.doUnmock("@/lib/supabase/server"); + vi.doUnmock("@/lib/auth/session"); }); }); From 2ba9b37aa73f41acbd885d5e8e9784a57336a048 Mon Sep 17 00:00:00 2001 From: Maximilian Stubhan Date: Fri, 31 Jul 2026 14:57:32 +0200 Subject: [PATCH 13/64] Hand the front door to Entra, and keep the keys out of the build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auth.js replaces GoTrue. The sign-in still goes to the same Entra tenant, but nothing sits between the app and the identity provider any more — the code exchange, state, nonce and the session cookie are ours. lib/auth/session.ts stays the only place that knows where a user id comes from, which is why this was one file and not fifty. What it returns is now app_users.id. app_upsert_user() maps the Entra `oid` onto it, and for an address that already has a profiles row it adopts that id instead of minting a new one — otherwise everyone would have been signed in and cut off from their own notes, drafts and audit trail at the same time. That upsert is the one write that cannot have a session context yet: the id is what it produces. It runs as a SECURITY DEFINER function that may touch app_users and nothing else, which is a far smaller lever than the service key that used to answer this class of problem. The proxy no longer checks HR rights. It has no database connection, and putting role/is_active in the token would have frozen the claim until the next sign-in. The check moved to where it can read the current truth: the app layout on every render, requireHrUser() for the export routes, and underneath both, RLS. Two things only came out by running it: - `export const proxy = auth(…)` is not a function declaration, so Next.js never found it and every request 404'd. `next build` reported success and listed the proxy. In the function config form auth() also returns the handler as a promise, so it needs an await. The proxy test now mocks it as a promise for that reason — a friendlier mock would let the same bug back in. - A missing AUTH_MICROSOFT_ENTRA_ID_ISSUER silently falls back to /common/, and the redirect really did go there. That would let any Microsoft account sign in, including a private one, and it would never look broken. It now refuses to start in production. Neither build nor image needs credentials any more: the pool is created on first use, the auth config is evaluated per request, and there are no NEXT_PUBLIC_* values left to bake in. One image now runs in every environment. Verified: typecheck, lint, 187 tests, build, and by hand in the browser — /employees redirects to /login, and the sign-in button reaches the Entra page with PKCE and the callback URL that goes into the app registration. Not verified against a real database; there is still no DATABASE_URL. Co-Authored-By: Claude Opus 5 --- .env.example | 26 ++-- DEPLOYMENT.md | 42 +++--- Dockerfile | 14 +- README.md | 29 ++-- actions/auth.ts | 45 ++---- app/(app)/layout.tsx | 14 +- app/(auth)/login/page.tsx | 12 +- app/api/auth/[...nextauth]/route.ts | 9 ++ app/auth/callback/route.ts | 30 ---- auth.ts | 63 ++++++++ docker-compose.yml | 8 +- docs/entra-sso.md | 134 ++++++++++-------- lib/auth/config.ts | 99 +++++++++++++ lib/auth/session.ts | 24 ++-- lib/db/index.ts | 7 +- lib/db/pool.ts | 68 +++++---- lib/supabase/client.ts | 19 --- lib/supabase/server.ts | 40 ------ next.config.ts | 30 ++-- package-lock.json | 95 ++++++++++++- package.json | 1 + proxy.ts | 116 ++++++++------- supabase/entra-claims.ts | 70 --------- ...30120000_app_users_and_session_context.sql | 30 +++- .../20260731090000_app_upsert_user.sql | 117 +++++++++++++++ supabase/relink-profile.ts | 110 -------------- tests/unit/proxy.test.ts | 95 +++++++++++++ tests/unit/security.test.ts | 12 +- types/next-auth.d.ts | 25 ++++ 29 files changed, 853 insertions(+), 531 deletions(-) create mode 100644 app/api/auth/[...nextauth]/route.ts delete mode 100644 app/auth/callback/route.ts create mode 100644 auth.ts create mode 100644 lib/auth/config.ts delete mode 100644 lib/supabase/client.ts delete mode 100644 lib/supabase/server.ts delete mode 100644 supabase/entra-claims.ts create mode 100644 supabase/migrations/20260731090000_app_upsert_user.sql delete mode 100644 supabase/relink-profile.ts create mode 100644 tests/unit/proxy.test.ts create mode 100644 types/next-auth.d.ts diff --git a/.env.example b/.env.example index c516bf8..62e070f 100644 --- a/.env.example +++ b/.env.example @@ -10,15 +10,25 @@ DATABASE_SSL= # Verbindungen im Pool; Vorgabe 10. DATABASE_POOL_MAX= -# Public: safe to expose to the browser (inlined into the client bundle at -# build time). Anon-key access is still fully gated by RLS server-side. -NEXT_PUBLIC_SUPABASE_URL= -NEXT_PUBLIC_SUPABASE_ANON_KEY= +# ── Anmeldung (Auth.js + Microsoft Entra ID) ───────────────────────── +# Schlüssel, mit dem das Sitzungscookie signiert und verschlüsselt wird. +# Erzeugen mit `npx auth secret` oder `openssl rand -base64 32`. Ein Wechsel +# meldet alle ab — was im Ernstfall genau das gewünschte Mittel ist. +AUTH_SECRET= -# Server-only: bypasses Row Level Security entirely. Never prefix with -# NEXT_PUBLIC_, never import outside lib/supabase/admin.ts (guarded by -# `import "server-only"`), never log or return in an API response. -SUPABASE_SERVICE_ROLE_KEY= +# Aus der Anwendungsregistrierung im Entra-Portal: Anwendungs-ID (Client), +# ein Geheimnis daraus, und der Aussteller mit der Verzeichnis-ID (Mandant). +# +# Der Aussteller darf NICHT auf /common/ stehen bleiben — sonst könnte sich +# jedes Microsoft-Konto anmelden, auch ein privates. +AUTH_MICROSOFT_ENTRA_ID_ID= +AUTH_MICROSOFT_ENTRA_ID_SECRET= +AUTH_MICROSOFT_ENTRA_ID_ISSUER=https://login.microsoftonline.com//v2.0 + +# Nur nötig, wenn die Anwendung hinter einem Reverse Proxy unter einer +# anderen Adresse erreichbar ist, als sie selbst sieht. Ohne diesen Wert baut +# Auth.js die Rückruf-Adresse aus den Request-Headern. +AUTH_URL= # Shared secret Vercel Cron sends as `Authorization: Bearer ` when it # calls /api/cron/apply-pending-changes (set the same value in the Vercel diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 3406a30..f47312d 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -37,21 +37,22 @@ Werte eintragen: | Variable | Woher | |---|---| -| `NEXT_PUBLIC_SUPABASE_URL` | Supabase-Projekt → Settings → API | -| `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Supabase-Projekt → Settings → API | -| `SUPABASE_SERVICE_ROLE_KEY` | Supabase-Projekt → Settings → API (geheim!) | +| `DATABASE_URL` | Verbindungsstring der PostgreSQL-Instanz. Die Rolle darf **kein** `BYPASSRLS` haben | +| `DATABASE_SSL` | nur setzen (`false`), wenn die Datenbank ohne TLS läuft | +| `AUTH_SECRET` | selbst generieren: `openssl rand -base64 32` | +| `AUTH_MICROSOFT_ENTRA_ID_ID` | Entra-Portal → App-Registrierung → Übersicht | +| `AUTH_MICROSOFT_ENTRA_ID_SECRET` | Entra-Portal → Zertifikate & Geheimnisse (nur einmal sichtbar!) | +| `AUTH_MICROSOFT_ENTRA_ID_ISSUER` | `https://login.microsoftonline.com//v2.0` | | `CRON_SECRET` | selbst generieren: `openssl rand -hex 32` | +Details zur Entra-Registrierung: [`docs/entra-sso.md`](docs/entra-sso.md). + Wichtig zum Verständnis: -- `NEXT_PUBLIC_*`-Variablen werden **beim Build** in das Browser-Bundle - eingebacken (Next.js-Verhalten, nicht Docker-spezifisch). Ändern sich - diese Werte, muss das Image **neu gebaut** werden – ein reiner Container- - Neustart reicht nicht. -- `SUPABASE_SERVICE_ROLE_KEY` und `CRON_SECRET` sind Server-only-Secrets. - Sie werden bewusst **nicht** als Build-Arg übergeben (das würde sie im - Image-Layer-History sichtbar machen), sondern erst zur Laufzeit über - `env_file` injiziert. +- **Nichts davon wird in das Image eingebacken.** Es gibt keine + `NEXT_PUBLIC_*`-Variablen mehr; alle Werte liest die Anwendung zur Laufzeit + über `env_file`. Eine Änderung braucht deshalb nur einen Neustart, keinen + neuen Build — und dasselbe Image läuft in Test und Produktion. - `.env` steht schon in `.gitignore` – nicht committen. ## 2. Bauen und lokal testen @@ -145,12 +146,19 @@ Single-Instance-Compose-Konfiguration ist das nicht nötig. ## Troubleshooting -- **Login-Redirect-Loop / `proxy.ts` verhält sich falsch:** meist falsche - `NEXT_PUBLIC_SUPABASE_URL`/`ANON_KEY` – Image neu bauen (siehe oben, diese - Werte sind eingebacken). +- **Anmeldung endet auf `/login?error=…`:** die Umleitungs-URI in der + Entra-Registrierung muss exakt + `https:///api/auth/callback/microsoft-entra-id` lauten. Steht die + Anwendung hinter einem Reverse Proxy unter einer anderen Adresse, als sie + selbst sieht, zusätzlich `AUTH_URL` setzen. +- **Angemeldet, aber sofort zurück auf `/login?error=no_hr_access`:** die + Anmeldung hat funktioniert, es fehlt die Freischaltung. Es braucht eine + `profiles`-Zeile mit `role = 'hr'` und `is_active = true` auf derselben + Kennung, die in `app_users` steht. - **Cron läuft nicht:** `docker compose logs cron` – prüft, ob `/etc/crontabs/root` korrekt geschrieben wurde und ob `CRON_SECRET` in `.env` gesetzt ist (leer/fehlend führt serverseitig zu `401`). -- **Healthcheck rot:** `docker compose logs app` – meist fehlende/falsche - Supabase-Env-Variablen zur Laufzeit (`SUPABASE_SERVICE_ROLE_KEY`, - Server-Komponenten). +- **Healthcheck rot:** `docker compose logs app` – meist `DATABASE_URL` + fehlend oder nicht erreichbar. Der Pool baut die Verbindung erst beim + ersten Zugriff auf, der Fehler steht deshalb im Log der Anfrage, nicht im + Start-Log. diff --git a/Dockerfile b/Dockerfile index 73ed13c..55c36a6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,13 +12,13 @@ WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . -# Public env vars are inlined into the client bundle at build time, so they -# must be available here, not just at runtime. Values are passed in via -# --build-arg (see DEPLOYMENT.md). -ARG NEXT_PUBLIC_SUPABASE_URL -ARG NEXT_PUBLIC_SUPABASE_ANON_KEY -ENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL -ENV NEXT_PUBLIC_SUPABASE_ANON_KEY=$NEXT_PUBLIC_SUPABASE_ANON_KEY +# Keine Build-Argumente mehr: es gibt keine NEXT_PUBLIC_*-Werte mehr, die in +# das Browser-Bundle eingebacken würden. Datenbank und Anmeldung sprechen +# ausschliesslich den Server an, und dessen Zugangsdaten kommen zur Laufzeit. +# +# Dadurch ist dieses Abbild umgebungsneutral: einmal gebaut, in Test und +# Produktion dasselbe. Vorher hätte jede Umgebung ihr eigenes gebraucht — und +# eine Baustrecke, die die Zugangsdaten schon zum Bauen kennt. ENV NEXT_TELEMETRY_DISABLED=1 RUN npm run build diff --git a/README.md b/README.md index dbb1467..b2b30ca 100644 --- a/README.md +++ b/README.md @@ -54,14 +54,18 @@ Liste. Kurzfassung: | Variable | Sichtbarkeit | Zweck | |---|---|---| -| `NEXT_PUBLIC_SUPABASE_URL` | Browser + Server | Supabase-Projekt-URL | -| `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Browser + Server | Anon-Key, RLS-gebunden | -| `SUPABASE_SERVICE_ROLE_KEY` | **Nur Server** | Umgeht RLS vollständig — niemals im Browser-Bundle, niemals loggen | +| `DATABASE_URL` | Nur Server | PostgreSQL-Verbindung. Die Rolle darf **kein** `BYPASSRLS` haben | +| `DATABASE_SSL` | Nur Server | `false` für lokal/CI ohne TLS | +| `AUTH_SECRET` | Nur Server | Signiert und verschlüsselt das Sitzungscookie | +| `AUTH_MICROSOFT_ENTRA_ID_ID` | Nur Server | Anwendungs-ID der Entra-Registrierung | +| `AUTH_MICROSOFT_ENTRA_ID_SECRET` | Nur Server | Client-Geheimnis dazu | +| `AUTH_MICROSOFT_ENTRA_ID_ISSUER` | Nur Server | Aussteller mit Mandanten-ID — nicht `common` | | `CRON_SECRET` | Nur Server | Schützt `/api/cron/apply-pending-changes` | -`NEXT_PUBLIC_*`-Werte werden beim Build in das Client-Bundle eingebacken — -eine Änderung erfordert einen Rebuild, nicht nur einen Neustart (relevant -für Docker-Deployments, siehe unten). +**Es gibt keine `NEXT_PUBLIC_*`-Variablen mehr.** Nichts wird in das +Browser-Bundle eingebacken, weil der Browser mit nichts ausser der Anwendung +selbst spricht. Ein Docker-Abbild ist damit umgebungsneutral: einmal gebaut, +überall dasselbe — vorher brauchte jede Umgebung ihr eigenes. ## Scripts @@ -85,10 +89,15 @@ für Docker-Deployments, siehe unten). - **Ein Rollenmodell:** `profiles.role = 'hr'` + `profiles.is_active = true`, geprüft über die SQL-Funktion `is_hr_user()`. Kein Sub-Rollensystem — siehe [`docs/data-model.md`](docs/data-model.md#zugriffsmodell). -- **Service-Role-Key ist server-only.** Einzige Verwendung: - `lib/supabase/admin.ts`, geschützt durch `import "server-only"` (macht - einen versehentlichen Client-Import zu einem Build-Fehler statt einem - Laufzeitproblem). +- **Es gibt keinen privilegierten Zugang mehr.** Der Dienstschlüssel, der RLS + aushebelte, ist ersatzlos entfallen; auch der nächtliche Lauf benutzt + dieselbe Rolle ohne `BYPASSRLS`. Was ohne angemeldete Person laufen muss, + steht als `SECURITY DEFINER`-Funktion in der Datenbank und prüft dort + selbst, was es tut. +- **Jede Abfrage läuft in einer Transaktion mit gesetztem Sitzungskontext.** + Die Kysely-Instanz wird nicht exportiert — der einzige Weg an die Datenbank + ist `withUser()` (`lib/db/index.ts`), und eine ESLint-Regel verbietet den + Import von `pg` ausserhalb von `lib/db/`. - **Audit-Log ist transaktional in der Datenbank**, nicht im App-Code: jede mutierende SQL-Funktion schreibt ihren `audit_log`-Eintrag in derselben Transaktion wie die Änderung selbst. Details und Prüfung siehe diff --git a/actions/auth.ts b/actions/auth.ts index 9ce36c3..8dbda3d 100644 --- a/actions/auth.ts +++ b/actions/auth.ts @@ -1,43 +1,22 @@ "use server"; -import { headers } from "next/headers"; -import { redirect } from "next/navigation"; -import { createClient } from "@/lib/supabase/server"; +import { signIn, signOut } from "@/auth"; -// Anmeldung ausschliesslich über Entra ID (in Supabase heisst der Anbieter -// „Azure"). Es gibt bewusst keinen Passwort-Pfad mehr: ein zweiter Anmeldeweg -// neben dem Firmenkonto hebelt jede Vorgabe des Mandanten aus — Mehrfaktor, -// bedingten Zugriff, Sperrung beim Austritt. +// Anmeldung ausschliesslich über Entra ID. Es gibt bewusst keinen +// Passwort-Pfad: ein zweiter Anmeldeweg neben dem Firmenkonto hebelt jede +// Vorgabe des Mandanten aus — Mehrfaktor, bedingten Zugriff, Sperrung beim +// Austritt. // -// Für die Datenbank ändert sich dadurch nichts. auth.uid() liefert weiterhin -// eine UUID, profiles.id trägt weiterhin role und is_active, und damit bleiben -// is_hr_user() und alle darauf gebauten RLS-Policies unverändert gültig. +// Die Herkunft muss hier nicht mehr aus dem Request geholt werden: Auth.js +// baut die Rückruf-Adresse selbst und akzeptiert nur Ziele auf demselben +// Host. Ein untergeschobener Host läuft also weiterhin ins Leere. export async function signInWithEntra() { - const supabase = await createClient(); - - // Die Herkunft kommt aus dem Request statt aus einer Umgebungsvariablen, - // damit lokal, Vorschau und Produktion denselben Code benutzen. Supabase - // nimmt das Ziel nur an, wenn es in der Redirect-Allowlist des Projekts - // steht — ein untergeschobener Host läuft also ins Leere. - const origin = (await headers()).get("origin") ?? "http://localhost:3000"; - - const { data, error } = await supabase.auth.signInWithOAuth({ - provider: "azure", - options: { - // openid/profile/email sind das Minimum für Anmeldung und Anzeigename. - // Weitere Berechtigungen holt sich die Anwendung bewusst nicht. - scopes: "openid profile email", - redirectTo: `${origin}/auth/callback`, - }, - }); - - if (error || !data.url) redirect("/login?error=sso_failed"); - redirect(data.url); + // Kehrt nicht zurück: signIn löst eine Weiterleitung aus, und die wirft in + // Next.js. + await signIn("microsoft-entra-id", { redirectTo: "/" }); } export async function logout() { - const supabase = await createClient(); - await supabase.auth.signOut(); - redirect("/login"); + await signOut({ redirectTo: "/login" }); } diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx index f7c1275..95608d3 100644 --- a/app/(app)/layout.tsx +++ b/app/(app)/layout.tsx @@ -14,10 +14,11 @@ export default async function AppLayout({ children }: { children: ReactNode }) { // Alles in *einer* Transaktion, weil nur dort der Sitzungskontext gilt — // und damit nebenbei auf einem einheitlichen Lesestand. const data = await withUser(userId, async (tx) => { - // Defense in depth: proxy.ts already redirects any non-active-HR session - // away before this layout ever renders. Re-checking here means a gap in - // the proxy matcher (or a future route added outside it) still fails - // closed instead of silently granting access — see docs/security.md. + // Hier — und nicht im Proxy — fällt die Entscheidung über den Zugang. + // Der Proxy prüft nur, ob überhaupt jemand angemeldet ist; er hat keine + // Datenbankverbindung. Diese Abfrage läuft bei jedem Aufbau frisch, eine + // entzogene Freischaltung wirkt also sofort statt erst mit dem nächsten + // Sitzungstoken. Die eigentliche Grenze bleibt darunter RLS. const profile = await tx .selectFrom("profiles") .select(["full_name", "email", "role", "is_active"]) @@ -40,7 +41,10 @@ export default async function AppLayout({ children }: { children: ReactNode }) { return { profile, openPositions, locations, drafts, openNotes }; }); - if (!data) redirect("/login"); + // `data` ist null, wenn die Person angemeldet, aber nicht freigeschaltet + // ist. Ohne den Grund in der Adresse stünde sie vor einer wortlosen + // Anmeldeseite und versuchte es endlos erneut. + if (!data) redirect("/login?error=no_hr_access"); const userLabel = data.profile.full_name || data.profile.email || ""; diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx index 189da87..1571a6f 100644 --- a/app/(auth)/login/page.tsx +++ b/app/(auth)/login/page.tsx @@ -25,7 +25,17 @@ type LoginPageProps = { export default async function LoginPage({ searchParams }: LoginPageProps) { const params = await searchParams; - const code = params.error && Object.hasOwn(ERROR_MESSAGES, params.error) ? (params.error as ErrorCode) : null; + // Ein unbekannter Code wird nicht verschluckt, sondern auf die allgemeine + // Meldung abgebildet: Auth.js schickt bei einem Fehlschlag seine eigenen + // Codes („Configuration", „AccessDenied", „OAuthCallbackError" …), und ohne + // diese Abbildung stünde man vor einer Anmeldeseite, die so tut, als wäre + // nichts gewesen. Angezeigt wird trotzdem nur eigener Text — der Parameter + // selbst kommt nie auf die Seite. + const code = params.error + ? Object.hasOwn(ERROR_MESSAGES, params.error) + ? (params.error as ErrorCode) + : "sso_failed" + : null; const error = code ? ERROR_MESSAGES[code] : null; return ( diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..11359e7 --- /dev/null +++ b/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,9 @@ +import { handlers } from "@/auth"; + +// Der Rückweg aus Entra ID und die Endpunkte für An- und Abmeldung. +// +// Tritt an die Stelle von app/auth/callback/route.ts: den Tausch des +// Einmal-Codes gegen eine Sitzung, die Prüfung von `state` und `nonce` und +// das Setzen des Cookies macht jetzt Auth.js. Die Rückruf-Adresse in der +// Entra-Anwendungsregistrierung ändert sich dadurch — siehe docs/entra-sso.md. +export const { GET, POST } = handlers; diff --git a/app/auth/callback/route.ts b/app/auth/callback/route.ts deleted file mode 100644 index 2772fd0..0000000 --- a/app/auth/callback/route.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { NextResponse, type NextRequest } from "next/server"; -import { createClient } from "@/lib/supabase/server"; - -// Rückweg aus Entra ID. @supabase/ssr benutzt PKCE, das heisst der Anbieter -// liefert einen einmaligen Code, der hier gegen eine Sitzung getauscht wird. -// Ohne diese Route landet die Anmeldung in einer Schleife: der Code steht in -// der URL, aber es entsteht nie ein Sitzungscookie, und der Proxy schickt -// zurück auf /login. -export async function GET(request: NextRequest) { - const { searchParams, origin } = request.nextUrl; - - // Entra meldet abgelehnte Zustimmung oder gesperrte Konten als Fehler - // zurück. Der Text daraus wird nicht angezeigt — er ist fremdbestimmt und - // stünde sonst auf der echten, korrekt gebrandeten Anmeldeseite. - if (searchParams.get("error")) { - return NextResponse.redirect(`${origin}/login?error=sso_failed`); - } - - const code = searchParams.get("code"); - if (!code) return NextResponse.redirect(`${origin}/login?error=sso_failed`); - - const supabase = await createClient(); - const { error } = await supabase.auth.exchangeCodeForSession(code); - if (error) return NextResponse.redirect(`${origin}/login?error=sso_failed`); - - // Ob die Person HR-Zugriff hat, entscheidet nicht diese Route, sondern - // proxy.ts anhand von profiles.role/is_active — und darunter, unabhängig - // davon, die RLS-Policies. Hier wird nur die Sitzung hergestellt. - return NextResponse.redirect(`${origin}/`); -} diff --git a/auth.ts b/auth.ts new file mode 100644 index 0000000..e59762c --- /dev/null +++ b/auth.ts @@ -0,0 +1,63 @@ +import "server-only"; +import NextAuth from "next-auth"; +import { authConfig } from "@/lib/auth/config"; +import { asSystem, sql } from "@/lib/db"; + +// Die vollständige Anmeldung — die Fassung, die die Datenbank kennt. +// +// Aufgeteilt ist sie, weil proxy.ts nur den Teil aus lib/auth/config.ts lädt. +// Hier kommt das dazu, was einmal pro Anmeldung passieren muss: aus der +// Kennung, die Entra ausstellt, eine Kennung machen, die diese Anwendung +// versteht. + +/** + * Legt die app_users-Zeile an oder frischt sie auf und liefert die Kennung, + * die überall sonst als `userId` durchgereicht wird. + * + * Die Datenbankfunktion ist SECURITY DEFINER und darf genau dieses eine: + * app_users schreiben. Für den einen Schreibvorgang, für den es noch keinen + * Sitzungskontext geben kann, ist das der kleinstmögliche Hebel — früher lag + * hier ein Dienstschlüssel, der jede Zeile jeder Tabelle lesen konnte. + */ +async function upsertAppUser(externalId: string, email: string, fullName: string | null): Promise { + const row = await asSystem(async (tx) => { + const result = await sql<{ id: string }>` + select app_upsert_user(${externalId}, ${email}, ${fullName}) as id + `.execute(tx); + return result.rows[0]; + }); + + if (!row?.id) throw new Error("app_upsert_user() lieferte keine Kennung."); + return row.id; +} + +export const { handlers, auth, signIn, signOut } = NextAuth(() => ({ + ...authConfig(), + callbacks: { + async jwt({ token, profile }) { + // `profile` liegt nur beim ersten Durchlauf nach der Rückkehr von Entra + // vor. Danach wird das Token nur noch weitergereicht — die Datenbank + // wird also einmal pro Anmeldung befragt, nicht einmal pro Aufruf. + if (!profile) return token; + + const externalId = typeof profile.oid === "string" ? profile.oid : null; + const email = [profile.email, profile.preferred_username, profile.upn].find( + (v): v is string => typeof v === "string" && v.length > 0 + ); + + // Lieber abbrechen als eine Sitzung ohne Kennung ausstellen: die käme + // als `null` bei withUser() an, und die Policies gäben dann konsequent + // nichts zurück — was sich als „die Anwendung ist leer" zeigt statt als + // Anmeldefehler. + if (!externalId || !email) throw new Error("Entra lieferte weder oid noch E-Mail-Adresse."); + + token.uid = await upsertAppUser(externalId, email, typeof profile.name === "string" ? profile.name : null); + return token; + }, + + async session({ session, token }) { + if (token.uid) session.user.id = token.uid; + return session; + }, + }, +})); diff --git a/docker-compose.yml b/docker-compose.yml index d394920..be90fcc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,12 +1,10 @@ services: app: + # Ohne Build-Argumente: alles, was die Anwendung braucht — DATABASE_URL, + # AUTH_* — liest sie zur Laufzeit aus .env. Das Abbild ist damit für jede + # Umgebung dasselbe. build: context: . - args: - # NEXT_PUBLIC_* vars are inlined into the browser bundle at build - # time, so they have to be passed here, not just in env_file below. - NEXT_PUBLIC_SUPABASE_URL: ${NEXT_PUBLIC_SUPABASE_URL} - NEXT_PUBLIC_SUPABASE_ANON_KEY: ${NEXT_PUBLIC_SUPABASE_ANON_KEY} restart: unless-stopped ports: - "3000:3000" diff --git a/docs/entra-sso.md b/docs/entra-sso.md index 74ea932..74b2e14 100644 --- a/docs/entra-sso.md +++ b/docs/entra-sso.md @@ -1,10 +1,11 @@ # Anmeldung über Entra ID -Die Anwendung meldet ausschliesslich über Microsoft Entra ID an. Supabase Auth -bleibt dabei die Sitzungsverwaltung — Entra ist der Anbieter, nicht der Ersatz. +Die Anwendung meldet ausschliesslich über Microsoft Entra ID an. Die +Sitzungsverwaltung macht **Auth.js** (`auth.ts`, `lib/auth/config.ts`) — es gibt +keinen Anmeldedienst eines Anbieters mehr dazwischen. -**Das ist der Grund, warum der Umstieg klein ist:** `auth.uid()` liefert -weiterhin eine UUID, `profiles.id` trägt weiterhin `role` und `is_active`, und +**Warum das trotzdem eine kleine Änderung ist:** die Anmeldung liefert nach wie +vor nur eine UUID. `profiles.id` trägt weiterhin `role` und `is_active`, und damit bleiben `is_hr_user()` und alle 58 RLS-Policies unverändert gültig. Die Sicherheitsgrenze wandert nicht in den Anwendungscode. @@ -16,55 +17,69 @@ App-Registrierung, einmalig — angelegt im Mandanten *loudspring management Gmb |---|---| | Name | Alpenwerk HR | | Kontotypen | Nur ein Mandant | -| Umleitungs-URI (Web) | `https://.supabase.co/auth/v1/callback` | +| Umleitungs-URI (Web) | `https:///api/auth/callback/microsoft-entra-id` | | Anwendungs-ID (Client) | `` | | Verzeichnis-ID (Mandant) | `` | Die konkreten Werte stehen bewusst nicht hier, sondern in der -Übergabedokumentation. Sie sind zwar keine Geheimnisse — ohne Schlüssel gibt -eine Projekt-URL nichts her, und RLS greift ohnehin —, aber sie zeigen auf die -echte Umgebung, und dieses Repository wandert weiter als sie. +Übergabedokumentation. Sie sind zwar keine Geheimnisse — ohne Client-Geheimnis +gibt eine ID nichts her, und RLS greift ohnehin —, aber sie zeigen auf die echte +Umgebung, und dieses Repository wandert weiter als sie. -Der *Wert* des Client-Geheimnisses gehört ausschliesslich ins Supabase-Feld -„Secret Value" und in keine Datei im Projekt. - -Die Umleitungs-URI ist **Supabases** Callback, nicht der der Anwendung. Der -eigene Callback (`/auth/callback`) steht nur in der Redirect-Allowlist des -Supabase-Projekts. +Die Umleitungs-URI zeigt jetzt auf die **Anwendung selbst**. Für die lokale +Entwicklung kommt `http://localhost:3000/api/auth/callback/microsoft-entra-id` +als zweite URI dazu; Entra erlaubt `http` nur für `localhost`. Danach: 1. **Zertifikate & Geheimnisse** → neues Client-Geheimnis. Der *Wert* wird gebraucht, nicht die Geheimnis-ID, und er ist nur einmal sichtbar. 2. **API-Berechtigungen** → `openid`, `profile`, `email` (Microsoft Graph, - delegiert), Administratorzustimmung erteilen. + delegiert), Administratorzustimmung erteilen. `User.Read` wird **nicht** + gebraucht: der eingebaute Anbieter von Auth.js fordert es an, um das + Profilbild aus dem Graph zu holen — `lib/auth/config.ts` schaltet beides ab. 3. **Tokenkonfiguration** → Gruppenanspruch, siehe unten. -## Einrichtung in Supabase +## Konfiguration der Anwendung -Authentication → Providers → Azure: +Vier Werte, alle server-seitig — nichts davon landet im Browser-Bundle: -| Feld | Wert | +| Variable | Wert | |---|---| -| Application (Client) ID | `` | -| Secret Value | der Wert aus „Zertifikate & Geheimnisse" | -| Azure Tenant URL | `https://login.microsoftonline.com/` | +| `AUTH_SECRET` | `npx auth secret` oder `openssl rand -base64 32` | +| `AUTH_MICROSOFT_ENTRA_ID_ID` | Anwendungs-ID (Client) | +| `AUTH_MICROSOFT_ENTRA_ID_SECRET` | der Wert aus „Zertifikate & Geheimnisse" | +| `AUTH_MICROSOFT_ENTRA_ID_ISSUER` | `https://login.microsoftonline.com//v2.0` | -Die Tenant URL ist bei „Nur ein Mandant" nicht optional. Bleibt sie leer, -benutzt Supabase `common`, und Entra weist die Anmeldung ab, weil die -Registrierung nur den eigenen Mandanten akzeptiert. +Der Aussteller ist bei „Nur ein Mandant" **nicht optional**. Bleibt er leer, +benutzt Auth.js `common` — dann dürfte sich jedes Microsoft-Konto anmelden, auch +ein privates Outlook-Konto. Die Freischaltung über `profiles` fängt das zwar ab, +aber die Eingangstür soll erst gar nicht so weit offenstehen. -Authentication → URL Configuration: +`AUTH_SECRET` verschlüsselt das Sitzungscookie. Ein Wechsel meldet alle ab — im +Ernstfall genau das gewünschte Mittel. -- Site URL: die Produktions-URL -- Redirect URLs: `http://localhost:3000/auth/callback` und - `https:///auth/callback` +## Was bei der ersten Anmeldung passiert + +1. Auth.js prüft das Token von Entra (`state`, `nonce`, Signatur, Aussteller). +2. Der Rückruf in `auth.ts` nimmt daraus die **`oid`** — nicht `sub`, nicht die + E-Mail. Die `oid` identifiziert dieselbe Person über Anwendungen hinweg und + überlebt Namens- und Adressänderungen. +3. `app_upsert_user(oid, email, name)` legt die `app_users`-Zeile an und liefert + die Kennung, die von da an in jeder Transaktion als `app.user_id` steht. +4. **Gibt es zu der Adresse bereits ein `profiles`-Eintrag, übernimmt die + Funktion dessen Kennung** statt eine neue zu vergeben. Das ist der Grund, + warum bestehende Zugänge nach der Umstellung weiterlaufen: Notizen, + Entwürfe und Protokolleinträge hängen an dieser ID. + +Der Abgleich über die Adresse ist genau hier vertretbar und sonst nirgends: sie +kommt aus einem von Entra ausgestellten Token, nicht aus einem Formular. Wer sie +behauptet, hat sie bereits bewiesen. ## Freischaltung über die Entra-Gruppe Wer sich anmeldet, hat damit **noch keinen Zugriff**. Zugriff hat, wer eine -`profiles`-Zeile mit `role = 'hr'` und `is_active = true` besitzt. Diese Zeile -entsteht aus der Mitgliedschaft in einer Entra-Gruppe. +`profiles`-Zeile mit `role = 'hr'` und `is_active = true` besitzt. ### Woher der Gruppen-Anspruch kommt @@ -76,46 +91,47 @@ ist. Zwei Varianten: | Sicherheitsgruppen | frei | Schickt *alle* Sicherheitsgruppen mit. Ab etwa 200 Gruppen liefert Entra statt der Liste einen Verweis, und die Auswertung greift ins Leere. | | Der Anwendung zugewiesene Gruppen | Entra ID P1 | Nur die zugewiesene Gruppe steht im Token. | -### Warum die Auswertung aus `auth.identities` liest, nicht aus `auth.users` +### Wo die Auswertung hingehört -Das ist kein Detail, sondern der Kern der Absicherung. +In den `jwt`-Rückruf in `auth.ts`, neben `app_upsert_user()` — dort liegt +`profile.groups` aus dem ID-Token vor. -`auth.users.raw_user_meta_data` ist **von der angemeldeten Person selbst -beschreibbar** — `supabase.auth.updateUser({ data: … })` schreibt genau dorthin. -Läse die Freischaltung von dort, könnte sich jede:r Angemeldete den HR-Anspruch -selbst eintragen und hätte damit Zugriff auf sämtliche Personaldaten. - -`auth.identities.identity_data` schreibt ausschliesslich GoTrue aus der Antwort -des Anbieters. Nur das ist eine belastbare Quelle. +Das ist belastbar, und der Grund ist wichtig: das ID-Token ist von Entra +signiert und wurde von Auth.js gegen den Aussteller geprüft. Die angemeldete +Person kann seinen Inhalt nicht beeinflussen. (Unter GoTrue war dieselbe Stelle +eine Falle: `auth.users.raw_user_meta_data` war von der Person selbst +beschreibbar, und eine Freischaltung, die von dort gelesen hätte, wäre +selbstbedienbar gewesen.) ### Reihenfolge -Der Trigger wird erst gebaut, wenn feststeht, wie der Anspruch tatsächlich -ankommt — das hängt an der gewählten Variante und an der Konfiguration des -Mandanten. Ablauf: +Gebaut wird das erst, wenn feststeht, wie der Anspruch tatsächlich ankommt — das +hängt an der gewählten Variante und an der Konfiguration des Mandanten. Ablauf: 1. SSO in Betrieb nehmen, einmal anmelden. -2. `node --env-file=.env.local supabase/entra-claims.ts ` zeigt, was in - `identity_data` gelandet ist. -3. Erst dann die Migration mit der konkreten Gruppen-ID schreiben. +2. Im `jwt`-Rückruf einmalig `console.log(profile)` — das zeigt die Ansprüche so, + wie der Mandant sie tatsächlich schickt. +3. Erst dann die Auswertung mit der konkreten Gruppen-ID schreiben. -Ohne Schritt 2 wäre die Migration geraten. +Ohne Schritt 2 wäre sie geraten. Bis dahin wird `profiles` von Hand gepflegt. ### Was die Gruppe nicht kann -Die Mitgliedschaft steht im Token. Wer aus der Gruppe entfernt wird, verliert -den Zugriff deshalb **bei der nächsten Anmeldung**, nicht sofort. Für den -sofortigen Entzug bleibt `profiles.is_active = false` das Mittel — das wirkt -beim nächsten Datenbankzugriff, weil `is_hr_user()` die Spalte je Abfrage liest. +Die Mitgliedschaft steht im Token. Wer aus der Gruppe entfernt wird, verliert den +Zugriff deshalb **bei der nächsten Anmeldung**, nicht sofort. Für den sofortigen +Entzug bleibt `profiles.is_active = false` das Mittel — das wirkt beim nächsten +Datenbankzugriff, weil `is_hr_user()` die Spalte je Abfrage liest. -## Bestehende Zugänge +## Wer prüft was -Ein bestehendes Konto mit Passwort-Anmeldung und ein Entra-Konto derselben -Person sind für Supabase **zwei verschiedene Benutzer** mit verschiedenen IDs. -Die `profiles`-Zeile hängt an der alten ID; nach der ersten Entra-Anmeldung -zeigt sie ins Leere und die Person ist ausgesperrt. +| Stelle | Prüft | Wann | +|---|---|---| +| `proxy.ts` | Gibt es überhaupt eine Sitzung? | jede Anfrage | +| `app/(app)/layout.tsx` | `profiles.role` / `is_active` | jeder Seitenaufbau | +| `lib/auth/require-hr.ts` | dasselbe, für `/api/export/*` | jeder Aufruf | +| RLS-Policies | `is_hr_user()` | jede einzelne Abfrage | -`supabase/relink-profile.ts` hängt sie um. Es überträgt auch die -Fremdschlüssel, die auf die alte Benutzer-ID zeigen (`audit_log.actor_user_id`, -`employee_notes.author_user_id`, …), sonst stünde in der Historie eine Kennung, -zu der es kein Konto mehr gibt. +Der Proxy prüft die HR-Rechte **nicht** — er hat keine Datenbankverbindung. Sie +in das Sitzungstoken zu schreiben wäre schneller gewesen und hätte eine +Behauptung eingefroren: eine entzogene Freischaltung wirkte dann erst mit dem +nächsten Token. Bei einer Personalanwendung ist das die falsche Richtung. diff --git a/lib/auth/config.ts b/lib/auth/config.ts new file mode 100644 index 0000000..92fac77 --- /dev/null +++ b/lib/auth/config.ts @@ -0,0 +1,99 @@ +import MicrosoftEntraID from "next-auth/providers/microsoft-entra-id"; +import type { NextAuthConfig } from "next-auth"; + +// Der Teil der Anmeldung, der **ohne Datenbank** auskommt. +// +// Das ist keine Stilfrage: proxy.ts läuft je nach Betriebsart in einer +// Umgebung ohne Node-Module — dort gibt es kein `pg` und keine Verbindung. +// Würde der Proxy die vollständige Konfiguration laden, zöge er die +// Zugriffsschicht mit hinein und liesse sich nicht mehr ausliefern. Deshalb +// hier nur Anbieter und Sitzungsregeln; alles, was die Datenbank berührt, +// steht in auth.ts. + +/** Wie lange eine Anmeldung ohne erneuten Besuch bei Entra gilt. */ +const SESSION_MAX_AGE_SECONDS = 60 * 60 * 9; // ein Arbeitstag + +/** + * Der Aussteller — mit Abbruch statt Rückfall. + * + * Ohne diese Prüfung setzt Auth.js bei fehlender Variablen stillschweigend + * `https://login.microsoftonline.com/common/v2.0` ein. Das ist beim ersten + * Ausprobieren aufgefallen: die Weiterleitung ging tatsächlich nach + * `/common/oauth2/v2.0/authorize`, und damit hätte sich **jedes** + * Microsoft-Konto anmelden dürfen, auch ein privates. + * + * Aufgefallen wäre das im Betrieb sonst nicht — die Anmeldung funktioniert + * ja, nur eben für zu viele. Ein vergessener Wert in der Deployment-Umgebung + * muss deshalb den Start verhindern, nicht die Tür aufmachen. + * + * In der Entwicklung bleibt es bei einer Warnung: dort ist nichts + * konfiguriert, und ein Abbruch beim Laden des Moduls nähme auch die + * Anmeldeseite mit. + */ +function tenantIssuer(): string | undefined { + const issuer = process.env.AUTH_MICROSOFT_ENTRA_ID_ISSUER; + if (issuer) return issuer; + + const hinweis = + "AUTH_MICROSOFT_ENTRA_ID_ISSUER fehlt. Ohne Mandanten-Aussteller fiele die " + + "Anmeldung auf /common/ zurück und stünde jedem Microsoft-Konto offen."; + if (process.env.NODE_ENV === "production") throw new Error(hinweis); + console.warn(`[auth] ${hinweis}`); + return undefined; +} + +/** + * Die Konfiguration als **Funktion**, nicht als Objekt. + * + * Auth.js wertet die Funktionsform pro Anfrage aus. Das ist hier nötig, weil + * tenantIssuer() in der Produktion abbricht: als Objekt gebaut liefe die + * Prüfung schon beim Import — und `next build` importiert jedes Route-Modul, + * um die Seitendaten einzusammeln. Der Bau bräuchte dann die + * Anmeldekonfiguration der Zielumgebung, und ein Abbild liesse sich nicht + * mehr einmal bauen und überall ausliefern. + */ +export function authConfig(): NextAuthConfig { + return { + providers: [ + MicrosoftEntraID({ + clientId: process.env.AUTH_MICROSOFT_ENTRA_ID_ID, + clientSecret: process.env.AUTH_MICROSOFT_ENTRA_ID_SECRET, + issuer: tenantIssuer(), + + // Der eingebaute Anbieter fordert zusätzlich `User.Read` an und holt + // damit das Profilbild aus dem Graph. Beides ist hier unerwünscht: eine + // Berechtigung, die niemand braucht, muss die Mandantenverwaltung + // trotzdem genehmigen — und das Bild landete base64-kodiert im + // Sitzungscookie, das dann in Teile zerfällt. + authorization: { params: { scope: "openid profile email" } }, + profile(profile) { + return { + // Die `oid`, nicht `sub`: `sub` ist pro Anwendung verschieden, die + // `oid` identifiziert dieselbe Person über Anwendungen hinweg und + // überlebt Namens- und Adressänderungen. + id: profile.oid, + name: profile.name ?? null, + // `email` ist im Token ein optionaler Anspruch — je nach Mandant + // fehlt er. `preferred_username` bzw. `upn` tragen dann dieselbe + // Adresse. Ohne diesen Rückfall scheitert die Anmeldung in genau + // den Mandanten, die den Anspruch nicht ausdrücklich konfiguriert + // haben. + email: profile.email ?? profile.preferred_username ?? profile.upn ?? null, + image: null, + }; + }, + }), + ], + + // Eigene Seite statt der von Auth.js mitgelieferten: die Anmeldung ist die + // erste Seite, die jemand sieht, und soll aussehen wie die Anwendung. + pages: { signIn: "/login", error: "/login" }, + + session: { strategy: "jwt", maxAge: SESSION_MAX_AGE_SECONDS }, + + // Hinter Reverse Proxy und Container-Netzwerk kommt der Host aus dem + // Header. Ohne das verweigert Auth.js in der Produktion den Dienst, weil + // es die Herkunft nicht bestätigen kann. + trustHost: true, + }; +} diff --git a/lib/auth/session.ts b/lib/auth/session.ts index 6c348bb..c764640 100644 --- a/lib/auth/session.ts +++ b/lib/auth/session.ts @@ -1,23 +1,21 @@ import "server-only"; -import { createClient } from "@/lib/supabase/server"; +import { auth } from "@/auth"; // Der einzige Ort, an dem die Kennung der angemeldeten Person herkommt. // -// Heute liefert sie GoTrue, morgen Auth.js mit Entra ID. Weil alles andere -// nur noch `currentUserId()` aufruft und den Wert an withUser() weiterreicht, -// ist der Wechsel des Anmeldeverfahrens eine Änderung an dieser Datei — nicht -// an fünfzig Aufrufstellen. +// Dass der Wechsel von GoTrue auf Auth.js eine Änderung an dieser Datei war +// und nicht an fünfzig Aufrufstellen, lag genau an dieser Bündelung: alles +// andere ruft `currentUserId()` auf und reicht den Wert an withUser() weiter. // -// Dass das aufgeht, liegt an einer Eigenschaft des Übergangs: profiles.id ist -// heute die auth.users.id. Die Kennung, die hier herauskommt, passt also -// bereits auf das, was app_current_user_id() in der Datenbank erwartet. +// Der Wert ist app_users.id — nicht die `oid` von Entra. Die Zuordnung +// zwischen beiden macht app_upsert_user() bei der Anmeldung, und sie +// übernimmt für eine bereits bekannte Adresse die vorhandene profiles.id. +// Deshalb passt die Kennung weiterhin auf das, was app_current_user_id() in +// der Datenbank erwartet, und die 58 RLS-Policies merken vom Wechsel nichts. export async function currentUserId(): Promise { - const supabase = await createClient(); - const { - data: { user }, - } = await supabase.auth.getUser(); - return user?.id ?? null; + const session = await auth(); + return session?.user?.id ?? null; } /** diff --git a/lib/db/index.ts b/lib/db/index.ts index 1c21838..c5975b4 100644 --- a/lib/db/index.ts +++ b/lib/db/index.ts @@ -1,6 +1,6 @@ import "server-only"; import { Kysely, PostgresDialect, sql, type Transaction } from "kysely"; -import { pool } from "./pool"; +import { getPool } from "./pool"; import type { Schema } from "./schema"; // Der einzige Weg an die Datenbank. @@ -33,8 +33,11 @@ import type { Schema } from "./schema"; // BYPASSRLS. Fehlt der Kontext trotz allem, liefern die Policies nichts // zurück — nicht alles. +// Der Pool wird als Funktion übergeben, nicht als fertige Instanz: Kysely +// ruft sie erst bei der ersten Abfrage auf. So verlangt der Import dieses +// Moduls noch keine Zugangsdaten — siehe getPool(). const db = new Kysely({ - dialect: new PostgresDialect({ pool }), + dialect: new PostgresDialect({ pool: async () => getPool() }), }); export type Tx = Transaction; diff --git a/lib/db/pool.ts b/lib/db/pool.ts index 583b05a..c71aeb9 100644 --- a/lib/db/pool.ts +++ b/lib/db/pool.ts @@ -10,30 +10,46 @@ import { Pool } from "pg"; // die Kysely-Instanz, die ihn benutzt, und eine ESLint-Regel verbietet den // Import von `pg` und von dieser Datei überall sonst. -const connectionString = process.env.DATABASE_URL; -if (!connectionString) { - throw new Error( - "DATABASE_URL fehlt. Erwartet wird ein PostgreSQL-Verbindungsstring — " + - "die Anwendung spricht direkt mit der Datenbank, nicht über eine API-Schicht." - ); +let instance: Pool | undefined; + +/** + * Der Verbindungspool — erst beim ersten Zugriff angelegt, nicht beim Import. + * + * Der Unterschied ist nicht kosmetisch: `next build` importiert jedes Route- + * Modul, um die Seitendaten einzusammeln. Entstünde der Pool dabei, bräuchte + * schon der Bau Zugangsdaten zur Datenbank — ein Container-Abbild liesse sich + * in einer Baustrecke ohne Produktionsgeheimnisse nicht mehr erzeugen. + */ +export function getPool(): Pool { + if (instance) return instance; + + const connectionString = process.env.DATABASE_URL; + if (!connectionString) { + throw new Error( + "DATABASE_URL fehlt. Erwartet wird ein PostgreSQL-Verbindungsstring — " + + "die Anwendung spricht direkt mit der Datenbank, nicht über eine API-Schicht." + ); + } + + instance = new Pool({ + connectionString, + // Der Standard sind 10; bei serverseitigem Rendering hängt an jeder + // Anfrage genau eine Transaktion, und mehr Verbindungen als die Datenbank + // zulässt bringen nur Wartezeit an einer anderen Stelle. + max: Number(process.env.DATABASE_POOL_MAX ?? 10), + // Eine Anfrage, die länger braucht, ist kaputt und soll das melden statt + // eine Verbindung zu belegen. + statement_timeout: 20_000, + idle_in_transaction_session_timeout: 20_000, + connectionTimeoutMillis: 10_000, + // Verwaltete Anbieter (Azure, RDS, Supabase) verlangen TLS; lokal nicht. + ssl: process.env.DATABASE_SSL === "false" ? undefined : { rejectUnauthorized: false }, + }); + + // Ein Fehler auf einer Leerlaufverbindung beendet sonst den Prozess. + instance.on("error", (err) => { + console.error("Unerwarteter Fehler auf einer Leerlaufverbindung:", err); + }); + + return instance; } - -export const pool = new Pool({ - connectionString, - // Der Standard sind 10; bei serverseitigem Rendering hängt an jeder - // Anfrage genau eine Transaktion, und mehr Verbindungen als die Datenbank - // zulässt bringen nur Wartezeit an einer anderen Stelle. - max: Number(process.env.DATABASE_POOL_MAX ?? 10), - // Eine Anfrage, die länger braucht, ist kaputt und soll das melden statt - // eine Verbindung zu belegen. - statement_timeout: 20_000, - idle_in_transaction_session_timeout: 20_000, - connectionTimeoutMillis: 10_000, - // Verwaltete Anbieter (Azure, RDS, Supabase) verlangen TLS; lokal nicht. - ssl: process.env.DATABASE_SSL === "false" ? undefined : { rejectUnauthorized: false }, -}); - -// Ein Fehler auf einer Leerlaufverbindung beendet sonst den Prozess. -pool.on("error", (err) => { - console.error("Unerwarteter Fehler auf einer Leerlaufverbindung:", err); -}); diff --git a/lib/supabase/client.ts b/lib/supabase/client.ts deleted file mode 100644 index e546941..0000000 --- a/lib/supabase/client.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { createBrowserClient } from "@supabase/ssr"; -import type { Database } from "./types"; - -// For use in Client Components that need interactivity (filters, live -// hints, etc). Server Components/Actions should use lib/supabase/server.ts. -// Only ever reads NEXT_PUBLIC_* vars — this file is bundled for the browser. -export function createClient() { - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; - const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; - - if (!supabaseUrl) { - throw new Error("Missing NEXT_PUBLIC_SUPABASE_URL"); - } - if (!supabaseAnonKey) { - throw new Error("Missing NEXT_PUBLIC_SUPABASE_ANON_KEY"); - } - - return createBrowserClient(supabaseUrl, supabaseAnonKey); -} diff --git a/lib/supabase/server.ts b/lib/supabase/server.ts deleted file mode 100644 index 60494a0..0000000 --- a/lib/supabase/server.ts +++ /dev/null @@ -1,40 +0,0 @@ -import "server-only"; -import { createServerClient } from "@supabase/ssr"; -import { cookies } from "next/headers"; -import type { Database } from "./types"; - -// For use in Server Components and Server Actions. Respects the signed-in -// user's session, so all reads/writes go through RLS as that user. Uses only -// the anon key (never the service role key) — the user's own session cookie -// is what determines access, via RLS. -export async function createClient() { - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; - const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; - - if (!supabaseUrl) { - throw new Error("Missing NEXT_PUBLIC_SUPABASE_URL"); - } - if (!supabaseAnonKey) { - throw new Error("Missing NEXT_PUBLIC_SUPABASE_ANON_KEY"); - } - - const cookieStore = await cookies(); - - return createServerClient(supabaseUrl, supabaseAnonKey, { - cookies: { - getAll() { - return cookieStore.getAll(); - }, - setAll(cookiesToSet) { - try { - cookiesToSet.forEach(({ name, value, options }) => - cookieStore.set(name, value, options) - ); - } catch { - // Called from a Server Component during render — safe to ignore - // because proxy.ts refreshes the session cookie on every request. - } - }, - }, - }); -} diff --git a/next.config.ts b/next.config.ts index 17ee181..2c459e4 100644 --- a/next.config.ts +++ b/next.config.ts @@ -2,25 +2,28 @@ import type { NextConfig } from "next"; // Report-only rather than enforcing, deliberately: the policy is derived from // what this app is known to load — its own bundle, the self-hosted Nunito -// files from next/font, and the Supabase project from -// NEXT_PUBLIC_SUPABASE_URL — but an unenforced policy that logs violations is -// worth more than a guessed one that blanks the app for every HR user. -// Promote it to `Content-Security-Policy` once the reports come back clean. +// files from next/font, and nothing else — but an unenforced policy that logs +// violations is worth more than a guessed one that blanks the app for every +// HR user. Promote it to `Content-Security-Policy` once the reports come back +// clean. // // 'unsafe-inline' on script-src is not laziness: Next.js inlines its // bootstrap and hydration payload as inline