-- Org-assignment history, so the Organigramm can be shown as of any date. -- -- Until now `employees` carried only the *current* placement (manager_id, -- team_id, division_id, job_title, is_lead, org_level) and every mutation -- overwrote it in place. employee_history recorded that something happened, -- but only as free text — no old/new values — so a past reporting line was -- gone for good. Forward-looking dates already worked (pending_org_changes -- holds not-yet-due changes structurally); it was the past that could not be -- reconstructed. This migration adds the missing timeline. -- -- Captured by a trigger rather than by editing the mutating RPCs: there are -- ~70 `update employees` statements spread over fifteen migrations (several -- of which redefine the same function repeatedly), so per-RPC bookkeeping -- would miss paths today and again with every future RPC. A trigger on the -- table catches all of them, including ones not written yet. create table employee_assignments ( id uuid primary key default gen_random_uuid(), employee_id uuid not null references employees(id) on delete cascade, manager_id uuid references employees(id) on delete set null, team_id uuid references teams(id), division_id uuid not null references divisions(id), job_title text not null, is_lead boolean not null default false, org_level int not null, valid_from date not null, -- Exclusive upper bound; null means "still in force". Note this tracks -- *placement*, not employment: the row of someone who has left stays open, -- because whether they were employed on a given date is derived separately -- from entry/exit/karenz dates. Keeping the two apart is what lets a -- rehire reuse the same open row instead of needing it reopened. valid_to date, created_at timestamptz not null default now(), constraint chk_assignment_range check (valid_to is null or valid_to > valid_from) ); create index on employee_assignments (employee_id, valid_from desc); create index on employee_assignments (valid_from); -- At most one open interval per employee — the invariant the trigger relies -- on when it looks up "the current row" to close. create unique index employee_assignments_one_open on employee_assignments (employee_id) where valid_to is null; alter table employee_assignments enable row level security; create policy "employee_assignments_hr_read" on employee_assignments for select using (is_hr_user()); -- ── Backfill ─────────────────────────────────────────────────────── -- One open interval per employee, starting at their entry date, holding -- today's placement. Changes made *before* this migration are not -- recoverable — employee_history never stored structured old values — so a -- Stichtag before today's date shows the placement as it stands now for -- anyone whose assignment predates this table. Everything from here on is -- exact. insert into employee_assignments (employee_id, manager_id, team_id, division_id, job_title, is_lead, org_level, valid_from) select id, manager_id, team_id, division_id, job_title, is_lead, org_level, entry_date from employees; -- ── Trigger ──────────────────────────────────────────────────────── -- The date a change takes effect is normally the day it is written: an RPC -- with a future effective date does not touch `employees` at all (it queues -- into pending_org_changes, and apply_due_pending_changes writes it on the -- due date), and one dated today writes immediately. A *backdated* change is -- the exception — it writes immediately although it should take effect -- earlier — so callers may set `app.effective_date` for the transaction to -- say so explicitly; unset, it falls back to the current date. create or replace function fn_track_employee_assignment() returns trigger language plpgsql security definer set search_path = public as $$ declare v_date date := coalesce(nullif(current_setting('app.effective_date', true), '')::date, current_date); v_open_from date; begin if tg_op = 'INSERT' then insert into employee_assignments (employee_id, manager_id, team_id, division_id, job_title, is_lead, org_level, valid_from) values (new.id, new.manager_id, new.team_id, new.division_id, new.job_title, new.is_lead, new.org_level, coalesce(new.entry_date, v_date)); return new; end if; if new.manager_id is not distinct from old.manager_id and new.team_id is not distinct from old.team_id and new.division_id is not distinct from old.division_id and new.job_title is not distinct from old.job_title and new.is_lead is not distinct from old.is_lead and new.org_level is not distinct from old.org_level then return new; end if; select valid_from into v_open_from from employee_assignments where employee_id = new.id and valid_to is null; if v_open_from is null then insert into employee_assignments (employee_id, manager_id, team_id, division_id, job_title, is_lead, org_level, valid_from) values (new.id, new.manager_id, new.team_id, new.division_id, new.job_title, new.is_lead, new.org_level, v_date); elsif v_date <= v_open_from then -- Two changes on the same day (or a backdate landing inside the open -- interval): overwrite in place, so no zero-length or inverted interval -- is ever stored. update employee_assignments set manager_id = new.manager_id, team_id = new.team_id, division_id = new.division_id, job_title = new.job_title, is_lead = new.is_lead, org_level = new.org_level where employee_id = new.id and valid_to is null; else update employee_assignments set valid_to = v_date where employee_id = new.id and valid_to is null; insert into employee_assignments (employee_id, manager_id, team_id, division_id, job_title, is_lead, org_level, valid_from) values (new.id, new.manager_id, new.team_id, new.division_id, new.job_title, new.is_lead, new.org_level, v_date); end if; return new; end; $$; drop trigger if exists trg_track_employee_assignment on employees; create trigger trg_track_employee_assignment after insert or update on employees for each row execute function fn_track_employee_assignment(); grant all on table employee_assignments to anon, authenticated, service_role;