Files
paliad/internal/services/approval_service.go
m 6506864730 feat(t-paliad-148) commit 2/6: ApprovalService + DerivationService — tuple-with-gate ladder
Rewires the 4 SQL ladder sites in approval_service.go (canApprove,
hasQualifiedApprover, ListPendingForApprover, PendingCountForUser) to read
the new tuple: project_teams.responsibility ∈ {lead, member} AND
users.profession at or above the threshold. observer/external rows close
the gate even if the user's profession would otherwise qualify — that's
the project-level call.

approval_levels.go renamed levelOf → professionLevel and added
responsibilityOpensGate helper. New constants: ProfessionPartner /
ProfessionOfCounsel / … and ResponsibilityLead / ResponsibilityMember /
ResponsibilityObserver / ResponsibilityExternal. New validators
IsValidProfession + IsValidResponsibility. RoleSeniorPA kept as legacy
alias for the one remaining call site that hasn't migrated yet.

CRITICAL trap pinned by TestProfessionLevel_NilIsZero: NULL profession
returns 0, never silently defaults to associate. External collaborators
must stay ineligible.

derivation_service.go: requireWritePermission switches from pt.role='lead'
to pt.responsibility='lead' — project-management writes gate on the
project responsibility, not the firm tier. EffectiveProjectRole replaced
by UserProjectAuthorityLevel (thin wrapper over the SQL function in
migration 057). The legacy method was unused dead code despite t-139
design intent.

Tests extended: profession ladder, responsibility gate, NULL trap,
new validators. Build + vet clean.
2026-05-07 21:44:14 +02:00

951 lines
39 KiB
Go

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 (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"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 approval policy for the given tuple, or nil if
// none exists. Read inside the same tx as Submit* so policy reads see
// whatever the calling tx may have already written.
func (s *ApprovalService) LookupPolicy(ctx context.Context, tx *sqlx.Tx, projectID uuid.UUID, entityType, lifecycleEvent string) (*models.ApprovalPolicy, error) {
var p models.ApprovalPolicy
q := `SELECT id, project_id, entity_type, lifecycle_event, required_role,
created_at, updated_at, created_by
FROM paliad.approval_policies
WHERE project_id = $1 AND entity_type = $2 AND lifecycle_event = $3`
row := txOrDB(tx, s.db).QueryRowxContext(ctx, q, projectID, entityType, lifecycleEvent)
if err := row.StructScan(&p); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, fmt.Errorf("lookup approval policy: %w", err)
}
return &p, 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)
}
// 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)
}
// 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)
}
// 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)
}
// submit is the shared lifecycle-handling kernel.
func (s *ApprovalService) submit(ctx context.Context, tx *sqlx.Tx, projectID, entityID, requesterID uuid.UUID, entityType, lifecycle string, preImage, payload map[string]any) (*uuid.UUID, error) {
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
}
// 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, policy.RequiredRole)
if err != nil {
return nil, err
}
if !ok {
return nil, fmt.Errorf("%w: required role %q", ErrNoQualifiedApprover, policy.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)
}
insertReqSQL := `INSERT INTO paliad.approval_requests
(id, project_id, entity_type, entity_id, lifecycle_event,
pre_image, payload, requested_by, required_role, status)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'pending')`
if _, err := tx.ExecContext(ctx, insertReqSQL,
requestID, projectID, entityType, entityID, lifecycle,
preImageJSON, payloadJSON, requesterID, policy.RequiredRole); 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", policy.RequiredRole, lifecycle)
meta := map[string]any{
"approval_request_id": requestID.String(),
"lifecycle_event": lifecycle,
"required_role": policy.RequiredRole,
entityType + "_id": entityID.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, "")
}
// 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,
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
}
// 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.
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"`
}
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.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`
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 (any one branch suffices):
// - caller is global_admin, OR
// - caller has direct/ancestor project_teams membership with
// responsibility ∈ {lead, member} AND profession at or above
// the threshold (t-paliad-148 tuple-with-gate), OR
// - caller is a partner-unit-derived member with derive_grants_authority=true
// on an attachment in the project's path, and the unit_role maps to a
// profession at or above the threshold (t-paliad-139).
`(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)
))`,
}
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. Visibility is gated upstream by the handler (anyone with project
// access can see the request).
func (s *ApprovalService) GetRequest(ctx context.Context, requestID uuid.UUID) (*ApprovalRequestView, error) {
q := fmt.Sprintf(`SELECT %s FROM %s WHERE ar.id = $1`,
approvalRequestViewColumns, approvalRequestViewJoins)
var v ApprovalRequestView
if err := s.db.GetContext(ctx, &v, q, 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 (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)
))`
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.
// ============================================================================
// ListPolicies returns the (up to 8) policy rows for a project. Caller
// must already have project visibility.
func (s *ApprovalService) ListPolicies(ctx context.Context, projectID uuid.UUID) ([]models.ApprovalPolicy, error) {
q := `SELECT id, project_id, entity_type, lifecycle_event, required_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 approval policies: %w", err)
}
return out, nil
}
// UpsertPolicy creates or replaces a single (project, entity, lifecycle)
// policy row. Caller must be global_admin (gate enforced at handler).
func (s *ApprovalService) UpsertPolicy(ctx context.Context, projectID, callerID uuid.UUID, entityType, lifecycle, requiredRole string) (*models.ApprovalPolicy, error) {
if !IsValidRequiredRole(requiredRole) {
return nil, fmt.Errorf("%w: required_role %q", ErrInvalidInput, requiredRole)
}
if entityType != EntityTypeDeadline && entityType != EntityTypeAppointment {
return nil, fmt.Errorf("%w: entity_type %q", ErrInvalidInput, entityType)
}
switch lifecycle {
case LifecycleCreate, LifecycleUpdate, LifecycleComplete, LifecycleDelete:
default:
return nil, fmt.Errorf("%w: lifecycle_event %q", ErrInvalidInput, lifecycle)
}
q := `INSERT INTO paliad.approval_policies
(project_id, entity_type, lifecycle_event, required_role, created_by)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (project_id, entity_type, lifecycle_event)
DO UPDATE SET required_role = EXCLUDED.required_role,
updated_at = now()
RETURNING id, project_id, entity_type, lifecycle_event, required_role,
created_at, updated_at, created_by`
var p models.ApprovalPolicy
if err := s.db.GetContext(ctx, &p, q, projectID, entityType, lifecycle, requiredRole, callerID); err != nil {
return nil, fmt.Errorf("upsert approval policy: %w", err)
}
return &p, nil
}
// DeletePolicy removes a single (project, entity, lifecycle) policy row,
// reverting that lifecycle event back to the no-approval-needed default.
func (s *ApprovalService) DeletePolicy(ctx context.Context, projectID uuid.UUID, entityType, lifecycle string) error {
q := `DELETE FROM paliad.approval_policies
WHERE project_id = $1 AND entity_type = $2 AND lifecycle_event = $3`
if _, err := s.db.ExecContext(ctx, q, projectID, entityType, lifecycle); err != nil {
return fmt.Errorf("delete approval policy: %w", err)
}
return nil
}