Files
paliad/internal/services/approval_levels.go
m 3a41aa9209 feat(approvals/t-paliad-160 slice1+2): split policy + 409 handler
m's locked redesign (2026-05-08 16:40): replace `required_role` (with
'none' sentinel) with two columns — `requires_approval boolean` (the
gate) + `min_role text` (the seniority threshold). Cleanly separates
"approval applies at all" from "who's allowed to approve".

M1 phase: additive migration 064 adds the columns, backfills from the
legacy required_role ('none' → false/NULL; else → true/role), and
rewrites paliad.approval_policy_effective() to most-strict-wins:
  - requires_approval := bool_or across project + ancestor + unit_default
  - min_role          := MAX(approval_role_level) among requires_approval=true
The legacy required_role column survives this slice as a dual-read
mirror (resolver returns it too) so any caller that hasn't cut over
keeps working. M2 will drop required_role.

Service layer (approval_service.go): LookupPolicy + GetEffectivePolicyOne
read the new columns; UpsertProjectPolicySplit / UpsertUnitPolicySplit
accept the new shape directly; legacy UpsertProjectPolicy /
UpsertUnitPolicy stay as thin shims that map required_role through
splitFromLegacy(). ApplyMatrixToDescendants writes both columns.

Handler 409 mapping (§B): writeServiceError now consults a shared
mapApprovalError() helper before falling through to the generic 500.
ErrConcurrentPending → HTTP 409 with body
{code: "awaiting_approval", message, request_id?, required_role?}.
PendingApprovalError wraps ErrConcurrentPending with the in-flight
request id + role so the UI knows which request to point a withdraw
button at. ErrNoQualifiedApprover, ErrSelfApproval, ErrNotApprover,
ErrRequestNotPending all mapped consistently. writeApprovalError
now defers to the same helper for shape consistency.

Models: ApprovalPolicy + EffectivePolicy gain RequiresApproval/MinRole
fields. RequiredRole stays as a dual-read mirror until M2.

Tests: TestMapApprovalError_* covers the four 409/403 branches and the
"no match — fall through" case. Existing approval service tests pass
unchanged.

Defers per task spec to follow-up slices:
  - A3 (admin UI 2-control flip)
  - C+E (badge + withdraw button on detail pages)
  - D   (/inbox Meine Anfragen visibility fix)
  - M2  (drop required_role column)
2026-05-08 16:54:45 +02:00

197 lines
7.0 KiB
Go

package services
import "errors"
// Strict-ladder helpers for the 4-Augen-Prüfung approval gate. The ladder
// drives both the t-paliad-138 single-value `required_role` policy
// grammar and the t-paliad-148 (profession, responsibility) tuple-with-
// gate evaluation in paliad.user_project_authority_level().
//
// The ladder values match paliad.approval_role_level(text) in migration
// 057. Higher level always satisfies lower; level 0 means ineligible to
// approve at any level.
// Profession values on paliad.users.profession. Drive the ladder. NULL is
// represented as the empty string in Go (`*string` nil) — the ladder
// returns 0 for unknown values, including empty.
const (
ProfessionPartner = "partner"
ProfessionOfCounsel = "of_counsel"
ProfessionAssociate = "associate"
ProfessionSeniorPA = "senior_pa"
ProfessionPA = "pa"
ProfessionParalegal = "paralegal"
)
// Project-level responsibility values on paliad.project_teams.responsibility.
// Open the ladder gate (lead/member) or close it (observer/external).
const (
ResponsibilityLead = "lead"
ResponsibilityMember = "member"
ResponsibilityObserver = "observer"
ResponsibilityExternal = "external"
)
// RoleSeniorPA — kept as the legacy constant from t-paliad-138 for any
// remaining reference site that hasn't migrated. Equal to ProfessionSeniorPA.
const RoleSeniorPA = ProfessionSeniorPA
// EntityType values for the polymorphic approval workflow.
const (
EntityTypeDeadline = "deadline"
EntityTypeAppointment = "appointment"
)
// LifecycleEvent values matching paliad.approval_policies.lifecycle_event
// and paliad.approval_requests.lifecycle_event CHECK constraints.
const (
LifecycleCreate = "create"
LifecycleUpdate = "update"
LifecycleComplete = "complete"
LifecycleDelete = "delete"
)
// ApprovalStatus values on paliad.deadlines.approval_status and
// paliad.appointments.approval_status.
const (
ApprovalStatusApproved = "approved"
ApprovalStatusPending = "pending"
ApprovalStatusLegacy = "legacy"
)
// RequestStatus values on paliad.approval_requests.status.
const (
RequestStatusPending = "pending"
RequestStatusApproved = "approved"
RequestStatusRejected = "rejected"
RequestStatusRevoked = "revoked"
RequestStatusSuperseded = "superseded"
)
// DecisionKind discriminates 'peer' (normal in-team sign-off) from
// 'admin_override' (global_admin used the escape-hatch path) and
// 'derived_peer' (a partner-unit-derived member with authority signed off
// — added by t-paliad-139 / migration 055). Verlauf chronology renders
// these distinctly.
const (
DecisionKindPeer = "peer"
DecisionKindAdminOverride = "admin_override"
DecisionKindDerivedPeer = "derived_peer"
)
// professionLevel maps a profession value to its strict-ladder level.
// Mirrors paliad.approval_role_level(text). NULL profession (empty
// string) returns 0 — explicit so the trap is visible.
//
// 5: partner — firm-tier ceiling (replaces legacy 'lead')
// 4: of_counsel
// 3: associate ← default required level on new policies
// 2: senior_pa
// 1: pa
// 0: paralegal / "" / unknown — ineligible to approve
//
// CRITICAL: do not silently default NULL/empty to 'associate'. NULL
// profession means "no firm tier", which is the explicit signal that
// the user (e.g. external local counsel) cannot satisfy any tier.
// Test: TestProfessionLevel_NilIsZero pins this behaviour.
func professionLevel(profession string) int {
switch profession {
case ProfessionPartner:
return 5
case ProfessionOfCounsel:
return 4
case ProfessionAssociate:
return 3
case ProfessionSeniorPA:
return 2
case ProfessionPA:
return 1
default:
return 0
}
}
// responsibilityOpensGate returns true iff the project responsibility
// opens the approval gate. Mirrors the SQL predicate
// `pt.responsibility IN ('lead','member')` used by
// paliad.user_project_authority_level().
func responsibilityOpensGate(responsibility string) bool {
return responsibility == ResponsibilityLead || responsibility == ResponsibilityMember
}
// IsValidRequiredRole returns true iff the role can be set as a policy's
// required_role (i.e. it has a non-zero strict-ladder level). Used by
// the policy-authoring page to validate the dropdown value.
func IsValidRequiredRole(role string) bool {
return professionLevel(role) > 0
}
// IsValidProfession returns true iff the value is one of the recognised
// profession enum values. Empty string is intentionally rejected — the
// service layer represents NULL as a *string nil, not as "".
func IsValidProfession(p string) bool {
switch p {
case ProfessionPartner, ProfessionOfCounsel, ProfessionAssociate,
ProfessionSeniorPA, ProfessionPA, ProfessionParalegal:
return true
}
return false
}
// IsValidResponsibility returns true iff the value is one of the
// recognised project-responsibility enum values. Used by TeamService.
func IsValidResponsibility(r string) bool {
switch r {
case ResponsibilityLead, ResponsibilityMember,
ResponsibilityObserver, ResponsibilityExternal:
return true
}
return false
}
// Approval-flow errors. Handlers map these to the right HTTP status:
//
// ErrSelfApproval -> 403
// ErrNoQualifiedApprover -> 409 (with required_role hint)
// ErrConcurrentPending -> 409 (with the existing request id hint)
// ErrNotApprover -> 403
// ErrRequestNotPending -> 409
// ErrUnknownEntityType -> 500 (programming error)
var (
ErrSelfApproval = errors.New("self-approval blocked")
ErrNoQualifiedApprover = errors.New("no qualified approver available")
ErrConcurrentPending = errors.New("entity already has a pending approval request")
ErrNotApprover = errors.New("not authorized to approve this request")
ErrRequestNotPending = errors.New("request is not pending")
ErrUnknownEntityType = errors.New("unknown entity type")
)
// PendingApprovalError wraps ErrConcurrentPending with the in-flight
// request's id + required role, so handlers can render a 409 body that
// tells the user which request is blocking them and lets the UI offer
// a "withdraw" affordance pointing at that request.
//
// Construct via NewPendingApprovalError(requestID, requiredRole). Unwraps
// to ErrConcurrentPending so existing errors.Is() checks still work.
type PendingApprovalError struct {
RequestID string
RequiredRole string
}
func (e *PendingApprovalError) Error() string {
if e.RequestID == "" {
return ErrConcurrentPending.Error()
}
return ErrConcurrentPending.Error() + ": request_id=" + e.RequestID
}
func (e *PendingApprovalError) Unwrap() error { return ErrConcurrentPending }
// NewPendingApprovalError builds a PendingApprovalError for an entity row
// whose pending_request_id is non-nil. requestID may be the empty string
// when the entity row's pending_request_id is unexpectedly NULL — the
// error still works as a generic ErrConcurrentPending in that case.
func NewPendingApprovalError(requestID, requiredRole string) *PendingApprovalError {
return &PendingApprovalError{RequestID: requestID, RequiredRole: requiredRole}
}