Consolidation pass: HR-only access, effective-dated mutations, data integrity guards, test suite

Reworks the app from a two-role (hr_admin/manager) model to a single
HR-only role gated by profiles.is_active, fixes transfer/promote/karenz/
reorg RPCs to actually defer future-dated changes via a new
pending_org_changes table instead of writing them immediately (applied
by a daily Vercel Cron route), makes reorg undo append-only instead of
deleting history, adds Karenz-return and history-date integrity guards,
deprecates the salary column, and adds explicit schema grants + perf
indexes needed to run against a fresh (non-hosted) Postgres instance.

Adds vitest unit + integration test suites (the latter against a real
local Supabase instance) covering all of the above, plus lint/typecheck/
build wiring (`npm run check`).
This commit is contained in:
2026-07-14 20:32:20 +02:00
parent 4299277af0
commit 901c5c426e
67 changed files with 4765 additions and 286 deletions

33
supabase/config.toml Normal file
View File

@@ -0,0 +1,33 @@
project_id = "alpenwerk-hr"
[api]
enabled = true
port = 55321
schemas = ["public", "graphql_public"]
extra_search_path = ["public"]
max_rows = 1000
[db]
port = 55322
major_version = 15
[studio]
enabled = true
port = 55323
[inbucket]
enabled = true
port = 55324
[analytics]
enabled = false
[auth]
enabled = true
site_url = "http://127.0.0.1:3000"
additional_redirect_urls = ["http://127.0.0.1:3000"]
jwt_expiry = 3600
enable_signup = true
[auth.email]
enable_confirmations = false

View File

@@ -0,0 +1,360 @@
-- Alpenwerk HR — initial schema
--
-- Based on spec §3, with the following corrections
-- (see the Phase 1 plan for the full rationale):
-- - gender_type restricted to m/w (spec's domain rules exclude "divers")
-- - employees.location replaced by a locations reference table + location_id FK
-- (the spec's own CHECK listed Wien/Linz/Graz, which contradicts §2's real site list)
-- - nationality constrained to the picklist named in §2
-- - added: locations, profiles (role-based access), employees_directory (salary-masked view)
-- - added: address/address_country/contract_end_date columns (required by §4.5's "Daten ändern"
-- panel and the befristet/"Befristet bis" rule, but missing from §3's literal table)
-- - added: 'Stammdatenänderung' history event type (required by §4.5, missing from §3's enum)
-- - added: reorg_scenarios.undo_snapshot jsonb (required by §4.7's undo feature)
-- - added: position number generation + org-unit auto-derivation as real functions/triggers
create extension if not exists "pgcrypto";
-- ── Org units ────────────────────────────────────────────────
create table divisions ( -- "Bereich", numbers 20xxxxxx
id uuid primary key default gen_random_uuid(),
org_number text not null unique check (org_number ~ '^20\d{6}$'),
name text not null unique
);
create table departments ( -- "Abteilung", numbers 21xxxxxx
id uuid primary key default gen_random_uuid(),
org_number text not null unique check (org_number ~ '^21\d{6}$'),
name text not null,
division_id uuid not null references divisions(id)
);
create table teams ( -- "Team", numbers 22xxxxxx
id uuid primary key default gen_random_uuid(),
org_number text not null unique check (org_number ~ '^22\d{6}$'),
name text not null,
department_id uuid not null references departments(id)
);
-- ── Locations (site ties to a country; country picklist drives the UI's
-- "select country auto-selects its locations" rule from §2) ─────────
create table locations (
id uuid primary key default gen_random_uuid(),
name text not null unique, -- Wien-Hernals, Wolkersdorf, Köln, Brünn, Ljubljana
country text not null check (country in ('Österreich', 'Deutschland', 'Tschechien', 'Slowenien'))
);
-- ── Profiles (role-based access per §6) ─────────────────────
create table profiles (
id uuid primary key references auth.users(id) on delete cascade,
email text not null,
full_name text,
role text not null default 'manager' check (role in ('hr_admin', 'manager')),
created_at timestamptz not null default now()
);
-- ── Employees ────────────────────────────────────────────────
create type employment_status as enum ('Aktiv', 'Karenz', 'Geplant', 'Ausgetreten');
create type employment_type as enum ('Vollzeit', 'Teilzeit');
create type contract_type as enum ('unbefristet', 'befristet');
create type paygrade_type as enum ('A', 'B', 'C', 'D', 'E', 'F');
create type source_type as enum ('Intern', 'Extern');
create type gender_type as enum ('m', 'w');
create table employees (
id uuid primary key default gen_random_uuid(),
personnel_number int generated always as identity (start with 1001), -- Pers.-Nr.
first_name text not null,
last_name text not null,
gender gender_type not null,
birth_date date not null,
sv_nummer text,
nationality text not null default 'Österreich' check (nationality in (
'Österreich', 'Deutschland', 'Tschechien', 'Slowenien', 'Türkei',
'Serbien', 'Kroatien', 'Bosnien', 'Ungarn', 'Andere'
)),
address text,
address_country text check (address_country in ('Österreich', 'Deutschland', 'Tschechien', 'Slowenien', 'Andere')),
email text not null unique,
phone text,
team_id uuid references teams(id), -- nullable only for CEO / division heads without a team
division_id uuid not null references divisions(id), -- auto-derived from team_id by trigger when team_id is set
job_title text not null,
location_id uuid not null references locations(id),
manager_id uuid references employees(id),
org_level int not null default 3 check (org_level between 0 and 3), -- 0=CEO,1=division head,2=team lead,3=IC
is_lead boolean not null default false,
employment_type employment_type not null default 'Vollzeit',
weekly_hours numeric(4,1) not null default 38.5,
monthly_salary_gross numeric(10,2) not null check (monthly_salary_gross > 0), -- 14x/year convention
contract_type contract_type not null default 'unbefristet',
contract_end_date date,
paygrade paygrade_type not null default 'B',
source source_type not null default 'Extern',
status employment_status not null default 'Aktiv',
entry_date date not null,
exit_date date,
exit_reason text,
karenz_return_date date,
avatar_color text, -- hex, for initials badge; app falls back to a deterministic hash if null
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
constraint chk_exit_after_entry check (exit_date is null or exit_date >= entry_date),
constraint chk_karenz_return_after_entry check (karenz_return_date is null or karenz_return_date >= entry_date),
constraint chk_befristet_end check (contract_type <> 'befristet' or contract_end_date is not null),
constraint chk_weekly_hours check (
(employment_type = 'Vollzeit' and weekly_hours = 38.5) or
(employment_type = 'Teilzeit' and weekly_hours > 0 and weekly_hours < 38.5)
)
);
create index on employees (team_id);
create index on employees (division_id);
create index on employees (manager_id);
create index on employees (status);
-- ── Employee history (append-only audit trail per person) ───
create type history_event_type as enum (
'Eintritt', 'Beförderung', 'Versetzung', 'Karenz', 'Vertragsänderung', 'Stammdatenänderung',
'Austritt', 'Wiedereintritt', 'Reorganisation', 'Gehaltsanpassung', 'Rückkehr'
);
create table employee_history (
id uuid primary key default gen_random_uuid(),
employee_id uuid not null references employees(id) on delete cascade,
event_date date not null,
event_type history_event_type not null,
description text not null,
created_at timestamptz not null default now()
);
create index on employee_history (employee_id, event_date desc);
-- ── Positions (Planstellen) ──────────────────────────────────
create table positions (
id uuid primary key default gen_random_uuid(),
position_number text not null unique check (position_number ~ '^6\d{7}$'),
title text not null,
team_id uuid not null references teams(id),
division_id uuid not null references divisions(id), -- auto-derived from team_id by trigger
is_lead boolean not null default false,
reports_to_employee_id uuid references employees(id), -- the superior manager chosen at creation
status text not null default 'open' check (status in ('open', 'filled')),
created_at timestamptz not null default now(),
filled_at timestamptz,
filled_by_employee_id uuid references employees(id)
);
create index on positions (team_id);
create index on positions (status);
-- ── Hire drafts (resumable wizard state) ─────────────────────
create table hire_drafts (
id uuid primary key default gen_random_uuid(),
created_by uuid references auth.users(id),
step int not null default 0,
payload jsonb not null, -- full wizard form state
updated_at timestamptz not null default now()
);
-- ── Saved reports ────────────────────────────────────────────
create table saved_reports (
id uuid primary key default gen_random_uuid(),
created_by uuid references auth.users(id),
name text not null,
config jsonb not null, -- { measure, group, split, filters... }
created_at timestamptz not null default now()
);
-- ── Audit log (system-wide, immutable) ───────────────────────
create table audit_log (
id uuid primary key default gen_random_uuid(),
occurred_at timestamptz not null default now(),
actor_user_id uuid references auth.users(id),
actor_name text not null,
action text not null, -- e.g. 'Neueinstellung','Austritt','Versetzung','Beförderung','Karenz',
-- 'Vertragsänderung','Stammdatenänderung','Wiedereinstellung','Ausschreibung',
-- 'Interne Besetzung','Reorganisation','Reorganisation rückgängig','Rückkehr',
-- 'Gehaltsanpassung'
target_label text not null, -- human-readable name of what changed
target_employee_id uuid references employees(id),
details text
);
create index on audit_log (occurred_at desc);
-- ── Reorg scenarios (persistence of in-progress/applied reorg plans) ─
create table reorg_scenarios (
id uuid primary key default gen_random_uuid(),
name text not null,
effective_date date not null,
created_by uuid references auth.users(id),
applied boolean not null default false,
applied_at timestamptz,
undo_snapshot jsonb, -- pre-change employee state + history/audit high-water marks, for undo
created_at timestamptz not null default now()
);
create table reorg_moves (
id uuid primary key default gen_random_uuid(),
scenario_id uuid not null references reorg_scenarios(id) on delete cascade,
kind text not null check (kind in ('emp', 'team', 'abt', 'dept')),
payload jsonb not null -- employee ids / team id / dept id / target division, counts, labels
);
-- ── Functions & triggers ─────────────────────────────────────
-- Auto-derive division_id from team_id (keeps the denormalized division in sync
-- with the team's real parent chain; §3's closing instruction).
create or replace function fn_set_employee_org_unit()
returns trigger
language plpgsql
as $$
begin
if new.team_id is not null then
select dep.division_id into new.division_id
from teams t
join departments dep on dep.id = t.department_id
where t.id = new.team_id;
end if;
return new;
end;
$$;
create trigger trg_employees_set_org_unit
before insert or update of team_id on employees
for each row execute function fn_set_employee_org_unit();
create or replace function fn_set_position_org_unit()
returns trigger
language plpgsql
as $$
begin
select dep.division_id into new.division_id
from teams t
join departments dep on dep.id = t.department_id
where t.id = new.team_id;
return new;
end;
$$;
create trigger trg_positions_set_org_unit
before insert or update of team_id on positions
for each row execute function fn_set_position_org_unit();
create or replace function fn_touch_updated_at()
returns trigger
language plpgsql
as $$
begin
new.updated_at = now();
return new;
end;
$$;
create trigger trg_employees_touch_updated_at
before update on employees
for each row execute function fn_touch_updated_at();
-- Unique 8-digit position numbers starting with '6' (§2).
create or replace function generate_position_number()
returns text
language plpgsql
as $$
declare
candidate text;
begin
loop
candidate := '6' || lpad(floor(random() * 10000000)::text, 7, '0');
exit when not exists (select 1 from positions where position_number = candidate);
end loop;
return candidate;
end;
$$;
alter table positions alter column position_number set default generate_position_number();
-- ── Role helper (SECURITY DEFINER avoids RLS recursion on profiles) ──
create or replace function is_hr_admin()
returns boolean
language sql
security definer
set search_path = public
stable
as $$
select exists (
select 1 from profiles p where p.id = auth.uid() and p.role = 'hr_admin'
);
$$;
-- ── Salary-masked read view for the manager role (§6) ────────
-- Owned by the migration role (postgres), which bypasses RLS on the base
-- table, so this view is reachable by both roles while column-masking
-- salary per session via is_hr_admin().
create view employees_directory as
select
e.id, e.personnel_number, e.first_name, e.last_name, e.gender, e.birth_date, e.sv_nummer,
e.nationality, e.address, e.address_country, e.email, e.phone, e.team_id, e.division_id,
e.job_title, e.location_id, e.manager_id, e.org_level, e.is_lead, e.employment_type,
e.weekly_hours,
case when is_hr_admin() then e.monthly_salary_gross else null end as monthly_salary_gross,
e.contract_type, e.contract_end_date, e.paygrade, e.source, e.status, e.entry_date, e.exit_date,
e.exit_reason, e.karenz_return_date, e.avatar_color, e.created_at, e.updated_at
from employees e;
grant select on employees_directory to authenticated;
-- ── Row Level Security ───────────────────────────────────────
alter table divisions enable row level security;
alter table departments enable row level security;
alter table teams enable row level security;
alter table locations enable row level security;
alter table profiles enable row level security;
alter table employees enable row level security;
alter table employee_history enable row level security;
alter table positions enable row level security;
alter table hire_drafts enable row level security;
alter table saved_reports enable row level security;
alter table audit_log enable row level security;
alter table reorg_scenarios enable row level security;
alter table reorg_moves enable row level security;
-- Org reference data: readable by any authenticated user, writable by hr_admin only.
create policy "org_read" on divisions for select using (auth.role() = 'authenticated');
create policy "org_write" on divisions for all using (is_hr_admin()) with check (is_hr_admin());
create policy "org_read" on departments for select using (auth.role() = 'authenticated');
create policy "org_write" on departments for all using (is_hr_admin()) with check (is_hr_admin());
create policy "org_read" on teams for select using (auth.role() = 'authenticated');
create policy "org_write" on teams for all using (is_hr_admin()) with check (is_hr_admin());
create policy "org_read" on locations for select using (auth.role() = 'authenticated');
create policy "org_write" on locations for all using (is_hr_admin()) with check (is_hr_admin());
-- Profiles: users read their own row; hr_admin reads/writes all.
create policy "profiles_select_own" on profiles for select using (auth.uid() = id);
create policy "profiles_select_admin" on profiles for select using (is_hr_admin());
create policy "profiles_write_admin" on profiles for insert with check (is_hr_admin());
create policy "profiles_update_admin" on profiles for update using (is_hr_admin()) with check (is_hr_admin());
-- Employees: only hr_admin reads/writes the base table directly. The manager
-- role reads through employees_directory instead (salary masked there).
create policy "employees_admin_all" on employees for all using (is_hr_admin()) with check (is_hr_admin());
-- Employee history: any authenticated user can read; only hr_admin can append; immutable otherwise.
create policy "history_read" on employee_history for select using (auth.role() = 'authenticated');
create policy "history_insert_admin" on employee_history for insert with check (is_hr_admin());
-- Positions: any authenticated user can browse open positions; hr_admin manages them.
create policy "positions_read" on positions for select using (auth.role() = 'authenticated');
create policy "positions_write_admin" on positions for all using (is_hr_admin()) with check (is_hr_admin());
-- Hire drafts: scoped to their creator.
create policy "hire_drafts_owner" on hire_drafts for all
using (created_by = auth.uid()) with check (created_by = auth.uid());
-- Saved reports: scoped to their creator.
create policy "saved_reports_owner" on saved_reports for all
using (created_by = auth.uid()) with check (created_by = auth.uid());
-- Audit log: any authenticated user can read; only hr_admin can append; immutable (no update/delete policy).
create policy "audit_read" on audit_log for select using (auth.role() = 'authenticated');
create policy "audit_insert_admin" on audit_log for insert with check (is_hr_admin());
-- Reorg scenarios/moves: any authenticated user can see the (small) recent list; hr_admin manages them.
create policy "reorg_scenarios_read" on reorg_scenarios for select using (auth.role() = 'authenticated');
create policy "reorg_scenarios_write_admin" on reorg_scenarios for all using (is_hr_admin()) with check (is_hr_admin());
create policy "reorg_moves_read" on reorg_moves for select using (auth.role() = 'authenticated');
create policy "reorg_moves_write_admin" on reorg_moves for all using (is_hr_admin()) with check (is_hr_admin());

View File

@@ -0,0 +1,11 @@
-- Addendum to supabase/schema.sql — run after that file.
--
-- Staatsbürgerschaft and Wohnland now use a searchable picker over the
-- full UN member states list (193 countries, see lib/countries.ts)
-- instead of the original ~9/5-value picklists. The old CHECK constraints
-- would reject nearly all of those values, so they're dropped here. The
-- app is the source of truth for valid values (same approach the rest of
-- the app already relies on for large open-ended pickers); the columns
-- stay plain text (nationality keeps its NOT NULL).
alter table employees drop constraint if exists employees_nationality_check;
alter table employees drop constraint if exists employees_address_country_check;

View File

@@ -0,0 +1,555 @@
-- Alpenwerk HR — mutation RPCs (Phase 2/3)
--
-- One Postgres function per business mutation from the spec
-- §4.4/§4.5/§4.6/§4.7. Each function runs as SECURITY INVOKER (the caller's own
-- session), so the existing RLS policy on `employees` (hr_admin only) is the
-- real authorization gate; the is_hr_admin() check at the top of each function
-- just produces a clearer error message than a bare RLS violation.
--
-- A single function call is one Postgres transaction: if any statement raises,
-- everything in that call rolls back automatically. Run this whole file in the
-- Supabase SQL Editor after supabase/schema.sql.
alter table employee_history add column if not exists reorg_scenario_id uuid references reorg_scenarios(id);
create or replace function current_actor_name()
returns text language sql stable as $$
select coalesce(p.full_name, p.email, 'Unbekannt') from profiles p where p.id = auth.uid();
$$;
create or replace function require_hr_admin()
returns void language plpgsql as $$
begin
if not is_hr_admin() then
raise exception 'Nicht berechtigt: nur HR-Admin darf diese Aktion ausführen.';
end if;
end;
$$;
-- Manager derivation (§2's "reports-to" rule), used by every mutation that
-- changes an employee's team/leadership status.
create or replace function resolve_manager_for(p_team_id uuid, p_is_lead boolean, p_division_id uuid)
returns uuid language plpgsql as $$
declare
v_manager uuid;
begin
if p_team_id is not null and not p_is_lead then
select id into v_manager from employees
where team_id = p_team_id and is_lead = true and status <> 'Ausgetreten' limit 1;
elsif p_team_id is not null and p_is_lead then
select id into v_manager from employees
where division_id = p_division_id and team_id is null and org_level = 1 and status <> 'Ausgetreten' limit 1;
else
select id into v_manager from employees where org_level = 0 and status <> 'Ausgetreten' limit 1;
end if;
return v_manager;
end;
$$;
create or replace function generate_company_email(p_first_name text, p_last_name text)
returns text language plpgsql as $$
declare
base text;
candidate text;
n int := 1;
translit text;
begin
translit := lower(p_first_name || '.' || p_last_name);
translit := replace(replace(replace(replace(translit, 'ä','ae'), 'ö','oe'), 'ü','ue'), 'ß','ss');
base := regexp_replace(translit, '[^a-z0-9.]', '', 'g');
candidate := base || '@test.manner.at';
while exists (select 1 from employees where email = candidate) loop
n := n + 1;
candidate := base || n::text || '@test.manner.at';
end loop;
return candidate;
end;
$$;
-- ── Hire (§4.4) ───────────────────────────────────────────────
create or replace function hire_employee(payload jsonb)
returns uuid language plpgsql as $$
declare
v_id uuid;
v_team_id uuid;
v_division_id uuid;
v_position record;
v_job_title text;
v_email text;
v_manager uuid;
begin
perform require_hr_admin();
if payload->>'position_id' is not null then
select * into v_position from positions where id = (payload->>'position_id')::uuid and status = 'open';
if not found then
raise exception 'Position ist nicht mehr offen.';
end if;
v_team_id := v_position.team_id;
v_division_id := v_position.division_id;
v_job_title := coalesce(payload->>'job_title', v_position.title);
else
v_team_id := (payload->>'team_id')::uuid;
select division_id into v_division_id from teams t join departments d on d.id = t.department_id where t.id = v_team_id;
v_job_title := payload->>'job_title';
end if;
v_manager := resolve_manager_for(v_team_id, false, v_division_id);
v_email := generate_company_email(payload->>'first_name', payload->>'last_name');
insert into employees (
first_name, last_name, gender, birth_date, sv_nummer, nationality, email, phone,
team_id, division_id, job_title, location_id, manager_id, org_level, is_lead,
employment_type, weekly_hours, monthly_salary_gross, contract_type, contract_end_date,
paygrade, source, status, entry_date
) values (
payload->>'first_name', payload->>'last_name', (payload->>'gender')::gender_type,
(payload->>'birth_date')::date, payload->>'sv_nummer', coalesce(payload->>'nationality', 'Österreich'),
v_email, payload->>'phone',
v_team_id, v_division_id, v_job_title, (payload->>'location_id')::uuid,
v_manager, 3, false,
coalesce((payload->>'employment_type')::employment_type, 'Vollzeit'),
coalesce((payload->>'weekly_hours')::numeric, 38.5),
(payload->>'monthly_salary_gross')::numeric,
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 (payload->>'entry_date')::date > current_date then 'Geplant' else 'Aktiv' end)::employment_status,
(payload->>'entry_date')::date
) returning id into v_id;
if payload->>'position_id' is not null then
update positions set status = 'filled', filled_at = now(), filled_by_employee_id = v_id
where id = (payload->>'position_id')::uuid;
end if;
insert into employee_history (employee_id, event_date, event_type, description)
values (v_id, (payload->>'entry_date')::date, 'Eintritt', 'Eintritt als ' || v_job_title);
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 ' || (payload->>'entry_date'));
return v_id;
end;
$$;
-- ── Austritt (§4.5) ───────────────────────────────────────────
create or replace function terminate_employee(payload jsonb)
returns void language plpgsql as $$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_manager uuid;
v_name text;
begin
perform require_hr_admin();
select manager_id, first_name || ' ' || last_name into v_manager, v_name from employees where id = v_employee_id;
update employees set manager_id = v_manager where manager_id = v_employee_id and status <> 'Ausgetreten';
update employees set
status = 'Ausgetreten',
exit_date = (payload->>'exit_date')::date,
exit_reason = payload->>'exit_reason'
where id = v_employee_id;
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, (payload->>'exit_date')::date, 'Austritt',
'Austritt (' || (payload->>'exit_reason') || ')' || case when payload->>'note' is not null and payload->>'note' <> '' then '' || (payload->>'note') else '' end);
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, payload->>'exit_reason');
end;
$$;
-- ── Versetzung (§4.5) ─────────────────────────────────────────
create or replace function transfer_employee(payload jsonb)
returns void language plpgsql as $$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_new_team_id uuid := (payload->>'new_team_id')::uuid;
v_division_id uuid;
v_manager uuid;
v_name text;
v_is_lead boolean;
begin
perform require_hr_admin();
select division_id into v_division_id from teams t join departments d on d.id = t.department_id where t.id = v_new_team_id;
select is_lead, first_name || ' ' || last_name into v_is_lead, v_name from employees where id = v_employee_id;
v_manager := resolve_manager_for(v_new_team_id, v_is_lead, v_division_id);
update employees set
team_id = v_new_team_id,
job_title = coalesce(nullif(payload->>'new_title', ''), job_title),
manager_id = v_manager
where id = v_employee_id;
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, (payload->>'effective_date')::date, 'Versetzung',
'Versetzung, wirksam ab ' || (payload->>'effective_date') ||
case when payload->>'new_title' is not null and payload->>'new_title' <> '' then ', neue Position: ' || (payload->>'new_title') else '' end);
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 ' || (payload->>'effective_date'));
end;
$$;
-- ── Beförderung (§4.5) ────────────────────────────────────────
create or replace function promote_employee(payload jsonb)
returns void language plpgsql as $$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_old_paygrade paygrade_type;
v_name text;
v_details text;
begin
perform require_hr_admin();
select paygrade, first_name || ' ' || last_name into v_old_paygrade, v_name from employees where id = v_employee_id;
update employees set
job_title = payload->>'new_title',
monthly_salary_gross = (payload->>'new_salary')::numeric,
paygrade = coalesce((payload->>'new_paygrade')::paygrade_type, paygrade)
where id = v_employee_id;
v_details := 'Neue Position: ' || (payload->>'new_title');
if payload->>'new_paygrade' is not null and (payload->>'new_paygrade')::paygrade_type <> v_old_paygrade then
v_details := v_details || ', neue Paygrade: ' || (payload->>'new_paygrade');
end if;
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, (payload->>'effective_date')::date, 'Beförderung', v_details);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Beförderung', v_name, v_employee_id, v_details);
end;
$$;
-- ── Karenz verwalten (§4.5) — adjust return date or record actual return ──
create or replace function adjust_karenz_return(payload jsonb)
returns void language plpgsql as $$
declare
v_employee_id uuid := (payload->>'employee_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;
update employees set karenz_return_date = (payload->>'new_return_date')::date where id = v_employee_id;
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, current_date, 'Karenz',
'Rückkehrdatum angepasst auf ' || (payload->>'new_return_date') ||
case when payload->>'note' is not null and payload->>'note' <> '' then '' || (payload->>'note') else '' end);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Karenz', v_name, v_employee_id, 'Neues Rückkehrdatum: ' || (payload->>'new_return_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_team_id uuid;
v_division_id uuid;
v_is_lead boolean;
v_manager uuid;
v_employment_type employment_type;
v_weekly_hours numeric;
begin
perform require_hr_admin();
select first_name || ' ' || last_name, team_id, division_id, is_lead
into v_name, v_team_id, v_division_id, v_is_lead
from employees where id = v_employee_id;
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
v_manager := resolve_manager_for(v_team_id, v_is_lead, v_division_id);
update employees set
status = 'Aktiv',
karenz_return_date = null,
manager_id = v_manager,
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,
employment_type = coalesce(v_employment_type, employment_type),
weekly_hours = coalesce(v_weekly_hours, weekly_hours)
where id = v_employee_id;
end if;
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, v_return_date, 'Rückkehr', 'Wiedereintritt aus Karenz am ' || (payload->>'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 ' || (payload->>'return_date'));
end;
$$;
-- ── Daten ändern (§4.5) — diffs person vs. contract fields ────
create or replace function change_employee_data(payload jsonb)
returns void language plpgsql as $$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_effective_date date := coalesce(nullif(payload->>'effective_date', '')::date, current_date);
v_old employees%rowtype;
v_name text;
v_person_changes text[] := '{}';
v_contract_changes text[] := '{}';
v_person jsonb := payload->'person';
v_contract jsonb := payload->'contract';
begin
perform require_hr_admin();
select * into v_old from employees where id = v_employee_id;
v_name := v_old.first_name || ' ' || v_old.last_name;
if v_person ? 'first_name' and (v_person->>'first_name') <> v_old.first_name then v_person_changes := array_append(v_person_changes, 'Vorname'); end if;
if v_person ? 'last_name' and (v_person->>'last_name') <> v_old.last_name then v_person_changes := array_append(v_person_changes, 'Nachname'); end if;
if v_person ? 'gender' and (v_person->>'gender') <> v_old.gender::text then v_person_changes := array_append(v_person_changes, 'Geschlecht'); end if;
if v_person ? 'birth_date' and (v_person->>'birth_date')::date <> v_old.birth_date then v_person_changes := array_append(v_person_changes, 'Geburtsdatum'); end if;
if v_person ? 'sv_nummer' and coalesce(v_person->>'sv_nummer','') <> coalesce(v_old.sv_nummer,'') then v_person_changes := array_append(v_person_changes, 'SV-Nummer'); end if;
if v_person ? 'nationality' and (v_person->>'nationality') <> v_old.nationality then v_person_changes := array_append(v_person_changes, 'Staatsbürgerschaft'); end if;
if v_person ? 'address' and coalesce(v_person->>'address','') <> coalesce(v_old.address,'') then v_person_changes := array_append(v_person_changes, 'Adresse'); end if;
if v_person ? 'address_country' and coalesce(v_person->>'address_country','') <> coalesce(v_old.address_country,'') then v_person_changes := array_append(v_person_changes, 'Land'); end if;
if v_person ? 'email' and (v_person->>'email') <> v_old.email then v_person_changes := array_append(v_person_changes, 'E-Mail'); end if;
if v_person ? 'phone' and coalesce(v_person->>'phone','') <> coalesce(v_old.phone,'') then v_person_changes := array_append(v_person_changes, 'Telefon'); end if;
if v_contract ? 'employment_type' and (v_contract->>'employment_type') <> v_old.employment_type::text then v_contract_changes := array_append(v_contract_changes, 'Beschäftigungsausmaß'); end if;
if v_contract ? 'weekly_hours' and (v_contract->>'weekly_hours')::numeric <> v_old.weekly_hours then v_contract_changes := array_append(v_contract_changes, 'Wochenstunden'); end if;
if v_contract ? 'contract_type' and (v_contract->>'contract_type') <> v_old.contract_type::text then v_contract_changes := array_append(v_contract_changes, 'Vertragsart'); end if;
if v_contract ? 'contract_end_date' and coalesce(nullif(v_contract->>'contract_end_date','')::date::text,'') <> coalesce(v_old.contract_end_date::text,'') then v_contract_changes := array_append(v_contract_changes, 'Befristet bis'); end if;
update employees set
first_name = coalesce(v_person->>'first_name', first_name),
last_name = coalesce(v_person->>'last_name', last_name),
gender = coalesce((v_person->>'gender')::gender_type, gender),
birth_date = coalesce((v_person->>'birth_date')::date, birth_date),
sv_nummer = coalesce(v_person->>'sv_nummer', sv_nummer),
nationality = coalesce(v_person->>'nationality', nationality),
address = coalesce(v_person->>'address', address),
address_country = coalesce(v_person->>'address_country', address_country),
email = coalesce(v_person->>'email', email),
phone = coalesce(v_person->>'phone', phone),
employment_type = coalesce((v_contract->>'employment_type')::employment_type, employment_type),
weekly_hours = coalesce((v_contract->>'weekly_hours')::numeric, weekly_hours),
contract_type = coalesce((v_contract->>'contract_type')::contract_type, contract_type),
contract_end_date = case when v_contract ? 'contract_end_date' then nullif(v_contract->>'contract_end_date','')::date else contract_end_date end
where id = v_employee_id;
if array_length(v_person_changes, 1) > 0 then
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, v_effective_date, 'Stammdatenänderung', 'Geänderte Felder: ' || array_to_string(v_person_changes, ', ') || ', wirksam ab ' || v_effective_date);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Stammdatenänderung', v_name, v_employee_id, array_to_string(v_person_changes, ', ') || ', wirksam ab ' || v_effective_date);
end if;
if array_length(v_contract_changes, 1) > 0 then
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, v_effective_date, 'Vertragsänderung', 'Geänderte Felder: ' || array_to_string(v_contract_changes, ', ') || ', wirksam ab ' || v_effective_date);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Vertragsänderung', v_name, v_employee_id, array_to_string(v_contract_changes, ', ') || ', wirksam ab ' || v_effective_date);
end if;
end;
$$;
-- ── Wiedereinstellung (§4.5) ──────────────────────────────────
create or replace function rehire_employee(payload jsonb)
returns void language plpgsql as $$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_rehire_date date := (payload->>'rehire_date')::date;
v_team_id uuid;
v_division_id uuid;
v_is_lead boolean;
v_manager uuid;
v_name text;
begin
perform require_hr_admin();
select team_id, division_id, is_lead, first_name || ' ' || last_name
into v_team_id, v_division_id, v_is_lead, v_name
from employees where id = v_employee_id;
v_manager := resolve_manager_for(v_team_id, v_is_lead, v_division_id);
update employees set
status = (case when v_rehire_date > current_date then 'Geplant' else 'Aktiv' end)::employment_status,
entry_date = v_rehire_date,
exit_date = null,
exit_reason = null,
manager_id = v_manager
where id = v_employee_id;
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, v_rehire_date, 'Wiedereintritt', 'Wiedereinstellung zum ' || (payload->>'rehire_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 ' || (payload->>'rehire_date'));
end;
$$;
-- ── Position ausschreiben (§4.6) ──────────────────────────────
create or replace function create_position(payload jsonb)
returns uuid language plpgsql as $$
declare
v_id uuid;
v_superior_id uuid := (payload->>'superior_employee_id')::uuid;
v_is_lead boolean := coalesce((payload->>'is_lead')::boolean, false);
v_team_id uuid;
begin
perform require_hr_admin();
if v_is_lead then
v_team_id := (payload->>'team_id')::uuid;
else
select team_id into v_team_id from employees where id = v_superior_id;
end if;
insert into positions (title, team_id, is_lead, reports_to_employee_id)
values (payload->>'title', v_team_id, v_is_lead, v_superior_id)
returning id into v_id;
insert into audit_log (actor_user_id, actor_name, action, target_label, details)
values (auth.uid(), current_actor_name(), 'Ausschreibung', payload->>'title', 'Position ausgeschrieben');
return v_id;
end;
$$;
-- ── Intern besetzen (§4.6) ────────────────────────────────────
create or replace function staff_position_internally(payload jsonb)
returns void language plpgsql as $$
declare
v_position record;
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_manager uuid;
v_name text;
begin
perform require_hr_admin();
select * into v_position from positions where id = (payload->>'position_id')::uuid and status = 'open';
if not found then
raise exception 'Position ist nicht mehr offen.';
end if;
select first_name || ' ' || last_name into v_name from employees where id = v_employee_id;
v_manager := resolve_manager_for(v_position.team_id, v_position.is_lead, v_position.division_id);
update employees set
team_id = v_position.team_id,
job_title = v_position.title,
source = 'Intern',
is_lead = case when v_position.is_lead then true else is_lead end,
org_level = case when v_position.is_lead then 2 else org_level end,
manager_id = v_manager
where id = v_employee_id;
if v_position.is_lead then
update employees set manager_id = v_employee_id
where team_id = v_position.team_id and id <> v_employee_id and is_lead = false and status <> 'Ausgetreten';
end if;
update positions set status = 'filled', filled_at = now(), filled_by_employee_id = v_employee_id
where id = v_position.id;
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, current_date, 'Versetzung', 'Interne Besetzung: ' || v_position.title);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Interne Besetzung', v_name, v_employee_id, v_position.title);
end;
$$;
-- ── Reorganisation (§4.7) ─────────────────────────────────────
-- payload: { name, effective_date, moves: [{ kind, label, employee_ids: uuid[], target_team_id }] }
create or replace function apply_reorg(payload jsonb)
returns uuid language plpgsql as $$
declare
v_scenario_id uuid;
v_move jsonb;
v_employee_id text;
v_target_team_id uuid;
v_division_id uuid;
v_is_lead boolean;
v_manager uuid;
v_snapshot jsonb := '{}'::jsonb;
v_old record;
v_total_moves int := 0;
begin
perform require_hr_admin();
insert into reorg_scenarios (name, effective_date, created_by, applied, applied_at)
values (payload->>'name', (payload->>'effective_date')::date, auth.uid(), true, now())
returning id into v_scenario_id;
for v_move in select * from jsonb_array_elements(payload->'moves')
loop
v_target_team_id := (v_move->>'target_team_id')::uuid;
select division_id into v_division_id from teams t join departments d on d.id = t.department_id where t.id = v_target_team_id;
insert into reorg_moves (scenario_id, kind, payload) values (v_scenario_id, v_move->>'kind', v_move);
for v_employee_id in select jsonb_array_elements_text(v_move->'employee_ids')
loop
select * into v_old from employees where id = v_employee_id::uuid;
v_snapshot := v_snapshot || jsonb_build_object(v_employee_id, jsonb_build_object(
'team_id', v_old.team_id, 'division_id', v_old.division_id, 'manager_id', v_old.manager_id
));
v_is_lead := v_old.is_lead;
v_manager := resolve_manager_for(v_target_team_id, v_is_lead, v_division_id);
update employees set team_id = v_target_team_id, manager_id = v_manager where id = v_employee_id::uuid;
insert into employee_history (employee_id, event_date, event_type, description, reorg_scenario_id)
values (v_employee_id::uuid, (payload->>'effective_date')::date, 'Reorganisation',
'Reorganisation "' || (payload->>'name') || '": neues Team zugewiesen', v_scenario_id);
v_total_moves := v_total_moves + 1;
end loop;
end loop;
update reorg_scenarios set undo_snapshot = v_snapshot where id = v_scenario_id;
insert into audit_log (actor_user_id, actor_name, action, target_label, details)
values (auth.uid(), current_actor_name(), 'Reorganisation', payload->>'name', v_total_moves || ' Mitarbeiter:innen betroffen');
return v_scenario_id;
end;
$$;
create or replace function undo_reorg(payload jsonb)
returns void language plpgsql as $$
declare
v_scenario record;
v_key text;
v_val jsonb;
begin
perform require_hr_admin();
select * into v_scenario from reorg_scenarios where id = (payload->>'scenario_id')::uuid and applied = true;
if not found or v_scenario.undo_snapshot is null then
raise exception 'Reorganisation kann nicht rückgängig gemacht werden (kein Snapshot vorhanden).';
end if;
for v_key, v_val in select * from jsonb_each(v_scenario.undo_snapshot)
loop
update employees set
team_id = nullif(v_val->>'team_id','')::uuid,
division_id = (v_val->>'division_id')::uuid,
manager_id = nullif(v_val->>'manager_id','')::uuid
where id = v_key::uuid;
end loop;
delete from employee_history where reorg_scenario_id = v_scenario.id;
update reorg_scenarios set applied = false where id = v_scenario.id;
insert into audit_log (actor_user_id, actor_name, action, target_label, details)
values (auth.uid(), current_actor_name(), 'Reorganisation rückgängig', v_scenario.name, 'Reorganisation zurückgesetzt');
end;
$$;

View File

@@ -0,0 +1,29 @@
-- Addendum to supabase/functions.sql — run after that file.
--
-- The spec's "Karenz verwalten" panel (§4.5) only covers employees already on
-- Karenz (adjust return date / record return). It doesn't specify the fields
-- for *starting* a Karenz period from Aktiv, even though §4.3 clearly shows a
-- "Karenz" button for that case. This fills that gap with a reasonable,
-- minimal form: start date + planned return date + optional note.
create or replace function start_karenz(payload jsonb)
returns void language plpgsql as $$
declare
v_employee_id uuid := (payload->>'employee_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;
update employees set status = 'Karenz', karenz_return_date = (payload->>'planned_return_date')::date
where id = v_employee_id;
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, (payload->>'karenz_start_date')::date, 'Karenz',
'Karenzantritt, geplante Rückkehr am ' || (payload->>'planned_return_date') ||
case when payload->>'note' is not null and payload->>'note' <> '' then '' || (payload->>'note') else '' end);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Karenz', v_name, v_employee_id, 'Karenzantritt, geplante Rückkehr ' || (payload->>'planned_return_date'));
end;
$$;

View File

@@ -0,0 +1,15 @@
-- Addendum to supabase/schema.sql + functions.sql — run after those.
--
-- employee_history intentionally has no UPDATE/DELETE policy (§4.9's
-- "unveraenderbar" / append-only requirement). But undo_reorg needs to
-- remove the specific history rows a reorg created — found via live
-- testing: the DELETE inside undo_reorg silently matched 0 rows under RLS
-- (no error, since RLS just filters DELETE-eligible rows to none), leaving
-- Reorganisation entries behind after an otherwise-successful undo.
--
-- Scope the exception as narrowly as possible: hr_admin may delete a
-- history row only if it carries a reorg_scenario_id, i.e. only rows
-- apply_reorg created. Eintritt/Austritt/Beförderung/etc. rows (always
-- reorg_scenario_id IS NULL) remain fully immutable.
create policy "history_delete_admin_reorg_undo" on employee_history for delete
using (is_hr_admin() and reorg_scenario_id is not null);

View File

@@ -0,0 +1,71 @@
-- Addendum to supabase/functions.sql — run after that file (and functions_2/3.sql).
--
-- "Daten ändern" had no "Wirksam ab" field, unlike Versetzung/Beförderung/
-- Karenz — every change was silently logged with today's date regardless
-- of when it should actually take effect. Adds an effective_date input
-- (defaults to today if omitted) used for both the history event_date and
-- noted in the change description.
create or replace function change_employee_data(payload jsonb)
returns void language plpgsql as $$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_effective_date date := coalesce(nullif(payload->>'effective_date', '')::date, current_date);
v_old employees%rowtype;
v_name text;
v_person_changes text[] := '{}';
v_contract_changes text[] := '{}';
v_person jsonb := payload->'person';
v_contract jsonb := payload->'contract';
begin
perform require_hr_admin();
select * into v_old from employees where id = v_employee_id;
v_name := v_old.first_name || ' ' || v_old.last_name;
if v_person ? 'first_name' and (v_person->>'first_name') <> v_old.first_name then v_person_changes := array_append(v_person_changes, 'Vorname'); end if;
if v_person ? 'last_name' and (v_person->>'last_name') <> v_old.last_name then v_person_changes := array_append(v_person_changes, 'Nachname'); end if;
if v_person ? 'gender' and (v_person->>'gender') <> v_old.gender::text then v_person_changes := array_append(v_person_changes, 'Geschlecht'); end if;
if v_person ? 'birth_date' and (v_person->>'birth_date')::date <> v_old.birth_date then v_person_changes := array_append(v_person_changes, 'Geburtsdatum'); end if;
if v_person ? 'sv_nummer' and coalesce(v_person->>'sv_nummer','') <> coalesce(v_old.sv_nummer,'') then v_person_changes := array_append(v_person_changes, 'SV-Nummer'); end if;
if v_person ? 'nationality' and (v_person->>'nationality') <> v_old.nationality then v_person_changes := array_append(v_person_changes, 'Staatsbürgerschaft'); end if;
if v_person ? 'address' and coalesce(v_person->>'address','') <> coalesce(v_old.address,'') then v_person_changes := array_append(v_person_changes, 'Adresse'); end if;
if v_person ? 'address_country' and coalesce(v_person->>'address_country','') <> coalesce(v_old.address_country,'') then v_person_changes := array_append(v_person_changes, 'Land'); end if;
if v_person ? 'email' and (v_person->>'email') <> v_old.email then v_person_changes := array_append(v_person_changes, 'E-Mail'); end if;
if v_person ? 'phone' and coalesce(v_person->>'phone','') <> coalesce(v_old.phone,'') then v_person_changes := array_append(v_person_changes, 'Telefon'); end if;
if v_contract ? 'employment_type' and (v_contract->>'employment_type') <> v_old.employment_type::text then v_contract_changes := array_append(v_contract_changes, 'Beschäftigungsausmaß'); end if;
if v_contract ? 'weekly_hours' and (v_contract->>'weekly_hours')::numeric <> v_old.weekly_hours then v_contract_changes := array_append(v_contract_changes, 'Wochenstunden'); end if;
if v_contract ? 'contract_type' and (v_contract->>'contract_type') <> v_old.contract_type::text then v_contract_changes := array_append(v_contract_changes, 'Vertragsart'); end if;
if v_contract ? 'contract_end_date' and coalesce(nullif(v_contract->>'contract_end_date','')::date::text,'') <> coalesce(v_old.contract_end_date::text,'') then v_contract_changes := array_append(v_contract_changes, 'Befristet bis'); end if;
update employees set
first_name = coalesce(v_person->>'first_name', first_name),
last_name = coalesce(v_person->>'last_name', last_name),
gender = coalesce((v_person->>'gender')::gender_type, gender),
birth_date = coalesce((v_person->>'birth_date')::date, birth_date),
sv_nummer = coalesce(v_person->>'sv_nummer', sv_nummer),
nationality = coalesce(v_person->>'nationality', nationality),
address = coalesce(v_person->>'address', address),
address_country = coalesce(v_person->>'address_country', address_country),
email = coalesce(v_person->>'email', email),
phone = coalesce(v_person->>'phone', phone),
employment_type = coalesce((v_contract->>'employment_type')::employment_type, employment_type),
weekly_hours = coalesce((v_contract->>'weekly_hours')::numeric, weekly_hours),
contract_type = coalesce((v_contract->>'contract_type')::contract_type, contract_type),
contract_end_date = case when v_contract ? 'contract_end_date' then nullif(v_contract->>'contract_end_date','')::date else contract_end_date end
where id = v_employee_id;
if array_length(v_person_changes, 1) > 0 then
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, v_effective_date, 'Stammdatenänderung', 'Geänderte Felder: ' || array_to_string(v_person_changes, ', ') || ', wirksam ab ' || v_effective_date);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Stammdatenänderung', v_name, v_employee_id, array_to_string(v_person_changes, ', ') || ', wirksam ab ' || v_effective_date);
end if;
if array_length(v_contract_changes, 1) > 0 then
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, v_effective_date, 'Vertragsänderung', 'Geänderte Felder: ' || array_to_string(v_contract_changes, ', ') || ', wirksam ab ' || v_effective_date);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Vertragsänderung', v_name, v_employee_id, array_to_string(v_contract_changes, ', ') || ', wirksam ab ' || v_effective_date);
end if;
end;
$$;

View File

@@ -0,0 +1,172 @@
-- HR-only access model (spec §2)
--
-- Rationale: the app previously had two profile roles (hr_admin / manager),
-- with "manager" intended as a read-only, salary-masked role. The revised
-- scope is HR-only: nobody except an active, explicitly-provisioned HR user
-- may open the app at all. This migration:
-- 1. Collapses profiles.role to the single allowed value 'hr'.
-- 2. Adds profiles.is_active (default false — new users get zero access
-- until an existing HR user explicitly activates them; see §2.3).
-- 3. Renames the authorization gate from is_hr_admin() to is_hr_user()
-- (checks role='hr' AND is_active=true) to match §2.4's naming and
-- semantics, and repoints every RLS policy at it.
-- 4. Fixes a real gap: several tables (divisions/departments/teams/
-- locations, employee_history, positions, audit_log, reorg_scenarios/
-- reorg_moves) had read policies scoped to `auth.role() = 'authenticated'`
-- — i.e. ANY signed-in Supabase Auth user, not just HR. Every one of
-- those is tightened to is_hr_user().
-- 5. Drops the employees_directory salary-masking view: with the
-- "manager" role gone and salary out of MVP scope (see the salary
-- deprecation migration), there is nothing left to mask and no second
-- role to mask it from. All reads now go through the base `employees`
-- table, gated by the same is_hr_user()-only policy as writes.
--
-- The application layer (app/(app)/layout.tsx) previously let any
-- authenticated Supabase user reach the shell (it only checked for a
-- session, not profiles.role/is_active) and merely hid the edit UI for
-- non-admins. That check is being replaced in the app code alongside this
-- migration — this migration is what makes that enforceable at the data
-- layer regardless of what the UI does.
-- ── profiles: single role, explicit activation ──────────────────────
alter table profiles drop constraint if exists profiles_role_check;
update profiles set role = 'hr' where role <> 'hr';
alter table profiles add constraint profiles_role_check check (role = 'hr');
alter table profiles alter column role set default 'hr';
alter table profiles add column if not exists is_active boolean not null default false;
alter table profiles add column if not exists created_by uuid references auth.users(id);
alter table profiles add column if not exists updated_at timestamptz not null default now();
-- Any *existing* profile row (i.e. someone already explicitly provisioned
-- before this migration) keeps working — activation is only "off by
-- default" for rows created from here on.
update profiles set is_active = true where is_active = false;
create or replace function fn_touch_profiles_updated_at()
returns trigger language plpgsql as $$
begin
new.updated_at = now();
return new;
end;
$$;
drop trigger if exists trg_profiles_touch_updated_at on profiles;
create trigger trg_profiles_touch_updated_at
before update on profiles
for each row execute function fn_touch_profiles_updated_at();
-- ── Authorization gate: is_hr_user() replaces is_hr_admin() ─────────
create or replace function is_hr_user()
returns boolean
language sql
security definer
set search_path = public
stable
as $$
select exists (
select 1 from profiles p where p.id = auth.uid() 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
stable
as $$
select p.id from profiles p where p.id = auth.uid() and p.role = 'hr' and p.is_active = true;
$$;
-- Back-compat shim so any not-yet-migrated call site (or a function defined
-- in an older addendum file not touched by this migration) keeps working;
-- new code should call is_hr_user() directly. Safe to drop once nothing
-- references is_hr_admin() anymore (tracked in docs/decisions).
create or replace function is_hr_admin()
returns boolean
language sql
stable
as $$
select is_hr_user();
$$;
create or replace function require_hr_admin()
returns void language plpgsql as $$
begin
if not is_hr_user() then
raise exception 'Nicht berechtigt: nur aktive HR-Benutzer:innen dürfen diese Aktion ausführen.';
end if;
end;
$$;
-- ── Re-scope every "any authenticated user" read policy to HR-only ──
drop policy if exists "org_read" on divisions;
create policy "org_read" on divisions for select using (is_hr_user());
drop policy if exists "org_write" on divisions;
create policy "org_write" on divisions for all using (is_hr_user()) with check (is_hr_user());
drop policy if exists "org_read" on departments;
create policy "org_read" on departments for select using (is_hr_user());
drop policy if exists "org_write" on departments;
create policy "org_write" on departments for all using (is_hr_user()) with check (is_hr_user());
drop policy if exists "org_read" on teams;
create policy "org_read" on teams for select using (is_hr_user());
drop policy if exists "org_write" on teams;
create policy "org_write" on teams for all using (is_hr_user()) with check (is_hr_user());
drop policy if exists "org_read" on locations;
create policy "org_read" on locations for select using (is_hr_user());
drop policy if exists "org_write" on locations;
create policy "org_write" on locations for all using (is_hr_user()) with check (is_hr_user());
drop policy if exists "history_read" on employee_history;
create policy "history_read" on employee_history for select using (is_hr_user());
drop policy if exists "history_insert_admin" on employee_history;
create policy "history_insert_admin" on employee_history for insert with check (is_hr_user());
drop policy if exists "positions_read" on positions;
create policy "positions_read" on positions for select using (is_hr_user());
drop policy if exists "positions_write_admin" on positions;
create policy "positions_write_admin" on positions for all using (is_hr_user()) with check (is_hr_user());
drop policy if exists "audit_read" on audit_log;
create policy "audit_read" on audit_log for select using (is_hr_user());
drop policy if exists "audit_insert_admin" on audit_log;
create policy "audit_insert_admin" on audit_log for insert with check (is_hr_user());
drop policy if exists "reorg_scenarios_read" on reorg_scenarios;
create policy "reorg_scenarios_read" on reorg_scenarios for select using (is_hr_user());
drop policy if exists "reorg_scenarios_write_admin" on reorg_scenarios;
create policy "reorg_scenarios_write_admin" on reorg_scenarios for all using (is_hr_user()) with check (is_hr_user());
drop policy if exists "reorg_moves_read" on reorg_moves;
create policy "reorg_moves_read" on reorg_moves for select using (is_hr_user());
drop policy if exists "reorg_moves_write_admin" on reorg_moves;
create policy "reorg_moves_write_admin" on reorg_moves for all using (is_hr_user()) with check (is_hr_user());
drop policy if exists "employees_admin_all" on employees;
create policy "employees_hr_all" on employees for all using (is_hr_user()) with check (is_hr_user());
-- profiles: users may always read their own row (needed to determine their
-- own HR status before is_hr_user() would otherwise apply); HR manages all.
drop policy if exists "profiles_select_admin" on profiles;
create policy "profiles_select_admin" on profiles for select using (is_hr_user());
drop policy if exists "profiles_write_admin" on profiles;
create policy "profiles_write_admin" on profiles for insert with check (is_hr_user());
drop policy if exists "profiles_update_admin" on profiles;
create policy "profiles_update_admin" on profiles for update using (is_hr_user()) with check (is_hr_user());
-- hire_drafts / saved_reports stay owner-scoped (unchanged) — but an owner
-- who is no longer an active HR user should not retain access either.
drop policy if exists "hire_drafts_owner" on hire_drafts;
create policy "hire_drafts_owner" on hire_drafts for all
using (created_by = auth.uid() and is_hr_user()) with check (created_by = auth.uid() 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 = auth.uid() and is_hr_user()) with check (created_by = auth.uid() and is_hr_user());
-- ── Drop the salary-masking view: no second role left to mask from ──
drop view if exists employees_directory;

View File

@@ -0,0 +1,38 @@
-- Deferred/effective-dated changes (spec §3.3)
--
-- Finding from the consolidation review: transfer_employee, promote_employee,
-- start_karenz, change_employee_data, and apply_reorg all accepted a
-- "Wirksam ab" / effective date, but only ever used it as metadata for the
-- employee_history/audit_log rows — the actual `update employees` always
-- ran immediately regardless of that date. A transfer or promotion dated
-- months in the future silently overwrote the *current* live record today.
-- This table backs the fix: when an effective date is in the future, the
-- mutating RPC stores the intended change here instead of writing it to
-- `employees` right away; a scheduled job (apply_due_pending_changes(),
-- see the following migration, invoked by a Vercel Cron route handler)
-- applies it once its date arrives. The employee_history/audit_log rows are
-- written immediately either way (dated with the effective date), which is
-- what already drives the "zukünftig" badge in the Historie tab.
create table pending_org_changes (
id uuid primary key default gen_random_uuid(),
employee_id uuid not null references employees(id) on delete cascade,
change_type text not null check (change_type in (
'transfer', 'promotion', 'karenz_start', 'karenz_return', 'contract_change', 'reorg'
)),
effective_date date not null,
payload jsonb not null,
reorg_scenario_id uuid references reorg_scenarios(id) on delete cascade,
status text not null default 'pending' check (status in ('pending', 'applied', 'cancelled')),
created_by uuid references auth.users(id),
created_at timestamptz not null default now(),
applied_at timestamptz
);
create index on pending_org_changes (employee_id);
create index on pending_org_changes (status, effective_date);
create index on pending_org_changes (reorg_scenario_id);
alter table pending_org_changes enable row level security;
create policy "pending_org_changes_hr_all" on pending_org_changes for all
using (is_hr_user()) with check (is_hr_user());

View File

@@ -0,0 +1,125 @@
-- Salary out of MVP scope (spec §4)
--
-- Decision: do NOT drop employees.monthly_salary_gross. This is a live
-- Supabase project that may already hold seeded/real rows with values in
-- this column; a destructive drop is unrecoverable and unnecessary to
-- achieve the actual goal (removing salary from the product surface).
-- Instead: relax the column so the app can stop supplying it, mark it
-- deprecated, and stop every RPC from reading/writing it. A future
-- migration MAY drop the column outright once it's confirmed nothing in
-- any environment still depends on it (tracked in docs/decisions).
alter table employees alter column monthly_salary_gross drop not null;
alter table employees drop constraint if exists employees_monthly_salary_gross_check;
comment on column employees.monthly_salary_gross is
'DEPRECATED (2026 consolidation): salary is out of MVP scope. Column kept '
'only because it may hold pre-existing data; the application no longer '
'reads or writes it (see hire_employee/promote_employee). Candidate for '
'a future DROP COLUMN once confirmed unused across all environments.';
-- hire_employee: stop requiring/writing salary.
create or replace function hire_employee(payload jsonb)
returns uuid language plpgsql as $$
declare
v_id uuid;
v_team_id uuid;
v_division_id uuid;
v_position record;
v_job_title text;
v_email text;
v_manager uuid;
begin
perform require_hr_admin();
if payload->>'position_id' is not null then
select * into v_position from positions where id = (payload->>'position_id')::uuid and status = 'open';
if not found then
raise exception 'Position ist nicht mehr offen.';
end if;
v_team_id := v_position.team_id;
v_division_id := v_position.division_id;
v_job_title := coalesce(payload->>'job_title', v_position.title);
else
v_team_id := (payload->>'team_id')::uuid;
select division_id into v_division_id from teams t join departments d on d.id = t.department_id where t.id = v_team_id;
v_job_title := payload->>'job_title';
end if;
v_manager := resolve_manager_for(v_team_id, false, v_division_id);
v_email := generate_company_email(payload->>'first_name', payload->>'last_name');
insert into employees (
first_name, last_name, gender, birth_date, sv_nummer, nationality, email, phone,
team_id, division_id, job_title, location_id, manager_id, org_level, is_lead,
employment_type, weekly_hours, contract_type, contract_end_date,
paygrade, source, status, entry_date
) values (
payload->>'first_name', payload->>'last_name', (payload->>'gender')::gender_type,
(payload->>'birth_date')::date, payload->>'sv_nummer', coalesce(payload->>'nationality', 'Österreich'),
v_email, payload->>'phone',
v_team_id, v_division_id, v_job_title, (payload->>'location_id')::uuid,
v_manager, 3, false,
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 (payload->>'entry_date')::date > current_date then 'Geplant' else 'Aktiv' end)::employment_status,
(payload->>'entry_date')::date
) returning id into v_id;
if payload->>'position_id' is not null then
update positions set status = 'filled', filled_at = now(), filled_by_employee_id = v_id
where id = (payload->>'position_id')::uuid;
end if;
insert into employee_history (employee_id, event_date, event_type, description)
values (v_id, (payload->>'entry_date')::date, 'Eintritt', 'Eintritt als ' || v_job_title);
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 ' || (payload->>'entry_date'));
return v_id;
end;
$$;
-- promote_employee: no longer accepts/writes new_salary; paygrade remains
-- (it's an organizational/functional classification, not a derived salary
-- figure — see §4 point 7).
create or replace function promote_employee(payload jsonb)
returns void language plpgsql as $$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_effective_date date := coalesce(nullif(payload->>'effective_date', '')::date, current_date);
v_old_paygrade paygrade_type;
v_name text;
v_details text;
begin
perform require_hr_admin();
select paygrade, first_name || ' ' || last_name into v_old_paygrade, v_name from employees where id = v_employee_id;
v_details := 'Neue Position: ' || (payload->>'new_title');
if payload->>'new_paygrade' is not null and (payload->>'new_paygrade')::paygrade_type <> v_old_paygrade then
v_details := v_details || ', neue Paygrade: ' || (payload->>'new_paygrade');
end if;
if v_effective_date <= current_date then
update employees set
job_title = payload->>'new_title',
paygrade = coalesce((payload->>'new_paygrade')::paygrade_type, paygrade)
where id = v_employee_id;
else
insert into pending_org_changes (employee_id, change_type, effective_date, payload)
values (v_employee_id, 'promotion', v_effective_date,
jsonb_build_object('new_title', payload->>'new_title', 'new_paygrade', payload->>'new_paygrade'));
end if;
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, v_effective_date, 'Beförderung', v_details);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Beförderung', v_name, v_employee_id, v_details);
end;
$$;

View File

@@ -0,0 +1,387 @@
-- Fix effective-dating for Versetzung, Karenz (start + return), Daten
-- ändern, and Reorganisation (spec §3.3, §7.14).
--
-- Pattern used throughout: if the effective/return date is today or in the
-- past, behave exactly as before (immediate write). If it's in the future,
-- skip the `update employees` and instead record the intended change in
-- pending_org_changes; employee_history/audit_log are written immediately
-- either way, dated with the effective date (this is what already drives
-- the "zukünftig" badge in the Historie tab — unchanged). A separate
-- apply_due_pending_changes() function (called by a scheduled job) applies
-- due rows once their date arrives, re-resolving manager_id fresh at apply
-- time rather than trusting a value computed when the change was requested.
-- ── Versetzung ───────────────────────────────────────────────────
create or replace function transfer_employee(payload jsonb)
returns void language plpgsql as $$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_new_team_id uuid := (payload->>'new_team_id')::uuid;
v_effective_date date := (payload->>'effective_date')::date;
v_division_id uuid;
v_manager uuid;
v_name text;
v_is_lead boolean;
begin
perform require_hr_admin();
select division_id into v_division_id from teams t join departments d on d.id = t.department_id where t.id = v_new_team_id;
select is_lead, first_name || ' ' || last_name into v_is_lead, v_name from employees where id = v_employee_id;
if v_effective_date <= current_date then
v_manager := resolve_manager_for(v_new_team_id, v_is_lead, v_division_id);
update employees set
team_id = v_new_team_id,
job_title = coalesce(nullif(payload->>'new_title', ''), job_title),
manager_id = v_manager
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_date,
jsonb_build_object('new_team_id', v_new_team_id, 'new_title', payload->>'new_title'));
end if;
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, v_effective_date, 'Versetzung',
'Versetzung, wirksam ab ' || (payload->>'effective_date') ||
case when payload->>'new_title' is not null and payload->>'new_title' <> '' then ', neue Position: ' || (payload->>'new_title') else '' end);
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 ' || (payload->>'effective_date'));
end;
$$;
-- ── Karenz antreten ──────────────────────────────────────────────
create or replace function start_karenz(payload jsonb)
returns void language plpgsql as $$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_start_date date := (payload->>'karenz_start_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;
if v_start_date <= current_date then
update employees set status = 'Karenz', karenz_return_date = (payload->>'planned_return_date')::date
where id = v_employee_id;
else
insert into pending_org_changes (employee_id, change_type, effective_date, payload)
values (v_employee_id, 'karenz_start', v_start_date,
jsonb_build_object('planned_return_date', payload->>'planned_return_date'));
end if;
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, v_start_date, 'Karenz',
'Karenzantritt, geplante Rückkehr am ' || (payload->>'planned_return_date') ||
case when payload->>'note' is not null and payload->>'note' <> '' then '' || (payload->>'note') else '' end);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Karenz', v_name, v_employee_id, 'Karenzantritt, geplante Rückkehr ' || (payload->>'planned_return_date'));
end;
$$;
-- ── Rückkehr aus Karenz ──────────────────────────────────────────
-- status/manager_id/karenz_return_date already correctly waited for the
-- return date; employment_type/weekly_hours did not (fixed here).
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_team_id uuid;
v_division_id uuid;
v_is_lead boolean;
v_manager uuid;
v_employment_type employment_type;
v_weekly_hours numeric;
begin
perform require_hr_admin();
select first_name || ' ' || last_name, team_id, division_id, is_lead
into v_name, v_team_id, v_division_id, v_is_lead
from employees where id = v_employee_id;
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
v_manager := resolve_manager_for(v_team_id, v_is_lead, v_division_id);
update employees set
status = 'Aktiv',
karenz_return_date = null,
manager_id = v_manager,
employment_type = coalesce(v_employment_type, employment_type),
weekly_hours = coalesce(v_weekly_hours, weekly_hours)
where id = v_employee_id;
else
-- Only record the planned date now; the employment-mode change itself
-- (and the status/manager flip) waits for record_karenz_return's
-- effective date, applied later by apply_due_pending_changes().
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', 'Wiedereintritt aus Karenz am ' || (payload->>'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 ' || (payload->>'return_date'));
end;
$$;
-- ── Daten ändern ─────────────────────────────────────────────────
create or replace function change_employee_data(payload jsonb)
returns void language plpgsql as $$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_effective_date date := coalesce(nullif(payload->>'effective_date', '')::date, current_date);
v_old employees%rowtype;
v_name text;
v_person_changes text[] := '{}';
v_contract_changes text[] := '{}';
v_person jsonb := payload->'person';
v_contract jsonb := payload->'contract';
v_immediate boolean;
begin
perform require_hr_admin();
select * into v_old from employees where id = v_employee_id;
v_name := v_old.first_name || ' ' || v_old.last_name;
v_immediate := v_effective_date <= current_date;
if v_person ? 'first_name' and (v_person->>'first_name') <> v_old.first_name then v_person_changes := array_append(v_person_changes, 'Vorname'); end if;
if v_person ? 'last_name' and (v_person->>'last_name') <> v_old.last_name then v_person_changes := array_append(v_person_changes, 'Nachname'); end if;
if v_person ? 'gender' and (v_person->>'gender') <> v_old.gender::text then v_person_changes := array_append(v_person_changes, 'Geschlecht'); end if;
if v_person ? 'birth_date' and (v_person->>'birth_date')::date <> v_old.birth_date then v_person_changes := array_append(v_person_changes, 'Geburtsdatum'); end if;
if v_person ? 'sv_nummer' and coalesce(v_person->>'sv_nummer','') <> coalesce(v_old.sv_nummer,'') then v_person_changes := array_append(v_person_changes, 'SV-Nummer'); end if;
if v_person ? 'nationality' and (v_person->>'nationality') <> v_old.nationality then v_person_changes := array_append(v_person_changes, 'Staatsbürgerschaft'); end if;
if v_person ? 'address' and coalesce(v_person->>'address','') <> coalesce(v_old.address,'') then v_person_changes := array_append(v_person_changes, 'Adresse'); end if;
if v_person ? 'address_country' and coalesce(v_person->>'address_country','') <> coalesce(v_old.address_country,'') then v_person_changes := array_append(v_person_changes, 'Land'); end if;
if v_person ? 'email' and (v_person->>'email') <> v_old.email then v_person_changes := array_append(v_person_changes, 'E-Mail'); end if;
if v_person ? 'phone' and coalesce(v_person->>'phone','') <> coalesce(v_old.phone,'') then v_person_changes := array_append(v_person_changes, 'Telefon'); end if;
if v_contract ? 'employment_type' and (v_contract->>'employment_type') <> v_old.employment_type::text then v_contract_changes := array_append(v_contract_changes, 'Beschäftigungsausmaß'); end if;
if v_contract ? 'weekly_hours' and (v_contract->>'weekly_hours')::numeric <> v_old.weekly_hours then v_contract_changes := array_append(v_contract_changes, 'Wochenstunden'); end if;
if v_contract ? 'contract_type' and (v_contract->>'contract_type') <> v_old.contract_type::text then v_contract_changes := array_append(v_contract_changes, 'Vertragsart'); end if;
if v_contract ? 'contract_end_date' and coalesce(nullif(v_contract->>'contract_end_date','')::date::text,'') <> coalesce(v_old.contract_end_date::text,'') then v_contract_changes := array_append(v_contract_changes, 'Befristet bis'); end if;
if v_immediate then
update employees set
first_name = coalesce(v_person->>'first_name', first_name),
last_name = coalesce(v_person->>'last_name', last_name),
gender = coalesce((v_person->>'gender')::gender_type, gender),
birth_date = coalesce((v_person->>'birth_date')::date, birth_date),
sv_nummer = coalesce(v_person->>'sv_nummer', sv_nummer),
nationality = coalesce(v_person->>'nationality', nationality),
address = coalesce(v_person->>'address', address),
address_country = coalesce(v_person->>'address_country', address_country),
email = coalesce(v_person->>'email', email),
phone = coalesce(v_person->>'phone', phone),
employment_type = coalesce((v_contract->>'employment_type')::employment_type, employment_type),
weekly_hours = coalesce((v_contract->>'weekly_hours')::numeric, weekly_hours),
contract_type = coalesce((v_contract->>'contract_type')::contract_type, contract_type),
contract_end_date = case when v_contract ? 'contract_end_date' then nullif(v_contract->>'contract_end_date','')::date else contract_end_date end
where id = v_employee_id;
elsif array_length(v_person_changes, 1) > 0 or array_length(v_contract_changes, 1) > 0 then
insert into pending_org_changes (employee_id, change_type, effective_date, payload)
values (v_employee_id, 'contract_change', v_effective_date, payload);
end if;
if array_length(v_person_changes, 1) > 0 then
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, v_effective_date, 'Stammdatenänderung', 'Geänderte Felder: ' || array_to_string(v_person_changes, ', ') || ', wirksam ab ' || v_effective_date);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Stammdatenänderung', v_name, v_employee_id, array_to_string(v_person_changes, ', ') || ', wirksam ab ' || v_effective_date);
end if;
if array_length(v_contract_changes, 1) > 0 then
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, v_effective_date, 'Vertragsänderung', 'Geänderte Felder: ' || array_to_string(v_contract_changes, ', ') || ', wirksam ab ' || v_effective_date);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Vertragsänderung', v_name, v_employee_id, array_to_string(v_contract_changes, ', ') || ', wirksam ab ' || v_effective_date);
end if;
end;
$$;
-- ── Reorganisation ───────────────────────────────────────────────
-- Immediate moves (effective_date <= today) behave exactly as before.
-- Future-dated scenarios write no employee mutations at all yet; one
-- pending_org_changes row per affected employee is queued instead, and
-- reorg_scenarios.applied stays false until every one of them is applied.
create or replace function apply_reorg(payload jsonb)
returns uuid language plpgsql as $$
declare
v_scenario_id uuid;
v_effective_date date := (payload->>'effective_date')::date;
v_immediate boolean := (payload->>'effective_date')::date <= current_date;
v_move jsonb;
v_employee_id text;
v_target_team_id uuid;
v_division_id uuid;
v_is_lead boolean;
v_manager uuid;
v_snapshot jsonb := '{}'::jsonb;
v_old record;
v_total_moves int := 0;
begin
perform require_hr_admin();
insert into reorg_scenarios (name, effective_date, created_by, applied, applied_at)
values (payload->>'name', v_effective_date, auth.uid(), v_immediate, case when v_immediate then now() else null end)
returning id into v_scenario_id;
for v_move in select * from jsonb_array_elements(payload->'moves')
loop
v_target_team_id := (v_move->>'target_team_id')::uuid;
select division_id into v_division_id from teams t join departments d on d.id = t.department_id where t.id = v_target_team_id;
insert into reorg_moves (scenario_id, kind, payload) values (v_scenario_id, v_move->>'kind', v_move);
for v_employee_id in select jsonb_array_elements_text(v_move->'employee_ids')
loop
select * into v_old from employees where id = v_employee_id::uuid;
v_snapshot := v_snapshot || jsonb_build_object(v_employee_id, jsonb_build_object(
'team_id', v_old.team_id, 'division_id', v_old.division_id, 'manager_id', v_old.manager_id
));
if v_immediate then
v_is_lead := v_old.is_lead;
v_manager := resolve_manager_for(v_target_team_id, v_is_lead, v_division_id);
update employees set team_id = v_target_team_id, manager_id = v_manager where id = v_employee_id::uuid;
else
insert into pending_org_changes (employee_id, change_type, effective_date, payload, reorg_scenario_id)
values (v_employee_id::uuid, 'reorg', v_effective_date,
jsonb_build_object('target_team_id', v_target_team_id), v_scenario_id);
end if;
insert into employee_history (employee_id, event_date, event_type, description, reorg_scenario_id)
values (v_employee_id::uuid, v_effective_date, 'Reorganisation',
'Reorganisation "' || (payload->>'name') || '": neues Team zugewiesen', v_scenario_id);
v_total_moves := v_total_moves + 1;
end loop;
end loop;
update reorg_scenarios set undo_snapshot = v_snapshot where id = v_scenario_id;
insert into audit_log (actor_user_id, actor_name, action, target_label, details)
values (auth.uid(), current_actor_name(), 'Reorganisation', payload->>'name', v_total_moves || ' Mitarbeiter:innen betroffen');
return v_scenario_id;
end;
$$;
-- ── Applies every due pending change ─────────────────────────────
-- Invoked by the /api/cron/apply-pending-changes route handler (Vercel
-- Cron, daily) using the service-role client — this is a system process,
-- not a user action, so it does not go through require_hr_admin(); it is
-- SECURITY DEFINER precisely so it can run outside any HR user's session.
create or replace function apply_due_pending_changes()
returns int
language plpgsql
security definer
set search_path = public
as $$
declare
v_rec record;
v_team_id uuid;
v_division_id uuid;
v_is_lead boolean;
v_manager uuid;
v_remaining int;
v_applied_count int := 0;
begin
for v_rec in
select * from pending_org_changes
where status = 'pending' and effective_date <= current_date
order by created_at
loop
if v_rec.change_type = 'transfer' then
select is_lead into v_is_lead from employees where id = v_rec.employee_id;
select division_id into v_division_id from teams t join departments d on d.id = t.department_id
where t.id = (v_rec.payload->>'new_team_id')::uuid;
v_manager := resolve_manager_for((v_rec.payload->>'new_team_id')::uuid, v_is_lead, v_division_id);
update employees set
team_id = (v_rec.payload->>'new_team_id')::uuid,
job_title = coalesce(nullif(v_rec.payload->>'new_title', ''), job_title),
manager_id = v_manager
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
where id = v_rec.employee_id;
elsif v_rec.change_type = 'karenz_return' then
select team_id, division_id, is_lead into v_team_id, v_division_id, v_is_lead
from employees where id = v_rec.employee_id;
v_manager := resolve_manager_for(v_team_id, v_is_lead, v_division_id);
update employees set
status = 'Aktiv',
karenz_return_date = null,
manager_id = v_manager,
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),
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),
contract_end_date = case when v_rec.payload->'contract' ? 'contract_end_date'
then nullif(v_rec.payload->'contract'->>'contract_end_date','')::date else contract_end_date end
where id = v_rec.employee_id;
elsif v_rec.change_type = 'reorg' then
select is_lead into v_is_lead from employees where id = v_rec.employee_id;
select division_id into v_division_id from teams t join departments d on d.id = t.department_id
where t.id = (v_rec.payload->>'target_team_id')::uuid;
v_manager := resolve_manager_for((v_rec.payload->>'target_team_id')::uuid, v_is_lead, v_division_id);
update employees set team_id = (v_rec.payload->>'target_team_id')::uuid, manager_id = v_manager
where id = v_rec.employee_id;
end if;
update pending_org_changes set status = 'applied', applied_at = now() where id = v_rec.id;
v_applied_count := v_applied_count + 1;
if v_rec.reorg_scenario_id is not null then
select count(*) into v_remaining from pending_org_changes
where reorg_scenario_id = v_rec.reorg_scenario_id and status = 'pending';
if v_remaining = 0 then
update reorg_scenarios set applied = true, applied_at = now() where id = v_rec.reorg_scenario_id;
end if;
end if;
end loop;
return v_applied_count;
end;
$$;
-- This bypasses RLS (SECURITY DEFINER) by design so the daily cron job can
-- run it without any HR user session — which means it must NOT be callable
-- by ordinary app roles (an authenticated HR user calling it early would
-- force-apply not-yet-due changes ahead of their effective date).
revoke execute on function apply_due_pending_changes() from public;
revoke execute on function apply_due_pending_changes() from anon;
revoke execute on function apply_due_pending_changes() from authenticated;
grant execute on function apply_due_pending_changes() to service_role;

View File

@@ -0,0 +1,59 @@
-- Make reorg undo respect employee_history's append-only contract
-- (spec §6.1: "append-only, keine normale
-- Update-/Delete-Funktion").
--
-- The previous undo_reorg deleted the employee_history rows a reorg had
-- created (functions.sql:549), which required a narrow RLS carve-out
-- (functions_3.sql's "history_delete_admin_reorg_undo" policy) allowing
-- hr_admin to delete reorg-tagged history rows. That is the one place in
-- the whole schema where history was not actually immutable. Fixed by
-- appending a compensating "Reorganisation rückgängig" history entry per
-- affected employee instead of deleting anything — the original
-- Reorganisation rows stay in the record, exactly like every other history
-- event type. The now-unused delete policy is dropped.
drop policy if exists "history_delete_admin_reorg_undo" on employee_history;
create or replace function undo_reorg(payload jsonb)
returns void language plpgsql as $$
declare
v_scenario record;
v_key text;
v_val jsonb;
v_name text;
v_count int := 0;
begin
perform require_hr_admin();
select * into v_scenario from reorg_scenarios where id = (payload->>'scenario_id')::uuid and applied = true;
if not found or v_scenario.undo_snapshot is null then
raise exception 'Reorganisation kann nicht rückgängig gemacht werden (kein Snapshot vorhanden).';
end if;
-- Any pending (not-yet-applied) deferred moves belonging to this scenario
-- are cancelled rather than left to fire later against a since-reverted
-- state.
update pending_org_changes set status = 'cancelled'
where reorg_scenario_id = v_scenario.id and status = 'pending';
for v_key, v_val in select * from jsonb_each(v_scenario.undo_snapshot)
loop
update employees set
team_id = nullif(v_val->>'team_id','')::uuid,
division_id = (v_val->>'division_id')::uuid,
manager_id = nullif(v_val->>'manager_id','')::uuid
where id = v_key::uuid;
select first_name || ' ' || last_name into v_name from employees where id = v_key::uuid;
insert into employee_history (employee_id, event_date, event_type, description, reorg_scenario_id)
values (v_key::uuid, current_date, 'Reorganisation',
'Reorganisation "' || v_scenario.name || '" rückgängig gemacht — vorheriges Team wiederhergestellt', v_scenario.id);
v_count := v_count + 1;
end loop;
update reorg_scenarios set applied = false where id = v_scenario.id;
insert into audit_log (actor_user_id, actor_name, action, target_label, details)
values (auth.uid(), current_actor_name(), 'Reorganisation rückgängig', v_scenario.name, v_count || ' Mitarbeiter:innen zurückgesetzt');
end;
$$;

View File

@@ -0,0 +1,33 @@
-- Performance indexes (spec §15).
--
-- Existing indexes already cover employees(team_id/division_id/manager_id/
-- status), employee_history(employee_id, event_date desc), positions(team_id/
-- status), audit_log(occurred_at desc), and the new pending_org_changes
-- table's own indexes (see its migration). This adds the gaps: location-based
-- filtering, entry/exit date range queries (dashboard "upcoming" widget +
-- Eintritte/Austritte reports), personnel-number lookup (also enforces the
-- uniqueness a personnel number should have, which the identity column
-- alone did not), reorg/audit foreign-key lookups, and trigram search
-- support for the employee list's free-text search box.
create unique index if not exists idx_employees_personnel_number on employees (personnel_number);
create index if not exists idx_employees_location_id on employees (location_id);
create index if not exists idx_employees_entry_date on employees (entry_date);
create index if not exists idx_employees_exit_date on employees (exit_date) where exit_date is not null;
create index if not exists idx_employees_karenz_return_date on employees (karenz_return_date) where karenz_return_date is not null;
create index if not exists idx_audit_log_target_employee_id on audit_log (target_employee_id) where target_employee_id is not null;
create index if not exists idx_employee_history_reorg_scenario_id on employee_history (reorg_scenario_id) where reorg_scenario_id is not null;
create index if not exists idx_employee_history_event_type on employee_history (event_type);
create index if not exists idx_reorg_scenarios_applied on reorg_scenarios (applied, applied_at desc);
create index if not exists idx_positions_division_id on positions (division_id);
-- Free-text search over name/title (Mitarbeiter:innen list search box) —
-- trigram index so ILIKE '%term%' queries can use an index instead of a
-- sequential scan.
create extension if not exists pg_trgm;
create index if not exists idx_employees_name_trgm
on employees using gin ((first_name || ' ' || last_name) gin_trgm_ops);
create index if not exists idx_employees_job_title_trgm
on employees using gin (job_title gin_trgm_ops);

View File

@@ -0,0 +1,25 @@
-- Explicit schema/table/sequence/routine grants for anon/authenticated/
-- service_role.
--
-- Found while standing up a local Supabase instance to run the integration
-- test suite (spec §17): every table in this schema
-- relies on RLS policies to restrict access, but RLS only ever *narrows*
-- what an already-GRANTed role may do — Postgres still checks ordinary
-- object privileges first, independent of a role's BYPASSRLS flag. On
-- Supabase's hosted platform this GRANT setup is applied automatically
-- during project provisioning, so it was invisible here: every previous
-- migration worked fine against the already-provisioned hosted project, but
-- the exact same migrations against a *fresh* Postgres (a new local dev
-- instance, a disaster-recovery restore, or CI) fail with "permission
-- denied for table X" even for the service role, since that grant was never
-- actually part of this repo's own migrations. Making it explicit here
-- makes the schema fully self-contained and reprovisionable from scratch.
grant usage on schema public to anon, authenticated, service_role;
grant all on all tables in schema public to anon, authenticated, service_role;
grant all on all sequences in schema public to anon, authenticated, service_role;
grant all on all routines in schema public to anon, authenticated, service_role;
alter default privileges in schema public grant all on tables to anon, authenticated, service_role;
alter default privileges in schema public grant all on sequences to anon, authenticated, service_role;
alter default privileges in schema public grant all on routines to anon, authenticated, service_role;

View File

@@ -0,0 +1,248 @@
-- Two data-integrity rules named explicitly in the spec (§3.7, §11 test scenarios #14 and #17) that had no enforcement
-- anywhere — not in the UI, not in a Server Action, not as a DB constraint:
--
-- 1. "Rückkehr darf nicht vor Beginn der Karenz liegen" — there was no
-- column tracking when a Karenz period actually started (only a
-- free-text history row), so this could not be validated at all.
-- 2. "Historieneinträge dürfen nicht vor dem Eintritt liegen" — nothing
-- stopped an employee_history row from being inserted with an
-- event_date earlier than that employee's entry_date.
-- ── 1. Track karenz_start_date; validate return against it ──────────
alter table employees add column if not exists karenz_start_date date;
create or replace function start_karenz(payload jsonb)
returns void language plpgsql as $$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_start_date date := (payload->>'karenz_start_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;
if v_start_date <= current_date then
update employees set status = 'Karenz', karenz_start_date = v_start_date,
karenz_return_date = (payload->>'planned_return_date')::date
where id = v_employee_id;
else
update employees set karenz_start_date = v_start_date where id = v_employee_id;
insert into pending_org_changes (employee_id, change_type, effective_date, payload)
values (v_employee_id, 'karenz_start', v_start_date,
jsonb_build_object('planned_return_date', payload->>'planned_return_date'));
end if;
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, v_start_date, 'Karenz',
'Karenzantritt, geplante Rückkehr am ' || (payload->>'planned_return_date') ||
case when payload->>'note' is not null and payload->>'note' <> '' then '' || (payload->>'note') else '' end);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Karenz', v_name, v_employee_id, 'Karenzantritt, geplante Rückkehr ' || (payload->>'planned_return_date'));
end;
$$;
create or replace function adjust_karenz_return(payload jsonb)
returns void language plpgsql as $$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_new_return_date date := (payload->>'new_return_date')::date;
v_karenz_start date;
v_name text;
begin
perform require_hr_admin();
select first_name || ' ' || last_name, karenz_start_date into v_name, v_karenz_start from employees where id = v_employee_id;
if v_karenz_start is not null and v_new_return_date <= v_karenz_start then
raise exception 'Das Rückkehrdatum muss nach dem Karenzbeginn (%) liegen.', v_karenz_start;
end if;
update employees set karenz_return_date = v_new_return_date where id = v_employee_id;
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, current_date, 'Karenz',
'Rückkehrdatum angepasst auf ' || (payload->>'new_return_date') ||
case when payload->>'note' is not null and payload->>'note' <> '' then '' || (payload->>'note') else '' end);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Karenz', v_name, v_employee_id, 'Neues Rückkehrdatum: ' || (payload->>'new_return_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_team_id uuid;
v_division_id uuid;
v_is_lead boolean;
v_manager uuid;
v_employment_type employment_type;
v_weekly_hours numeric;
v_karenz_start date;
begin
perform require_hr_admin();
select first_name || ' ' || last_name, team_id, division_id, is_lead, karenz_start_date
into v_name, v_team_id, v_division_id, v_is_lead, v_karenz_start
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 Karenzbeginn (%) 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
v_manager := resolve_manager_for(v_team_id, v_is_lead, v_division_id);
update employees set
status = 'Aktiv',
karenz_return_date = null,
karenz_start_date = null,
manager_id = v_manager,
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', 'Wiedereintritt aus Karenz am ' || (payload->>'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 ' || (payload->>'return_date'));
end;
$$;
-- apply_due_pending_changes: clear karenz_start_date once the deferred
-- return actually applies (mirrors the immediate branch above).
create or replace function apply_due_pending_changes()
returns int
language plpgsql
security definer
set search_path = public
as $$
declare
v_rec record;
v_team_id uuid;
v_division_id uuid;
v_is_lead boolean;
v_manager uuid;
v_remaining int;
v_applied_count int := 0;
begin
for v_rec in
select * from pending_org_changes
where status = 'pending' and effective_date <= current_date
order by created_at
loop
if v_rec.change_type = 'transfer' then
select is_lead into v_is_lead from employees where id = v_rec.employee_id;
select division_id into v_division_id from teams t join departments d on d.id = t.department_id
where t.id = (v_rec.payload->>'new_team_id')::uuid;
v_manager := resolve_manager_for((v_rec.payload->>'new_team_id')::uuid, v_is_lead, v_division_id);
update employees set
team_id = (v_rec.payload->>'new_team_id')::uuid,
job_title = coalesce(nullif(v_rec.payload->>'new_title', ''), job_title),
manager_id = v_manager
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
where id = v_rec.employee_id;
elsif v_rec.change_type = 'karenz_return' then
select team_id, division_id, is_lead into v_team_id, v_division_id, v_is_lead
from employees where id = v_rec.employee_id;
v_manager := resolve_manager_for(v_team_id, v_is_lead, v_division_id);
update employees set
status = 'Aktiv',
karenz_return_date = null,
karenz_start_date = null,
manager_id = v_manager,
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),
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),
contract_end_date = case when v_rec.payload->'contract' ? 'contract_end_date'
then nullif(v_rec.payload->'contract'->>'contract_end_date','')::date else contract_end_date end
where id = v_rec.employee_id;
elsif v_rec.change_type = 'reorg' then
select is_lead into v_is_lead from employees where id = v_rec.employee_id;
select division_id into v_division_id from teams t join departments d on d.id = t.department_id
where t.id = (v_rec.payload->>'target_team_id')::uuid;
v_manager := resolve_manager_for((v_rec.payload->>'target_team_id')::uuid, v_is_lead, v_division_id);
update employees set team_id = (v_rec.payload->>'target_team_id')::uuid, manager_id = v_manager
where id = v_rec.employee_id;
end if;
update pending_org_changes set status = 'applied', applied_at = now() where id = v_rec.id;
v_applied_count := v_applied_count + 1;
if v_rec.reorg_scenario_id is not null then
select count(*) into v_remaining from pending_org_changes
where reorg_scenario_id = v_rec.reorg_scenario_id and status = 'pending';
if v_remaining = 0 then
update reorg_scenarios set applied = true, applied_at = now() where id = v_rec.reorg_scenario_id;
end if;
end if;
end loop;
return v_applied_count;
end;
$$;
revoke execute on function apply_due_pending_changes() from public, anon, authenticated;
grant execute on function apply_due_pending_changes() to service_role;
-- ── 2. History entries may never predate the employee's entry date ───
create or replace function fn_check_history_not_before_entry()
returns trigger
language plpgsql
as $$
declare
v_entry_date date;
begin
select entry_date into v_entry_date from employees where id = new.employee_id;
if v_entry_date is not null and new.event_date < v_entry_date then
raise exception 'Historieneintrag (%) darf nicht vor dem Eintrittsdatum (%) liegen.', new.event_date, v_entry_date;
end if;
return new;
end;
$$;
drop trigger if exists trg_history_not_before_entry on employee_history;
create trigger trg_history_not_before_entry
before insert on employee_history
for each row execute function fn_check_history_not_before_entry();

View File

@@ -16,7 +16,6 @@ if (!SUPABASE_URL || !SERVICE_ROLE_KEY) {
}
const ADMIN_EMAIL = "m.stubhan@loudspring.at";
const MANAGER_TEST_EMAIL = "manager-test@test.manner.at";
const supabase = createClient(SUPABASE_URL, SERVICE_ROLE_KEY, {
auth: { autoRefreshToken: false, persistSession: false },
@@ -106,6 +105,17 @@ function addressCountryFor(nationality: string): string {
}
const STREETS = ["Hauptstraße", "Bahnhofstraße", "Schulgasse", "Kirchenplatz", "Gartenweg", "Industriestraße", "Ringstraße", "Feldweg"];
// Home address should be plausible for the employee's actual work location,
// not a one-size-fits-all Vienna postal code regardless of where they're
// based (found during the consolidation review).
const HOME_LOCALE_BY_LOCATION: Record<string, { city: string; postal: () => string }> = {
"Wien-Hernals": { city: "Wien", postal: () => String(randInt(1100, 1230)) },
Wolkersdorf: { city: "Wolkersdorf", postal: () => "2120" },
Köln: { city: "Köln", postal: () => String(randInt(50667, 51149)) },
Brünn: { city: "Brno", postal: () => `${randInt(600, 664)} ${randInt(10, 99)}` },
Ljubljana: { city: "Ljubljana", postal: () => "1000" },
};
const EXIT_REASONS = [
"Einvernehmliche Auflösung", "Kündigung AN", "Kündigung AG", "Befristungsablauf", "Pensionierung", "Entlassung",
];
@@ -321,7 +331,6 @@ type EmployeeRow = {
is_lead: boolean;
employment_type: "Vollzeit" | "Teilzeit";
weekly_hours: number;
monthly_salary_gross: number;
contract_type: "unbefristet" | "befristet";
contract_end_date: string | null;
paygrade: "A" | "B" | "C" | "D" | "E" | "F";
@@ -367,21 +376,13 @@ function birthDateForAge(age: number): Date {
return new Date(year, randInt(0, 11), randInt(1, 28));
}
function paygradeAndSalaryForIc(): { paygrade: EmployeeRow["paygrade"]; salary: number } {
const grade = weightedPick<EmployeeRow["paygrade"]>([
function paygradeForIc(): EmployeeRow["paygrade"] {
return weightedPick<EmployeeRow["paygrade"]>([
["A", 15],
["B", 35],
["C", 30],
["D", 20],
]);
const ranges: Record<string, [number, number]> = {
A: [2200, 2700],
B: [2600, 3300],
C: [3200, 4100],
D: [4000, 5200],
};
const [min, max] = ranges[grade];
return { paygrade: grade, salary: randInt(min, max) };
}
function newHireBase(jobTitle: string, orgLevel: number, isLead: boolean, teamId: string | null, divisionId: string, managerId: string | null) {
@@ -390,6 +391,7 @@ function newHireBase(jobTitle: string, orgLevel: number, isLead: boolean, teamId
const lastName = pick(LAST_NAMES);
const nationality = weightedPick(NATIONALITIES);
const location = weightedPick(LOCATION_WEIGHTS);
const homeLocale = HOME_LOCALE_BY_LOCATION[location.name];
return {
id: randomUUID(),
@@ -397,7 +399,7 @@ function newHireBase(jobTitle: string, orgLevel: number, isLead: boolean, teamId
last_name: lastName,
gender,
nationality,
address: `${pick(STREETS)} ${randInt(1, 90)}, ${randInt(1010, 2500)} Wien`,
address: `${pick(STREETS)} ${randInt(1, 90)}, ${homeLocale.postal()} ${homeLocale.city}`,
address_country: addressCountryFor(nationality),
email: makeEmail(firstName, lastName),
phone: `+43 664 ${randInt(1000000, 9999999)}`,
@@ -415,7 +417,7 @@ const employees: EmployeeRow[] = [];
const history: HistoryRow[] = [];
const icPoolForStatusAssignment: EmployeeRow[] = [];
function finalizeEmployee(base: ReturnType<typeof newHireBase>, opts: { salary: number; paygrade: EmployeeRow["paygrade"] }): EmployeeRow {
function finalizeEmployee(base: ReturnType<typeof newHireBase>, opts: { paygrade: EmployeeRow["paygrade"] }): EmployeeRow {
const age = randInt(22, 60);
const birthDate = birthDateForAge(age);
const maxTenureYears = Math.min(15, age - 20);
@@ -433,7 +435,6 @@ function finalizeEmployee(base: ReturnType<typeof newHireBase>, opts: { salary:
sv_nummer: makeSvNummer(birthDate),
employment_type: employmentType,
weekly_hours: weeklyHours,
monthly_salary_gross: opts.salary,
contract_type: isBefristet ? "befristet" : "unbefristet",
contract_end_date: contractEndDate ? isoDate(contractEndDate) : null,
paygrade: opts.paygrade,
@@ -461,16 +462,6 @@ function finalizeEmployee(base: ReturnType<typeof newHireBase>, opts: { salary:
description: `Beförderung im Rahmen der Laufbahnentwicklung, neue Position: ${row.job_title}`,
});
}
if (chance(0.08)) {
const adjDate = randomDateBetween(addDays(entryDate, 180), TODAY);
history.push({
employee_id: row.id,
event_date: isoDate(adjDate),
event_type: "Gehaltsanpassung",
description: "Jährliche Gehaltsanpassung im Rahmen der Kollektivvertragsrunde",
});
}
return row;
}
@@ -489,11 +480,11 @@ divisionRows.push({ id: gfDivisionId, org_number: "20900000", name: "Geschäftsf
const ceo = finalizeEmployee(
newHireBase("Geschäftsführer:in", 0, true, null, gfDivisionId, null),
{ salary: 15500, paygrade: "F" }
{ paygrade: "F" }
);
const gfAssistant = finalizeEmployee(
newHireBase("Assistenz der Geschäftsführung", 3, false, null, gfDivisionId, ceo.id),
{ salary: 3900, paygrade: "C" }
{ paygrade: "C" }
);
employees.push(ceo, gfAssistant);
@@ -509,7 +500,7 @@ for (const div of DIVISIONS) {
const divisionHead = finalizeEmployee(
newHireBase(div.headTitle, 1, true, null, divisionId, ceo.id),
{ salary: randInt(9000, 11500), paygrade: "F" }
{ paygrade: "F" }
);
employees.push(divisionHead);
@@ -529,16 +520,16 @@ for (const div of DIVISIONS) {
const teamLead = finalizeEmployee(
newHireBase(team.leadTitle, 2, true, teamId, divisionId, divisionHead.id),
{ salary: randInt(5000, 6800), paygrade: "E" }
{ paygrade: "E" }
);
employees.push(teamLead);
for (let i = 0; i < size - 1; i++) {
const jobTitle = pick(team.icTitles);
const { paygrade, salary } = paygradeAndSalaryForIc();
const paygrade = paygradeForIc();
const ic = finalizeEmployee(
newHireBase(jobTitle, 3, false, teamId, divisionId, teamLead.id),
{ salary, paygrade }
{ paygrade }
);
employees.push(ic);
icPoolForStatusAssignment.push(ic);
@@ -586,12 +577,22 @@ for (let i = 0; i < 12 && cursor < shuffledIcs.length; i++, cursor++) {
});
}
// ~3 Geplant (future entry — overwrite entry_date/history)
// ~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;
}
@@ -666,40 +667,30 @@ async function main() {
console.log(`Seeding ${positions.length} open positions...`);
await insertInChunks("positions", positions);
console.log("Creating hr_admin account...");
const adminPassword = randomUUID().slice(0, 12) + "!Aa1";
const { data: adminUser, error: adminErr } = await supabase.auth.admin.createUser({
// 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: adminPassword,
password: hrPassword,
email_confirm: true,
});
if (adminErr) throw new Error(`Creating admin user failed: ${adminErr.message}`);
if (hrErr) throw new Error(`Creating HR user failed: ${hrErr.message}`);
await supabase.from("profiles").insert({
id: adminUser.user.id,
id: hrUser.user.id,
email: ADMIN_EMAIL,
full_name: "Maximilian Stubhan",
role: "hr_admin",
});
console.log("Creating manager test account...");
const managerPassword = randomUUID().slice(0, 12) + "!Bb2";
const { data: managerUser, error: managerErr } = await supabase.auth.admin.createUser({
email: MANAGER_TEST_EMAIL,
password: managerPassword,
email_confirm: true,
});
if (managerErr) throw new Error(`Creating manager test user failed: ${managerErr.message}`);
await supabase.from("profiles").insert({
id: managerUser.user.id,
email: MANAGER_TEST_EMAIL,
full_name: "Test Manager",
role: "manager",
role: "hr",
is_active: true,
});
console.log("\nDone.");
console.log(`hr_admin login: ${ADMIN_EMAIL} / ${adminPassword}`);
console.log(`manager login: ${MANAGER_TEST_EMAIL} / ${managerPassword}`);
console.log("(Passwords are shown once here only — store them somewhere safe.)");
console.log(`HR login: ${ADMIN_EMAIL} / ${hrPassword}`);
console.log("(Password is shown once here only — store it somewhere safe.)");
}
main().catch((err) => {