Wires DerivationService.EffectiveProjectRole into the t-paliad-138
approval ladder so partner-unit-derived members with derive_grants_authority=true
can act as approvers (per design §4.2). When they sign off, the audit row
records decision_kind='derived_peer' — a third value alongside the existing
'peer' and 'admin_override' — so the chronology discloses the derivation
chain.
Schema (migration 055 update)
-----------------------------
- paliad.approval_requests.decision_kind CHECK extended to accept
'derived_peer'. Down migration restores the t-138 two-value CHECK.
Live SQL dry-run confirmed the new value is accepted.
Service layer
-------------
- approval_levels.go: new constant DecisionKindDerivedPeer.
- approval_service.go (4 sites widened with the derivation EXISTS branch):
1. canApprove — third resolution step after global_admin + direct/
ancestor team membership: matches partner-unit-derived members
on path with derive_grants_authority=true and a unit_role whose
approval_role_from_unit_role mapping meets the threshold.
Returns DecisionKindDerivedPeer when this branch is the one that
passed.
2. hasQualifiedApprover (the deadlock-check at submit time) —
widened so a project with no direct approvers but an authority-
granting unit attachment is still submittable.
3. ListPendingForApprover (the /inbox query) — third UNION ALL
branch so derived authority sees their queue.
4. PendingCountForUser (the bell-badge query) — same widening so
derived authority sees the count tick.
All four queries reuse paliad.approval_role_from_unit_role(text) added
by Phase 2 of migration 055.
Frontend
--------
- 2 i18n keys (DE+EN): approvals.decision_kind.derived_peer →
"Genehmigt durch abgeleitetes Mitglied (Partner Unit)" / "Approved by
derived member (Partner Unit)". Verlauf rendering of the third
decision_kind value works through the existing translateEvent /
decision_kind switch with no other change. 1606 keys total.
Strict-default unchanged
------------------------
Derived members are visibility-only by default. Authority requires the
project lead/admin to explicitly flip derive_grants_authority=true on the
project_partner_units row (UI on /projects/{id} Team tab, Phase 2). This
preserves the m-locked Q12 stance.
Phase 3 closes the t-paliad-139 implementation. m's bug closes (Phase 1),
the derivation schema is in place (Phase 2), and approval authority
flows through the new ladder (Phase 3).
175 lines
8.2 KiB
PL/PgSQL
175 lines
8.2 KiB
PL/PgSQL
-- t-paliad-139: hierarchy aggregation — partner-unit derivation schema.
|
|
--
|
|
-- Design: docs/design-hierarchy-aggregation-2026-05-06.md (noether, m-locked 2026-05-06).
|
|
--
|
|
-- This is the Phase 2 schema migration. Day-1 deploy = zero behaviour change
|
|
-- because:
|
|
-- - Every existing partner_unit_members row defaults to unit_role='attorney'.
|
|
-- - The default derive_unit_roles on the new junction is {'pa','senior_pa'}.
|
|
-- - No project_partner_units rows exist yet; admins opt-in by attaching
|
|
-- units to projects.
|
|
-- Until those two conditions diverge, no derivation happens and visibility
|
|
-- behaves identically to the pre-055 world.
|
|
--
|
|
-- Sections:
|
|
-- 1. ALTER paliad.partner_unit_members ADD COLUMN unit_role.
|
|
-- 2. CREATE paliad.project_partner_units junction (with RLS).
|
|
-- 3. CREATE paliad.approval_role_from_unit_role helper.
|
|
-- 4. CREATE OR REPLACE paliad.can_see_project — extended with derivation
|
|
-- branch.
|
|
|
|
-- ============================================================================
|
|
-- 1. unit_role on paliad.partner_unit_members.
|
|
--
|
|
-- Per-unit role distinction so derivation can target specific tiers (default
|
|
-- {pa, senior_pa}) without re-introducing a firm-wide rank column. The same
|
|
-- user can have a different unit_role in different units; in practice most
|
|
-- users belong to one unit so this is effectively a firm-rank, but the per-
|
|
-- unit framing preserves the t-paliad-051/-138 three-axis principle on the
|
|
-- user side (job_title remains free-text display, global_role stays
|
|
-- standard|global_admin).
|
|
-- ============================================================================
|
|
|
|
ALTER TABLE paliad.partner_unit_members
|
|
ADD COLUMN unit_role text NOT NULL DEFAULT 'attorney'
|
|
CHECK (unit_role IN ('lead', 'attorney', 'senior_pa', 'pa', 'paralegal'));
|
|
|
|
-- ============================================================================
|
|
-- 2. paliad.project_partner_units — project ↔ unit involvement.
|
|
--
|
|
-- A row here means "this unit is involved on this project, and the listed
|
|
-- unit_roles auto-derive onto the project team". Authority defaults to off
|
|
-- (visibility-only): set derive_grants_authority=true to let derived members
|
|
-- count as approvers (per t-paliad-139 §3.4). Composite PK enforces "one
|
|
-- attachment per (project, unit)".
|
|
-- ============================================================================
|
|
|
|
CREATE TABLE paliad.project_partner_units (
|
|
project_id uuid NOT NULL REFERENCES paliad.projects(id) ON DELETE CASCADE,
|
|
partner_unit_id uuid NOT NULL REFERENCES paliad.partner_units(id) ON DELETE CASCADE,
|
|
-- Roles in the unit that auto-derive onto the project team. Defaults
|
|
-- target PAs only; a project can widen to ['pa','senior_pa','attorney']
|
|
-- to pull the whole unit, or narrow to ['pa'] to exclude senior_pa.
|
|
derive_unit_roles text[] NOT NULL DEFAULT ARRAY['pa', 'senior_pa'],
|
|
-- Strict default: derived members are visibility-only. Flipping this on
|
|
-- lets them be eligible approvers per the t-138 ladder via the mapping
|
|
-- in paliad.approval_role_from_unit_role.
|
|
derive_grants_authority boolean NOT NULL DEFAULT false,
|
|
attached_at timestamptz NOT NULL DEFAULT now(),
|
|
attached_by uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
|
|
PRIMARY KEY (project_id, partner_unit_id)
|
|
);
|
|
|
|
CREATE INDEX project_partner_units_unit_idx
|
|
ON paliad.project_partner_units (partner_unit_id, project_id);
|
|
|
|
ALTER TABLE paliad.project_partner_units ENABLE ROW LEVEL SECURITY;
|
|
|
|
-- Anyone who can see the project can see the unit attachment. Mirrors the
|
|
-- approval_requests / deadlines / appointments policy.
|
|
CREATE POLICY project_partner_units_select
|
|
ON paliad.project_partner_units FOR SELECT
|
|
USING (paliad.can_see_project(project_id));
|
|
|
|
-- Writes gated to global_admin OR project lead. Same pattern as
|
|
-- /admin/team and /admin/partner-units precedent.
|
|
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')
|
|
);
|
|
|
|
-- ============================================================================
|
|
-- 3. paliad.approval_role_from_unit_role — unit_role → project_role mapping.
|
|
--
|
|
-- Used when a derived member's authority is evaluated by the t-138 strict
|
|
-- ladder. The mapping is intentional:
|
|
-- lead → lead (the unit's lead, matches project lead tier)
|
|
-- attorney → associate (default for working lawyers)
|
|
-- senior_pa → senior_pa (1:1)
|
|
-- pa → pa (1:1)
|
|
-- paralegal → observer (level 0 — ineligible to approve)
|
|
-- The ApprovalService (t-138) reads project_teams.role first; only when that
|
|
-- has no row does it fall back to derived authority via this mapping (and
|
|
-- only when the project_partner_units row has derive_grants_authority=true).
|
|
-- ============================================================================
|
|
|
|
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
|
|
$$;
|
|
|
|
-- ============================================================================
|
|
-- 4. Extend paliad.approval_requests.decision_kind CHECK to allow
|
|
-- 'derived_peer' — a derived (partner-unit) member with authority who
|
|
-- signed off via the t-paliad-138 inbox path. Distinct from plain
|
|
-- 'peer' so the audit trail discloses the derivation chain.
|
|
-- ============================================================================
|
|
|
|
ALTER TABLE paliad.approval_requests DROP CONSTRAINT IF EXISTS approval_requests_decision_kind_check;
|
|
ALTER TABLE paliad.approval_requests ADD CONSTRAINT approval_requests_decision_kind_check
|
|
CHECK (decision_kind IS NULL OR decision_kind IN ('peer', 'admin_override', 'derived_peer'));
|
|
|
|
-- ============================================================================
|
|
-- 5. paliad.can_see_project — extended with derivation branch.
|
|
--
|
|
-- Same shape as the migration-023 body, plus one EXISTS branch: a user is
|
|
-- visible on a project if there is any (ancestor of project) attached to a
|
|
-- partner_unit they are a member of, AND their unit_role is in the derive
|
|
-- set for that attachment. Read-cost is small (project_partner_units +
|
|
-- partner_unit_members are tiny).
|
|
--
|
|
-- t-paliad-139 §3.3 Option B: compute on read, no materialised state.
|
|
-- ============================================================================
|
|
|
|
CREATE OR REPLACE FUNCTION paliad.can_see_project(_project_id uuid)
|
|
RETURNS boolean
|
|
LANGUAGE sql
|
|
STABLE
|
|
SECURITY DEFINER
|
|
SET search_path = paliad, public
|
|
AS $$
|
|
SELECT EXISTS (
|
|
SELECT 1 FROM paliad.users u
|
|
WHERE u.id = auth.uid() AND u.global_role = 'global_admin'
|
|
)
|
|
OR EXISTS (
|
|
SELECT 1
|
|
FROM paliad.projects target
|
|
JOIN paliad.project_teams pt
|
|
ON pt.user_id = auth.uid()
|
|
AND pt.project_id = ANY(string_to_array(target.path, '.')::uuid[])
|
|
WHERE target.id = _project_id
|
|
)
|
|
OR EXISTS (
|
|
SELECT 1
|
|
FROM paliad.projects target
|
|
JOIN paliad.project_partner_units ppu
|
|
ON ppu.project_id = ANY(string_to_array(target.path, '.')::uuid[])
|
|
JOIN paliad.partner_unit_members pum
|
|
ON pum.partner_unit_id = ppu.partner_unit_id
|
|
AND pum.user_id = auth.uid()
|
|
AND pum.unit_role = ANY(ppu.derive_unit_roles)
|
|
WHERE target.id = _project_id
|
|
);
|
|
$$;
|