Wires DerivationService.EffectiveProjectRole into the t-paliad-138
approval ladder so partner-unit-derived members with derive_grants_authority=true
can act as approvers (per design §4.2). When they sign off, the audit row
records decision_kind='derived_peer' — a third value alongside the existing
'peer' and 'admin_override' — so the chronology discloses the derivation
chain.
Schema (migration 055 update)
-----------------------------
- paliad.approval_requests.decision_kind CHECK extended to accept
'derived_peer'. Down migration restores the t-138 two-value CHECK.
Live SQL dry-run confirmed the new value is accepted.
Service layer
-------------
- approval_levels.go: new constant DecisionKindDerivedPeer.
- approval_service.go (4 sites widened with the derivation EXISTS branch):
1. canApprove — third resolution step after global_admin + direct/
ancestor team membership: matches partner-unit-derived members
on path with derive_grants_authority=true and a unit_role whose
approval_role_from_unit_role mapping meets the threshold.
Returns DecisionKindDerivedPeer when this branch is the one that
passed.
2. hasQualifiedApprover (the deadlock-check at submit time) —
widened so a project with no direct approvers but an authority-
granting unit attachment is still submittable.
3. ListPendingForApprover (the /inbox query) — third UNION ALL
branch so derived authority sees their queue.
4. PendingCountForUser (the bell-badge query) — same widening so
derived authority sees the count tick.
All four queries reuse paliad.approval_role_from_unit_role(text) added
by Phase 2 of migration 055.
Frontend
--------
- 2 i18n keys (DE+EN): approvals.decision_kind.derived_peer →
"Genehmigt durch abgeleitetes Mitglied (Partner Unit)" / "Approved by
derived member (Partner Unit)". Verlauf rendering of the third
decision_kind value works through the existing translateEvent /
decision_kind switch with no other change. 1606 keys total.
Strict-default unchanged
------------------------
Derived members are visibility-only by default. Authority requires the
project lead/admin to explicitly flip derive_grants_authority=true on the
project_partner_units row (UI on /projects/{id} Team tab, Phase 2). This
preserves the m-locked Q12 stance.
Phase 3 closes the t-paliad-139 implementation. m's bug closes (Phase 1),
the derivation schema is in place (Phase 2), and approval authority
flows through the new ladder (Phase 3).
109 lines
3.6 KiB
Go
109 lines
3.6 KiB
Go
package services
|
|
|
|
import "errors"
|
|
|
|
// Strict-ladder level helper for the 4-Augen-Prüfung approval gate
|
|
// (t-paliad-138). Mirrors paliad.approval_role_level(text) in migration
|
|
// 054. A user with project_teams.role R can approve any request whose
|
|
// required_role has level <= levelOf(R). Roles outside the approval
|
|
// ladder (local_counsel, expert, observer, anything new) return 0 and
|
|
// are ineligible to approve at any level.
|
|
|
|
// RoleSeniorPA is the new project_teams.role value added by migration 054.
|
|
// It sits between associate (3) and pa (1) and gives a named tier between
|
|
// "associate" and "PA" for projects that want PAs supervised by senior PAs
|
|
// rather than by associates.
|
|
const RoleSeniorPA = "senior_pa"
|
|
|
|
// 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"
|
|
)
|
|
|
|
// levelOf maps a project_teams.role value to its strict-ladder level.
|
|
// Mirrors paliad.approval_role_level(text) in SQL.
|
|
//
|
|
// 5: lead — partner-tier on this project
|
|
// 4: of_counsel
|
|
// 3: associate ← default required level on new policies
|
|
// 2: senior_pa — added by migration 054
|
|
// 1: pa
|
|
// 0: local_counsel / expert / observer / anything new — ineligible to approve
|
|
func levelOf(role string) int {
|
|
switch role {
|
|
case "lead":
|
|
return 5
|
|
case "of_counsel":
|
|
return 4
|
|
case "associate":
|
|
return 3
|
|
case RoleSeniorPA:
|
|
return 2
|
|
case "pa":
|
|
return 1
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
// 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).
|
|
func IsValidRequiredRole(role string) bool {
|
|
return levelOf(role) > 0
|
|
}
|
|
|
|
// 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")
|
|
)
|