Files
paliad/internal/services/approval_service.go
mAi 0263a0e932 feat(approvals): t-paliad-216 — server-side hydration for back-link
Server-side additions so /inbox can render the suggest-changes back-link
without an extra client round-trip:

  - ApprovalRequestView gains NextRequestID. Hydrated via correlated
    subquery on previous_request_id; mig 103's partial index makes the
    lookup O(1) per row.
  - view_service.go approvalRowSubtitle picks up the changes_requested
    case ("Abgelehnt mit Vorschlag von <decider>").
  - filter_spec.go validRequestStatuses includes "changes_requested" so
    user-views can filter on it.
  - handlers/approvals.go isValidInboxStatus accepts "changes_requested"
    on the /api/inbox/{mine,pending-mine}?status= query. Test case added
    to TestParseInboxFilter_DropsUnknownStatus.
2026-05-20 10:02:36 +02:00

2007 lines
80 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package services
// ApprovalService implements the 4-Augen-Prüfung workflow on
// paliad.deadlines and paliad.appointments (t-paliad-138).
//
// Architecture: write-then-approve (m's Q5 choice). The mutation lands on
// the entity row immediately; the entity carries approval_status='pending'
// + pending_request_id until an approver flips it to 'approved'. Delete is
// the one stage-then-write exception — we mark the row pending instead of
// hard-deleting, then hard-delete on approve / restore on reject.
//
// Submission entry points (Submit{Create,Update,Complete,Delete}) are
// invoked by DeadlineService / AppointmentService inside their existing
// transactions. They:
// 1. Look up the policy for (project, entity_type, lifecycle_event).
// 2. If no policy → no-op (entity stays approval_status='approved').
// 3. If policy → run a deadlock check (qualified approver != requester
// must exist), insert an approval_requests row, mark the entity
// pending, emit a *_approval_requested project_events row.
//
// Decision entry points (Approve / Reject / Revoke) run their own tx and:
// - Approve: validate canApprove(caller, request); flip the entity back
// to approved (or hard-delete for delete-lifecycle); emit
// *_approval_approved.
// - Reject: validate canApprove; revert the entity from pre_image (or
// hard-delete a pending-create); emit *_approval_rejected.
// - Revoke: validate caller == requester; same revert as Reject; emit
// *_approval_revoked.
//
// Self-approval is blocked at three layers:
// 1. canApprove() returns ErrSelfApproval when caller == requester.
// 2. The DB CHECK constraint approval_requests_no_self_approval refuses
// decided_by == requested_by writes.
// 3. The deadlock-check excludes the requester from the qualified-approver
// pool, so the deadlock path can't be silently bypassed.
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"github.com/lib/pq"
"mgit.msbls.de/m/paliad/internal/models"
)
// ApprovalService is the workflow orchestrator. It holds no entity-specific
// knowledge — DeadlineService / AppointmentService call its Submit*
// methods, and the Approve / Reject / Revoke paths run direct SQL on the
// entity tables to keep the dependency graph acyclic.
type ApprovalService struct {
db *sqlx.DB
users *UserService
}
// NewApprovalService wires the service.
func NewApprovalService(db *sqlx.DB, users *UserService) *ApprovalService {
return &ApprovalService{db: db, users: users}
}
// LookupPolicy returns the effective approval policy for the given tuple,
// 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-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:
//
// - 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.
//
// When the gate is off (requires_approval=false OR no candidates), this
// returns nil and the caller skips creating an approval_request entirely.
func (s *ApprovalService) LookupPolicy(ctx context.Context, tx *sqlx.Tx, projectID uuid.UUID, entityType, lifecycleEvent string) (*models.ApprovalPolicy, error) {
var row struct {
RequiresApproval bool `db:"requires_approval"`
MinRole sql.NullString `db:"min_role"`
Source sql.NullString `db:"source"`
SourceID *uuid.UUID `db:"source_id"`
}
q := `SELECT requires_approval, min_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) {
return nil, nil // no candidates → no policy applies
}
return nil, fmt.Errorf("lookup approval policy: %w", err)
}
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,
RequiresApproval: true,
MinRole: &row.MinRole.String,
}, nil
}
// hasQualifiedApprover counts users on the project's team-membership path
// (direct OR ancestor) whose (profession, responsibility) tuple meets the
// strict-ladder threshold, plus any global_admin user, plus any partner-
// unit-derived member where the attachment grants authority (t-paliad-139).
// Excludes requesterID.
//
// Returns true if at least one such user exists. The path-walk JOIN matches
// the visibility predicate so an ancestor partner qualifies for a
// descendant's approval, just like they have visibility.
//
// t-paliad-148: peer authority requires BOTH a profession with sufficient
// level AND a responsibility ∈ {lead, member} that opens the gate.
// observer/external rows are excluded even if the user's profession would
// otherwise qualify — that's the point of the project-level gate.
func (s *ApprovalService) hasQualifiedApprover(ctx context.Context, tx *sqlx.Tx, projectID, requesterID uuid.UUID, requiredRole string) (bool, error) {
q := `WITH path AS (
SELECT string_to_array(p.path, '.')::uuid[] AS ids
FROM paliad.projects p WHERE p.id = $1
)
SELECT EXISTS (
SELECT 1 FROM paliad.project_teams pt
JOIN paliad.users u ON u.id = pt.user_id
JOIN path ON pt.project_id = ANY(path.ids)
WHERE pt.user_id <> $2
AND pt.responsibility IN ('lead', 'member')
AND paliad.approval_role_level(u.profession) >= paliad.approval_role_level($3)
UNION ALL
SELECT 1 FROM paliad.users u
WHERE u.global_role = 'global_admin' AND u.id <> $2
UNION ALL
SELECT 1 FROM paliad.project_partner_units ppu
JOIN paliad.partner_unit_members pum ON pum.partner_unit_id = ppu.partner_unit_id
JOIN path ON ppu.project_id = ANY(path.ids)
WHERE pum.user_id <> $2
AND ppu.derive_grants_authority = true
AND pum.unit_role = ANY(ppu.derive_unit_roles)
AND paliad.approval_role_level(
paliad.approval_role_from_unit_role(pum.unit_role)
) >= paliad.approval_role_level($3)
LIMIT 1
) AS ok`
var ok bool
if err := txOrDB(tx, s.db).GetContext(ctx, &ok, q, projectID, requesterID, requiredRole); err != nil {
return false, fmt.Errorf("deadlock check: %w", err)
}
return ok, nil
}
// SubmitCreate is invoked by Deadline/AppointmentService inside their
// create-tx, after the entity row has been INSERTed but before the
// commit. If a (project, entity_type, 'create') policy applies, it inserts
// the approval_requests row, marks the entity pending, and emits the
// *_approval_requested audit event.
//
// payload is the just-inserted entity's field values (used as audit echo).
//
// Returns the new request ID if pending, nil if no policy applied.
func (s *ApprovalService) SubmitCreate(ctx context.Context, tx *sqlx.Tx, projectID, entityID, requesterID uuid.UUID, entityType string, payload map[string]any) (*uuid.UUID, error) {
return s.submit(ctx, tx, projectID, entityID, requesterID, entityType, LifecycleCreate, nil, payload, nil)
}
// SubmitAgentCreate is the agent-drafted variant of SubmitCreate
// (t-paliad-161). Used when Paliadin drafts a row on the user's behalf —
// the request is created UNCONDITIONALLY (even when no policy applies),
// stamped with requester_kind='agent', and linked to the originating
// paliadin_turns row via agent_turn_id.
//
// The unconditional gate is by design: every agent suggestion needs the
// user's eye (m's lock-in for Q11). Bypassing the policy lookup keeps a
// single audit shape — agent-drafted entities never appear "live" in the
// approved column without an approve-decision behind them, regardless of
// whether the project would have skipped 4-eye for a direct user create.
func (s *ApprovalService) SubmitAgentCreate(ctx context.Context, tx *sqlx.Tx, projectID, entityID, requesterID, agentTurnID uuid.UUID, entityType string, payload map[string]any) (*uuid.UUID, error) {
return s.submit(ctx, tx, projectID, entityID, requesterID, entityType, LifecycleCreate, nil, payload, &agentTurnID)
}
// SubmitUpdate is invoked after the entity row has been UPDATEd. preImage
// carries the date-bearing fields that were just overwritten (per Q4
// allowlist) so a rejection can restore them. payload echoes the new values.
func (s *ApprovalService) SubmitUpdate(ctx context.Context, tx *sqlx.Tx, projectID, entityID, requesterID uuid.UUID, entityType string, preImage, payload map[string]any) (*uuid.UUID, error) {
if len(preImage) == 0 {
// Nothing in the date-bearing allowlist actually changed — bypass
// the approval flow entirely (the underlying UPDATE was cosmetic).
return nil, nil
}
return s.submit(ctx, tx, projectID, entityID, requesterID, entityType, LifecycleUpdate, preImage, payload, nil)
}
// SubmitComplete is invoked after status was flipped to 'completed'
// (deadline) or completed_at was set (appointment). preImage stores the
// pre-completion state so a rejection can revert.
func (s *ApprovalService) SubmitComplete(ctx context.Context, tx *sqlx.Tx, projectID, entityID, requesterID uuid.UUID, entityType string, preImage, payload map[string]any) (*uuid.UUID, error) {
return s.submit(ctx, tx, projectID, entityID, requesterID, entityType, LifecycleComplete, preImage, payload, nil)
}
// SubmitDelete is invoked WITHOUT a prior delete on the entity (delete is
// the stage-then-write exception). The entity row stays alive with
// approval_status='pending'; on approve we hard-delete, on reject we just
// clear the pending markers.
//
// preImage stores the full row state so the inbox can render
// "About to delete: Frist X (due 2026-05-12)".
func (s *ApprovalService) SubmitDelete(ctx context.Context, tx *sqlx.Tx, projectID, entityID, requesterID uuid.UUID, entityType string, preImage map[string]any) (*uuid.UUID, error) {
return s.submit(ctx, tx, projectID, entityID, requesterID, entityType, LifecycleDelete, preImage, nil, nil)
}
// submit is the shared lifecycle-handling kernel.
//
// agentTurnID, when non-nil, marks the request as agent-drafted: the
// request gets requester_kind='agent' + agent_turn_id=<id>, and the
// policy gate is BYPASSED — every agent suggestion goes into pending
// (m's lock-in for t-paliad-161 Q11). When nil, the standard flow runs
// (look up policy, return nil + no-op when no policy applies).
func (s *ApprovalService) submit(ctx context.Context, tx *sqlx.Tx, projectID, entityID, requesterID uuid.UUID, entityType, lifecycle string, preImage, payload map[string]any, agentTurnID *uuid.UUID) (*uuid.UUID, error) {
// Resolve required role:
// - User path: lookup policy, no-op if none, error if no qualified
// approver exists for the threshold.
// - Agent path: bypass policy lookup; required_role defaults to the
// associate floor (the conservative seed across paliad's policy
// baseline) so the inbox query for who-can-approve still has a
// non-NULL threshold to fold into the strict ladder. The user
// receiving the suggestion to approve is themselves and they're
// not in the qualified-approver pool (CHECK decided_by != requested_by),
// so the deadlock check needs to verify someone OTHER than the
// suggesting user can approve — which they can, because the
// suggesting user is m (the only Paliadin owner today) and any
// project lead / global_admin not equal to m qualifies.
var requiredRole string
if agentTurnID == nil {
policy, err := s.LookupPolicy(ctx, tx, projectID, entityType, lifecycle)
if err != nil {
return nil, err
}
if policy == nil {
// No policy applies — entity stays approval_status='approved'. No-op.
return nil, nil
}
// LookupPolicy guarantees MinRole is non-nil whenever a non-nil
// policy is returned (gate on + threshold set).
requiredRole = *policy.MinRole
} else {
// Agent path: associate threshold (the firm-wide seed baseline).
requiredRole = "associate"
}
// Deadlock check: somebody other than the requester must be qualified
// to approve, either via project team membership or as global_admin.
ok, err := s.hasQualifiedApprover(ctx, tx, projectID, requesterID, requiredRole)
if err != nil {
return nil, err
}
if !ok {
return nil, fmt.Errorf("%w: required role %q", ErrNoQualifiedApprover, requiredRole)
}
// Concurrent-pending guard: the entity table has a CHECK / NOT NULL
// guard against double-pending — but we surface a clean error rather
// than letting the UPDATE silently fail. The guard relies on
// approval_status='approved' being the precondition for a fresh
// pending state.
currentStatus, err := s.entityApprovalStatus(ctx, tx, entityType, entityID)
if err != nil {
return nil, err
}
if currentStatus == ApprovalStatusPending {
return nil, ErrConcurrentPending
}
requestID := uuid.New()
preImageJSON, err := marshalJSONOrNull(preImage)
if err != nil {
return nil, fmt.Errorf("marshal pre_image: %w", err)
}
payloadJSON, err := marshalJSONOrNull(payload)
if err != nil {
return nil, fmt.Errorf("marshal payload: %w", err)
}
// Agent fields are nil on the user path, set on the agent path. The
// xor-check on approval_requests_agent_xor enforces that
// requester_kind='agent' implies agent_turn_id IS NOT NULL and vice
// versa, so we always set both columns coherently here.
requesterKind := "user"
var agentTurnArg any
if agentTurnID != nil {
requesterKind = "agent"
agentTurnArg = *agentTurnID
}
insertReqSQL := `INSERT INTO paliad.approval_requests
(id, project_id, entity_type, entity_id, lifecycle_event,
pre_image, payload, requested_by, required_role, status,
requester_kind, agent_turn_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'pending', $10, $11)`
if _, err := tx.ExecContext(ctx, insertReqSQL,
requestID, projectID, entityType, entityID, lifecycle,
preImageJSON, payloadJSON, requesterID, requiredRole,
requesterKind, agentTurnArg); err != nil {
return nil, fmt.Errorf("insert approval_request: %w", err)
}
// Mark the entity row pending. The WHERE approval_status='approved'
// (or 'legacy') guard makes the UPDATE atomic vs concurrent pending.
updateEntitySQL := fmt.Sprintf(`UPDATE paliad.%s
SET approval_status = 'pending', pending_request_id = $1, updated_at = now()
WHERE id = $2 AND approval_status IN ('approved','legacy')`,
entityTableName(entityType))
res, err := tx.ExecContext(ctx, updateEntitySQL, requestID, entityID)
if err != nil {
return nil, fmt.Errorf("mark entity pending: %w", err)
}
rows, _ := res.RowsAffected()
if rows != 1 {
// Either the entity vanished or another tx flipped it pending.
return nil, ErrConcurrentPending
}
// Audit emit.
eventType := approvalEventType(entityType, "requested")
descPtr := approvalDescription("requested", requiredRole, lifecycle)
meta := map[string]any{
"approval_request_id": requestID.String(),
"lifecycle_event": lifecycle,
"required_role": requiredRole,
entityType + "_id": entityID.String(),
"requester_kind": requesterKind,
}
if agentTurnID != nil {
meta["agent_turn_id"] = agentTurnID.String()
}
if err := insertProjectEventWithMeta(ctx, tx, projectID, requesterID, eventType, eventType, descPtr, meta); err != nil {
return nil, err
}
return &requestID, nil
}
// Approve flips a pending request to 'approved' and applies the lifecycle
// to the entity. Runs in its own transaction.
func (s *ApprovalService) Approve(ctx context.Context, requestID, callerID uuid.UUID, note string) error {
return s.decide(ctx, requestID, callerID, RequestStatusApproved, note)
}
// Reject flips a pending request to 'rejected' and reverts the entity from
// pre_image. Runs in its own transaction.
func (s *ApprovalService) Reject(ctx context.Context, requestID, callerID uuid.UUID, note string) error {
return s.decide(ctx, requestID, callerID, RequestStatusRejected, note)
}
// Revoke is invoked by the requester to undo their own pending submission
// before any approver acts on it. The entity reverts as if the request had
// been rejected, but the request status is 'revoked'. Runs in its own tx.
func (s *ApprovalService) Revoke(ctx context.Context, requestID, callerID uuid.UUID) error {
return s.decide(ctx, requestID, callerID, RequestStatusRevoked, "")
}
// SuggestChanges is the fourth approval action (t-paliad-216). The caller
// proposes a counter-payload + optional free-text note; in one transaction
// we close the old request as 'changes_requested', revert the entity from
// pre_image, then immediately spawn a NEW 'pending' approval_request
// authored by the caller carrying counter_payload as the new payload. The
// new row enters the normal pending flow — anyone eligible (including the
// original requester) can approve, reject, or suggest changes back on it.
// 4-Augen still holds: the suggesting caller is now the new row's
// requested_by, so self-approval is blocked by the standard 3-layer guard.
//
// Authorization is the same as Approve/Reject on the OLD row (canApprove).
// The new row's deadlock check (qualified-approver-exists-other-than-
// caller) runs before the new INSERT so we never spawn an unapprovable
// request.
//
// counterPayload must differ from the old row's payload OR a non-empty
// note must be present — a no-op suggestion (same values, no note) is
// indistinguishable from "I have no opinion" and is rejected with
// ErrSuggestionRequiresChange. counterPayload field shape is the same
// allowlist used by Submit*/applyRevert (the date-bearing columns per
// entity_type); unknown keys are silently dropped at apply time.
//
// SuggestChanges is only valid for lifecycle in (update, complete). For
// create the original entity would be deleted by applyRevert, leaving no
// row to apply a counter to. For delete the original is "remove this
// entity" — a counter-proposal would be a different lifecycle entirely.
// Both return ErrSuggestionLifecycleInvalid; the caller (handler) maps
// it to 400.
//
// Returns the new request ID on success.
func (s *ApprovalService) SuggestChanges(ctx context.Context, requestID, callerID uuid.UUID, counterPayload map[string]any, note string) (*uuid.UUID, error) {
trimmedNote := strings.TrimSpace(note)
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback() //nolint:errcheck
old, err := s.getRequestForUpdate(ctx, tx, requestID)
if err != nil {
return nil, err
}
if old.Status != RequestStatusPending {
return nil, fmt.Errorf("%w: status=%s", ErrRequestNotPending, old.Status)
}
if old.LifecycleEvent != LifecycleUpdate && old.LifecycleEvent != LifecycleComplete {
return nil, fmt.Errorf("%w: lifecycle=%s", ErrSuggestionLifecycleInvalid, old.LifecycleEvent)
}
// No-op guard: counter must differ from old.payload OR note must be present.
payloadDiffers, err := payloadsDiffer(old.Payload, counterPayload)
if err != nil {
return nil, err
}
if !payloadDiffers && trimmedNote == "" {
return nil, ErrSuggestionRequiresChange
}
// Authorization on the OLD row: caller must satisfy canApprove (same
// gate as Approve/Reject). Self-approval blocks here too.
decisionKind, err := s.canApprove(ctx, tx, callerID, old)
if err != nil {
return nil, err
}
now := time.Now().UTC()
counterJSON, err := marshalJSONOrNull(counterPayload)
if err != nil {
return nil, fmt.Errorf("marshal counter_payload: %w", err)
}
// Validate counter has at least one allowlisted field for the entity
// type — otherwise the entity-update below would be a no-op and the
// new row would just resubmit the SAME values, which is a degenerate
// case we should reject cleanly. Only run this check when the
// payload "differs" (i.e. caller actually provided something).
if payloadDiffers {
if _, _, err := buildRevertSetClauses(old.EntityType, counterPayload); err != nil {
// ErrUnknownEntityType wraps "empty pre_image for X" when no
// allowlisted key is present. Rebrand as suggestion-input
// failure for the handler's 400 mapping.
return nil, fmt.Errorf("%w: %v", ErrSuggestionRequiresChange, err)
}
}
// 1. Close the OLD row as changes_requested.
var noteArg any
if trimmedNote != "" {
noteArg = trimmedNote
}
updateOldSQL := `UPDATE paliad.approval_requests
SET status = $1, decided_by = $2, decided_at = $3, decision_kind = $4,
decision_note = $5, counter_payload = $6, updated_at = $3
WHERE id = $7`
if _, err := tx.ExecContext(ctx, updateOldSQL,
RequestStatusChangesRequested, callerID, now, decisionKind,
noteArg, counterJSON, requestID); err != nil {
return nil, fmt.Errorf("close old request: %w", err)
}
// 2. Revert the entity from old.pre_image (same as Reject).
if err := s.applyRevert(ctx, tx, old); err != nil {
return nil, err
}
// 3. Deadlock check on the NEW row: someone other than the caller
// must be qualified to approve. Original requester is no longer
// excluded (they're a regular team member now from the new row's
// POV), so they count if their role is sufficient.
ok, err := s.hasQualifiedApprover(ctx, tx, old.ProjectID, callerID, old.RequiredRole)
if err != nil {
return nil, err
}
if !ok {
return nil, fmt.Errorf("%w: required role %q", ErrNoQualifiedApprover, old.RequiredRole)
}
// 4. Re-apply the counter_payload to the entity row (write-then-approve).
// Reuses buildRevertSetClauses (date-allowlist translation). Always
// runs because we validated payloadDiffers + a valid set of keys
// above; even when only a note was provided (payloadDiffers=false),
// the original payload is re-applied for symmetry with Submit*.
applyPayload := counterPayload
if !payloadDiffers {
// Counter is identical to original — resubmit the same values as
// the new row's payload so the standard Submit* shape holds.
if err := json.Unmarshal(old.Payload, &applyPayload); err != nil {
return nil, fmt.Errorf("unmarshal original payload: %w", err)
}
}
if err := s.applyEntityUpdate(ctx, tx, old.EntityType, old.EntityID, applyPayload); err != nil {
return nil, err
}
// 5. INSERT the NEW pending row, authored by the caller, with
// previous_request_id pointing back at the old row.
newID := uuid.New()
applyPayloadJSON, err := marshalJSONOrNull(applyPayload)
if err != nil {
return nil, fmt.Errorf("marshal new payload: %w", err)
}
insertNewSQL := `INSERT INTO paliad.approval_requests
(id, project_id, entity_type, entity_id, lifecycle_event,
pre_image, payload, requested_by, required_role, status,
requester_kind, agent_turn_id, previous_request_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'pending', 'user', NULL, $10)`
if _, err := tx.ExecContext(ctx, insertNewSQL,
newID, old.ProjectID, old.EntityType, old.EntityID, old.LifecycleEvent,
[]byte(old.PreImage), applyPayloadJSON, callerID, old.RequiredRole,
requestID); err != nil {
return nil, fmt.Errorf("insert new approval_request: %w", err)
}
// 6. Mark the entity pending pointing at the new row.
updateEntitySQL := fmt.Sprintf(`UPDATE paliad.%s
SET approval_status = 'pending', pending_request_id = $1, updated_at = now()
WHERE id = $2 AND approval_status IN ('approved','legacy')`,
entityTableName(old.EntityType))
res, err := tx.ExecContext(ctx, updateEntitySQL, newID, old.EntityID)
if err != nil {
return nil, fmt.Errorf("mark entity pending: %w", err)
}
rows, _ := res.RowsAffected()
if rows != 1 {
return nil, ErrConcurrentPending
}
// 7. Emit *_approval_changes_suggested for the OLD row's transition.
suggestedEvent := approvalEventType(old.EntityType, "changes_suggested")
suggestedDesc := approvalDescription("changes_suggested", old.RequiredRole, old.LifecycleEvent)
suggestedMeta := map[string]any{
"approval_request_id": requestID.String(),
"new_request_id": newID.String(),
"lifecycle_event": old.LifecycleEvent,
"decision_kind": decisionKind,
old.EntityType + "_id": old.EntityID.String(),
}
if trimmedNote != "" {
suggestedMeta["decision_note"] = trimmedNote
}
if counterJSON != nil {
suggestedMeta["counter_payload"] = json.RawMessage(counterJSON)
}
if err := insertProjectEventWithMeta(ctx, tx, old.ProjectID, callerID, suggestedEvent, suggestedEvent, suggestedDesc, suggestedMeta); err != nil {
return nil, err
}
// 8. Emit *_approval_requested for the NEW row (same shape as Submit*).
requestedEvent := approvalEventType(old.EntityType, "requested")
requestedDesc := approvalDescription("requested", old.RequiredRole, old.LifecycleEvent)
requestedMeta := map[string]any{
"approval_request_id": newID.String(),
"previous_request_id": requestID.String(),
"lifecycle_event": old.LifecycleEvent,
"required_role": old.RequiredRole,
"requester_kind": "user",
old.EntityType + "_id": old.EntityID.String(),
}
if err := insertProjectEventWithMeta(ctx, tx, old.ProjectID, callerID, requestedEvent, requestedEvent, requestedDesc, requestedMeta); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit: %w", err)
}
return &newID, nil
}
// applyEntityUpdate writes the allowlisted fields from payload onto the
// entity row. Mirrors the write side of write-then-approve (which lives in
// DeadlineService / AppointmentService for the user-driven path) — used
// by SuggestChanges to apply an approver's counter-proposal back onto the
// entity inside the same tx. Reuses buildRevertSetClauses for the
// jsonb-key-to-SQL-SET translation so the allowlist is one source of
// truth.
func (s *ApprovalService) applyEntityUpdate(ctx context.Context, tx *sqlx.Tx, entityType string, entityID uuid.UUID, payload map[string]any) error {
if len(payload) == 0 {
return fmt.Errorf("%w: empty payload", ErrSuggestionRequiresChange)
}
setClauses, args, err := buildRevertSetClauses(entityType, payload)
if err != nil {
return err
}
setClauses = append(setClauses, "updated_at = now()")
args = append(args, entityID)
q := fmt.Sprintf(`UPDATE paliad.%s SET %s WHERE id = $%d`,
entityTableName(entityType), strings.Join(setClauses, ", "), len(args))
if _, err := tx.ExecContext(ctx, q, args...); err != nil {
return fmt.Errorf("apply counter payload to entity: %w", err)
}
return nil
}
// payloadsDiffer returns true iff the candidate counter map decodes to a
// value that differs from the old row's payload jsonb. Used by
// SuggestChanges to detect "no-op suggestion". Both NULL or both empty
// map = identical → false. Comparison is by canonical re-marshal so
// jsonb-key-ordering doesn't poison the equality check.
func payloadsDiffer(old models.NullableJSON, candidate map[string]any) (bool, error) {
if len(candidate) == 0 && len(old) == 0 {
return false, nil
}
if len(candidate) == 0 || len(old) == 0 {
return true, nil
}
var oldMap map[string]any
if err := json.Unmarshal(old, &oldMap); err != nil {
return false, fmt.Errorf("unmarshal old payload: %w", err)
}
oldCanonical, err := json.Marshal(oldMap)
if err != nil {
return false, fmt.Errorf("re-marshal old payload: %w", err)
}
candCanonical, err := json.Marshal(candidate)
if err != nil {
return false, fmt.Errorf("marshal candidate payload: %w", err)
}
return !bytes.Equal(oldCanonical, candCanonical), nil
}
// decide is the shared kernel for Approve / Reject / Revoke. The decision
// kind is derived from the (caller, request) relationship and the requested
// final status:
// - RequestStatusApproved: caller must NOT be requester; admin override or peer.
// - RequestStatusRejected: same authorization rules as Approve.
// - RequestStatusRevoked: caller MUST be requester.
func (s *ApprovalService) decide(ctx context.Context, requestID, callerID uuid.UUID, finalStatus, note string) error {
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
req, err := s.getRequestForUpdate(ctx, tx, requestID)
if err != nil {
return err
}
if req.Status != RequestStatusPending {
return fmt.Errorf("%w: status=%s", ErrRequestNotPending, req.Status)
}
var decisionKind string
switch finalStatus {
case RequestStatusApproved, RequestStatusRejected:
kind, err := s.canApprove(ctx, tx, callerID, req)
if err != nil {
return err
}
decisionKind = kind
case RequestStatusRevoked:
if callerID != req.RequestedBy {
return ErrNotApprover
}
decisionKind = DecisionKindPeer // unused for revoke but keeps non-NULL audit
default:
return fmt.Errorf("invalid final status %q", finalStatus)
}
// Apply the lifecycle outcome to the entity.
switch finalStatus {
case RequestStatusApproved:
if err := s.applyApproved(ctx, tx, req, callerID); err != nil {
return err
}
case RequestStatusRejected, RequestStatusRevoked:
if err := s.applyRevert(ctx, tx, req); err != nil {
return err
}
}
// Update the request row.
now := time.Now().UTC()
var trimmedNote *string
if n := strings.TrimSpace(note); n != "" {
trimmedNote = &n
}
updateReqSQL := `UPDATE paliad.approval_requests
SET status = $1, decided_by = $2, decided_at = $3, decision_kind = $4,
decision_note = $5, updated_at = $3
WHERE id = $6`
// For revoke, decided_by stays NULL (the requester didn't "decide" to
// approve, they pulled the request) — but a CHECK (decided_by != requested_by)
// would block decided_by=requester anyway. NULL is correct.
var decidedBy any
var decisionKindArg any
if finalStatus != RequestStatusRevoked {
decidedBy = callerID
decisionKindArg = decisionKind
} else {
decidedBy = nil
decisionKindArg = nil
}
if _, err := tx.ExecContext(ctx, updateReqSQL,
finalStatus, decidedBy, now, decisionKindArg, trimmedNote, requestID); err != nil {
return fmt.Errorf("update approval_request: %w", err)
}
// Audit emit.
var verlaufKind string
switch finalStatus {
case RequestStatusApproved:
verlaufKind = "approved"
case RequestStatusRejected:
verlaufKind = "rejected"
case RequestStatusRevoked:
verlaufKind = "revoked"
}
eventType := approvalEventType(req.EntityType, verlaufKind)
descPtr := approvalDescription(verlaufKind, req.RequiredRole, req.LifecycleEvent)
meta := map[string]any{
"approval_request_id": req.ID.String(),
"lifecycle_event": req.LifecycleEvent,
req.EntityType + "_id": req.EntityID.String(),
}
if finalStatus != RequestStatusRevoked {
meta["decision_kind"] = decisionKind
}
if trimmedNote != nil {
meta["decision_note"] = *trimmedNote
}
if err := insertProjectEventWithMeta(ctx, tx, req.ProjectID, callerID, eventType, eventType, descPtr, meta); err != nil {
return err
}
return tx.Commit()
}
// canApprove enforces the strict-ladder gate plus the no-self-approval
// rule. Returns the decision_kind ('peer' | 'admin_override' |
// 'derived_peer') the caller should record, or an error.
//
// Resolution order (t-paliad-139 §4.2):
// 1. Self-approval is hard-blocked.
// 2. global_admin always wins ('admin_override').
// 3. Direct or ancestor project_teams membership with sufficient role
// ('peer').
// 4. Partner-unit-derived membership with derive_grants_authority=true
// and a unit_role that maps (via approval_role_from_unit_role) to a
// project_role with sufficient level ('derived_peer').
func (s *ApprovalService) canApprove(ctx context.Context, tx *sqlx.Tx, callerID uuid.UUID, req *models.ApprovalRequest) (string, error) {
if callerID == req.RequestedBy {
return "", ErrSelfApproval
}
user, err := s.users.GetByID(ctx, callerID)
if err != nil {
return "", err
}
if user == nil {
return "", ErrNotApprover
}
if user.GlobalRole == "global_admin" {
return DecisionKindAdminOverride, nil
}
// Path-walk: check direct OR ancestor team membership with a
// responsibility that opens the gate (lead/member) AND a profession
// whose level meets the threshold (t-paliad-148 tuple-with-gate).
q := `SELECT EXISTS (
SELECT 1 FROM paliad.project_teams pt
JOIN paliad.users u ON u.id = pt.user_id
WHERE pt.user_id = $1
AND pt.project_id = ANY(string_to_array(
(SELECT path FROM paliad.projects WHERE id = $2), '.')::uuid[])
AND pt.responsibility IN ('lead', 'member')
AND paliad.approval_role_level(u.profession) >= paliad.approval_role_level($3)
)`
var ok bool
if err := tx.GetContext(ctx, &ok, q, callerID, req.ProjectID, req.RequiredRole); err != nil {
return "", fmt.Errorf("authorization check: %w", err)
}
if ok {
return DecisionKindPeer, nil
}
// t-paliad-139 derivation branch: check authority-granting partner-unit
// attachments on the project's path.
qDerived := `SELECT EXISTS (
SELECT 1 FROM paliad.project_partner_units ppu
JOIN paliad.partner_unit_members pum ON pum.partner_unit_id = ppu.partner_unit_id
WHERE pum.user_id = $1
AND ppu.project_id = ANY(string_to_array(
(SELECT path FROM paliad.projects WHERE id = $2), '.')::uuid[])
AND ppu.derive_grants_authority = true
AND pum.unit_role = ANY(ppu.derive_unit_roles)
AND paliad.approval_role_level(
paliad.approval_role_from_unit_role(pum.unit_role)
) >= paliad.approval_role_level($3)
)`
var derivedOK bool
if err := tx.GetContext(ctx, &derivedOK, qDerived, callerID, req.ProjectID, req.RequiredRole); err != nil {
return "", fmt.Errorf("derived authorization check: %w", err)
}
if derivedOK {
return DecisionKindDerivedPeer, nil
}
return "", ErrNotApprover
}
// applyApproved finalises the lifecycle on the entity row.
func (s *ApprovalService) applyApproved(ctx context.Context, tx *sqlx.Tx, req *models.ApprovalRequest, approverID uuid.UUID) error {
table := entityTableName(req.EntityType)
now := time.Now().UTC()
if req.LifecycleEvent == LifecycleDelete {
// Hard-delete the entity. The approval_requests.entity_id reference
// is a polymorphic uuid (no FK) so it survives the row going away.
// pending_request_id on the entity has ON DELETE SET NULL but the
// entity is the one being deleted, not the request — so this is
// just a plain DELETE.
if _, err := tx.ExecContext(ctx,
fmt.Sprintf(`DELETE FROM paliad.%s WHERE id = $1`, table),
req.EntityID); err != nil {
return fmt.Errorf("delete on approve: %w", err)
}
return nil
}
// Non-delete approve = clear pending markers, set approved_by/at.
q := fmt.Sprintf(`UPDATE paliad.%s
SET approval_status = 'approved',
pending_request_id = NULL,
approved_by = $1,
approved_at = $2,
updated_at = $2
WHERE id = $3`, table)
if _, err := tx.ExecContext(ctx, q, approverID, now, req.EntityID); err != nil {
return fmt.Errorf("clear pending on approve: %w", err)
}
return nil
}
// applyRevert undoes the in-flight change on the entity row, restoring it
// from the request's pre_image jsonb. Used by both Reject and Revoke.
func (s *ApprovalService) applyRevert(ctx context.Context, tx *sqlx.Tx, req *models.ApprovalRequest) error {
table := entityTableName(req.EntityType)
switch req.LifecycleEvent {
case LifecycleCreate:
// The entity should never have existed. Hard-delete.
if _, err := tx.ExecContext(ctx,
fmt.Sprintf(`DELETE FROM paliad.%s WHERE id = $1`, table),
req.EntityID); err != nil {
return fmt.Errorf("delete on reject-create: %w", err)
}
return nil
case LifecycleDelete:
// We never deleted the entity (delete is stage-then-write); just
// clear the pending markers so the row is fully alive again.
q := fmt.Sprintf(`UPDATE paliad.%s
SET approval_status = CASE WHEN approval_status = 'pending'
THEN 'approved' ELSE approval_status END,
pending_request_id = NULL,
updated_at = now()
WHERE id = $1`, table)
if _, err := tx.ExecContext(ctx, q, req.EntityID); err != nil {
return fmt.Errorf("clear pending on reject-delete: %w", err)
}
return nil
case LifecycleUpdate, LifecycleComplete:
// Restore pre_image fields, clear pending markers.
preImage := map[string]any{}
if len(req.PreImage) > 0 {
if err := json.Unmarshal(req.PreImage, &preImage); err != nil {
return fmt.Errorf("unmarshal pre_image: %w", err)
}
}
setClauses, args, err := buildRevertSetClauses(req.EntityType, preImage)
if err != nil {
return err
}
// Always clear pending markers + revert approval_status.
setClauses = append(setClauses,
"approval_status = 'approved'",
"pending_request_id = NULL",
"updated_at = now()")
args = append(args, req.EntityID)
q := fmt.Sprintf(`UPDATE paliad.%s SET %s WHERE id = $%d`,
table, strings.Join(setClauses, ", "), len(args))
if _, err := tx.ExecContext(ctx, q, args...); err != nil {
return fmt.Errorf("revert entity from pre_image: %w", err)
}
return nil
default:
return fmt.Errorf("%w: lifecycle %q", ErrUnknownEntityType, req.LifecycleEvent)
}
}
// buildRevertSetClauses translates pre_image jsonb keys into SQL SET
// fragments. Only the date-bearing allowlist (Q4) is honoured; unknown
// keys are silently dropped to defend against malformed pre_image rows
// (defence-in-depth: callers should already be sending only allowlisted
// fields, but a hostile UPDATE on the request row shouldn't let arbitrary
// fields be reverted).
func buildRevertSetClauses(entityType string, preImage map[string]any) ([]string, []any, error) {
var setClauses []string
var args []any
add := func(col string, val any) {
args = append(args, val)
setClauses = append(setClauses, fmt.Sprintf("%s = $%d", col, len(args)))
}
switch entityType {
case EntityTypeDeadline:
for _, col := range []string{"due_date", "original_due_date", "warning_date"} {
if v, ok := preImage[col]; ok {
add(col, v)
}
}
// Complete-revert restores status='pending' + completed_at NULL.
// We detect this branch by the presence of a status key; lifecycle
// is the formal source but pre_image is what the caller stored.
if v, ok := preImage["status"]; ok {
add("status", v)
}
if _, ok := preImage["completed_at"]; ok {
// Always NULL on revert — completion didn't really happen.
args = append(args, nil)
setClauses = append(setClauses, fmt.Sprintf("completed_at = $%d", len(args)))
}
case EntityTypeAppointment:
for _, col := range []string{"start_at", "end_at"} {
if v, ok := preImage[col]; ok {
add(col, v)
}
}
if _, ok := preImage["completed_at"]; ok {
args = append(args, nil)
setClauses = append(setClauses, fmt.Sprintf("completed_at = $%d", len(args)))
}
default:
return nil, nil, fmt.Errorf("%w: %q", ErrUnknownEntityType, entityType)
}
if len(setClauses) == 0 {
return nil, nil, fmt.Errorf("%w: empty pre_image for %s", ErrUnknownEntityType, entityType)
}
return setClauses, args, nil
}
// getRequestForUpdate locks an approval_requests row inside the tx for
// decision processing.
func (s *ApprovalService) getRequestForUpdate(ctx context.Context, tx *sqlx.Tx, requestID uuid.UUID) (*models.ApprovalRequest, error) {
var req models.ApprovalRequest
q := `SELECT id, project_id, entity_type, entity_id, lifecycle_event,
pre_image, payload, requested_by, requested_at, required_role,
status, decided_by, decided_at, decision_kind, decision_note,
requester_kind, agent_turn_id,
counter_payload, previous_request_id,
created_at, updated_at
FROM paliad.approval_requests
WHERE id = $1
FOR UPDATE`
if err := tx.GetContext(ctx, &req, q, requestID); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrRequestNotPending
}
return nil, fmt.Errorf("load request: %w", err)
}
return &req, nil
}
// entityApprovalStatus reads the current approval_status on the entity
// row. Returns "" if the row doesn't exist.
func (s *ApprovalService) entityApprovalStatus(ctx context.Context, tx *sqlx.Tx, entityType string, entityID uuid.UUID) (string, error) {
q := fmt.Sprintf(`SELECT approval_status FROM paliad.%s WHERE id = $1`,
entityTableName(entityType))
var status string
if err := txOrDB(tx, s.db).GetContext(ctx, &status, q, entityID); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return "", nil
}
return "", fmt.Errorf("read approval_status: %w", err)
}
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.
func entityTableName(entityType string) string {
switch entityType {
case EntityTypeDeadline:
return "deadlines"
case EntityTypeAppointment:
return "appointments"
default:
panic(fmt.Sprintf("approval: unknown entity_type %q", entityType))
}
}
// approvalEventType returns the project_events.event_type value for a
// given (entity, lifecycle-step) pair. Step is one of "requested" |
// "approved" | "rejected" | "revoked".
func approvalEventType(entityType, step string) string {
return entityType + "_approval_" + step
}
// approvalDescription returns the short audit description string. Frontend
// renders the localized version via translateEvent; this is the raw audit
// row's description column, used as a fallback and for /admin/audit-log.
func approvalDescription(step, requiredRole, lifecycle string) *string {
d := fmt.Sprintf("%s — %s/%s", step, lifecycle, requiredRole)
return &d
}
// txOrDB returns the tx if non-nil, else the db. Lets read helpers run
// either inside a calling tx (for consistency with concurrent writes) or
// standalone for List endpoints.
func txOrDB(tx *sqlx.Tx, db *sqlx.DB) sqlxQueryer {
if tx != nil {
return tx
}
return db
}
// sqlxQueryer is the minimal subset of *sqlx.DB / *sqlx.Tx we need.
// Defined here to avoid adding a public abstraction across the package.
type sqlxQueryer interface {
GetContext(ctx context.Context, dest any, query string, args ...any) error
SelectContext(ctx context.Context, dest any, query string, args ...any) error
QueryRowxContext(ctx context.Context, query string, args ...any) *sqlx.Row
}
// marshalJSONOrNull returns []byte("null") JSON-RawMessage style for
// nil/empty maps so callers can pass it directly to a jsonb column without
// branching at every call site.
func marshalJSONOrNull(m map[string]any) ([]byte, error) {
if len(m) == 0 {
return nil, nil
}
return json.Marshal(m)
}
// ============================================================================
// Read paths — inbox + policy CRUD.
// ============================================================================
// ApprovalRequestView is the inbox-friendly projection of an approval
// request: the bare ApprovalRequest plus the contextual labels the inbox
// needs to render a row without further fetches.
//
// ViewerCanApprove + ViewerIsRequester are per-viewer eligibility flags
// computed against the $1 callerID bound at query time (t-paliad-202).
// The frontend uses them to grey out the action buttons it knows the
// server would reject, replacing the previous click-then-alert UX.
type ApprovalRequestView struct {
models.ApprovalRequest
ProjectTitle string `db:"project_title" json:"project_title"`
EntityTitle *string `db:"entity_title" json:"entity_title,omitempty"`
RequesterName string `db:"requester_name" json:"requester_name"`
RequesterEmail string `db:"requester_email" json:"requester_email"`
DeciderName *string `db:"decider_name" json:"decider_name,omitempty"`
DeciderEmail *string `db:"decider_email" json:"decider_email,omitempty"`
ViewerCanApprove bool `db:"viewer_can_approve" json:"viewer_can_approve"`
ViewerIsRequester bool `db:"viewer_is_requester" json:"viewer_is_requester"`
// NextRequestID is the forward-pointer from a changes_requested row
// to the new pending row spawned by SuggestChanges (t-paliad-216).
// Hydrated via correlated subquery on previous_request_id; the
// partial index approval_requests_previous_idx keeps the lookup O(1).
// NULL on every row that hasn't been counter-proposed.
NextRequestID *uuid.UUID `db:"next_request_id" json:"next_request_id,omitempty"`
}
// approvalEligibilitySQL is the SELECT-and-WHERE-compatible boolean
// expression that returns true iff the user bound to $1 is qualified to
// approve the approval_requests row aliased `ar` on the project aliased
// `p` (i.e. the SELECT must include `paliad.approval_requests ar JOIN
// paliad.projects p ON p.id = ar.project_id`). The three eligibility
// branches mirror canApprove (line 484):
//
// - $1 is global_admin, OR
// - $1 has direct/ancestor project_teams membership with responsibility
// ∈ {lead, member} AND a profession at or above the threshold
// (t-paliad-148 tuple-with-gate), OR
// - $1 has partner-unit-derived authority (t-paliad-139).
//
// Self-authorship is NOT subtracted here — callers add the
// `ar.requested_by <> $1` predicate when they want the strict
// "can approve" semantics (the inbox WHERE) or fold it into the
// SELECT (viewer_can_approve column). Keeping the two predicates
// separate lets the same fragment serve both ListPendingForApprover's
// filter and the per-row viewer flag without duplicating SQL.
const approvalEligibilitySQL = `(
EXISTS (SELECT 1 FROM paliad.users u WHERE u.id = $1 AND u.global_role = 'global_admin')
OR EXISTS (
SELECT 1 FROM paliad.project_teams pt
JOIN paliad.users u ON u.id = pt.user_id
WHERE pt.user_id = $1
AND pt.project_id = ANY(string_to_array(p.path, '.')::uuid[])
AND pt.responsibility IN ('lead', 'member')
AND paliad.approval_role_level(u.profession) >= paliad.approval_role_level(ar.required_role)
)
OR EXISTS (
SELECT 1 FROM paliad.project_partner_units ppu
JOIN paliad.partner_unit_members pum ON pum.partner_unit_id = ppu.partner_unit_id
WHERE pum.user_id = $1
AND ppu.project_id = ANY(string_to_array(p.path, '.')::uuid[])
AND ppu.derive_grants_authority = true
AND pum.unit_role = ANY(ppu.derive_unit_roles)
AND paliad.approval_role_level(
paliad.approval_role_from_unit_role(pum.unit_role)
) >= paliad.approval_role_level(ar.required_role)
)
)`
// approvalRequestViewColumns binds $1 = callerID via the two viewer_*
// flags. Every caller must pass the caller's UUID as the first arg.
const approvalRequestViewColumns = `
ar.id, ar.project_id, ar.entity_type, ar.entity_id, ar.lifecycle_event,
ar.pre_image, ar.payload, ar.requested_by, ar.requested_at, ar.required_role,
ar.status, ar.decided_by, ar.decided_at, ar.decision_kind, ar.decision_note,
ar.requester_kind, ar.agent_turn_id,
ar.counter_payload, ar.previous_request_id,
ar.created_at, ar.updated_at,
p.title AS project_title,
CASE WHEN ar.entity_type = 'deadline' THEN d.title
WHEN ar.entity_type = 'appointment' THEN a.title
END AS entity_title,
COALESCE(ru.display_name, ru.email) AS requester_name,
ru.email AS requester_email,
du.display_name AS decider_name,
du.email AS decider_email,
(ar.status = 'pending' AND ar.requested_by <> $1 AND ` + approvalEligibilitySQL + `) AS viewer_can_approve,
(ar.requested_by = $1) AS viewer_is_requester,
(SELECT nxt.id FROM paliad.approval_requests nxt
WHERE nxt.previous_request_id = ar.id
ORDER BY nxt.requested_at DESC
LIMIT 1) AS next_request_id`
const approvalRequestViewJoins = `
paliad.approval_requests ar
JOIN paliad.projects p ON p.id = ar.project_id
JOIN paliad.users ru ON ru.id = ar.requested_by
LEFT JOIN paliad.users du ON du.id = ar.decided_by
LEFT JOIN paliad.deadlines d ON ar.entity_type = 'deadline' AND d.id = ar.entity_id
LEFT JOIN paliad.appointments a ON ar.entity_type = 'appointment' AND a.id = ar.entity_id`
// InboxFilter narrows the inbox listings.
type InboxFilter struct {
Status string // "" → no filter; otherwise one of RequestStatus*
ProjectID *uuid.UUID
EntityType string // "" → both
Limit int // 0 → 100
}
// ListPendingForApprover returns approval requests where the caller is
// qualified to approve and is not the requester.
func (s *ApprovalService) ListPendingForApprover(ctx context.Context, callerID uuid.UUID, filter InboxFilter) ([]ApprovalRequestView, error) {
limit := filter.Limit
if limit <= 0 || limit > 200 {
limit = 100
}
conds := []string{
"ar.status = 'pending'",
"ar.requested_by <> $1",
// Eligibility predicate (the three branches mirror canApprove and
// the viewer_can_approve SELECT expression — same fragment, single
// source of truth).
approvalEligibilitySQL,
}
args := []any{callerID}
if filter.ProjectID != nil {
args = append(args, *filter.ProjectID)
conds = append(conds, fmt.Sprintf("ar.project_id = $%d", len(args)))
}
if filter.EntityType != "" {
args = append(args, filter.EntityType)
conds = append(conds, fmt.Sprintf("ar.entity_type = $%d", len(args)))
}
args = append(args, limit)
q := fmt.Sprintf(`SELECT %s FROM %s WHERE %s ORDER BY ar.requested_at ASC LIMIT $%d`,
approvalRequestViewColumns, approvalRequestViewJoins,
strings.Join(conds, " AND "), len(args))
var out []ApprovalRequestView
if err := s.db.SelectContext(ctx, &out, q, args...); err != nil {
return nil, fmt.Errorf("list pending for approver: %w", err)
}
return out, nil
}
// ListSubmittedByUser returns approval requests authored by the caller.
// Status filter optional.
func (s *ApprovalService) ListSubmittedByUser(ctx context.Context, callerID uuid.UUID, filter InboxFilter) ([]ApprovalRequestView, error) {
limit := filter.Limit
if limit <= 0 || limit > 200 {
limit = 100
}
conds := []string{"ar.requested_by = $1"}
args := []any{callerID}
if filter.Status != "" {
args = append(args, filter.Status)
conds = append(conds, fmt.Sprintf("ar.status = $%d", len(args)))
}
if filter.ProjectID != nil {
args = append(args, *filter.ProjectID)
conds = append(conds, fmt.Sprintf("ar.project_id = $%d", len(args)))
}
if filter.EntityType != "" {
args = append(args, filter.EntityType)
conds = append(conds, fmt.Sprintf("ar.entity_type = $%d", len(args)))
}
args = append(args, limit)
q := fmt.Sprintf(`SELECT %s FROM %s WHERE %s ORDER BY ar.requested_at DESC LIMIT $%d`,
approvalRequestViewColumns, approvalRequestViewJoins,
strings.Join(conds, " AND "), len(args))
var out []ApprovalRequestView
if err := s.db.SelectContext(ctx, &out, q, args...); err != nil {
return nil, fmt.Errorf("list submitted by user: %w", err)
}
return out, nil
}
// GetRequest returns one approval request hydrated for the inbox detail
// view, with viewer_can_approve / viewer_is_requester resolved for
// callerID. Visibility is gated upstream by the handler (anyone with
// project access can see the request).
func (s *ApprovalService) GetRequest(ctx context.Context, callerID, requestID uuid.UUID) (*ApprovalRequestView, error) {
// $1 = callerID (binds the viewer_* flags); $2 = requestID.
q := fmt.Sprintf(`SELECT %s FROM %s WHERE ar.id = $2`,
approvalRequestViewColumns, approvalRequestViewJoins)
var v ApprovalRequestView
if err := s.db.GetContext(ctx, &v, q, callerID, requestID); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, fmt.Errorf("get approval request: %w", err)
}
return &v, nil
}
// PendingCountForUser returns how many requests await this user's approval.
// Cheap query for the sidebar bell badge.
//
// Eligibility mirrors ListPendingForApprover: global_admin OR direct/
// ancestor project_teams membership with responsibility ∈ {lead, member}
// AND profession meeting the threshold (t-paliad-148) OR partner-unit-
// derived authority (t-paliad-139).
func (s *ApprovalService) PendingCountForUser(ctx context.Context, callerID uuid.UUID) (int, error) {
q := `SELECT COUNT(*)
FROM paliad.approval_requests ar
JOIN paliad.projects p ON p.id = ar.project_id
WHERE ar.status = 'pending'
AND ar.requested_by <> $1
AND ` + approvalEligibilitySQL
var n int
if err := s.db.GetContext(ctx, &n, q, callerID); err != nil {
return 0, fmt.Errorf("pending count: %w", err)
}
return n, nil
}
// ============================================================================
// Policy CRUD — paliad.approval_policies (t-paliad-138 + t-paliad-154).
//
// Two scopes coexist:
//
// - Project rows (project_id IS NOT NULL, partner_unit_id IS NULL):
// the most-specific override for that one project.
// - Unit defaults (project_id IS NULL, partner_unit_id IS NOT NULL):
// firm-wide defaults applied to every project attached
// to that partner unit (via paliad.project_partner_units).
//
// XOR enforced by approval_policies_scope_xor in migration 062.
//
// Audit emission: every set / cleared writes one row to paliad.policy_audit_log
// (Q8 of the locked design — surfaces on /admin/audit-log only, never on
// per-project /verlauf). The actor is always a global_admin.
// ============================================================================
// IsValidPolicyRole returns true iff the value is a valid required_role for
// an approval_policies row. Accepts the strict-ladder roles AND the 'none'
// sentinel that suppresses inherited defaults at project-row level. Distinct
// from IsValidRequiredRole, which is used by the gate (and rejects 'none' as
// a level-0 ineligible value).
func IsValidPolicyRole(role string) bool {
switch role {
case ProfessionPartner, ProfessionOfCounsel, ProfessionAssociate,
ProfessionSeniorPA, ProfessionPA, "none":
return true
}
return false
}
// ListProjectPolicies returns the project-specific policy rows for a single
// project (up to 8: deadline×4 + appointment×4). Does NOT include inherited
// 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,
requires_approval, min_role,
created_at, updated_at, created_by
FROM paliad.approval_policies
WHERE project_id = $1
ORDER BY entity_type, lifecycle_event`
var out []models.ApprovalPolicy
if err := s.db.SelectContext(ctx, &out, q, projectID); err != nil {
return nil, fmt.Errorf("list project approval policies: %w", err)
}
return out, nil
}
// ListUnitPolicies returns the unit-default policy rows for a single
// 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,
requires_approval, min_role,
created_at, updated_at, created_by
FROM paliad.approval_policies
WHERE partner_unit_id = $1
ORDER BY entity_type, lifecycle_event`
var out []models.ApprovalPolicy
if err := s.db.SelectContext(ctx, &out, q, unitID); err != nil {
return nil, fmt.Errorf("list unit approval policies: %w", err)
}
return out, nil
}
// validatePolicyTuple returns ErrInvalidInput if any of the three policy
// strings are out of range for the underlying CHECK constraints.
func validatePolicyTuple(entityType, lifecycle, requiredRole string) error {
if !IsValidPolicyRole(requiredRole) {
return fmt.Errorf("%w: required_role %q", ErrInvalidInput, requiredRole)
}
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)
}
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 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 {
return nil, fmt.Errorf("upsert project policy: begin tx: %w", err)
}
defer tx.Rollback() //nolint:errcheck
// Snapshot pre-existing (requires_approval, min_role) for the audit
// row. The audit log still uses the legacy string format
// (partner|of_counsel|...|none) so we project through legacyFromSplit.
var preReq sql.NullBool
var preMin sql.NullString
if err := tx.QueryRowxContext(ctx,
`SELECT requires_approval, min_role FROM paliad.approval_policies
WHERE project_id = $1 AND entity_type = $2 AND lifecycle_event = $3`,
projectID, entityType, lifecycle).Scan(&preReq, &preMin); err != nil && !errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("upsert project policy: read pre-image: %w", err)
}
var oldRole *string
if preReq.Valid {
var pm *string
if preMin.Valid {
s := preMin.String
pm = &s
}
legacy := legacyFromSplit(preReq.Bool, pm)
oldRole = &legacy
}
requiredRole := legacyFromSplit(requiresApproval, minRole)
q := `INSERT INTO paliad.approval_policies
(project_id, partner_unit_id, entity_type, lifecycle_event,
requires_approval, min_role, created_by)
VALUES ($1, NULL, $2, $3, $4, $5, $6)
ON CONFLICT (project_id, entity_type, lifecycle_event)
WHERE project_id IS NOT NULL
DO UPDATE SET requires_approval = EXCLUDED.requires_approval,
min_role = EXCLUDED.min_role,
updated_at = now()
RETURNING id, project_id, partner_unit_id, entity_type, lifecycle_event,
requires_approval, min_role,
created_at, updated_at, created_by`
var p models.ApprovalPolicy
if err := tx.GetContext(ctx, &p, q,
projectID, entityType, lifecycle,
requiresApproval, minRole, callerID); err != nil {
return nil, fmt.Errorf("upsert project policy: %w", err)
}
// Snapshot project name for the audit row (so cascade-set-null doesn't
// lose the human label).
var scopeName string
if err := tx.GetContext(ctx, &scopeName,
`SELECT title FROM paliad.projects WHERE id = $1`, projectID); err != nil {
// Tolerate name lookup failure — still audit with empty scope_name.
scopeName = ""
}
if err := s.writePolicyAudit(ctx, tx, callerID, "approval_policy_set",
"project", &projectID, nil, scopeName, entityType, lifecycle,
oldRole, &requiredRole); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("upsert project policy: commit: %w", err)
}
return &p, nil
}
// DeleteProjectPolicy removes a single project-scoped policy row, reverting
// that cell to inherit from ancestors / unit defaults. Audit row written.
func (s *ApprovalService) DeleteProjectPolicy(ctx context.Context, callerID, projectID uuid.UUID, entityType, lifecycle string) error {
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return fmt.Errorf("delete project policy: begin tx: %w", err)
}
defer tx.Rollback() //nolint:errcheck
var preReq sql.NullBool
var preMin sql.NullString
if err := tx.QueryRowxContext(ctx,
`SELECT requires_approval, min_role FROM paliad.approval_policies
WHERE project_id = $1 AND entity_type = $2 AND lifecycle_event = $3`,
projectID, entityType, lifecycle).Scan(&preReq, &preMin); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil // nothing to delete — no audit needed
}
return fmt.Errorf("delete project policy: read pre-image: %w", err)
}
var pm *string
if preMin.Valid {
s := preMin.String
pm = &s
}
oldRoleStr := legacyFromSplit(preReq.Bool, pm)
if _, err := tx.ExecContext(ctx,
`DELETE FROM paliad.approval_policies
WHERE project_id = $1 AND entity_type = $2 AND lifecycle_event = $3`,
projectID, entityType, lifecycle); err != nil {
return fmt.Errorf("delete project policy: %w", err)
}
var scopeName string
if err := tx.GetContext(ctx, &scopeName,
`SELECT title FROM paliad.projects WHERE id = $1`, projectID); err != nil {
scopeName = ""
}
if err := s.writePolicyAudit(ctx, tx, callerID, "approval_policy_cleared",
"project", &projectID, nil, scopeName, entityType, lifecycle,
&oldRoleStr, nil); err != nil {
return err
}
return tx.Commit()
}
// 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 {
return nil, fmt.Errorf("upsert unit policy: begin tx: %w", err)
}
defer tx.Rollback() //nolint:errcheck
var preReq sql.NullBool
var preMin sql.NullString
if err := tx.QueryRowxContext(ctx,
`SELECT requires_approval, min_role FROM paliad.approval_policies
WHERE partner_unit_id = $1 AND entity_type = $2 AND lifecycle_event = $3`,
unitID, entityType, lifecycle).Scan(&preReq, &preMin); err != nil && !errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("upsert unit policy: read pre-image: %w", err)
}
var oldRole *string
if preReq.Valid {
var pm *string
if preMin.Valid {
s := preMin.String
pm = &s
}
legacy := legacyFromSplit(preReq.Bool, pm)
oldRole = &legacy
}
requiredRole := legacyFromSplit(requiresApproval, minRole)
q := `INSERT INTO paliad.approval_policies
(project_id, partner_unit_id, entity_type, lifecycle_event,
requires_approval, min_role, created_by)
VALUES (NULL, $1, $2, $3, $4, $5, $6)
ON CONFLICT (partner_unit_id, entity_type, lifecycle_event)
WHERE partner_unit_id IS NOT NULL
DO UPDATE SET requires_approval = EXCLUDED.requires_approval,
min_role = EXCLUDED.min_role,
updated_at = now()
RETURNING id, project_id, partner_unit_id, entity_type, lifecycle_event,
requires_approval, min_role,
created_at, updated_at, created_by`
var p models.ApprovalPolicy
if err := tx.GetContext(ctx, &p, q,
unitID, entityType, lifecycle,
requiresApproval, minRole, callerID); err != nil {
return nil, fmt.Errorf("upsert unit policy: %w", err)
}
var scopeName string
if err := tx.GetContext(ctx, &scopeName,
`SELECT name FROM paliad.partner_units WHERE id = $1`, unitID); err != nil {
scopeName = ""
}
if err := s.writePolicyAudit(ctx, tx, callerID, "approval_policy_set",
"unit", nil, &unitID, scopeName, entityType, lifecycle,
oldRole, &requiredRole); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("upsert unit policy: commit: %w", err)
}
return &p, nil
}
// DeleteUnitPolicy removes a single unit-default policy row.
func (s *ApprovalService) DeleteUnitPolicy(ctx context.Context, callerID, unitID uuid.UUID, entityType, lifecycle string) error {
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return fmt.Errorf("delete unit policy: begin tx: %w", err)
}
defer tx.Rollback() //nolint:errcheck
var preReq sql.NullBool
var preMin sql.NullString
if err := tx.QueryRowxContext(ctx,
`SELECT requires_approval, min_role FROM paliad.approval_policies
WHERE partner_unit_id = $1 AND entity_type = $2 AND lifecycle_event = $3`,
unitID, entityType, lifecycle).Scan(&preReq, &preMin); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil
}
return fmt.Errorf("delete unit policy: read pre-image: %w", err)
}
var pm *string
if preMin.Valid {
s := preMin.String
pm = &s
}
oldRoleStr := legacyFromSplit(preReq.Bool, pm)
if _, err := tx.ExecContext(ctx,
`DELETE FROM paliad.approval_policies
WHERE partner_unit_id = $1 AND entity_type = $2 AND lifecycle_event = $3`,
unitID, entityType, lifecycle); err != nil {
return fmt.Errorf("delete unit policy: %w", err)
}
var scopeName string
if err := tx.GetContext(ctx, &scopeName,
`SELECT name FROM paliad.partner_units WHERE id = $1`, unitID); err != nil {
scopeName = ""
}
if err := s.writePolicyAudit(ctx, tx, callerID, "approval_policy_cleared",
"unit", nil, &unitID, scopeName, entityType, lifecycle,
&oldRoleStr, nil); err != nil {
return err
}
return tx.Commit()
}
// allLifecycleEvents enumerates the 8 (entity_type, lifecycle) cells in
// stable display order: Fristen first (create / update / complete / delete),
// then Termine.
var allLifecycleEvents = []struct {
EntityType string
Lifecycle string
}{
{EntityTypeDeadline, LifecycleCreate},
{EntityTypeDeadline, LifecycleUpdate},
{EntityTypeDeadline, LifecycleComplete},
{EntityTypeDeadline, LifecycleDelete},
{EntityTypeAppointment, LifecycleCreate},
{EntityTypeAppointment, LifecycleUpdate},
{EntityTypeAppointment, LifecycleComplete},
{EntityTypeAppointment, LifecycleDelete},
}
// GetEffectivePoliciesMatrix returns one EffectivePolicy per (entity_type,
// lifecycle_event) cell — 8 rows in stable display order. Each row carries
// the resolved (requires_approval, min_role) pair + attribution
// (source ∈ {project, ancestor, unit_default}) + a human-readable
// source name (project title or partner unit name).
//
// requires_approval=false with a non-nil source means the cell has been
// explicitly authored as "no approval needed" at that scope; cells with
// no candidates at all return Source=nil so the admin UI can distinguish
// "inherited off" from "never authored".
func (s *ApprovalService) GetEffectivePoliciesMatrix(ctx context.Context, projectID uuid.UUID) ([]models.EffectivePolicy, error) {
out := make([]models.EffectivePolicy, 0, len(allLifecycleEvents))
for _, c := range allLifecycleEvents {
row, err := s.GetEffectivePolicyOne(ctx, projectID, c.EntityType, c.Lifecycle)
if err != nil {
return nil, err
}
out = append(out, *row)
}
return out, nil
}
// GetEffectivePolicyOne returns the EffectivePolicy for a single cell.
// Used by the form-time hint endpoint on /projects/{id}/deadlines/new etc.
//
// Carries the split-grammar fields: RequiresApproval is the gate, MinRole
// the seniority threshold (NULL when gate off).
func (s *ApprovalService) GetEffectivePolicyOne(ctx context.Context, projectID uuid.UUID, entityType, lifecycle string) (*models.EffectivePolicy, error) {
var row struct {
RequiresApproval bool `db:"requires_approval"`
MinRole sql.NullString `db:"min_role"`
Source sql.NullString `db:"source"`
SourceID *uuid.UUID `db:"source_id"`
}
q := `SELECT requires_approval, min_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) {
return &models.EffectivePolicy{
EntityType: entityType,
LifecycleEvent: lifecycle,
}, nil
}
return nil, fmt.Errorf("effective policy: %w", err)
}
res := &models.EffectivePolicy{
EntityType: entityType,
LifecycleEvent: lifecycle,
RequiresApproval: row.RequiresApproval,
}
if row.MinRole.Valid {
mr := row.MinRole.String
res.MinRole = &mr
}
if row.Source.Valid {
src := row.Source.String
res.Source = &src
}
if row.SourceID != nil {
res.SourceID = row.SourceID
if name, err := s.lookupSourceName(ctx, *row.SourceID, row.Source.String); err == nil {
res.SourceName = &name
}
}
return res, nil
}
// lookupSourceName resolves a source_id to a human label depending on the
// source kind. project / ancestor → projects.title; unit_default →
// partner_units.name. Returns ("", err) if the row vanished.
func (s *ApprovalService) lookupSourceName(ctx context.Context, id uuid.UUID, source string) (string, error) {
var q string
switch source {
case "project", "ancestor":
q = `SELECT title FROM paliad.projects WHERE id = $1`
case "unit_default":
q = `SELECT name FROM paliad.partner_units WHERE id = $1`
default:
return "", fmt.Errorf("unknown source %q", source)
}
var name string
if err := s.db.GetContext(ctx, &name, q, id); err != nil {
return "", err
}
return name, nil
}
// PoliciesExist returns true iff any approval_policies row exists firm-wide
// (project or unit, any cell). Used by the /inbox empty-state nudge to hide
// the "configure policies" card once any policy is set.
func (s *ApprovalService) PoliciesExist(ctx context.Context) (bool, error) {
var ok bool
if err := s.db.GetContext(ctx, &ok,
`SELECT EXISTS(SELECT 1 FROM paliad.approval_policies LIMIT 1)`); err != nil {
return false, fmt.Errorf("policies exist check: %w", err)
}
return ok, nil
}
// ApplyMatrixToDescendants copies the source project's effective matrix
// down to every project in `targetIDs` as project-specific rows. Idempotent
// fanout — each target's existing project rows for the 8 cells are first
// DELETEd, then the source's effective values INSERTed (excluding cells
// where the source resolves to no policy and the target already has none).
//
// Validates every target is an actual descendant of source via the project
// path. Self-target (source ∈ targetIDs) is silently skipped. Caller must
// be global_admin (handler-layer gate). Audit row per affected target+cell.
//
// Returns the number of policy-cell writes performed (INSERTs + post-clear
// re-applies).
func (s *ApprovalService) ApplyMatrixToDescendants(ctx context.Context, callerID, sourceProjectID uuid.UUID, targetIDs []uuid.UUID) (int, error) {
if len(targetIDs) == 0 {
return 0, nil
}
// Resolve source's effective matrix (fold inherited values into the
// target's project-scoped rows for predictable behaviour).
matrix, err := s.GetEffectivePoliciesMatrix(ctx, sourceProjectID)
if err != nil {
return 0, fmt.Errorf("apply matrix: source resolve: %w", err)
}
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return 0, fmt.Errorf("apply matrix: begin tx: %w", err)
}
defer tx.Rollback() //nolint:errcheck
// Validate each target_id is a descendant of source. Anything else =
// caller-bug → ErrInvalidInput.
if err := s.validateDescendants(ctx, tx, sourceProjectID, targetIDs); err != nil {
return 0, err
}
writes := 0
for _, target := range targetIDs {
if target == sourceProjectID {
continue // skip self
}
// Snapshot pre-existing project rows for audit.
oldRows, err := s.snapshotProjectRows(ctx, tx, target)
if err != nil {
return 0, err
}
// Wipe the target's 8 cells (project-scoped only — leaves unit-default
// inheritance intact).
if _, err := tx.ExecContext(ctx,
`DELETE FROM paliad.approval_policies
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. 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.Source == nil {
continue // no candidates for this cell at the source
}
requiresApproval := cell.RequiresApproval
minRole := cell.MinRole
if _, err := tx.ExecContext(ctx,
`INSERT INTO paliad.approval_policies
(project_id, partner_unit_id, entity_type, lifecycle_event,
requires_approval, min_role, created_by)
VALUES ($1, NULL, $2, $3, $4, $5, $6)`,
target, cell.EntityType, cell.LifecycleEvent,
requiresApproval, minRole, callerID); err != nil {
return 0, fmt.Errorf("apply matrix: write target %s cell %s/%s: %w",
target, cell.EntityType, cell.LifecycleEvent, err)
}
writes++
}
// Audit one row per target (set-event with a synthesised payload —
// individual cells are too noisy for the audit timeline).
var scopeName string
if err := tx.GetContext(ctx, &scopeName,
`SELECT title FROM paliad.projects WHERE id = $1`, target); err != nil {
scopeName = ""
}
// Use lifecycle='create' as a stand-in marker for the bulk apply
// audit row — the meaningful payload is "matrix copied from source".
// The audit row is informational; the per-cell set/clear are not
// re-emitted for bulk to avoid log spam.
_ = oldRows // pre-image not currently surfaced; reserved for future
if err := s.writePolicyAuditRaw(ctx, tx, callerID, "approval_policy_set",
"project", &target, nil, scopeName, "deadline", "create",
nil, strPtr(fmt.Sprintf("bulk-apply from source=%s", sourceProjectID))); err != nil {
return 0, err
}
}
if err := tx.Commit(); err != nil {
return 0, fmt.Errorf("apply matrix: commit: %w", err)
}
return writes, nil
}
// validateDescendants checks that every target_id is on the source's
// descendant subtree (path LIKE source.path || '.%'). Returns ErrInvalidInput
// listing offending IDs if any are not descendants.
func (s *ApprovalService) validateDescendants(ctx context.Context, tx *sqlx.Tx, sourceID uuid.UUID, targetIDs []uuid.UUID) error {
if len(targetIDs) == 0 {
return nil
}
q := `WITH src AS (
SELECT path FROM paliad.projects WHERE id = $1
)
SELECT p.id::text
FROM paliad.projects p, src
WHERE p.id = ANY($2)
AND p.path NOT LIKE src.path || '.%'`
rows, err := tx.QueryxContext(ctx, q, sourceID, pqUUIDArray(targetIDs))
if err != nil {
return fmt.Errorf("apply matrix: validate descendants: %w", err)
}
defer rows.Close()
var bad []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return err
}
bad = append(bad, id)
}
if len(bad) > 0 {
return fmt.Errorf("%w: not descendants of %s: %s",
ErrInvalidInput, sourceID, strings.Join(bad, ", "))
}
return nil
}
// snapshotProjectRows reads the current project-scoped policy rows for a
// project. Used as audit pre-image during ApplyMatrixToDescendants.
func (s *ApprovalService) snapshotProjectRows(ctx context.Context, tx *sqlx.Tx, projectID uuid.UUID) ([]models.ApprovalPolicy, error) {
var rows []models.ApprovalPolicy
if err := tx.SelectContext(ctx, &rows,
`SELECT id, project_id, partner_unit_id, entity_type, lifecycle_event,
requires_approval, min_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)
}
return rows, nil
}
// writePolicyAudit writes one paliad.policy_audit_log row inside the calling
// tx. tx may be nil in which case we run on s.db directly.
func (s *ApprovalService) writePolicyAudit(
ctx context.Context, tx *sqlx.Tx, actorID uuid.UUID,
eventType, scopeType string, projectID, partnerUnitID *uuid.UUID,
scopeName, entityType, lifecycle string,
oldRole, newRole *string,
) error {
return s.writePolicyAuditRaw(ctx, tx, actorID, eventType, scopeType,
projectID, partnerUnitID, scopeName, entityType, lifecycle, oldRole, newRole)
}
// writePolicyAuditRaw expects a non-nil tx (the audit row must commit
// atomically with the data mutation).
func (s *ApprovalService) writePolicyAuditRaw(
ctx context.Context, tx *sqlx.Tx, actorID uuid.UUID,
eventType, scopeType string, projectID, partnerUnitID *uuid.UUID,
scopeName, entityType, lifecycle string,
oldRole, newRole *string,
) error {
q := `INSERT INTO paliad.policy_audit_log
(actor_id, event_type, scope_type, project_id, partner_unit_id,
scope_name, entity_type, lifecycle_event,
old_required_role, new_required_role)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`
if _, err := tx.ExecContext(ctx, q,
actorID, eventType, scopeType, projectID, partnerUnitID,
scopeName, entityType, lifecycle, oldRole, newRole); err != nil {
return fmt.Errorf("write policy audit: %w", err)
}
return nil
}
// strPtr is a small helper for inline string literals.
func strPtr(s string) *string { return &s }
// pqUUIDArray converts a []uuid.UUID to the pq array format used by the
// sqlx driver. Reuses the github.com/lib/pq Array helper.
func pqUUIDArray(ids []uuid.UUID) any {
strs := make([]string, len(ids))
for i, id := range ids {
strs[i] = id.String()
}
return pq.Array(strs)
}