Files
paliad/internal/services/approval_levels.go
m a61c1490e3 feat(t-paliad-139): Phase 3 — derived_peer authority extension to t-138 approval gate
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).
2026-05-06 16:45:19 +02:00

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