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)
197 lines
7.0 KiB
Go
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}
|
|
}
|