Files
paliad/internal/db/migrations/055_hierarchy_aggregation.up.sql
m a61c1490e3 feat(t-paliad-139): Phase 3 — derived_peer authority extension to t-138 approval gate
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).
2026-05-06 16:45:19 +02:00

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
);
$$;