Service-layer changes implementing the locked design (Q5/Q6/Q8):
LookupPolicy (existing, called by SubmitCreate/Update/Complete/Delete)
delegates to paliad.approval_policy_effective() resolver. Returns nil
for the 'none' sentinel — explicit project-level suppression of inherited
defaults. Synthesizes a *models.ApprovalPolicy carrying the actual
project_id so the existing submit chain branches don't change.
Policy CRUD split into project + unit scope methods:
- ListProjectPolicies / ListUnitPolicies — read-only per scope.
- UpsertProjectPolicy / DeleteProjectPolicy — project-scoped writes,
audit-emitting (writes paliad.policy_audit_log inside the same tx).
- UpsertUnitPolicy / DeleteUnitPolicy — unit-default writes, same shape.
- All four use validatePolicyTuple for entity_type/lifecycle/required_role
ranges. IsValidPolicyRole accepts the 'none' sentinel; the existing
IsValidRequiredRole keeps rejecting 'none' (gate-only contract).
Effective-policy reads:
- GetEffectivePolicyOne(projectID, entity, lifecycle) — single-cell,
used by the form-time hint endpoint above /projects/{id}/deadlines/new.
- GetEffectivePoliciesMatrix(projectID) — 8 cells in stable display order
(Fristen/Termine × create/update/complete/delete), each w/ attribution.
- lookupSourceName resolves source_id to projects.title or partner_units.name.
ApplyMatrixToDescendants — bulk-apply (Q10): copies source project's
effective matrix down to listed descendants as project-specific rows,
inside one tx. Validates targetIDs are actual descendants via path-prefix
NOT LIKE check. Idempotent fanout: deletes target's project rows first
then writes the source's effective values. Self-target skipped. Audit
row per affected target.
PoliciesExist() — bool, used by /inbox empty-state nudge.
Models:
- ApprovalPolicy.ProjectID is now *uuid.UUID (was uuid.UUID); new
*uuid.UUID PartnerUnitID. Existing handler code only reads RequiredRole
so no upstream breakage.
- New EffectivePolicy struct (resolved cell + source attribution).
- New PolicyAuditEntry struct (paliad.policy_audit_log row).
Handlers:
- handleListApprovalPolicies → ListProjectPolicies (renamed).
- handlePutApprovalPolicy → UpsertProjectPolicy (caller-id reordering).
- handleDeleteApprovalPolicy → DeleteProjectPolicy (now needs uid for
audit; took the existing requireUser path).
Tests:
- Existing TestApprovalService_PolicyCRUD updated for new method names
+ post-148 enum (partner, not lead) + new 'none' sentinel acceptance.
- New TestIsValidPolicyRole pins the helper that gates writes.
- TestIsValidRequiredRole extended with 'none' rejection (gate-only).
Build + go vet + role-tests clean.
Q8: audit emission writes to paliad.policy_audit_log only — never to
project_events — so /admin/audit-log surfaces the change while /verlauf
stays focused on entity-level lifecycle.
1499 lines
59 KiB
Go
1499 lines
59 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"
|
||
"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-154): delegates to paliad.approval_policy_effective(),
|
||
// which returns at most one row after walking the project-row → ancestor-row
|
||
// → unit-default cascade and picking most-restrictive across candidates.
|
||
//
|
||
// 'none' short-circuit: when the resolver yields required_role='none' (only
|
||
// possible from a project-specific row, since unit/ancestor candidates with
|
||
// 'none' lose MAX to any non-none), this returns nil — the gate is
|
||
// suppressed and no approval request is created.
|
||
//
|
||
// The returned ApprovalPolicy is synthetic when source != 'project': it
|
||
// carries the resolved required_role + the actual project_id (so downstream
|
||
// code that branches on ProjectID still works), but no DB id since the
|
||
// effective rule may have been computed across multiple rows.
|
||
func (s *ApprovalService) LookupPolicy(ctx context.Context, tx *sqlx.Tx, projectID uuid.UUID, entityType, lifecycleEvent string) (*models.ApprovalPolicy, error) {
|
||
var row struct {
|
||
RequiredRole string `db:"required_role"`
|
||
Source sql.NullString `db:"source"`
|
||
SourceID *uuid.UUID `db:"source_id"`
|
||
}
|
||
q := `SELECT required_role, source, source_id
|
||
FROM paliad.approval_policy_effective($1, $2, $3)`
|
||
if err := txOrDB(tx, s.db).GetContext(ctx, &row, q, projectID, entityType, lifecycleEvent); err != nil {
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
return nil, nil // no candidates → no policy applies
|
||
}
|
||
return nil, fmt.Errorf("lookup approval policy: %w", err)
|
||
}
|
||
if row.RequiredRole == "none" {
|
||
return nil, nil // explicit suppression at project-row level
|
||
}
|
||
pid := projectID
|
||
return &models.ApprovalPolicy{
|
||
ProjectID: &pid,
|
||
EntityType: entityType,
|
||
LifecycleEvent: lifecycleEvent,
|
||
RequiredRole: row.RequiredRole,
|
||
}, 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 (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,
|
||
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 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,
|
||
required_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
|
||
}
|
||
|
||
// UpsertProjectPolicy creates or replaces a single project-scoped policy
|
||
// row. Caller must be global_admin (gate enforced at the handler layer).
|
||
// Audit row written via writePolicyAudit. 'none' as required_role is
|
||
// allowed and suppresses inherited defaults explicitly.
|
||
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
|
||
}
|
||
|
||
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 required_role for the audit row.
|
||
var oldRole sql.NullString
|
||
if err := tx.GetContext(ctx, &oldRole,
|
||
`SELECT required_role FROM paliad.approval_policies
|
||
WHERE project_id = $1 AND entity_type = $2 AND lifecycle_event = $3`,
|
||
projectID, entityType, lifecycle); err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||
return nil, fmt.Errorf("upsert project policy: read pre-image: %w", err)
|
||
}
|
||
|
||
q := `INSERT INTO paliad.approval_policies
|
||
(project_id, partner_unit_id, entity_type, lifecycle_event, required_role, created_by)
|
||
VALUES ($1, NULL, $2, $3, $4, $5)
|
||
ON CONFLICT (project_id, entity_type, lifecycle_event)
|
||
WHERE project_id IS NOT NULL
|
||
DO UPDATE SET required_role = EXCLUDED.required_role,
|
||
updated_at = now()
|
||
RETURNING id, project_id, partner_unit_id, entity_type, lifecycle_event,
|
||
required_role, created_at, updated_at, created_by`
|
||
var p models.ApprovalPolicy
|
||
if err := tx.GetContext(ctx, &p, q, projectID, entityType, lifecycle, requiredRole, 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,
|
||
nullToPtr(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 oldRole sql.NullString
|
||
if err := tx.GetContext(ctx, &oldRole,
|
||
`SELECT required_role FROM paliad.approval_policies
|
||
WHERE project_id = $1 AND entity_type = $2 AND lifecycle_event = $3`,
|
||
projectID, entityType, lifecycle); err != nil {
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
// Nothing to delete — exit cleanly without auditing a no-op.
|
||
return nil
|
||
}
|
||
return fmt.Errorf("delete project policy: read pre-image: %w", err)
|
||
}
|
||
|
||
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,
|
||
nullToPtr(oldRole), nil); err != nil {
|
||
return err
|
||
}
|
||
return tx.Commit()
|
||
}
|
||
|
||
// UpsertUnitPolicy creates or replaces a single unit-default policy row.
|
||
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
|
||
}
|
||
|
||
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 oldRole sql.NullString
|
||
if err := tx.GetContext(ctx, &oldRole,
|
||
`SELECT required_role FROM paliad.approval_policies
|
||
WHERE partner_unit_id = $1 AND entity_type = $2 AND lifecycle_event = $3`,
|
||
unitID, entityType, lifecycle); err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||
return nil, fmt.Errorf("upsert unit policy: read pre-image: %w", err)
|
||
}
|
||
|
||
q := `INSERT INTO paliad.approval_policies
|
||
(project_id, partner_unit_id, entity_type, lifecycle_event, required_role, created_by)
|
||
VALUES (NULL, $1, $2, $3, $4, $5)
|
||
ON CONFLICT (partner_unit_id, entity_type, lifecycle_event)
|
||
WHERE partner_unit_id IS NOT NULL
|
||
DO UPDATE SET required_role = EXCLUDED.required_role,
|
||
updated_at = now()
|
||
RETURNING id, project_id, partner_unit_id, entity_type, lifecycle_event,
|
||
required_role, created_at, updated_at, created_by`
|
||
var p models.ApprovalPolicy
|
||
if err := tx.GetContext(ctx, &p, q, unitID, entityType, lifecycle, requiredRole, 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,
|
||
nullToPtr(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 oldRole sql.NullString
|
||
if err := tx.GetContext(ctx, &oldRole,
|
||
`SELECT required_role FROM paliad.approval_policies
|
||
WHERE partner_unit_id = $1 AND entity_type = $2 AND lifecycle_event = $3`,
|
||
unitID, entityType, lifecycle); err != nil {
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
return nil
|
||
}
|
||
return fmt.Errorf("delete unit policy: read pre-image: %w", err)
|
||
}
|
||
|
||
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,
|
||
nullToPtr(oldRole), 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 required_role + attribution (source ∈ {project, ancestor,
|
||
// unit_default}) + a human-readable source name (project title or partner
|
||
// unit name).
|
||
//
|
||
// RequiredRole is nil iff no policy applies to that cell. 'none' surfaces
|
||
// as required_role='none' with source='project' so the admin UI can render
|
||
// "Keine Genehmigung erforderlich (projektspezifisch)".
|
||
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.
|
||
func (s *ApprovalService) GetEffectivePolicyOne(ctx context.Context, projectID uuid.UUID, entityType, lifecycle string) (*models.EffectivePolicy, error) {
|
||
var row struct {
|
||
RequiredRole sql.NullString `db:"required_role"`
|
||
Source sql.NullString `db:"source"`
|
||
SourceID *uuid.UUID `db:"source_id"`
|
||
}
|
||
q := `SELECT required_role, source, source_id
|
||
FROM paliad.approval_policy_effective($1, $2, $3)`
|
||
if err := s.db.GetContext(ctx, &row, q, projectID, entityType, lifecycle); err != nil {
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
return &models.EffectivePolicy{
|
||
EntityType: entityType,
|
||
LifecycleEvent: lifecycle,
|
||
}, nil
|
||
}
|
||
return nil, fmt.Errorf("effective policy: %w", err)
|
||
}
|
||
|
||
res := &models.EffectivePolicy{
|
||
EntityType: entityType,
|
||
LifecycleEvent: lifecycle,
|
||
}
|
||
if row.RequiredRole.Valid {
|
||
rr := row.RequiredRole.String
|
||
res.RequiredRole = &rr
|
||
}
|
||
if row.Source.Valid {
|
||
src := row.Source.String
|
||
res.Source = &src
|
||
}
|
||
if row.SourceID != nil {
|
||
res.SourceID = row.SourceID
|
||
// Best-effort source-name lookup. Failure is non-fatal — chip just
|
||
// renders the unattributed source label.
|
||
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.
|
||
for _, cell := range matrix {
|
||
if cell.RequiredRole == nil {
|
||
continue
|
||
}
|
||
if _, err := tx.ExecContext(ctx,
|
||
`INSERT INTO paliad.approval_policies
|
||
(project_id, partner_unit_id, entity_type, lifecycle_event, required_role, created_by)
|
||
VALUES ($1, NULL, $2, $3, $4, $5)`,
|
||
target, cell.EntityType, cell.LifecycleEvent, *cell.RequiredRole, callerID); err != nil {
|
||
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,
|
||
required_role, created_at, updated_at, created_by
|
||
FROM paliad.approval_policies
|
||
WHERE project_id = $1`, projectID); err != nil {
|
||
return nil, fmt.Errorf("snapshot project rows: %w", err)
|
||
}
|
||
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
|
||
}
|
||
|
||
// nullToPtr converts a sql.NullString to a *string pointer.
|
||
func nullToPtr(s sql.NullString) *string {
|
||
if !s.Valid {
|
||
return nil
|
||
}
|
||
v := s.String
|
||
return &v
|
||
}
|
||
|
||
// 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)
|
||
}
|