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:
360
supabase/migrations/20260601000000_initial_schema.sql
Normal file
360
supabase/migrations/20260601000000_initial_schema.sql
Normal 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());
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
$$;
|
||||
29
supabase/migrations/20260601000300_start_karenz_function.sql
Normal file
29
supabase/migrations/20260601000300_start_karenz_function.sql
Normal 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;
|
||||
$$;
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
$$;
|
||||
172
supabase/migrations/20260714120000_hr_only_access.sql
Normal file
172
supabase/migrations/20260714120000_hr_only_access.sql
Normal 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;
|
||||
38
supabase/migrations/20260714120050_pending_org_changes.sql
Normal file
38
supabase/migrations/20260714120050_pending_org_changes.sql
Normal 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());
|
||||
125
supabase/migrations/20260714120100_salary_deprecation.sql
Normal file
125
supabase/migrations/20260714120100_salary_deprecation.sql
Normal 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;
|
||||
$$;
|
||||
387
supabase/migrations/20260714120200_effective_dating_rpcs.sql
Normal file
387
supabase/migrations/20260714120200_effective_dating_rpcs.sql
Normal 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;
|
||||
@@ -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;
|
||||
$$;
|
||||
33
supabase/migrations/20260714120400_performance_indexes.sql
Normal file
33
supabase/migrations/20260714120400_performance_indexes.sql
Normal 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);
|
||||
25
supabase/migrations/20260714120500_default_grants.sql
Normal file
25
supabase/migrations/20260714120500_default_grants.sql
Normal 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;
|
||||
248
supabase/migrations/20260714120600_data_integrity_guards.sql
Normal file
248
supabase/migrations/20260714120600_data_integrity_guards.sql
Normal 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();
|
||||
Reference in New Issue
Block a user