340 lines
15 KiB
PL/PgSQL
340 lines
15 KiB
PL/PgSQL
-- 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')
|
|
);
|