Rewires the 4 SQL ladder sites in approval_service.go (canApprove,
hasQualifiedApprover, ListPendingForApprover, PendingCountForUser) to read
the new tuple: project_teams.responsibility ∈ {lead, member} AND
users.profession at or above the threshold. observer/external rows close
the gate even if the user's profession would otherwise qualify — that's
the project-level call.
approval_levels.go renamed levelOf → professionLevel and added
responsibilityOpensGate helper. New constants: ProfessionPartner /
ProfessionOfCounsel / … and ResponsibilityLead / ResponsibilityMember /
ResponsibilityObserver / ResponsibilityExternal. New validators
IsValidProfession + IsValidResponsibility. RoleSeniorPA kept as legacy
alias for the one remaining call site that hasn't migrated yet.
CRITICAL trap pinned by TestProfessionLevel_NilIsZero: NULL profession
returns 0, never silently defaults to associate. External collaborators
must stay ineligible.
derivation_service.go: requireWritePermission switches from pt.role='lead'
to pt.responsibility='lead' — project-management writes gate on the
project responsibility, not the firm tier. EffectiveProjectRole replaced
by UserProjectAuthorityLevel (thin wrapper over the SQL function in
migration 057). The legacy method was unused dead code despite t-139
design intent.
Tests extended: profession ladder, responsibility gate, NULL trap,
new validators. Build + vet clean.
168 lines
5.8 KiB
Go
168 lines
5.8 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")
|
|
)
|