refactor(approvals/t-paliad-160 slice3 / M2): drop required_role column

Cleanup of the t-paliad-160 dual-read shim. After slice 1+2 every writer
hits both `required_role` and the new `(requires_approval, min_role)`
columns, and every reader prefers the new ones. M2 (migration 065) drops
the legacy column from `paliad.approval_policies` and rewrites
`paliad.approval_policy_effective()` to a 4-column return shape.

`paliad.approval_requests.required_role` is intentionally untouched —
that's the in-flight policy snapshot at submission time, a separate
concern from the policy authoring grammar.

Go side:
  - models.ApprovalPolicy and models.EffectivePolicy lose RequiredRole.
    The MinRole pointer is now the only seniority-threshold surface.
  - LookupPolicy / GetEffectivePolicyOne / List* / snapshotProjectRows
    drop the required_role SELECT projection.
  - UpsertProjectPolicySplit / UpsertUnitPolicySplit /
    DeleteProjectPolicy / DeleteUnitPolicy / ApplyMatrixToDescendants
    drop the required_role write. The audit-log row still uses the
    legacy string format ('partner|...|none'); composed via
    legacyFromSplit() from the new columns so the audit table layout
    keeps working without a parallel migration.
  - submit() reads policy.MinRole directly (LookupPolicy guarantees
    non-nil when a non-nil policy is returned).
  - nullToPtr helper retired (no remaining callers).

Frontend side:
  - admin-approval-policies.ts UnitPolicy / EffectivePolicy lose the
    legacy required_role optional. The 2-control UI was already on the
    split-grammar path.
  - deadlines-new.ts + appointments-new.ts form-time hint readers prefer
    requires_approval+min_role. They keep a soft-fall back to the
    legacy required_role for one cycle in case any cached pre-M2 server
    is still serving the old shape — that path is dead-code post-deploy
    and can be dropped later.

Test:
  - TestApprovalService_PolicyCRUD asserts MinRole instead of
    RequiredRole after re-upsert.

Build: bun build OK, go build ./... OK, go test ./... OK.

Deploy ordering: this slice MUST land after slice 2 is merged so the
pre-deploy code paths that still reference required_role have already
been retired.
This commit is contained in:
m
2026-05-08 17:15:05 +02:00
parent 073af975f7
commit aec6cf6104
8 changed files with 378 additions and 108 deletions

View File

@@ -0,0 +1,102 @@
-- Reverse t-paliad-160 M2: re-add the required_role column on
-- paliad.approval_policies and re-introduce the dual-read function shape
-- from migration 064.
ALTER TABLE paliad.approval_policies
ADD COLUMN required_role text;
UPDATE paliad.approval_policies
SET required_role = CASE
WHEN requires_approval = false OR min_role IS NULL THEN 'none'
ELSE min_role
END;
ALTER TABLE paliad.approval_policies
ALTER COLUMN required_role SET NOT NULL;
ALTER TABLE paliad.approval_policies
ADD CONSTRAINT approval_policies_required_role_check CHECK (
required_role IN ('partner', 'of_counsel', 'associate', 'senior_pa', 'pa', 'none')
);
DROP FUNCTION IF EXISTS paliad.approval_policy_effective(uuid, text, text);
CREATE FUNCTION paliad.approval_policy_effective(
p_project_id uuid,
p_entity_type text,
p_lifecycle text
) RETURNS TABLE (
requires_approval boolean,
min_role text,
required_role text,
source text,
source_id uuid
)
LANGUAGE plpgsql STABLE AS $$
BEGIN
RETURN QUERY
WITH path AS (
SELECT string_to_array(p.path, '.')::uuid[] AS ids
FROM paliad.projects p WHERE p.id = p_project_id
),
project_rows AS (
SELECT ap.requires_approval, ap.min_role,
'project'::text AS src, ap.project_id AS sid, 1 AS src_priority
FROM paliad.approval_policies ap
WHERE ap.project_id = p_project_id
AND ap.entity_type = p_entity_type
AND ap.lifecycle_event = p_lifecycle
),
ancestor_rows AS (
SELECT ap.requires_approval, ap.min_role,
'ancestor'::text AS src, ap.project_id AS sid, 2 AS src_priority
FROM paliad.approval_policies ap, path
WHERE ap.project_id = ANY(path.ids)
AND ap.project_id <> p_project_id
AND ap.entity_type = p_entity_type
AND ap.lifecycle_event = p_lifecycle
),
unit_rows AS (
SELECT ap.requires_approval, ap.min_role,
'unit_default'::text AS src, ap.partner_unit_id AS sid, 3 AS src_priority
FROM paliad.approval_policies ap
JOIN paliad.project_partner_units ppu
ON ppu.partner_unit_id = ap.partner_unit_id
WHERE ppu.project_id = p_project_id
AND ap.entity_type = p_entity_type
AND ap.lifecycle_event = p_lifecycle
),
candidates AS (
SELECT * FROM project_rows UNION ALL
SELECT * FROM ancestor_rows UNION ALL
SELECT * FROM unit_rows
),
strictest_role AS (
SELECT c.min_role, c.src AS source, c.sid AS source_id
FROM candidates c
WHERE c.requires_approval = true AND c.min_role IS NOT NULL
ORDER BY paliad.approval_role_level(c.min_role) DESC, c.src_priority ASC
LIMIT 1
),
no_approval_attribution AS (
SELECT c.src AS source, c.sid AS source_id
FROM candidates c
WHERE c.requires_approval = false
ORDER BY c.src_priority ASC
LIMIT 1
),
summary AS (
SELECT bool_or(c.requires_approval) AS req FROM candidates c
)
SELECT
COALESCE(s.req, false) AS requires_approval,
sr.min_role AS min_role,
sr.min_role AS required_role,
COALESCE(sr.source, na.source) AS source,
COALESCE(sr.source_id, na.source_id) AS source_id
FROM summary s
LEFT JOIN strictest_role sr ON true
LEFT JOIN no_approval_attribution na ON true
WHERE EXISTS (SELECT 1 FROM candidates);
END;
$$;

View File

@@ -0,0 +1,139 @@
-- t-paliad-160 (M2): drop the legacy `required_role` column from
-- paliad.approval_policies. Migration 064 (M1) introduced the
-- requires_approval + min_role split-grammar columns and kept
-- required_role as a dual-read mirror so a rollback to pre-deploy
-- code would still work. M2 retires the mirror once all writers have
-- cut over.
--
-- Deploy ordering — IMPORTANT:
--
-- 1. Migration 064 must already be applied (introduces the new
-- columns and rewrites approval_policy_effective() to the
-- new shape). The Go service layer in slice 1+2 reads the new
-- columns AND writes both old + new on every Upsert*. With
-- this migration the writes drop the legacy column path.
-- 2. After 065, NO code path may reference
-- paliad.approval_policies.required_role.
-- 3. paliad.approval_requests.required_role is a different column
-- (the in-flight snapshot of the policy at submission time) and
-- is intentionally untouched here.
--
-- The function paliad.approval_policy_effective() is also updated to
-- stop returning the redundant required_role column.
-- ============================================================================
-- 1. Replace approval_policy_effective() with a 4-column return that no
-- longer mirrors required_role.
-- ============================================================================
DROP FUNCTION IF EXISTS paliad.approval_policy_effective(uuid, text, text);
CREATE FUNCTION paliad.approval_policy_effective(
p_project_id uuid,
p_entity_type text,
p_lifecycle text
) RETURNS TABLE (
requires_approval boolean,
min_role text,
source text,
source_id uuid
)
LANGUAGE plpgsql STABLE AS $$
BEGIN
RETURN QUERY
WITH path AS (
SELECT string_to_array(p.path, '.')::uuid[] AS ids
FROM paliad.projects p WHERE p.id = p_project_id
),
project_rows AS (
SELECT ap.requires_approval,
ap.min_role,
'project'::text AS src,
ap.project_id AS sid,
1 AS src_priority
FROM paliad.approval_policies ap
WHERE ap.project_id = p_project_id
AND ap.entity_type = p_entity_type
AND ap.lifecycle_event = p_lifecycle
),
ancestor_rows AS (
SELECT ap.requires_approval,
ap.min_role,
'ancestor'::text AS src,
ap.project_id AS sid,
2 AS src_priority
FROM paliad.approval_policies ap, path
WHERE ap.project_id = ANY(path.ids)
AND ap.project_id <> p_project_id
AND ap.entity_type = p_entity_type
AND ap.lifecycle_event = p_lifecycle
),
unit_rows AS (
SELECT ap.requires_approval,
ap.min_role,
'unit_default'::text AS src,
ap.partner_unit_id AS sid,
3 AS src_priority
FROM paliad.approval_policies ap
JOIN paliad.project_partner_units ppu
ON ppu.partner_unit_id = ap.partner_unit_id
WHERE ppu.project_id = p_project_id
AND ap.entity_type = p_entity_type
AND ap.lifecycle_event = p_lifecycle
),
candidates AS (
SELECT * FROM project_rows
UNION ALL
SELECT * FROM ancestor_rows
UNION ALL
SELECT * FROM unit_rows
),
strictest_role AS (
SELECT c.min_role,
c.src AS source,
c.sid AS source_id
FROM candidates c
WHERE c.requires_approval = true
AND c.min_role IS NOT NULL
ORDER BY paliad.approval_role_level(c.min_role) DESC,
c.src_priority ASC
LIMIT 1
),
no_approval_attribution AS (
SELECT c.src AS source, c.sid AS source_id
FROM candidates c
WHERE c.requires_approval = false
ORDER BY c.src_priority ASC
LIMIT 1
),
summary AS (
SELECT bool_or(c.requires_approval) AS req
FROM candidates c
)
SELECT
COALESCE(s.req, false) AS requires_approval,
sr.min_role AS min_role,
COALESCE(sr.source, na.source) AS source,
COALESCE(sr.source_id, na.source_id) AS source_id
FROM summary s
LEFT JOIN strictest_role sr ON true
LEFT JOIN no_approval_attribution na ON true
WHERE EXISTS (SELECT 1 FROM candidates);
END;
$$;
COMMENT ON FUNCTION paliad.approval_policy_effective(uuid, text, text) IS
'Effective approval policy resolver (t-paliad-160 M2). Returns '
'requires_approval (OR across candidates) + min_role (MAX along the '
'role ladder among requires_approval=true candidates) + source '
'attribution. Zero rows when no policy candidates exist.';
-- ============================================================================
-- 2. Drop the legacy column.
-- ============================================================================
ALTER TABLE paliad.approval_policies
DROP CONSTRAINT IF EXISTS approval_policies_required_role_check;
ALTER TABLE paliad.approval_policies
DROP COLUMN required_role;