-- 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());