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:
m
2026-05-08 16:54:45 +02:00
parent b23a08867b
commit 3a41aa9209
10 changed files with 824 additions and 93 deletions

View 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;

View 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).';

View File

@@ -62,7 +62,8 @@ func handleListApprovalPolicies(w http.ResponseWriter, r *http.Request) {
// PUT /api/projects/{id}/approval-policies/{entity_type}/{lifecycle} // 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 // Semantics: upsert. Replaces any existing row for the same
// (project, entity_type, lifecycle) tuple. // (project, entity_type, lifecycle) tuple.
@@ -81,14 +82,17 @@ func handlePutApprovalPolicy(w http.ResponseWriter, r *http.Request) {
} }
entityType := r.PathValue("entity_type") entityType := r.PathValue("entity_type")
lifecycle := r.PathValue("lifecycle") lifecycle := r.PathValue("lifecycle")
var body struct { body, err := decodePolicyBody(r)
RequiredRole string `json:"required_role"` if err != nil {
} writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return 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 { if err != nil {
writeServiceError(w, err) writeServiceError(w, err)
return return
@@ -96,6 +100,51 @@ func handlePutApprovalPolicy(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, policy) 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} // DELETE /api/projects/{id}/approval-policies/{entity_type}/{lifecycle}
// //
// Removes one policy row, reverting that lifecycle event back to the // 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} // 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) { func handlePutUnitApprovalPolicy(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) { if !requireDB(w) {
return return
@@ -338,14 +388,17 @@ func handlePutUnitApprovalPolicy(w http.ResponseWriter, r *http.Request) {
} }
entityType := r.PathValue("entity_type") entityType := r.PathValue("entity_type")
lifecycle := r.PathValue("lifecycle") lifecycle := r.PathValue("lifecycle")
var body struct { body, err := decodePolicyBody(r)
RequiredRole string `json:"required_role"` if err != nil {
} writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return 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 { if err != nil {
writeServiceError(w, err) writeServiceError(w, err)
return return
@@ -491,20 +544,13 @@ func handleProjectEffectivePolicy(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, row) 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) { func writeApprovalError(w http.ResponseWriter, err error) {
switch { if mapApprovalError(w, err) {
case errors.Is(err, services.ErrSelfApproval): return
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)
} }
writeServiceError(w, err)
} }

View 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)
}
}

View File

@@ -79,6 +79,9 @@ func requireUser(w http.ResponseWriter, r *http.Request) (uuid.UUID, bool) {
// writeServiceError maps a services error to an HTTP status. // writeServiceError maps a services error to an HTTP status.
func writeServiceError(w http.ResponseWriter, err error) { func writeServiceError(w http.ResponseWriter, err error) {
if mapApprovalError(w, err) {
return
}
switch { switch {
case errors.Is(err, services.ErrNotVisible): case errors.Is(err, services.ErrNotVisible):
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"}) 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. // GET /api/projects — list visible projects.
// Query params: ?type=case&status=active&parent_id=<uuid>&parent_null=1&search=foo // Query params: ?type=case&status=active&parent_id=<uuid>&parent_null=1&search=foo
func handleListProjects(w http.ResponseWriter, r *http.Request) { func handleListProjects(w http.ResponseWriter, r *http.Request) {

View File

@@ -536,10 +536,19 @@ type ApprovalPolicy struct {
PartnerUnitID *uuid.UUID `db:"partner_unit_id" json:"partner_unit_id,omitempty"` PartnerUnitID *uuid.UUID `db:"partner_unit_id" json:"partner_unit_id,omitempty"`
EntityType string `db:"entity_type" json:"entity_type"` EntityType string `db:"entity_type" json:"entity_type"`
LifecycleEvent string `db:"lifecycle_event" json:"lifecycle_event"` LifecycleEvent string `db:"lifecycle_event" json:"lifecycle_event"`
RequiredRole string `db:"required_role" json:"required_role"` // RequiresApproval is the gate (t-paliad-160). False = lifecycle event
CreatedAt time.Time `db:"created_at" json:"created_at"` // auto-passes, no approval_request inserted.
UpdatedAt time.Time `db:"updated_at" json:"updated_at"` RequiresApproval bool `db:"requires_approval" json:"requires_approval"`
CreatedBy *uuid.UUID `db:"created_by" json:"created_by,omitempty"` // 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, // 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 // "unit_default"} when RequiredRole is non-nil. SourceID is the project_id
// for project / ancestor sources; the partner_unit_id for unit_default. // for project / ancestor sources; the partner_unit_id for unit_default.
type EffectivePolicy struct { type EffectivePolicy struct {
EntityType string `json:"entity_type"` EntityType string `json:"entity_type"`
LifecycleEvent string `json:"lifecycle_event"` LifecycleEvent string `json:"lifecycle_event"`
RequiredRole *string `json:"required_role,omitempty"` // RequiresApproval is the gate (t-paliad-160 split-grammar). True iff
Source *string `json:"source,omitempty"` // the resolver yielded a policy that demands approval.
SourceID *uuid.UUID `json:"source_id,omitempty"` RequiresApproval bool `json:"requires_approval"`
SourceName *string `json:"source_name,omitempty"` // 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 // PolicyAuditEntry is one row of paliad.policy_audit_log — admin-only audit

View File

@@ -39,6 +39,20 @@ func (s *AppointmentService) SetApprovalService(a *ApprovalService) {
s.approvals = a 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 // AppointmentCalDAVPusher is the contract the CalDAV service implements so the
// AppointmentService can push individual appointment changes without importing the // AppointmentService can push individual appointment changes without importing the
// caldav package directly. // caldav package directly.
@@ -384,7 +398,7 @@ func (s *AppointmentService) Update(ctx context.Context, userID, appointmentID u
return nil, err return nil, err
} }
if current.ApprovalStatus == ApprovalStatusPending { if current.ApprovalStatus == ApprovalStatusPending {
return nil, ErrConcurrentPending return nil, s.pendingApprovalErr(ctx, appointmentID)
} }
sets := []string{} sets := []string{}
@@ -587,7 +601,7 @@ func (s *AppointmentService) Delete(ctx context.Context, userID, appointmentID u
return err return err
} }
if current.ApprovalStatus == ApprovalStatusPending { if current.ApprovalStatus == ApprovalStatusPending {
return ErrConcurrentPending return s.pendingApprovalErr(ctx, appointmentID)
} }
tx, err := s.db.BeginTxx(ctx, nil) tx, err := s.db.BeginTxx(ctx, nil)

View File

@@ -165,3 +165,32 @@ var (
ErrRequestNotPending = errors.New("request is not pending") ErrRequestNotPending = errors.New("request is not pending")
ErrUnknownEntityType = errors.New("unknown entity type") 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}
}

View File

@@ -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 // or nil if none applies. Reads inside the same tx as Submit* so policy
// reads see whatever the calling tx may have already written. // reads see whatever the calling tx may have already written.
// //
// Resolution (t-paliad-154): delegates to paliad.approval_policy_effective(), // Resolution (t-paliad-160): delegates to paliad.approval_policy_effective(),
// which returns at most one row after walking the project-row → ancestor-row // which returns at most one row after the most-strict-wins fold over the
// → unit-default cascade and picking most-restrictive across candidates. // project-row / ancestor-row / unit-default candidates. The split-grammar
// columns are:
// //
// 'none' short-circuit: when the resolver yields required_role='none' (only // - requires_approval — the gate (OR across candidates).
// possible from a project-specific row, since unit/ancestor candidates with // - min_role — the seniority threshold (MAX along the role
// 'none' lose MAX to any non-none), this returns nil — the gate is // ladder among the requires_approval=true
// suppressed and no approval request is created. // candidates). NULL when the gate is off.
// //
// The returned ApprovalPolicy is synthetic when source != 'project': it // When the gate is off (requires_approval=false OR no candidates), this
// carries the resolved required_role + the actual project_id (so downstream // returns nil and the caller skips creating an approval_request entirely.
// code that branches on ProjectID still works), but no DB id since the // The legacy required_role column is mirrored from min_role for any caller
// effective rule may have been computed across multiple rows. // 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) { func (s *ApprovalService) LookupPolicy(ctx context.Context, tx *sqlx.Tx, projectID uuid.UUID, entityType, lifecycleEvent string) (*models.ApprovalPolicy, error) {
var row struct { var row struct {
RequiredRole string `db:"required_role"` RequiresApproval bool `db:"requires_approval"`
Source sql.NullString `db:"source"` MinRole sql.NullString `db:"min_role"`
SourceID *uuid.UUID `db:"source_id"` 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)` FROM paliad.approval_policy_effective($1, $2, $3)`
if err := txOrDB(tx, s.db).GetContext(ctx, &row, q, projectID, entityType, lifecycleEvent); err != nil { if err := txOrDB(tx, s.db).GetContext(ctx, &row, q, projectID, entityType, lifecycleEvent); err != nil {
if errors.Is(err, sql.ErrNoRows) { 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) return nil, fmt.Errorf("lookup approval policy: %w", err)
} }
if row.RequiredRole == "none" { if !row.RequiresApproval || !row.MinRole.Valid {
return nil, nil // explicit suppression at project-row level return nil, nil // gate off — no approval request needed
} }
pid := projectID pid := projectID
return &models.ApprovalPolicy{ return &models.ApprovalPolicy{
ProjectID: &pid, ProjectID: &pid,
EntityType: entityType, EntityType: entityType,
LifecycleEvent: lifecycleEvent, LifecycleEvent: lifecycleEvent,
RequiredRole: row.RequiredRole, RequiredRole: row.MinRole.String,
}, nil }, nil
} }
@@ -659,6 +662,31 @@ func (s *ApprovalService) entityApprovalStatus(ctx context.Context, tx *sqlx.Tx,
return status, nil 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. // entityTableName resolves the SQL table name for a given entity_type.
// Internal helper — entityType comes from server-side constants, not user // Internal helper — entityType comes from server-side constants, not user
// input, so a panic on an unknown value is a programming error. // 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. // rows or unit defaults — those come via GetEffectivePoliciesMatrix.
func (s *ApprovalService) ListProjectPolicies(ctx context.Context, projectID uuid.UUID) ([]models.ApprovalPolicy, error) { 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, 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 FROM paliad.approval_policies
WHERE project_id = $1 WHERE project_id = $1
ORDER BY entity_type, lifecycle_event` ORDER BY entity_type, lifecycle_event`
@@ -966,7 +995,8 @@ func (s *ApprovalService) ListProjectPolicies(ctx context.Context, projectID uui
// partner unit (up to 8). // partner unit (up to 8).
func (s *ApprovalService) ListUnitPolicies(ctx context.Context, unitID uuid.UUID) ([]models.ApprovalPolicy, error) { 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, 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 FROM paliad.approval_policies
WHERE partner_unit_id = $1 WHERE partner_unit_id = $1
ORDER BY entity_type, lifecycle_event` ORDER BY entity_type, lifecycle_event`
@@ -994,14 +1024,79 @@ func validatePolicyTuple(entityType, lifecycle, requiredRole string) error {
return nil 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 // UpsertProjectPolicy creates or replaces a single project-scoped policy
// row. Caller must be global_admin (gate enforced at the handler layer). // row using the legacy required_role grammar ('none' → no approval; else
// Audit row written via writePolicyAudit. 'none' as required_role is // the strict-ladder role). Thin shim around UpsertProjectPolicySplit kept
// allowed and suppresses inherited defaults explicitly. // 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) { 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 { if err := validatePolicyTuple(entityType, lifecycle, requiredRole); err != nil {
return nil, err 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) tx, err := s.db.BeginTxx(ctx, nil)
if err != 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) return nil, fmt.Errorf("upsert project policy: read pre-image: %w", err)
} }
requiredRole := legacyFromSplit(requiresApproval, minRole)
q := `INSERT INTO paliad.approval_policies q := `INSERT INTO paliad.approval_policies
(project_id, partner_unit_id, entity_type, lifecycle_event, required_role, created_by) (project_id, partner_unit_id, entity_type, lifecycle_event,
VALUES ($1, NULL, $2, $3, $4, $5) 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) ON CONFLICT (project_id, entity_type, lifecycle_event)
WHERE project_id IS NOT NULL 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() updated_at = now()
RETURNING id, project_id, partner_unit_id, entity_type, lifecycle_event, 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 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) 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() 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) { 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 { if err := validatePolicyTuple(entityType, lifecycle, requiredRole); err != nil {
return nil, err 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) tx, err := s.db.BeginTxx(ctx, nil)
if err != 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) return nil, fmt.Errorf("upsert unit policy: read pre-image: %w", err)
} }
requiredRole := legacyFromSplit(requiresApproval, minRole)
q := `INSERT INTO paliad.approval_policies q := `INSERT INTO paliad.approval_policies
(project_id, partner_unit_id, entity_type, lifecycle_event, required_role, created_by) (project_id, partner_unit_id, entity_type, lifecycle_event,
VALUES (NULL, $1, $2, $3, $4, $5) 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) ON CONFLICT (partner_unit_id, entity_type, lifecycle_event)
WHERE partner_unit_id IS NOT NULL 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() updated_at = now()
RETURNING id, project_id, partner_unit_id, entity_type, lifecycle_event, 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 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) 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. // GetEffectivePolicyOne returns the EffectivePolicy for a single cell.
// Used by the form-time hint endpoint on /projects/{id}/deadlines/new etc. // 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) { func (s *ApprovalService) GetEffectivePolicyOne(ctx context.Context, projectID uuid.UUID, entityType, lifecycle string) (*models.EffectivePolicy, error) {
var row struct { var row struct {
RequiredRole sql.NullString `db:"required_role"` RequiresApproval bool `db:"requires_approval"`
Source sql.NullString `db:"source"` MinRole sql.NullString `db:"min_role"`
SourceID *uuid.UUID `db:"source_id"` 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)` FROM paliad.approval_policy_effective($1, $2, $3)`
if err := s.db.GetContext(ctx, &row, q, projectID, entityType, lifecycle); err != nil { if err := s.db.GetContext(ctx, &row, q, projectID, entityType, lifecycle); err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
@@ -1246,12 +1377,14 @@ func (s *ApprovalService) GetEffectivePolicyOne(ctx context.Context, projectID u
} }
res := &models.EffectivePolicy{ res := &models.EffectivePolicy{
EntityType: entityType, EntityType: entityType,
LifecycleEvent: lifecycle, LifecycleEvent: lifecycle,
RequiresApproval: row.RequiresApproval,
} }
if row.RequiredRole.Valid { if row.MinRole.Valid {
rr := row.RequiredRole.String mr := row.MinRole.String
res.RequiredRole = &rr res.MinRole = &mr
res.RequiredRole = &mr // dual-read mirror until M2
} }
if row.Source.Valid { if row.Source.Valid {
src := row.Source.String src := row.Source.String
@@ -1353,16 +1486,24 @@ func (s *ApprovalService) ApplyMatrixToDescendants(ctx context.Context, callerID
WHERE project_id = $1`, target); err != nil { WHERE project_id = $1`, target); err != nil {
return 0, fmt.Errorf("apply matrix: clear target %s: %w", target, err) 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 { for _, cell := range matrix {
if cell.RequiredRole == nil { if cell.Source == nil {
continue continue // no candidates for this cell at the source
} }
requiresApproval := cell.RequiresApproval
minRole := cell.MinRole
requiredRole := legacyFromSplit(requiresApproval, minRole)
if _, err := tx.ExecContext(ctx, if _, err := tx.ExecContext(ctx,
`INSERT INTO paliad.approval_policies `INSERT INTO paliad.approval_policies
(project_id, partner_unit_id, entity_type, lifecycle_event, required_role, created_by) (project_id, partner_unit_id, entity_type, lifecycle_event,
VALUES ($1, NULL, $2, $3, $4, $5)`, requires_approval, min_role, required_role, created_by)
target, cell.EntityType, cell.LifecycleEvent, *cell.RequiredRole, callerID); err != nil { 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", return 0, fmt.Errorf("apply matrix: write target %s cell %s/%s: %w",
target, cell.EntityType, cell.LifecycleEvent, err) target, cell.EntityType, cell.LifecycleEvent, err)
} }
@@ -1434,7 +1575,8 @@ func (s *ApprovalService) snapshotProjectRows(ctx context.Context, tx *sqlx.Tx,
var rows []models.ApprovalPolicy var rows []models.ApprovalPolicy
if err := tx.SelectContext(ctx, &rows, if err := tx.SelectContext(ctx, &rows,
`SELECT id, project_id, partner_unit_id, entity_type, lifecycle_event, `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 FROM paliad.approval_policies
WHERE project_id = $1`, projectID); err != nil { WHERE project_id = $1`, projectID); err != nil {
return nil, fmt.Errorf("snapshot project rows: %w", err) return nil, fmt.Errorf("snapshot project rows: %w", err)

View File

@@ -48,6 +48,23 @@ func (s *DeadlineService) SetApprovalService(a *ApprovalService) {
s.approvals = a 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, 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, warning_date, source, rule_id, rule_code, status, completed_at, caldav_uid, caldav_etag,
notes, created_by, created_at, updated_at, 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 return nil, err
} }
if current.ApprovalStatus == ApprovalStatusPending { if current.ApprovalStatus == ApprovalStatusPending {
return nil, ErrConcurrentPending return nil, s.pendingApprovalErr(ctx, deadlineID)
} }
sets := []string{} sets := []string{}
@@ -594,7 +611,7 @@ func (s *DeadlineService) Complete(ctx context.Context, userID, deadlineID uuid.
return current, nil return current, nil
} }
if current.ApprovalStatus == ApprovalStatusPending { if current.ApprovalStatus == ApprovalStatusPending {
return nil, ErrConcurrentPending return nil, s.pendingApprovalErr(ctx, deadlineID)
} }
tx, err := s.db.BeginTxx(ctx, nil) tx, err := s.db.BeginTxx(ctx, nil)
@@ -741,7 +758,7 @@ func (s *DeadlineService) Delete(ctx context.Context, userID, deadlineID uuid.UU
return err return err
} }
if current.ApprovalStatus == ApprovalStatusPending { if current.ApprovalStatus == ApprovalStatusPending {
return ErrConcurrentPending return s.pendingApprovalErr(ctx, deadlineID)
} }
tx, err := s.db.BeginTxx(ctx, nil) tx, err := s.db.BeginTxx(ctx, nil)