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());
|
||||
Reference in New Issue
Block a user