feat(t-paliad-148) commit 1/6: migration 057 — schema + backfill + user_project_authority_level

Adds paliad.users.profession (firm-wide career tier) and paliad.project_teams.responsibility
(per-project responsibility, default 'member'). Backfills both from the legacy
project_teams.role column — highest-tier-per-user for profession, single-row map
for responsibility (lead→lead, observer→observer, local_counsel/expert→external,
others→member).

Updates paliad.approval_role_level to recognise 'partner' as the new ceiling
(replaces 'lead' as the firm-tier ceiling), keeping 'lead' at level 5 as a
deprecated-shadow row until follow-up migration 058 retires project_teams.role.

Updates paliad.approval_role_from_unit_role: lead → partner.

Creates paliad.user_project_authority_level(user_id, project_id) — the
tuple-with-gate ladder. Returns profession_level if responsibility ∈ {lead,member}
else 0; max with derived authority via partner-unit attachments where
derive_grants_authority=true.

Updates approval_policies.required_role + approval_requests.required_role CHECK
constraints (drop 'lead', add 'partner'); backfills any existing rows.

Rewrites project_partner_units write RLS policy to read pt.responsibility='lead'
instead of pt.role='lead'.

Live-DB BEGIN/ROLLBACK dry-run verified: 2 users get profession='partner'
(matthias.siebels, tester@hlc.de — the only users currently on project_teams),
45 users get profession=NULL (admin fills via /admin/team).

project_teams.role kept as deprecated shadow column. Drop in follow-up migration 058.
This commit is contained in:
m
2026-05-07 21:39:56 +02:00
parent 1eb43ceb6b
commit ab2530ff44
2 changed files with 463 additions and 0 deletions

View File

@@ -0,0 +1,124 @@
-- Reverse of 057_profession_vs_responsibility.up.sql.
--
-- Best-effort rollback. The new columns are dropped; the legacy
-- project_teams.role column is re-derived from (responsibility, profession).
-- Down-migration loses information on edges:
-- * external responsibility → role='local_counsel' (loses expert distinction)
-- * member + profession=partner → role='of_counsel' (no legacy 'partner'
-- existed in project_teams.role; closest legacy ceiling)
-- * member + profession=paralegal → role='pa' (no legacy paralegal)
-- * member + profession=NULL → role='associate' (safe default, matches
-- the legacy RoleAssociate default)
-- These edges are documented; if the down is run on real production data,
-- review per-row before commit.
-- ============================================================================
-- 1. Restore approval_role_level to point at legacy ladder values.
-- ============================================================================
CREATE OR REPLACE FUNCTION paliad.approval_role_level(role text)
RETURNS int LANGUAGE SQL IMMUTABLE AS $$
SELECT CASE role
WHEN 'lead' THEN 5
WHEN 'of_counsel' THEN 4
WHEN 'associate' THEN 3
WHEN 'senior_pa' THEN 2
WHEN 'pa' THEN 1
ELSE 0
END
$$;
-- ============================================================================
-- 2. Restore approval_role_from_unit_role lead → lead.
-- ============================================================================
CREATE OR REPLACE FUNCTION paliad.approval_role_from_unit_role(unit_role text)
RETURNS text LANGUAGE SQL IMMUTABLE AS $$
SELECT CASE unit_role
WHEN 'lead' THEN 'lead'
WHEN 'attorney' THEN 'associate'
WHEN 'senior_pa' THEN 'senior_pa'
WHEN 'pa' THEN 'pa'
ELSE 'observer'
END
$$;
-- ============================================================================
-- 3. Re-derive project_teams.role from (responsibility, profession).
-- ============================================================================
UPDATE paliad.project_teams pt
SET role = CASE
WHEN pt.responsibility = 'lead' THEN 'lead'
WHEN pt.responsibility = 'observer' THEN 'observer'
WHEN pt.responsibility = 'external' THEN 'local_counsel'
ELSE COALESCE(
(SELECT CASE u.profession
WHEN 'partner' THEN 'of_counsel' -- best-effort: no legacy 'partner' role
WHEN 'of_counsel' THEN 'of_counsel'
WHEN 'associate' THEN 'associate'
WHEN 'senior_pa' THEN 'senior_pa'
WHEN 'pa' THEN 'pa'
WHEN 'paralegal' THEN 'pa' -- closest legacy fit
END
FROM paliad.users u WHERE u.id = pt.user_id),
'associate'
)
END;
-- ============================================================================
-- 4. Restore approval_policies + approval_requests CHECK constraints.
-- ============================================================================
UPDATE paliad.approval_policies
SET required_role = 'lead'
WHERE required_role = 'partner';
ALTER TABLE paliad.approval_policies DROP CONSTRAINT IF EXISTS approval_policies_required_role_check;
ALTER TABLE paliad.approval_policies ADD CONSTRAINT approval_policies_required_role_check
CHECK (required_role IN ('lead', 'of_counsel', 'associate', 'senior_pa', 'pa'));
UPDATE paliad.approval_requests
SET required_role = 'lead'
WHERE required_role = 'partner';
ALTER TABLE paliad.approval_requests DROP CONSTRAINT IF EXISTS approval_requests_required_role_check;
ALTER TABLE paliad.approval_requests ADD CONSTRAINT approval_requests_required_role_check
CHECK (required_role IN ('lead', 'of_counsel', 'associate', 'senior_pa', 'pa'));
-- ============================================================================
-- 5. Restore project_partner_units RLS to read pt.role = 'lead'.
-- ============================================================================
DROP POLICY IF EXISTS project_partner_units_write ON paliad.project_partner_units;
CREATE POLICY project_partner_units_write
ON paliad.project_partner_units FOR ALL
USING (
EXISTS (SELECT 1 FROM paliad.users u
WHERE u.id = auth.uid() AND u.global_role = 'global_admin')
OR EXISTS (SELECT 1 FROM paliad.project_teams pt
WHERE pt.user_id = auth.uid()
AND pt.project_id = project_partner_units.project_id
AND pt.role = 'lead')
)
WITH CHECK (
EXISTS (SELECT 1 FROM paliad.users u
WHERE u.id = auth.uid() AND u.global_role = 'global_admin')
OR EXISTS (SELECT 1 FROM paliad.project_teams pt
WHERE pt.user_id = auth.uid()
AND pt.project_id = project_partner_units.project_id
AND pt.role = 'lead')
);
-- ============================================================================
-- 6. Drop the new function and columns.
-- ============================================================================
DROP FUNCTION IF EXISTS paliad.user_project_authority_level(uuid, uuid);
DROP INDEX IF EXISTS paliad.project_teams_responsibility_idx;
ALTER TABLE paliad.project_teams DROP COLUMN IF EXISTS responsibility;
DROP INDEX IF EXISTS paliad.users_profession_idx;
ALTER TABLE paliad.users DROP COLUMN IF EXISTS profession;

View File

@@ -0,0 +1,339 @@
-- t-paliad-148: split paliad.project_teams.role into firm-level profession
-- and project-level responsibility.
--
-- Design: docs/design-profession-vs-project-role-2026-05-07.md (kepler,
-- m-locked 2026-05-07 21:35).
--
-- The legacy column did two jobs at once:
-- - career tier at the firm (PA, Associate, Of Counsel, …)
-- - responsibility on this matter (Lead, Member, Observer)
-- This migration introduces two clean axes and backfills both from the
-- legacy column. The legacy column is kept as a deprecated shadow for one
-- release; a follow-up migration drops it after Go code has fully
-- migrated and the production data is verified clean.
--
-- Day-1 deploy = zero behaviour change because the new code paths read
-- the new columns and the backfill is run inside this migration.
--
-- Sections:
-- 1. ALTER paliad.users ADD COLUMN profession.
-- 2. ALTER paliad.project_teams ADD COLUMN responsibility.
-- 3. Backfill profession from highest legacy project_teams.role per user.
-- 4. Backfill responsibility from legacy project_teams.role.
-- 5. UPDATE paliad.approval_policies.required_role CHECK + 'lead' → 'partner'.
-- 6. UPDATE paliad.approval_requests.required_role CHECK + 'lead' → 'partner'.
-- 7. UPDATE paliad.approval_role_from_unit_role: lead → partner.
-- 8. CREATE paliad.user_project_authority_level — tuple-with-gate ladder.
-- 9. CASCADE-rebuild paliad.project_partner_units RLS policies that
-- reference pt.role = 'lead'.
-- 10. UPDATE COMMENT on paliad.approval_role_level pointing at users.profession.
-- ============================================================================
-- 1. paliad.users.profession — firm-wide career tier.
--
-- NULL means "no firm tier" (external local counsel, expert, admin
-- accounts that aren't practicing lawyers). NULL → ladder level 0 →
-- ineligible to approve. Required-on-invite for firm members; admin
-- editable on /admin/team.
-- ============================================================================
ALTER TABLE paliad.users
ADD COLUMN profession text NULL
CHECK (profession IS NULL OR profession IN (
'partner', 'of_counsel', 'associate',
'senior_pa', 'pa', 'paralegal'
));
CREATE INDEX users_profession_idx ON paliad.users (profession);
COMMENT ON COLUMN paliad.users.profession IS
'Firm-wide career tier driving the t-paliad-138 approval ladder. '
'NULL = no firm tier (external collaborators, admin accounts). '
'Distinct from job_title (free-text display) and global_role (tool admin gate).';
-- ============================================================================
-- 2. paliad.project_teams.responsibility — per-project responsibility.
--
-- Replaces the project-axis values that were mixed into project_teams.role.
-- Default 'member'. 'lead' has additional manage-project privileges (already
-- wired in derivation_service.go). 'observer' and 'external' close the
-- approval gate (level 0 regardless of profession).
--
-- The legacy `role` column is kept on the table as a deprecated shadow for
-- one release. New code reads .responsibility; old code paths that still
-- read .role continue working until the follow-up migration drops the
-- column.
-- ============================================================================
ALTER TABLE paliad.project_teams
ADD COLUMN responsibility text NOT NULL DEFAULT 'member'
CHECK (responsibility IN ('lead', 'member', 'observer', 'external'));
CREATE INDEX project_teams_responsibility_idx
ON paliad.project_teams (project_id, responsibility);
COMMENT ON COLUMN paliad.project_teams.responsibility IS
'Per-project responsibility on this matter. lead/member open the '
'approval gate; observer/external close it. Profession provides the '
'level (paliad.users.profession).';
COMMENT ON COLUMN paliad.project_teams.role IS
'DEPRECATED — split into users.profession + project_teams.responsibility '
'in migration 057 (t-paliad-148). Kept as a shadow column for one release. '
'Drop in follow-up migration 058.';
-- ============================================================================
-- 3. Backfill paliad.users.profession from highest legacy tier per user.
--
-- Mapping (legacy role → profession):
-- lead → partner
-- of_counsel → of_counsel
-- associate → associate
-- senior_pa → senior_pa
-- pa → pa
-- local_counsel/expert/observer → IGNORED (no firm tier inferable)
--
-- For each user with at least one project_teams row carrying a firm-tier
-- value, take the HIGHEST tier (per the t-138 ladder). Ties at same tier
-- collapse trivially (same value). Users with only project-only labels
-- (observer / local_counsel / expert) get profession=NULL — admin will
-- need to fill them in via /admin/team.
-- ============================================================================
WITH legacy_to_profession AS (
SELECT pt.user_id,
CASE pt.role
WHEN 'lead' THEN 'partner'
WHEN 'of_counsel' THEN 'of_counsel'
WHEN 'associate' THEN 'associate'
WHEN 'senior_pa' THEN 'senior_pa'
WHEN 'pa' THEN 'pa'
-- observer / local_counsel / expert → NULL (filtered below)
END AS profession,
CASE pt.role
WHEN 'lead' THEN 5
WHEN 'of_counsel' THEN 4
WHEN 'associate' THEN 3
WHEN 'senior_pa' THEN 2
WHEN 'pa' THEN 1
ELSE 0
END AS lvl
FROM paliad.project_teams pt
),
ranked AS (
SELECT user_id, profession,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY lvl DESC) AS rn
FROM legacy_to_profession
WHERE profession IS NOT NULL
)
UPDATE paliad.users u
SET profession = r.profession
FROM ranked r
WHERE u.id = r.user_id
AND r.rn = 1
AND u.profession IS NULL;
-- ============================================================================
-- 4. Backfill paliad.project_teams.responsibility from legacy role.
--
-- Per-row mapping:
-- lead → lead
-- observer → observer
-- local_counsel → external
-- expert → external
-- associate / pa / of_counsel / senior_pa → member
--
-- Authority for "member" rows now comes from the user's profession.
-- ============================================================================
UPDATE paliad.project_teams
SET responsibility = CASE role
WHEN 'lead' THEN 'lead'
WHEN 'observer' THEN 'observer'
WHEN 'local_counsel' THEN 'external'
WHEN 'expert' THEN 'external'
ELSE 'member'
END;
-- ============================================================================
-- 5. paliad.approval_policies.required_role — drop 'lead', add 'partner'.
--
-- Legacy 'lead' was the project-level value at the ladder ceiling; under the
-- new model the ceiling is profession='partner'. Backfill any existing
-- policy rows from 'lead' to 'partner', then tighten the CHECK.
-- ============================================================================
UPDATE paliad.approval_policies
SET required_role = 'partner'
WHERE required_role = 'lead';
ALTER TABLE paliad.approval_policies DROP CONSTRAINT IF EXISTS approval_policies_required_role_check;
ALTER TABLE paliad.approval_policies ADD CONSTRAINT approval_policies_required_role_check
CHECK (required_role IN ('partner', 'of_counsel', 'associate', 'senior_pa', 'pa'));
-- ============================================================================
-- 6. paliad.approval_requests.required_role — same rename for snapshots.
--
-- Each request snapshots the policy's required_role at submission time so
-- mid-flight policy edits don't change the bar. Backfill 'lead' → 'partner'
-- for parity with the new policy enum, then tighten the CHECK.
-- ============================================================================
UPDATE paliad.approval_requests
SET required_role = 'partner'
WHERE required_role = 'lead';
ALTER TABLE paliad.approval_requests DROP CONSTRAINT IF EXISTS approval_requests_required_role_check;
ALTER TABLE paliad.approval_requests ADD CONSTRAINT approval_requests_required_role_check
CHECK (required_role IN ('partner', 'of_counsel', 'associate', 'senior_pa', 'pa'));
-- ============================================================================
-- 7. paliad.approval_role_from_unit_role — bridge maps lead → partner now.
--
-- Derived authority via partner-unit attachments (t-paliad-139) bridges
-- unit_role to the project-tier ladder. Under the new ladder, the highest
-- tier is 'partner' (was 'lead'). Update the lead → lead row to lead →
-- partner; the rest of the bridge mapping stays unchanged.
-- ============================================================================
CREATE OR REPLACE FUNCTION paliad.approval_role_from_unit_role(unit_role text)
RETURNS text LANGUAGE SQL IMMUTABLE AS $$
SELECT CASE unit_role
WHEN 'lead' THEN 'partner'
WHEN 'attorney' THEN 'associate'
WHEN 'senior_pa' THEN 'senior_pa'
WHEN 'pa' THEN 'pa'
ELSE 'observer'
END
$$;
-- Update the level helper too: 'partner' replaces 'lead' as the ceiling
-- value that the function recognises. The numeric ladder is identical;
-- only the named tier shifts.
CREATE OR REPLACE FUNCTION paliad.approval_role_level(role text)
RETURNS int LANGUAGE SQL IMMUTABLE AS $$
SELECT CASE role
WHEN 'partner' THEN 5
WHEN 'of_counsel' THEN 4
WHEN 'associate' THEN 3
WHEN 'senior_pa' THEN 2
WHEN 'pa' THEN 1
WHEN 'paralegal' THEN 0
-- Legacy 'lead' kept at level 5 for the deprecated-shadow window:
-- old call sites that still read pt.role would otherwise return
-- level 0 and break authority for projects where the migration
-- has run but the Go redirect hasn't. Removed in migration 058.
WHEN 'lead' THEN 5
ELSE 0
END
$$;
COMMENT ON FUNCTION paliad.approval_role_level(text) IS
'Strict-ladder level for the t-paliad-138 / t-paliad-148 approval gate. '
'Reads paliad.users.profession; legacy project_teams.role values still '
'recognised via the lead→5 shadow row until migration 058 retires the '
'column. Higher level always satisfies lower; level 0 = ineligible.';
-- ============================================================================
-- 8. paliad.user_project_authority_level — tuple-with-gate ladder.
--
-- effective_level for user U on project P:
--
-- profession_level = approval_role_level(U.profession) -- 0 if NULL
-- responsibility = direct or ancestor on project P
-- gate_open = responsibility IN ('lead', 'member')
-- derived_role = approval_role_from_unit_role(unit_role)
-- when project_partner_units.derive_grants_authority
-- effective_level = MAX over sources, gated as above
--
-- Direct/ancestor responsibility opens the gate, profession provides the
-- level. Derivation is its own source — derived authority always opens
-- its own gate (the unit attachment's grants_authority flag is the gate).
-- A user can hit this function via direct membership AND derivation; the
-- result is the max of both sources.
-- ============================================================================
CREATE OR REPLACE FUNCTION paliad.user_project_authority_level(
_user_id uuid,
_project_id uuid
) RETURNS int
LANGUAGE sql
STABLE
AS $$
WITH path AS (
SELECT string_to_array(p.path, '.')::uuid[] AS ids
FROM paliad.projects p WHERE p.id = _project_id
),
direct_or_ancestor AS (
SELECT pt.responsibility
FROM paliad.project_teams pt
JOIN path ON pt.project_id = ANY(path.ids)
WHERE pt.user_id = _user_id
),
profession_level AS (
SELECT paliad.approval_role_level(u.profession) AS lvl
FROM paliad.users u WHERE u.id = _user_id
),
direct_level AS (
-- Profession-level if any membership row opens the gate, else 0.
SELECT CASE
WHEN EXISTS (
SELECT 1 FROM direct_or_ancestor doa
WHERE doa.responsibility IN ('lead', 'member')
) THEN COALESCE((SELECT lvl FROM profession_level), 0)
ELSE 0
END AS lvl
),
derived_level AS (
SELECT COALESCE(MAX(paliad.approval_role_level(
paliad.approval_role_from_unit_role(pum.unit_role)
)), 0) AS lvl
FROM paliad.project_partner_units ppu
JOIN paliad.partner_unit_members pum
ON pum.partner_unit_id = ppu.partner_unit_id
AND pum.user_id = _user_id
AND pum.unit_role = ANY(ppu.derive_unit_roles)
JOIN path ON ppu.project_id = ANY(path.ids)
WHERE ppu.derive_grants_authority = true
)
SELECT GREATEST(
(SELECT lvl FROM direct_level),
(SELECT lvl FROM derived_level)
);
$$;
COMMENT ON FUNCTION paliad.user_project_authority_level(uuid, uuid) IS
'Effective approval-ladder level for user U on project P, evaluated as '
'a tuple-with-gate: profession_level if responsibility ∈ {lead,member} '
'else 0; max with derived authority (partner-unit attachment with '
'grants_authority=true). t-paliad-148.';
-- ============================================================================
-- 9. paliad.project_partner_units RLS — switch lead-gate to .responsibility.
--
-- Migration 055 wrote two policies that gate writes on pt.role = 'lead'.
-- Under the new model, lead is a project responsibility, not a profession.
-- Drop and rewrite both policies to read .responsibility.
-- ============================================================================
DROP POLICY IF EXISTS project_partner_units_write ON paliad.project_partner_units;
CREATE POLICY project_partner_units_write
ON paliad.project_partner_units FOR ALL
USING (
EXISTS (SELECT 1 FROM paliad.users u
WHERE u.id = auth.uid() AND u.global_role = 'global_admin')
OR EXISTS (SELECT 1 FROM paliad.project_teams pt
WHERE pt.user_id = auth.uid()
AND pt.project_id = project_partner_units.project_id
AND pt.responsibility = 'lead')
)
WITH CHECK (
EXISTS (SELECT 1 FROM paliad.users u
WHERE u.id = auth.uid() AND u.global_role = 'global_admin')
OR EXISTS (SELECT 1 FROM paliad.project_teams pt
WHERE pt.user_id = auth.uid()
AND pt.project_id = project_partner_units.project_id
AND pt.responsibility = 'lead')
);