feat(approvals/t-paliad-160 slice1+2): split policy + 409 handler
m's locked redesign (2026-05-08 16:40): replace `required_role` (with
'none' sentinel) with two columns — `requires_approval boolean` (the
gate) + `min_role text` (the seniority threshold). Cleanly separates
"approval applies at all" from "who's allowed to approve".
M1 phase: additive migration 064 adds the columns, backfills from the
legacy required_role ('none' → false/NULL; else → true/role), and
rewrites paliad.approval_policy_effective() to most-strict-wins:
- requires_approval := bool_or across project + ancestor + unit_default
- min_role := MAX(approval_role_level) among requires_approval=true
The legacy required_role column survives this slice as a dual-read
mirror (resolver returns it too) so any caller that hasn't cut over
keeps working. M2 will drop required_role.
Service layer (approval_service.go): LookupPolicy + GetEffectivePolicyOne
read the new columns; UpsertProjectPolicySplit / UpsertUnitPolicySplit
accept the new shape directly; legacy UpsertProjectPolicy /
UpsertUnitPolicy stay as thin shims that map required_role through
splitFromLegacy(). ApplyMatrixToDescendants writes both columns.
Handler 409 mapping (§B): writeServiceError now consults a shared
mapApprovalError() helper before falling through to the generic 500.
ErrConcurrentPending → HTTP 409 with body
{code: "awaiting_approval", message, request_id?, required_role?}.
PendingApprovalError wraps ErrConcurrentPending with the in-flight
request id + role so the UI knows which request to point a withdraw
button at. ErrNoQualifiedApprover, ErrSelfApproval, ErrNotApprover,
ErrRequestNotPending all mapped consistently. writeApprovalError
now defers to the same helper for shape consistency.
Models: ApprovalPolicy + EffectivePolicy gain RequiresApproval/MinRole
fields. RequiredRole stays as a dual-read mirror until M2.
Tests: TestMapApprovalError_* covers the four 409/403 branches and the
"no match — fall through" case. Existing approval service tests pass
unchanged.
Defers per task spec to follow-up slices:
- A3 (admin UI 2-control flip)
- C+E (badge + withdraw button on detail pages)
- D (/inbox Meine Anfragen visibility fix)
- M2 (drop required_role column)
This commit is contained in:
77
internal/db/migrations/064_approval_policy_split.down.sql
Normal file
77
internal/db/migrations/064_approval_policy_split.down.sql
Normal file
@@ -0,0 +1,77 @@
|
||||
-- Reverse t-paliad-160 M1: drop the new columns + restore the previous
|
||||
-- paliad.approval_policy_effective() shape from migration 062.
|
||||
--
|
||||
-- M1 is additive in code (dual-read), so this down migration restores the
|
||||
-- previous resolver semantics (project row wins outright, MAX(level) over
|
||||
-- ancestors+unit defaults). The required_role column was never dropped
|
||||
-- in M1 so the legacy values are still the source of truth.
|
||||
|
||||
DROP FUNCTION IF EXISTS paliad.approval_policy_effective(uuid, text, text);
|
||||
|
||||
CREATE OR REPLACE FUNCTION paliad.approval_policy_effective(
|
||||
p_project_id uuid,
|
||||
p_entity_type text,
|
||||
p_lifecycle text
|
||||
) RETURNS TABLE (
|
||||
required_role text,
|
||||
source text,
|
||||
source_id uuid
|
||||
)
|
||||
LANGUAGE plpgsql STABLE AS $$
|
||||
BEGIN
|
||||
RETURN QUERY
|
||||
SELECT ap.required_role, 'project'::text AS source, ap.project_id AS source_id
|
||||
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;
|
||||
IF FOUND THEN
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
RETURN QUERY
|
||||
WITH path AS (
|
||||
SELECT string_to_array(p.path, '.')::uuid[] AS ids
|
||||
FROM paliad.projects p WHERE p.id = p_project_id
|
||||
),
|
||||
ancestor_rows AS (
|
||||
SELECT ap.required_role,
|
||||
'ancestor'::text AS src,
|
||||
ap.project_id AS sid,
|
||||
paliad.approval_role_level(ap.required_role) AS lvl
|
||||
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.required_role,
|
||||
'unit_default'::text AS src,
|
||||
ap.partner_unit_id AS sid,
|
||||
paliad.approval_role_level(ap.required_role) AS lvl
|
||||
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
|
||||
)
|
||||
SELECT a.required_role, a.src, a.sid
|
||||
FROM (
|
||||
SELECT * FROM ancestor_rows
|
||||
UNION ALL
|
||||
SELECT * FROM unit_rows
|
||||
) AS a
|
||||
ORDER BY a.lvl DESC, a.src ASC
|
||||
LIMIT 1;
|
||||
END;
|
||||
$$;
|
||||
|
||||
ALTER TABLE paliad.approval_policies
|
||||
DROP CONSTRAINT IF EXISTS approval_policies_min_role_xor_required;
|
||||
ALTER TABLE paliad.approval_policies
|
||||
DROP CONSTRAINT IF EXISTS approval_policies_min_role_check;
|
||||
ALTER TABLE paliad.approval_policies
|
||||
DROP COLUMN IF EXISTS requires_approval,
|
||||
DROP COLUMN IF EXISTS min_role;
|
||||
237
internal/db/migrations/064_approval_policy_split.up.sql
Normal file
237
internal/db/migrations/064_approval_policy_split.up.sql
Normal file
@@ -0,0 +1,237 @@
|
||||
-- t-paliad-160 (M1, slice 1): split approval_policies.required_role into
|
||||
-- two columns — requires_approval (the gate) + min_role (the seniority
|
||||
-- threshold). The legacy required_role='none' sentinel conflated two
|
||||
-- concepts: "approval applies at all" vs "who can approve". This
|
||||
-- migration introduces the split and backfills.
|
||||
--
|
||||
-- M1 = additive + dual-read. New code paths read both old and new columns
|
||||
-- so a rollback to pre-deploy code keeps working. M2 (follow-up
|
||||
-- migration) will drop required_role once everything writes the new
|
||||
-- shape exclusively.
|
||||
--
|
||||
-- Resolver semantics also change with this split: when both a project-
|
||||
-- level row and a partner-unit-default row resolve for the same
|
||||
-- (entity_type, lifecycle_event), most-strict-wins now applies on BOTH
|
||||
-- axes:
|
||||
-- - requires_approval: OR (true if either side says true).
|
||||
-- - min_role: MAX along approval_role_level().
|
||||
-- That update lives in the paliad.approval_policy_effective() rewrite
|
||||
-- in §4 below.
|
||||
--
|
||||
-- Sections:
|
||||
-- 1. ALTER paliad.approval_policies ADD COLUMN requires_approval + min_role.
|
||||
-- 2. Backfill: required_role='none' → (false, NULL); else → (true, role).
|
||||
-- 3. Constraint: (requires_approval=false) OR (min_role IS NOT NULL).
|
||||
-- 4. Replace paliad.approval_policy_effective() with most-strict-wins
|
||||
-- across the new columns. Returns (requires_approval, min_role,
|
||||
-- source, source_id) — back-compat shim required_role column kept
|
||||
-- in result type so callers reading the old column don't break
|
||||
-- until they cut over.
|
||||
|
||||
-- ============================================================================
|
||||
-- 1. New columns. Both nullable in the schema; the constraint in §3
|
||||
-- enforces the relationship instead of a NOT NULL on requires_approval
|
||||
-- (we want Postgres to keep the row out cleanly when min_role is NULL
|
||||
-- and requires_approval = false).
|
||||
-- ============================================================================
|
||||
|
||||
ALTER TABLE paliad.approval_policies
|
||||
ADD COLUMN requires_approval boolean,
|
||||
ADD COLUMN min_role text;
|
||||
|
||||
-- ============================================================================
|
||||
-- 2. Backfill from required_role.
|
||||
-- 'none' → (false, NULL)
|
||||
-- else (any of partner/of_counsel/associate/senior_pa/pa) → (true, role)
|
||||
-- ============================================================================
|
||||
|
||||
UPDATE paliad.approval_policies
|
||||
SET requires_approval = false,
|
||||
min_role = NULL
|
||||
WHERE required_role = 'none';
|
||||
|
||||
UPDATE paliad.approval_policies
|
||||
SET requires_approval = true,
|
||||
min_role = required_role
|
||||
WHERE required_role <> 'none';
|
||||
|
||||
-- After backfill every row has a non-NULL requires_approval. Tighten.
|
||||
ALTER TABLE paliad.approval_policies
|
||||
ALTER COLUMN requires_approval SET NOT NULL;
|
||||
|
||||
-- ============================================================================
|
||||
-- 3. The split-grammar invariant: a row that demands approval must name
|
||||
-- a min_role; a row that does not demand approval has min_role NULL.
|
||||
-- ============================================================================
|
||||
|
||||
ALTER TABLE paliad.approval_policies
|
||||
ADD CONSTRAINT approval_policies_min_role_xor_required CHECK (
|
||||
(requires_approval = false AND min_role IS NULL)
|
||||
OR
|
||||
(requires_approval = true AND min_role IS NOT NULL)
|
||||
);
|
||||
|
||||
-- min_role values mirror the approval ladder. NULL is allowed (the
|
||||
-- requires_approval=false branch); any other value must be on the ladder.
|
||||
ALTER TABLE paliad.approval_policies
|
||||
ADD CONSTRAINT approval_policies_min_role_check CHECK (
|
||||
min_role IS NULL OR min_role IN (
|
||||
'partner', 'of_counsel', 'associate', 'senior_pa', 'pa'
|
||||
)
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- 4. paliad.approval_policy_effective — most-strict-wins resolver.
|
||||
--
|
||||
-- Returns at most one row, or zero rows when no policy applies. The result
|
||||
-- shape adds two columns (requires_approval, min_role) while keeping the
|
||||
-- legacy required_role column for back-compat dual-read. Old callers that
|
||||
-- still read required_role keep working; new callers branch on
|
||||
-- requires_approval/min_role.
|
||||
--
|
||||
-- Resolution:
|
||||
-- Step 1 — collect candidates for (project, entity, lifecycle):
|
||||
-- a) project-specific row (project_id = p_project_id).
|
||||
-- b) ancestor rows on the project's ltree path (excluding self).
|
||||
-- c) unit-default rows for partner units attached to this project.
|
||||
-- Step 2 — most-strict-wins over the union:
|
||||
-- requires_approval := bool_or(c.requires_approval) -- true if any says true
|
||||
-- min_role := role with MAX(approval_role_level) among the
|
||||
-- candidates whose requires_approval=true.
|
||||
-- NULL if no candidate demands approval.
|
||||
-- Step 3 — the project-specific row no longer wins outright. The 'none'
|
||||
-- sentinel is gone; suppression is now expressed as an explicit
|
||||
-- requires_approval=false at project level, which loses to any
|
||||
-- ancestor / unit_default with requires_approval=true under
|
||||
-- most-strict-wins. This is intentional: the user-locked semantics
|
||||
-- is "tighten only, never loosen by inheritance" and the project
|
||||
-- row that wants to relax inherited rules has to be authored at the
|
||||
-- ancestor / unit level instead. (See t-paliad-160 §A resolver lock.)
|
||||
--
|
||||
-- Returned columns:
|
||||
-- requires_approval boolean — the gate
|
||||
-- min_role text — the threshold (NULL when gate is off)
|
||||
-- required_role text — back-compat: NULL when gate is off,
|
||||
-- else equals min_role. Old callers that
|
||||
-- read required_role keep working until
|
||||
-- M2 drops the column.
|
||||
-- source text — 'project' | 'ancestor' | 'unit_default'
|
||||
-- (the source of the WINNING min_role; for
|
||||
-- a pure requires_approval=false result,
|
||||
-- the source of the highest-priority
|
||||
-- 'false' row in the order project >
|
||||
-- ancestor > unit_default).
|
||||
-- source_id uuid — project_id or partner_unit_id of source.
|
||||
-- ============================================================================
|
||||
|
||||
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
|
||||
),
|
||||
-- Pick the strictest min_role: highest approval_role_level among the
|
||||
-- requires_approval=true candidates. Tie-break: project > ancestor >
|
||||
-- unit_default for stable attribution.
|
||||
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
|
||||
),
|
||||
-- If nothing demands approval, surface the project row's "no approval"
|
||||
-- if present, else any (false) row with stable tie-break, so attribution
|
||||
-- still works for the UI ("inherited from <unit>").
|
||||
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); -- zero rows when no policy applies
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION paliad.approval_policy_effective(uuid, text, text) IS
|
||||
'Effective approval policy resolver (t-paliad-160 most-strict-wins). '
|
||||
'Returns requires_approval (OR across candidates), min_role (MAX along '
|
||||
'the role ladder among requires_approval=true candidates), and the '
|
||||
'source attribution. required_role mirrors min_role for back-compat '
|
||||
'dual-read with code that hasn''t cut over yet. Zero rows when no '
|
||||
'policy candidates exist for the (project, entity_type, lifecycle).';
|
||||
@@ -62,7 +62,8 @@ func handleListApprovalPolicies(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// PUT /api/projects/{id}/approval-policies/{entity_type}/{lifecycle}
|
||||
//
|
||||
// Body: {"required_role": "associate"}
|
||||
// Body (split-grammar, t-paliad-160): {"requires_approval": bool, "min_role": "associate"|null}
|
||||
// Body (legacy, dual-read window): {"required_role": "associate"|"none"}
|
||||
//
|
||||
// Semantics: upsert. Replaces any existing row for the same
|
||||
// (project, entity_type, lifecycle) tuple.
|
||||
@@ -81,14 +82,17 @@ func handlePutApprovalPolicy(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
entityType := r.PathValue("entity_type")
|
||||
lifecycle := r.PathValue("lifecycle")
|
||||
var body struct {
|
||||
RequiredRole string `json:"required_role"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||
body, err := decodePolicyBody(r)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
policy, err := dbSvc.approval.UpsertProjectPolicy(r.Context(), uid, projectID, entityType, lifecycle, body.RequiredRole)
|
||||
var policy *models.ApprovalPolicy
|
||||
if body.useSplit {
|
||||
policy, err = dbSvc.approval.UpsertProjectPolicySplit(r.Context(), uid, projectID, entityType, lifecycle, body.requiresApproval, body.minRole)
|
||||
} else {
|
||||
policy, err = dbSvc.approval.UpsertProjectPolicy(r.Context(), uid, projectID, entityType, lifecycle, body.requiredRole)
|
||||
}
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
@@ -96,6 +100,51 @@ func handlePutApprovalPolicy(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, policy)
|
||||
}
|
||||
|
||||
// policyUpsertBody is the parsed payload for the policy PUT endpoints,
|
||||
// supporting both the new split-grammar shape and the legacy single-string
|
||||
// shape during the M1 dual-read window. Exactly one path is taken:
|
||||
// - useSplit=true → call *Split with (requiresApproval, minRole).
|
||||
// - useSplit=false → call legacy with requiredRole.
|
||||
type policyUpsertBody struct {
|
||||
useSplit bool
|
||||
requiresApproval bool
|
||||
minRole *string
|
||||
requiredRole string
|
||||
}
|
||||
|
||||
// decodePolicyBody parses either split-grammar or legacy payload. The
|
||||
// presence of the "requires_approval" key wins — explicit absence falls
|
||||
// back to the legacy required_role path.
|
||||
func decodePolicyBody(r *http.Request) (policyUpsertBody, error) {
|
||||
var raw map[string]json.RawMessage
|
||||
if err := json.NewDecoder(r.Body).Decode(&raw); err != nil {
|
||||
return policyUpsertBody{}, errors.New("invalid JSON")
|
||||
}
|
||||
if v, ok := raw["requires_approval"]; ok {
|
||||
var b policyUpsertBody
|
||||
b.useSplit = true
|
||||
if err := json.Unmarshal(v, &b.requiresApproval); err != nil {
|
||||
return policyUpsertBody{}, errors.New("requires_approval must be boolean")
|
||||
}
|
||||
if mr, ok := raw["min_role"]; ok && string(mr) != "null" {
|
||||
var s string
|
||||
if err := json.Unmarshal(mr, &s); err != nil {
|
||||
return policyUpsertBody{}, errors.New("min_role must be string or null")
|
||||
}
|
||||
b.minRole = &s
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
if v, ok := raw["required_role"]; ok {
|
||||
var s string
|
||||
if err := json.Unmarshal(v, &s); err != nil {
|
||||
return policyUpsertBody{}, errors.New("required_role must be string")
|
||||
}
|
||||
return policyUpsertBody{useSplit: false, requiredRole: s}, nil
|
||||
}
|
||||
return policyUpsertBody{}, errors.New("requires_approval or required_role required")
|
||||
}
|
||||
|
||||
// DELETE /api/projects/{id}/approval-policies/{entity_type}/{lifecycle}
|
||||
//
|
||||
// Removes one policy row, reverting that lifecycle event back to the
|
||||
@@ -322,7 +371,8 @@ func handleListUnitApprovalPolicies(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// PUT /api/admin/partner-units/{unit_id}/approval-policies/{entity_type}/{lifecycle}
|
||||
//
|
||||
// Body: {"required_role": "associate"}
|
||||
// Body (split-grammar): {"requires_approval": bool, "min_role": "associate"|null}
|
||||
// Body (legacy): {"required_role": "associate"|"none"}
|
||||
func handlePutUnitApprovalPolicy(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
@@ -338,14 +388,17 @@ func handlePutUnitApprovalPolicy(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
entityType := r.PathValue("entity_type")
|
||||
lifecycle := r.PathValue("lifecycle")
|
||||
var body struct {
|
||||
RequiredRole string `json:"required_role"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||
body, err := decodePolicyBody(r)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
policy, err := dbSvc.approval.UpsertUnitPolicy(r.Context(), uid, unitID, entityType, lifecycle, body.RequiredRole)
|
||||
var policy *models.ApprovalPolicy
|
||||
if body.useSplit {
|
||||
policy, err = dbSvc.approval.UpsertUnitPolicySplit(r.Context(), uid, unitID, entityType, lifecycle, body.requiresApproval, body.minRole)
|
||||
} else {
|
||||
policy, err = dbSvc.approval.UpsertUnitPolicy(r.Context(), uid, unitID, entityType, lifecycle, body.requiredRole)
|
||||
}
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
@@ -491,20 +544,13 @@ func handleProjectEffectivePolicy(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, row)
|
||||
}
|
||||
|
||||
// writeApprovalError maps approval-flow errors to HTTP status codes.
|
||||
// writeApprovalError maps approval-flow errors to HTTP status codes. The
|
||||
// code/message body shape is shared with mapApprovalError so the frontend
|
||||
// has a single switch on `code` regardless of which endpoint surfaced
|
||||
// the error (entity mutation vs explicit approve/reject/revoke decision).
|
||||
func writeApprovalError(w http.ResponseWriter, err error) {
|
||||
switch {
|
||||
case errors.Is(err, services.ErrSelfApproval):
|
||||
writeJSON(w, http.StatusForbidden, map[string]string{"error": "self_approval_blocked"})
|
||||
case errors.Is(err, services.ErrNoQualifiedApprover):
|
||||
writeJSON(w, http.StatusConflict, map[string]string{"error": "no_qualified_approver"})
|
||||
case errors.Is(err, services.ErrConcurrentPending):
|
||||
writeJSON(w, http.StatusConflict, map[string]string{"error": "concurrent_pending"})
|
||||
case errors.Is(err, services.ErrNotApprover):
|
||||
writeJSON(w, http.StatusForbidden, map[string]string{"error": "not_authorized"})
|
||||
case errors.Is(err, services.ErrRequestNotPending):
|
||||
writeJSON(w, http.StatusConflict, map[string]string{"error": "request_not_pending"})
|
||||
default:
|
||||
writeServiceError(w, err)
|
||||
if mapApprovalError(w, err) {
|
||||
return
|
||||
}
|
||||
writeServiceError(w, err)
|
||||
}
|
||||
|
||||
83
internal/handlers/approvals_test.go
Normal file
83
internal/handlers/approvals_test.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
)
|
||||
|
||||
// Pins t-paliad-160 §B: mapApprovalError must surface ErrConcurrentPending
|
||||
// as a 409 with code=awaiting_approval, and PendingApprovalError must
|
||||
// additionally carry the request_id + required_role so the UI can offer a
|
||||
// withdraw button.
|
||||
func TestMapApprovalError_ConcurrentPending409(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
if !mapApprovalError(w, services.ErrConcurrentPending) {
|
||||
t.Fatal("mapApprovalError returned false for ErrConcurrentPending")
|
||||
}
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Fatalf("status = %d, want 409", w.Code)
|
||||
}
|
||||
var body map[string]string
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
if body["code"] != "awaiting_approval" {
|
||||
t.Errorf("code = %q, want awaiting_approval", body["code"])
|
||||
}
|
||||
if _, ok := body["request_id"]; ok {
|
||||
t.Errorf("bare ErrConcurrentPending should not carry request_id, got %q", body["request_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapApprovalError_PendingApprovalErrorCarriesRequestID(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
pe := services.NewPendingApprovalError("11111111-2222-3333-4444-555555555555", "associate")
|
||||
if !mapApprovalError(w, pe) {
|
||||
t.Fatal("mapApprovalError returned false for PendingApprovalError")
|
||||
}
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Fatalf("status = %d, want 409", w.Code)
|
||||
}
|
||||
var body map[string]string
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
if body["code"] != "awaiting_approval" {
|
||||
t.Errorf("code = %q, want awaiting_approval", body["code"])
|
||||
}
|
||||
if body["request_id"] != "11111111-2222-3333-4444-555555555555" {
|
||||
t.Errorf("request_id = %q, want the wrapped uuid", body["request_id"])
|
||||
}
|
||||
if body["required_role"] != "associate" {
|
||||
t.Errorf("required_role = %q, want associate", body["required_role"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapApprovalError_NoQualifiedApprover409(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
if !mapApprovalError(w, services.ErrNoQualifiedApprover) {
|
||||
t.Fatal("mapApprovalError returned false for ErrNoQualifiedApprover")
|
||||
}
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Fatalf("status = %d, want 409", w.Code)
|
||||
}
|
||||
var body map[string]string
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &body)
|
||||
if body["code"] != "no_qualified_approver" {
|
||||
t.Errorf("code = %q, want no_qualified_approver", body["code"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapApprovalError_MissReturnsFalse(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
if mapApprovalError(w, services.ErrInvalidInput) {
|
||||
t.Error("mapApprovalError matched ErrInvalidInput; that's writeServiceError's job")
|
||||
}
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200 (recorder default — nothing written)", w.Code)
|
||||
}
|
||||
}
|
||||
@@ -79,6 +79,9 @@ func requireUser(w http.ResponseWriter, r *http.Request) (uuid.UUID, bool) {
|
||||
|
||||
// writeServiceError maps a services error to an HTTP status.
|
||||
func writeServiceError(w http.ResponseWriter, err error) {
|
||||
if mapApprovalError(w, err) {
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, services.ErrNotVisible):
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
|
||||
@@ -94,6 +97,72 @@ func writeServiceError(w http.ResponseWriter, err error) {
|
||||
}
|
||||
}
|
||||
|
||||
// mapApprovalError handles approval-flow errors that bubble through the
|
||||
// shared writeServiceError path (entity mutation handlers — deadlines,
|
||||
// appointments — go through writeServiceError, not writeApprovalError).
|
||||
//
|
||||
// Returns true iff err matched an approval-flow case and the response
|
||||
// has been written. False = caller should keep walking the switch.
|
||||
//
|
||||
// Response shape (t-paliad-160 §B):
|
||||
//
|
||||
// {
|
||||
// code: "awaiting_approval" | "no_qualified_approver" | ...,
|
||||
// message: "<localizable German hint>",
|
||||
// request_id?: "<uuid>", // present when known
|
||||
// required_role?: "<role>", // present when known
|
||||
// }
|
||||
func mapApprovalError(w http.ResponseWriter, err error) bool {
|
||||
var pendingErr *services.PendingApprovalError
|
||||
if errors.As(err, &pendingErr) {
|
||||
body := map[string]string{
|
||||
"code": "awaiting_approval",
|
||||
"message": "Diese Anforderung wartet auf Genehmigung.",
|
||||
}
|
||||
if pendingErr.RequestID != "" {
|
||||
body["request_id"] = pendingErr.RequestID
|
||||
}
|
||||
if pendingErr.RequiredRole != "" {
|
||||
body["required_role"] = pendingErr.RequiredRole
|
||||
}
|
||||
writeJSON(w, http.StatusConflict, body)
|
||||
return true
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, services.ErrConcurrentPending):
|
||||
writeJSON(w, http.StatusConflict, map[string]string{
|
||||
"code": "awaiting_approval",
|
||||
"message": "Diese Anforderung wartet auf Genehmigung.",
|
||||
})
|
||||
return true
|
||||
case errors.Is(err, services.ErrNoQualifiedApprover):
|
||||
writeJSON(w, http.StatusConflict, map[string]string{
|
||||
"code": "no_qualified_approver",
|
||||
"message": "Es gibt keinen anderen Benutzer, der diese Anfrage genehmigen kann.",
|
||||
})
|
||||
return true
|
||||
case errors.Is(err, services.ErrSelfApproval):
|
||||
writeJSON(w, http.StatusForbidden, map[string]string{
|
||||
"code": "self_approval_blocked",
|
||||
"message": "Selbst-Genehmigung ist nicht erlaubt.",
|
||||
})
|
||||
return true
|
||||
case errors.Is(err, services.ErrNotApprover):
|
||||
writeJSON(w, http.StatusForbidden, map[string]string{
|
||||
"code": "not_authorized",
|
||||
"message": "Sie sind für diese Genehmigung nicht berechtigt.",
|
||||
})
|
||||
return true
|
||||
case errors.Is(err, services.ErrRequestNotPending):
|
||||
writeJSON(w, http.StatusConflict, map[string]string{
|
||||
"code": "request_not_pending",
|
||||
"message": "Die Anfrage ist nicht mehr offen.",
|
||||
})
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GET /api/projects — list visible projects.
|
||||
// Query params: ?type=case&status=active&parent_id=<uuid>&parent_null=1&search=foo
|
||||
func handleListProjects(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -536,10 +536,19 @@ type ApprovalPolicy struct {
|
||||
PartnerUnitID *uuid.UUID `db:"partner_unit_id" json:"partner_unit_id,omitempty"`
|
||||
EntityType string `db:"entity_type" json:"entity_type"`
|
||||
LifecycleEvent string `db:"lifecycle_event" json:"lifecycle_event"`
|
||||
RequiredRole string `db:"required_role" json:"required_role"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
CreatedBy *uuid.UUID `db:"created_by" json:"created_by,omitempty"`
|
||||
// RequiresApproval is the gate (t-paliad-160). False = lifecycle event
|
||||
// auto-passes, no approval_request inserted.
|
||||
RequiresApproval bool `db:"requires_approval" json:"requires_approval"`
|
||||
// MinRole is the minimum profession tier qualified to approve. NULL
|
||||
// (nil) when RequiresApproval=false. Constraint: the two columns are
|
||||
// XOR-locked — either (false, NULL) or (true, role).
|
||||
MinRole *string `db:"min_role" json:"min_role,omitempty"`
|
||||
// RequiredRole is the legacy column kept for one release as a dual-read
|
||||
// mirror of MinRole during the M1 window. Drop in M2.
|
||||
RequiredRole string `db:"required_role" json:"required_role"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
CreatedBy *uuid.UUID `db:"created_by" json:"created_by,omitempty"`
|
||||
}
|
||||
|
||||
// EffectivePolicy is the resolved policy for one (project, entity_type,
|
||||
@@ -554,12 +563,20 @@ type ApprovalPolicy struct {
|
||||
// "unit_default"} when RequiredRole is non-nil. SourceID is the project_id
|
||||
// for project / ancestor sources; the partner_unit_id for unit_default.
|
||||
type EffectivePolicy struct {
|
||||
EntityType string `json:"entity_type"`
|
||||
LifecycleEvent string `json:"lifecycle_event"`
|
||||
RequiredRole *string `json:"required_role,omitempty"`
|
||||
Source *string `json:"source,omitempty"`
|
||||
SourceID *uuid.UUID `json:"source_id,omitempty"`
|
||||
SourceName *string `json:"source_name,omitempty"`
|
||||
EntityType string `json:"entity_type"`
|
||||
LifecycleEvent string `json:"lifecycle_event"`
|
||||
// RequiresApproval is the gate (t-paliad-160 split-grammar). True iff
|
||||
// the resolver yielded a policy that demands approval.
|
||||
RequiresApproval bool `json:"requires_approval"`
|
||||
// MinRole is the seniority threshold (NULL when gate is off).
|
||||
MinRole *string `json:"min_role,omitempty"`
|
||||
// RequiredRole mirrors MinRole during the M1 dual-read window so the
|
||||
// legacy frontend / API consumers keep working until they cut over.
|
||||
// Drop in M2.
|
||||
RequiredRole *string `json:"required_role,omitempty"`
|
||||
Source *string `json:"source,omitempty"`
|
||||
SourceID *uuid.UUID `json:"source_id,omitempty"`
|
||||
SourceName *string `json:"source_name,omitempty"`
|
||||
}
|
||||
|
||||
// PolicyAuditEntry is one row of paliad.policy_audit_log — admin-only audit
|
||||
|
||||
@@ -39,6 +39,20 @@ func (s *AppointmentService) SetApprovalService(a *ApprovalService) {
|
||||
s.approvals = a
|
||||
}
|
||||
|
||||
// pendingApprovalErr enriches ErrConcurrentPending with the in-flight
|
||||
// request id + required role for a 409 hint. Falls back to the bare
|
||||
// ErrConcurrentPending when approvals is unwired or the lookup fails.
|
||||
func (s *AppointmentService) pendingApprovalErr(ctx context.Context, appointmentID uuid.UUID) error {
|
||||
if s.approvals == nil {
|
||||
return ErrConcurrentPending
|
||||
}
|
||||
rid, role, err := s.approvals.PendingRequestForEntity(ctx, EntityTypeAppointment, appointmentID)
|
||||
if err != nil || rid == "" {
|
||||
return ErrConcurrentPending
|
||||
}
|
||||
return NewPendingApprovalError(rid, role)
|
||||
}
|
||||
|
||||
// AppointmentCalDAVPusher is the contract the CalDAV service implements so the
|
||||
// AppointmentService can push individual appointment changes without importing the
|
||||
// caldav package directly.
|
||||
@@ -384,7 +398,7 @@ func (s *AppointmentService) Update(ctx context.Context, userID, appointmentID u
|
||||
return nil, err
|
||||
}
|
||||
if current.ApprovalStatus == ApprovalStatusPending {
|
||||
return nil, ErrConcurrentPending
|
||||
return nil, s.pendingApprovalErr(ctx, appointmentID)
|
||||
}
|
||||
|
||||
sets := []string{}
|
||||
@@ -587,7 +601,7 @@ func (s *AppointmentService) Delete(ctx context.Context, userID, appointmentID u
|
||||
return err
|
||||
}
|
||||
if current.ApprovalStatus == ApprovalStatusPending {
|
||||
return ErrConcurrentPending
|
||||
return s.pendingApprovalErr(ctx, appointmentID)
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
|
||||
@@ -165,3 +165,32 @@ var (
|
||||
ErrRequestNotPending = errors.New("request is not pending")
|
||||
ErrUnknownEntityType = errors.New("unknown entity type")
|
||||
)
|
||||
|
||||
// PendingApprovalError wraps ErrConcurrentPending with the in-flight
|
||||
// request's id + required role, so handlers can render a 409 body that
|
||||
// tells the user which request is blocking them and lets the UI offer
|
||||
// a "withdraw" affordance pointing at that request.
|
||||
//
|
||||
// Construct via NewPendingApprovalError(requestID, requiredRole). Unwraps
|
||||
// to ErrConcurrentPending so existing errors.Is() checks still work.
|
||||
type PendingApprovalError struct {
|
||||
RequestID string
|
||||
RequiredRole string
|
||||
}
|
||||
|
||||
func (e *PendingApprovalError) Error() string {
|
||||
if e.RequestID == "" {
|
||||
return ErrConcurrentPending.Error()
|
||||
}
|
||||
return ErrConcurrentPending.Error() + ": request_id=" + e.RequestID
|
||||
}
|
||||
|
||||
func (e *PendingApprovalError) Unwrap() error { return ErrConcurrentPending }
|
||||
|
||||
// NewPendingApprovalError builds a PendingApprovalError for an entity row
|
||||
// whose pending_request_id is non-nil. requestID may be the empty string
|
||||
// when the entity row's pending_request_id is unexpectedly NULL — the
|
||||
// error still works as a generic ErrConcurrentPending in that case.
|
||||
func NewPendingApprovalError(requestID, requiredRole string) *PendingApprovalError {
|
||||
return &PendingApprovalError{RequestID: requestID, RequiredRole: requiredRole}
|
||||
}
|
||||
|
||||
@@ -68,26 +68,29 @@ func NewApprovalService(db *sqlx.DB, users *UserService) *ApprovalService {
|
||||
// or nil if none applies. Reads inside the same tx as Submit* so policy
|
||||
// reads see whatever the calling tx may have already written.
|
||||
//
|
||||
// Resolution (t-paliad-154): delegates to paliad.approval_policy_effective(),
|
||||
// which returns at most one row after walking the project-row → ancestor-row
|
||||
// → unit-default cascade and picking most-restrictive across candidates.
|
||||
// Resolution (t-paliad-160): delegates to paliad.approval_policy_effective(),
|
||||
// which returns at most one row after the most-strict-wins fold over the
|
||||
// project-row / ancestor-row / unit-default candidates. The split-grammar
|
||||
// columns are:
|
||||
//
|
||||
// 'none' short-circuit: when the resolver yields required_role='none' (only
|
||||
// possible from a project-specific row, since unit/ancestor candidates with
|
||||
// 'none' lose MAX to any non-none), this returns nil — the gate is
|
||||
// suppressed and no approval request is created.
|
||||
// - requires_approval — the gate (OR across candidates).
|
||||
// - min_role — the seniority threshold (MAX along the role
|
||||
// ladder among the requires_approval=true
|
||||
// candidates). NULL when the gate is off.
|
||||
//
|
||||
// The returned ApprovalPolicy is synthetic when source != 'project': it
|
||||
// carries the resolved required_role + the actual project_id (so downstream
|
||||
// code that branches on ProjectID still works), but no DB id since the
|
||||
// effective rule may have been computed across multiple rows.
|
||||
// When the gate is off (requires_approval=false OR no candidates), this
|
||||
// returns nil and the caller skips creating an approval_request entirely.
|
||||
// The legacy required_role column is mirrored from min_role for any caller
|
||||
// still on the old grammar (M1 dual-read).
|
||||
func (s *ApprovalService) LookupPolicy(ctx context.Context, tx *sqlx.Tx, projectID uuid.UUID, entityType, lifecycleEvent string) (*models.ApprovalPolicy, error) {
|
||||
var row struct {
|
||||
RequiredRole string `db:"required_role"`
|
||||
Source sql.NullString `db:"source"`
|
||||
SourceID *uuid.UUID `db:"source_id"`
|
||||
RequiresApproval bool `db:"requires_approval"`
|
||||
MinRole sql.NullString `db:"min_role"`
|
||||
RequiredRole sql.NullString `db:"required_role"`
|
||||
Source sql.NullString `db:"source"`
|
||||
SourceID *uuid.UUID `db:"source_id"`
|
||||
}
|
||||
q := `SELECT required_role, source, source_id
|
||||
q := `SELECT requires_approval, min_role, required_role, source, source_id
|
||||
FROM paliad.approval_policy_effective($1, $2, $3)`
|
||||
if err := txOrDB(tx, s.db).GetContext(ctx, &row, q, projectID, entityType, lifecycleEvent); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
@@ -95,15 +98,15 @@ func (s *ApprovalService) LookupPolicy(ctx context.Context, tx *sqlx.Tx, project
|
||||
}
|
||||
return nil, fmt.Errorf("lookup approval policy: %w", err)
|
||||
}
|
||||
if row.RequiredRole == "none" {
|
||||
return nil, nil // explicit suppression at project-row level
|
||||
if !row.RequiresApproval || !row.MinRole.Valid {
|
||||
return nil, nil // gate off — no approval request needed
|
||||
}
|
||||
pid := projectID
|
||||
return &models.ApprovalPolicy{
|
||||
ProjectID: &pid,
|
||||
EntityType: entityType,
|
||||
LifecycleEvent: lifecycleEvent,
|
||||
RequiredRole: row.RequiredRole,
|
||||
RequiredRole: row.MinRole.String,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -659,6 +662,31 @@ func (s *ApprovalService) entityApprovalStatus(ctx context.Context, tx *sqlx.Tx,
|
||||
return status, nil
|
||||
}
|
||||
|
||||
// PendingRequestForEntity returns the request_id + required_role of the
|
||||
// in-flight approval_request for an entity in approval_status='pending'.
|
||||
// Returns ("", "", nil) when no pending request is associated. Used by
|
||||
// the entity services to enrich ErrConcurrentPending into a
|
||||
// PendingApprovalError that handlers can render as a 409 with structured
|
||||
// payload.
|
||||
func (s *ApprovalService) PendingRequestForEntity(ctx context.Context, entityType string, entityID uuid.UUID) (string, string, error) {
|
||||
q := `SELECT id::text, required_role
|
||||
FROM paliad.approval_requests
|
||||
WHERE entity_type = $1 AND entity_id = $2 AND status = 'pending'
|
||||
ORDER BY requested_at DESC
|
||||
LIMIT 1`
|
||||
var row struct {
|
||||
ID string `db:"id"`
|
||||
RequiredRole string `db:"required_role"`
|
||||
}
|
||||
if err := s.db.GetContext(ctx, &row, q, entityType, entityID); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", "", nil
|
||||
}
|
||||
return "", "", fmt.Errorf("lookup pending request: %w", err)
|
||||
}
|
||||
return row.ID, row.RequiredRole, nil
|
||||
}
|
||||
|
||||
// entityTableName resolves the SQL table name for a given entity_type.
|
||||
// Internal helper — entityType comes from server-side constants, not user
|
||||
// input, so a panic on an unknown value is a programming error.
|
||||
@@ -951,7 +979,8 @@ func IsValidPolicyRole(role string) bool {
|
||||
// rows or unit defaults — those come via GetEffectivePoliciesMatrix.
|
||||
func (s *ApprovalService) ListProjectPolicies(ctx context.Context, projectID uuid.UUID) ([]models.ApprovalPolicy, error) {
|
||||
q := `SELECT id, project_id, partner_unit_id, entity_type, lifecycle_event,
|
||||
required_role, created_at, updated_at, created_by
|
||||
requires_approval, min_role, required_role,
|
||||
created_at, updated_at, created_by
|
||||
FROM paliad.approval_policies
|
||||
WHERE project_id = $1
|
||||
ORDER BY entity_type, lifecycle_event`
|
||||
@@ -966,7 +995,8 @@ func (s *ApprovalService) ListProjectPolicies(ctx context.Context, projectID uui
|
||||
// partner unit (up to 8).
|
||||
func (s *ApprovalService) ListUnitPolicies(ctx context.Context, unitID uuid.UUID) ([]models.ApprovalPolicy, error) {
|
||||
q := `SELECT id, project_id, partner_unit_id, entity_type, lifecycle_event,
|
||||
required_role, created_at, updated_at, created_by
|
||||
requires_approval, min_role, required_role,
|
||||
created_at, updated_at, created_by
|
||||
FROM paliad.approval_policies
|
||||
WHERE partner_unit_id = $1
|
||||
ORDER BY entity_type, lifecycle_event`
|
||||
@@ -994,14 +1024,79 @@ func validatePolicyTuple(entityType, lifecycle, requiredRole string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// validatePolicySplit validates the split-grammar tuple (requires_approval,
|
||||
// min_role). When requires_approval=true, min_role must be one of the
|
||||
// strict-ladder professions; when false, min_role must be nil.
|
||||
func validatePolicySplit(entityType, lifecycle string, requiresApproval bool, minRole *string) error {
|
||||
if entityType != EntityTypeDeadline && entityType != EntityTypeAppointment {
|
||||
return fmt.Errorf("%w: entity_type %q", ErrInvalidInput, entityType)
|
||||
}
|
||||
switch lifecycle {
|
||||
case LifecycleCreate, LifecycleUpdate, LifecycleComplete, LifecycleDelete:
|
||||
default:
|
||||
return fmt.Errorf("%w: lifecycle_event %q", ErrInvalidInput, lifecycle)
|
||||
}
|
||||
if requiresApproval {
|
||||
if minRole == nil || !IsValidRequiredRole(*minRole) {
|
||||
role := ""
|
||||
if minRole != nil {
|
||||
role = *minRole
|
||||
}
|
||||
return fmt.Errorf("%w: min_role %q (required when requires_approval=true)", ErrInvalidInput, role)
|
||||
}
|
||||
} else if minRole != nil {
|
||||
return fmt.Errorf("%w: min_role must be NULL when requires_approval=false", ErrInvalidInput)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// splitFromLegacy maps the legacy required_role grammar into the
|
||||
// split-grammar pair. 'none' → (false, nil); else → (true, &role). Used by
|
||||
// the back-compat Upsert*Policy shims that still take required_role.
|
||||
func splitFromLegacy(requiredRole string) (bool, *string) {
|
||||
if requiredRole == "none" {
|
||||
return false, nil
|
||||
}
|
||||
r := requiredRole
|
||||
return true, &r
|
||||
}
|
||||
|
||||
// legacyFromSplit is the inverse: produce the audit-row required_role
|
||||
// string. Used so the policy_audit_log keeps the human-readable role
|
||||
// (or 'none') under the old grammar even after callers cut over to the
|
||||
// split-grammar API.
|
||||
func legacyFromSplit(requiresApproval bool, minRole *string) string {
|
||||
if !requiresApproval || minRole == nil {
|
||||
return "none"
|
||||
}
|
||||
return *minRole
|
||||
}
|
||||
|
||||
// UpsertProjectPolicy creates or replaces a single project-scoped policy
|
||||
// row. Caller must be global_admin (gate enforced at the handler layer).
|
||||
// Audit row written via writePolicyAudit. 'none' as required_role is
|
||||
// allowed and suppresses inherited defaults explicitly.
|
||||
// row using the legacy required_role grammar ('none' → no approval; else
|
||||
// the strict-ladder role). Thin shim around UpsertProjectPolicySplit kept
|
||||
// for callers (and tests) that haven't cut over yet.
|
||||
func (s *ApprovalService) UpsertProjectPolicy(ctx context.Context, callerID, projectID uuid.UUID, entityType, lifecycle, requiredRole string) (*models.ApprovalPolicy, error) {
|
||||
if err := validatePolicyTuple(entityType, lifecycle, requiredRole); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
requiresApproval, minRole := splitFromLegacy(requiredRole)
|
||||
return s.UpsertProjectPolicySplit(ctx, callerID, projectID, entityType, lifecycle, requiresApproval, minRole)
|
||||
}
|
||||
|
||||
// UpsertProjectPolicySplit creates or replaces a single project-scoped
|
||||
// policy row using the split-grammar (requires_approval, min_role) shape
|
||||
// (t-paliad-160). Caller must be global_admin (gate enforced at the
|
||||
// handler layer). Audit row written via writePolicyAudit using the
|
||||
// legacy required_role string for compatibility with the existing
|
||||
// policy_audit_log shape.
|
||||
func (s *ApprovalService) UpsertProjectPolicySplit(
|
||||
ctx context.Context, callerID, projectID uuid.UUID,
|
||||
entityType, lifecycle string, requiresApproval bool, minRole *string,
|
||||
) (*models.ApprovalPolicy, error) {
|
||||
if err := validatePolicySplit(entityType, lifecycle, requiresApproval, minRole); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
@@ -1018,17 +1113,24 @@ func (s *ApprovalService) UpsertProjectPolicy(ctx context.Context, callerID, pro
|
||||
return nil, fmt.Errorf("upsert project policy: read pre-image: %w", err)
|
||||
}
|
||||
|
||||
requiredRole := legacyFromSplit(requiresApproval, minRole)
|
||||
q := `INSERT INTO paliad.approval_policies
|
||||
(project_id, partner_unit_id, entity_type, lifecycle_event, required_role, created_by)
|
||||
VALUES ($1, NULL, $2, $3, $4, $5)
|
||||
(project_id, partner_unit_id, entity_type, lifecycle_event,
|
||||
requires_approval, min_role, required_role, created_by)
|
||||
VALUES ($1, NULL, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (project_id, entity_type, lifecycle_event)
|
||||
WHERE project_id IS NOT NULL
|
||||
DO UPDATE SET required_role = EXCLUDED.required_role,
|
||||
DO UPDATE SET requires_approval = EXCLUDED.requires_approval,
|
||||
min_role = EXCLUDED.min_role,
|
||||
required_role = EXCLUDED.required_role,
|
||||
updated_at = now()
|
||||
RETURNING id, project_id, partner_unit_id, entity_type, lifecycle_event,
|
||||
required_role, created_at, updated_at, created_by`
|
||||
requires_approval, min_role, required_role,
|
||||
created_at, updated_at, created_by`
|
||||
var p models.ApprovalPolicy
|
||||
if err := tx.GetContext(ctx, &p, q, projectID, entityType, lifecycle, requiredRole, callerID); err != nil {
|
||||
if err := tx.GetContext(ctx, &p, q,
|
||||
projectID, entityType, lifecycle,
|
||||
requiresApproval, minRole, requiredRole, callerID); err != nil {
|
||||
return nil, fmt.Errorf("upsert project policy: %w", err)
|
||||
}
|
||||
|
||||
@@ -1095,11 +1197,26 @@ func (s *ApprovalService) DeleteProjectPolicy(ctx context.Context, callerID, pro
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// UpsertUnitPolicy creates or replaces a single unit-default policy row.
|
||||
// UpsertUnitPolicy creates or replaces a single unit-default policy row
|
||||
// using the legacy required_role grammar. Thin shim around
|
||||
// UpsertUnitPolicySplit kept for callers / tests that haven't cut over.
|
||||
func (s *ApprovalService) UpsertUnitPolicy(ctx context.Context, callerID, unitID uuid.UUID, entityType, lifecycle, requiredRole string) (*models.ApprovalPolicy, error) {
|
||||
if err := validatePolicyTuple(entityType, lifecycle, requiredRole); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
requiresApproval, minRole := splitFromLegacy(requiredRole)
|
||||
return s.UpsertUnitPolicySplit(ctx, callerID, unitID, entityType, lifecycle, requiresApproval, minRole)
|
||||
}
|
||||
|
||||
// UpsertUnitPolicySplit creates or replaces a single unit-default policy
|
||||
// row using the split-grammar (requires_approval, min_role) shape.
|
||||
func (s *ApprovalService) UpsertUnitPolicySplit(
|
||||
ctx context.Context, callerID, unitID uuid.UUID,
|
||||
entityType, lifecycle string, requiresApproval bool, minRole *string,
|
||||
) (*models.ApprovalPolicy, error) {
|
||||
if err := validatePolicySplit(entityType, lifecycle, requiresApproval, minRole); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
@@ -1115,17 +1232,24 @@ func (s *ApprovalService) UpsertUnitPolicy(ctx context.Context, callerID, unitID
|
||||
return nil, fmt.Errorf("upsert unit policy: read pre-image: %w", err)
|
||||
}
|
||||
|
||||
requiredRole := legacyFromSplit(requiresApproval, minRole)
|
||||
q := `INSERT INTO paliad.approval_policies
|
||||
(project_id, partner_unit_id, entity_type, lifecycle_event, required_role, created_by)
|
||||
VALUES (NULL, $1, $2, $3, $4, $5)
|
||||
(project_id, partner_unit_id, entity_type, lifecycle_event,
|
||||
requires_approval, min_role, required_role, created_by)
|
||||
VALUES (NULL, $1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (partner_unit_id, entity_type, lifecycle_event)
|
||||
WHERE partner_unit_id IS NOT NULL
|
||||
DO UPDATE SET required_role = EXCLUDED.required_role,
|
||||
DO UPDATE SET requires_approval = EXCLUDED.requires_approval,
|
||||
min_role = EXCLUDED.min_role,
|
||||
required_role = EXCLUDED.required_role,
|
||||
updated_at = now()
|
||||
RETURNING id, project_id, partner_unit_id, entity_type, lifecycle_event,
|
||||
required_role, created_at, updated_at, created_by`
|
||||
requires_approval, min_role, required_role,
|
||||
created_at, updated_at, created_by`
|
||||
var p models.ApprovalPolicy
|
||||
if err := tx.GetContext(ctx, &p, q, unitID, entityType, lifecycle, requiredRole, callerID); err != nil {
|
||||
if err := tx.GetContext(ctx, &p, q,
|
||||
unitID, entityType, lifecycle,
|
||||
requiresApproval, minRole, requiredRole, callerID); err != nil {
|
||||
return nil, fmt.Errorf("upsert unit policy: %w", err)
|
||||
}
|
||||
|
||||
@@ -1227,13 +1351,20 @@ func (s *ApprovalService) GetEffectivePoliciesMatrix(ctx context.Context, projec
|
||||
|
||||
// GetEffectivePolicyOne returns the EffectivePolicy for a single cell.
|
||||
// Used by the form-time hint endpoint on /projects/{id}/deadlines/new etc.
|
||||
//
|
||||
// Carries the t-paliad-160 split-grammar fields: RequiresApproval is the
|
||||
// gate, MinRole the seniority threshold (NULL when gate off). RequiredRole
|
||||
// is mirrored from MinRole for back-compat with callers that still read the
|
||||
// legacy column (M1 dual-read window).
|
||||
func (s *ApprovalService) GetEffectivePolicyOne(ctx context.Context, projectID uuid.UUID, entityType, lifecycle string) (*models.EffectivePolicy, error) {
|
||||
var row struct {
|
||||
RequiredRole sql.NullString `db:"required_role"`
|
||||
Source sql.NullString `db:"source"`
|
||||
SourceID *uuid.UUID `db:"source_id"`
|
||||
RequiresApproval bool `db:"requires_approval"`
|
||||
MinRole sql.NullString `db:"min_role"`
|
||||
RequiredRole sql.NullString `db:"required_role"`
|
||||
Source sql.NullString `db:"source"`
|
||||
SourceID *uuid.UUID `db:"source_id"`
|
||||
}
|
||||
q := `SELECT required_role, source, source_id
|
||||
q := `SELECT requires_approval, min_role, required_role, source, source_id
|
||||
FROM paliad.approval_policy_effective($1, $2, $3)`
|
||||
if err := s.db.GetContext(ctx, &row, q, projectID, entityType, lifecycle); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
@@ -1246,12 +1377,14 @@ func (s *ApprovalService) GetEffectivePolicyOne(ctx context.Context, projectID u
|
||||
}
|
||||
|
||||
res := &models.EffectivePolicy{
|
||||
EntityType: entityType,
|
||||
LifecycleEvent: lifecycle,
|
||||
EntityType: entityType,
|
||||
LifecycleEvent: lifecycle,
|
||||
RequiresApproval: row.RequiresApproval,
|
||||
}
|
||||
if row.RequiredRole.Valid {
|
||||
rr := row.RequiredRole.String
|
||||
res.RequiredRole = &rr
|
||||
if row.MinRole.Valid {
|
||||
mr := row.MinRole.String
|
||||
res.MinRole = &mr
|
||||
res.RequiredRole = &mr // dual-read mirror until M2
|
||||
}
|
||||
if row.Source.Valid {
|
||||
src := row.Source.String
|
||||
@@ -1353,16 +1486,24 @@ func (s *ApprovalService) ApplyMatrixToDescendants(ctx context.Context, callerID
|
||||
WHERE project_id = $1`, target); err != nil {
|
||||
return 0, fmt.Errorf("apply matrix: clear target %s: %w", target, err)
|
||||
}
|
||||
// Apply source's effective values as project-scoped rows.
|
||||
// Apply source's effective values as project-scoped rows. Skip
|
||||
// cells where the source has no policy at all (no candidates) —
|
||||
// the target is left to inherit from its own ancestors / unit
|
||||
// defaults rather than getting a synthetic project row written.
|
||||
for _, cell := range matrix {
|
||||
if cell.RequiredRole == nil {
|
||||
continue
|
||||
if cell.Source == nil {
|
||||
continue // no candidates for this cell at the source
|
||||
}
|
||||
requiresApproval := cell.RequiresApproval
|
||||
minRole := cell.MinRole
|
||||
requiredRole := legacyFromSplit(requiresApproval, minRole)
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO paliad.approval_policies
|
||||
(project_id, partner_unit_id, entity_type, lifecycle_event, required_role, created_by)
|
||||
VALUES ($1, NULL, $2, $3, $4, $5)`,
|
||||
target, cell.EntityType, cell.LifecycleEvent, *cell.RequiredRole, callerID); err != nil {
|
||||
(project_id, partner_unit_id, entity_type, lifecycle_event,
|
||||
requires_approval, min_role, required_role, created_by)
|
||||
VALUES ($1, NULL, $2, $3, $4, $5, $6, $7)`,
|
||||
target, cell.EntityType, cell.LifecycleEvent,
|
||||
requiresApproval, minRole, requiredRole, callerID); err != nil {
|
||||
return 0, fmt.Errorf("apply matrix: write target %s cell %s/%s: %w",
|
||||
target, cell.EntityType, cell.LifecycleEvent, err)
|
||||
}
|
||||
@@ -1434,7 +1575,8 @@ func (s *ApprovalService) snapshotProjectRows(ctx context.Context, tx *sqlx.Tx,
|
||||
var rows []models.ApprovalPolicy
|
||||
if err := tx.SelectContext(ctx, &rows,
|
||||
`SELECT id, project_id, partner_unit_id, entity_type, lifecycle_event,
|
||||
required_role, created_at, updated_at, created_by
|
||||
requires_approval, min_role, required_role,
|
||||
created_at, updated_at, created_by
|
||||
FROM paliad.approval_policies
|
||||
WHERE project_id = $1`, projectID); err != nil {
|
||||
return nil, fmt.Errorf("snapshot project rows: %w", err)
|
||||
|
||||
@@ -48,6 +48,23 @@ func (s *DeadlineService) SetApprovalService(a *ApprovalService) {
|
||||
s.approvals = a
|
||||
}
|
||||
|
||||
// pendingApprovalErr enriches ErrConcurrentPending with the in-flight
|
||||
// approval_request id + required_role for an entity, so handlers can
|
||||
// render a 409 body that points the UI at the blocking request. Falls
|
||||
// back to the bare ErrConcurrentPending if approvals isn't wired or the
|
||||
// lookup fails — the user still gets a 409, just without the structured
|
||||
// hint.
|
||||
func (s *DeadlineService) pendingApprovalErr(ctx context.Context, deadlineID uuid.UUID) error {
|
||||
if s.approvals == nil {
|
||||
return ErrConcurrentPending
|
||||
}
|
||||
rid, role, err := s.approvals.PendingRequestForEntity(ctx, EntityTypeDeadline, deadlineID)
|
||||
if err != nil || rid == "" {
|
||||
return ErrConcurrentPending
|
||||
}
|
||||
return NewPendingApprovalError(rid, role)
|
||||
}
|
||||
|
||||
const deadlineColumns = `id, project_id, title, description, due_date, original_due_date,
|
||||
warning_date, source, rule_id, rule_code, status, completed_at, caldav_uid, caldav_etag,
|
||||
notes, created_by, created_at, updated_at,
|
||||
@@ -420,7 +437,7 @@ func (s *DeadlineService) Update(ctx context.Context, userID, deadlineID uuid.UU
|
||||
return nil, err
|
||||
}
|
||||
if current.ApprovalStatus == ApprovalStatusPending {
|
||||
return nil, ErrConcurrentPending
|
||||
return nil, s.pendingApprovalErr(ctx, deadlineID)
|
||||
}
|
||||
|
||||
sets := []string{}
|
||||
@@ -594,7 +611,7 @@ func (s *DeadlineService) Complete(ctx context.Context, userID, deadlineID uuid.
|
||||
return current, nil
|
||||
}
|
||||
if current.ApprovalStatus == ApprovalStatusPending {
|
||||
return nil, ErrConcurrentPending
|
||||
return nil, s.pendingApprovalErr(ctx, deadlineID)
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
@@ -741,7 +758,7 @@ func (s *DeadlineService) Delete(ctx context.Context, userID, deadlineID uuid.UU
|
||||
return err
|
||||
}
|
||||
if current.ApprovalStatus == ApprovalStatusPending {
|
||||
return ErrConcurrentPending
|
||||
return s.pendingApprovalErr(ctx, deadlineID)
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
|
||||
Reference in New Issue
Block a user