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

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

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

View File

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