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 }