Schema-only commit (1 of 8) for the 4-Augen-Prüfung workflow per docs/design-approvals-2026-05-06.md. No Go code reads these yet — paliad behaves identically until commit 2 wires ApprovalService into the mutation paths. Migration 054 adds: 1. `senior_pa` to paliad.project_teams.role CHECK. Drops both the English `project_teams_role_check` and the German-legacy `projekt_teams_role_check` (live-DB constraint name carried over from migration 018's pre-rename era). 2. `paliad.approval_role_level(text) RETURNS int IMMUTABLE` — strict ladder helper: lead(5) > of_counsel(4) > associate(3) > senior_pa(2) > pa(1) > [local_counsel/expert/observer = 0 = ineligible]. Mirrors the upcoming Go `levelOf()`. 3. `paliad.approval_policies` (project_id, entity_type, lifecycle_event, required_role) — UNIQUE composite key gives at most 8 rows per project. RLS: SELECT via can_see_project; INSERT/UPDATE/DELETE only for global_admin (defence-in-depth; service-role pool bypasses RLS, so the actual gate is service-layer). 4. `paliad.approval_requests` — operational pending workflow. pre_image jsonb captures revert state; payload echoes the diff; required_role snapshots the policy at request time. CHECK `decided_by != requested_by` is the second layer of self-approval block. RLS = same can_see_project predicate as deadlines / appointments — anyone with project visibility sees pending requests. 5. `approval_status` (default 'approved'), `pending_request_id`, `approved_by`, `approved_at` columns on both deadlines and appointments. `appointments.completed_at` (new) lands the appointment:complete lifecycle event. 6. Backfill: every existing deadline + appointment row marked approval_status='legacy'. Per Q11, no retroactive approval; the next mutation on a legacy row that hits an active policy follows the normal flow. Live-DB dry-run verified end-to-end: 20 deadlines + 5 appointments backfill, both new tables instantiate cleanly, helper function returns correct levels, self-approval CHECK fires on invalid INSERT, valid pending insert succeeds.
238 lines
11 KiB
PL/PgSQL
238 lines
11 KiB
PL/PgSQL
-- t-paliad-138: dual-control approvals (4-Augen-Prüfung) for Deadlines + Appointments.
|
||
--
|
||
-- Design: docs/design-approvals-2026-05-06.md (cronus, m-locked 2026-05-06).
|
||
--
|
||
-- Schema-only migration (commit 1 of 8). Adds the operational tables, the
|
||
-- strict-ladder helper, and the per-entity tracking columns. No Go code
|
||
-- reads these yet — paliad behaves identically until commit 2 wires the
|
||
-- ApprovalService into the mutation paths.
|
||
--
|
||
-- Sections:
|
||
-- 1. Add 'senior_pa' to paliad.project_teams.role CHECK.
|
||
-- 2. paliad.approval_role_level(text) — strict ladder helper.
|
||
-- 3. paliad.approval_policies — per-(project, entity_type, lifecycle_event).
|
||
-- 4. paliad.approval_requests — operational pending workflow.
|
||
-- 5. ALTER paliad.deadlines + paliad.appointments — approval columns
|
||
-- (approval_status, pending_request_id, approved_by, approved_at;
|
||
-- appointments also gains completed_at).
|
||
-- 6. Backfill: mark every existing row approval_status='legacy'.
|
||
--
|
||
-- ============================================================================
|
||
-- 1. Add 'senior_pa' to paliad.project_teams.role CHECK.
|
||
--
|
||
-- Live-DB finding (cronus, 2026-05-06): the existing constraint is named
|
||
-- `projekt_teams_role_check` (German leftover from migration 018, when the
|
||
-- table was `paliad.projekt_teams`; the table was renamed in 020 but the
|
||
-- constraint name was preserved by Postgres). Dropping by both names
|
||
-- defensively handles any future re-creation under the English name.
|
||
-- ============================================================================
|
||
|
||
ALTER TABLE paliad.project_teams DROP CONSTRAINT IF EXISTS projekt_teams_role_check;
|
||
ALTER TABLE paliad.project_teams DROP CONSTRAINT IF EXISTS project_teams_role_check;
|
||
ALTER TABLE paliad.project_teams ADD CONSTRAINT project_teams_role_check
|
||
CHECK (role IN (
|
||
'lead', 'associate', 'pa', 'of_counsel',
|
||
'local_counsel', 'expert', 'observer',
|
||
'senior_pa'
|
||
));
|
||
|
||
-- ============================================================================
|
||
-- 2. paliad.approval_role_level — strict ladder over project_teams.role.
|
||
--
|
||
-- Mirrors internal/services/approval_levels.go:levelOf. A user with
|
||
-- project_teams.role R can approve any request whose required_role has level
|
||
-- <= level(R). Roles outside the approval ladder (local_counsel, expert,
|
||
-- observer, anything new) return 0 and are ineligible to approve at any
|
||
-- level. Default required_role on policies is 'associate' (level 3).
|
||
-- ============================================================================
|
||
|
||
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
|
||
$$;
|
||
|
||
COMMENT ON FUNCTION paliad.approval_role_level(text) IS
|
||
'Strict-ladder level for approval gating (t-paliad-138). '
|
||
'Higher level always satisfies lower. Level 0 = ineligible. '
|
||
'Default policy required_role=associate (level 3) — eligible: lead, of_counsel, associate.';
|
||
|
||
-- ============================================================================
|
||
-- 3. paliad.approval_policies — per-(project, entity_type, lifecycle_event).
|
||
--
|
||
-- Up to 8 rows per project (deadline×4 + appointment×4). UNIQUE composite key
|
||
-- enforces this. No row = no approval needed for that event. Authoring is
|
||
-- gated to global_admin in the service layer; RLS lets project members read
|
||
-- their own project's policies (transparency: "do my edits need 4-eye?").
|
||
-- ============================================================================
|
||
|
||
CREATE TABLE paliad.approval_policies (
|
||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
project_id uuid NOT NULL REFERENCES paliad.projects(id) ON DELETE CASCADE,
|
||
entity_type text NOT NULL CHECK (entity_type IN ('deadline', 'appointment')),
|
||
lifecycle_event text NOT NULL CHECK (lifecycle_event IN ('create', 'update', 'complete', 'delete')),
|
||
required_role text NOT NULL CHECK (required_role IN (
|
||
'lead', 'of_counsel', 'associate', 'senior_pa', 'pa'
|
||
)),
|
||
created_at timestamptz NOT NULL DEFAULT now(),
|
||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||
created_by uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
|
||
UNIQUE (project_id, entity_type, lifecycle_event)
|
||
);
|
||
|
||
CREATE INDEX approval_policies_project_idx
|
||
ON paliad.approval_policies (project_id);
|
||
|
||
ALTER TABLE paliad.approval_policies ENABLE ROW LEVEL SECURITY;
|
||
|
||
CREATE POLICY approval_policies_select ON paliad.approval_policies
|
||
FOR SELECT TO authenticated
|
||
USING (paliad.can_see_project(project_id));
|
||
|
||
-- Writes are restricted to global_admin in the application layer. The
|
||
-- service-role connection bypasses RLS, so these policies are
|
||
-- defence-in-depth for any future direct-DB access path.
|
||
CREATE POLICY approval_policies_write ON paliad.approval_policies
|
||
FOR ALL TO authenticated
|
||
USING (
|
||
EXISTS (SELECT 1 FROM paliad.users u
|
||
WHERE u.id = auth.uid() AND u.global_role = 'global_admin')
|
||
)
|
||
WITH CHECK (
|
||
EXISTS (SELECT 1 FROM paliad.users u
|
||
WHERE u.id = auth.uid() AND u.global_role = 'global_admin')
|
||
);
|
||
|
||
-- ============================================================================
|
||
-- 4. paliad.approval_requests — operational pending workflow.
|
||
--
|
||
-- One row per submitted state-change that needs 4-eye sign-off. The entity
|
||
-- being changed is referenced by (entity_type, entity_id) — polymorphic
|
||
-- across deadlines / appointments, so no FK constraint on entity_id.
|
||
--
|
||
-- pre_image carries the field values needed to revert on rejection
|
||
-- (NULL for 'create' since there's nothing to revert to). payload echoes
|
||
-- the diff or new values that were written, for audit display.
|
||
--
|
||
-- required_role is a snapshot of the policy at request time — even if the
|
||
-- policy changes mid-flight, the request honours the level it was submitted
|
||
-- under.
|
||
--
|
||
-- decision_kind discriminates 'peer' (normal in-team sign-off) from
|
||
-- 'admin_override' (global_admin used the escape-hatch path). Verlauf
|
||
-- chronology renders these distinctly.
|
||
--
|
||
-- The CHECK on (decided_by != requested_by) is defence-in-depth alongside
|
||
-- the service-layer self-approval block.
|
||
-- ============================================================================
|
||
|
||
CREATE TABLE paliad.approval_requests (
|
||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
project_id uuid NOT NULL REFERENCES paliad.projects(id) ON DELETE CASCADE,
|
||
entity_type text NOT NULL CHECK (entity_type IN ('deadline', 'appointment')),
|
||
entity_id uuid NOT NULL,
|
||
lifecycle_event text NOT NULL CHECK (lifecycle_event IN ('create', 'update', 'complete', 'delete')),
|
||
pre_image jsonb,
|
||
payload jsonb,
|
||
requested_by uuid NOT NULL REFERENCES paliad.users(id) ON DELETE RESTRICT,
|
||
requested_at timestamptz NOT NULL DEFAULT now(),
|
||
required_role text NOT NULL CHECK (required_role IN (
|
||
'lead', 'of_counsel', 'associate', 'senior_pa', 'pa'
|
||
)),
|
||
status text NOT NULL DEFAULT 'pending'
|
||
CHECK (status IN ('pending', 'approved', 'rejected', 'revoked', 'superseded')),
|
||
decided_by uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
|
||
decided_at timestamptz,
|
||
decision_kind text CHECK (decision_kind IS NULL OR decision_kind IN ('peer', 'admin_override')),
|
||
decision_note text,
|
||
created_at timestamptz NOT NULL DEFAULT now(),
|
||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||
CONSTRAINT approval_requests_no_self_approval
|
||
CHECK (decided_by IS NULL OR decided_by <> requested_by)
|
||
);
|
||
|
||
CREATE INDEX approval_requests_project_status_idx
|
||
ON paliad.approval_requests (project_id, status);
|
||
CREATE INDEX approval_requests_entity_idx
|
||
ON paliad.approval_requests (entity_type, entity_id);
|
||
CREATE INDEX approval_requests_requested_by_idx
|
||
ON paliad.approval_requests (requested_by, status);
|
||
CREATE INDEX approval_requests_pending_idx
|
||
ON paliad.approval_requests (status, requested_at)
|
||
WHERE status = 'pending';
|
||
|
||
ALTER TABLE paliad.approval_requests ENABLE ROW LEVEL SECURITY;
|
||
|
||
-- Visible to anyone with project visibility (mirrors deadlines / appointments).
|
||
-- The approve/reject action is gated at the service layer, not here.
|
||
CREATE POLICY approval_requests_all ON paliad.approval_requests
|
||
FOR ALL TO authenticated
|
||
USING (paliad.can_see_project(project_id))
|
||
WITH CHECK (paliad.can_see_project(project_id));
|
||
|
||
-- ============================================================================
|
||
-- 5. Approval columns on paliad.deadlines + paliad.appointments.
|
||
--
|
||
-- approval_status:
|
||
-- 'approved' (default for new + existing-after-backfill-clears),
|
||
-- 'pending' (an approval_request is in flight; pending_request_id set),
|
||
-- 'legacy' (predates 4-eye; backfilled in §6 below).
|
||
--
|
||
-- pending_request_id: FK to the in-flight approval_requests row. NULL when
|
||
-- approval_status != 'pending'. ON DELETE SET NULL keeps the entity row
|
||
-- intact if an approval_requests row is ever pruned.
|
||
--
|
||
-- approved_by / approved_at: set on transition to approval_status='approved'
|
||
-- after a 4-eye approval. NULL for 'legacy' rows and rows that never went
|
||
-- through 4-eye (no policy applied).
|
||
--
|
||
-- appointments.completed_at: new column for the appointment:complete
|
||
-- lifecycle event. Nullable; NULL means "not yet marked done".
|
||
-- ============================================================================
|
||
|
||
ALTER TABLE paliad.deadlines
|
||
ADD COLUMN approval_status text NOT NULL DEFAULT 'approved'
|
||
CHECK (approval_status IN ('approved', 'pending', 'legacy')),
|
||
ADD COLUMN pending_request_id uuid
|
||
REFERENCES paliad.approval_requests(id) ON DELETE SET NULL,
|
||
ADD COLUMN approved_by uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
|
||
ADD COLUMN approved_at timestamptz;
|
||
|
||
CREATE INDEX deadlines_approval_status_pending_idx
|
||
ON paliad.deadlines (approval_status)
|
||
WHERE approval_status = 'pending';
|
||
|
||
ALTER TABLE paliad.appointments
|
||
ADD COLUMN approval_status text NOT NULL DEFAULT 'approved'
|
||
CHECK (approval_status IN ('approved', 'pending', 'legacy')),
|
||
ADD COLUMN pending_request_id uuid
|
||
REFERENCES paliad.approval_requests(id) ON DELETE SET NULL,
|
||
ADD COLUMN approved_by uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
|
||
ADD COLUMN approved_at timestamptz,
|
||
ADD COLUMN completed_at timestamptz;
|
||
|
||
CREATE INDEX appointments_approval_status_pending_idx
|
||
ON paliad.appointments (approval_status)
|
||
WHERE approval_status = 'pending';
|
||
|
||
-- ============================================================================
|
||
-- 6. Backfill: mark every existing row legacy.
|
||
--
|
||
-- Per design §6.5 / m's Q11 answer: existing pre-4-eye rows are read-clean;
|
||
-- they don't need retroactive approval. The next mutation on a legacy row
|
||
-- that hits an active policy (none exist on day 1) will trigger normal flow
|
||
-- and lift the row to 'approved' (or 'pending' until signed off).
|
||
--
|
||
-- created_by is already populated since migration 005. approved_by stays
|
||
-- NULL on legacy rows.
|
||
-- ============================================================================
|
||
|
||
UPDATE paliad.deadlines SET approval_status = 'legacy';
|
||
UPDATE paliad.appointments SET approval_status = 'legacy';
|