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") )