Files
paliad/internal/services/approval_levels.go
mAi 2ed0ef3177 feat(team-admin): t-paliad-223 Slice A — Project Admin role + inheritable role-edit gate
#48 — adds 'admin' as fifth project_teams.responsibility value, plumbs an
inheritable role-edit gate via the materialised ltree path.

- migration 110: ALTER responsibility CHECK, CREATE paliad.effective_project_admin(uuid,uuid) STABLE SECURITY DEFINER (mirrors can_see_project shape), REPLACE project_teams_update / _insert / _delete RLS policies. Idempotent + down-mig provided. Dry-run BEGIN..ROLLBACK clean on live supabase.
- services/approval_levels.go: ResponsibilityAdmin const + IsValidResponsibility extension. responsibilityOpensGate UNCHANGED — admin is orthogonal to the 4-Augen approval gate.
- services/team_service.go: ChangeResponsibility() with last-admin guard inside tx (counts admins on project + ancestor chain, excludes the row being changed). RemoveMember() also runs the guard when removing an admin row. New IsEffectiveProjectAdmin() driving the frontend affordance. legacyRoleFromResponsibility: admin → 'lead' (deprecated shadow column).
- services/project_service.go: ErrLastProjectAdmin sentinel mapped to 409 in writeServiceError.
- handlers/teams.go: new PATCH /api/projects/{id}/team/{user_id}. RLS-enforced; non-admins get 404 to avoid existence leakage.
- handlers/projects.go: GET /api/projects/{id} now wraps the payload with effective_admin bool so the frontend drives the inline-select affordance without a second round-trip.
- frontend/src/projects-detail.tsx + client/projects-detail.ts: admin appears as 5th option in 'Mitglied hinzufügen' dropdown. Team-list Rolle cell switches to an inline <select> for callers with effective_admin (read-only span otherwise). Optimistic PATCH with rollback on error (last-admin guard / 403 from RLS / etc.) surfaced as transient toast in #team-msg.
- i18n: +6 keys (admin label + admin.hint + 3 error toasts × 2 langs).
- tests: TestIsValidResponsibility now covers admin; new TestLegacyRoleFromResponsibility pins the mapping table.

go build && go test -short ./internal/... && bun run build all clean.
2026-05-20 14:46:36 +02:00

207 lines
7.7 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).
//
// ResponsibilityAdmin (t-paliad-223) is orthogonal to the approval gate —
// it grants role-edit authority on the project + descendants via the
// paliad.effective_project_admin predicate, but does NOT by itself open
// the 4-Augen approval gate. An Admin who has no profession set is still
// not an approver. Use responsibilityOpensGate to test the approval axis.
const (
ResponsibilityAdmin = "admin"
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"
RequestStatusChangesRequested = "changes_requested"
)
// 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 ResponsibilityAdmin, 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")
ErrSuggestionRequiresChange = errors.New("suggestion requires a counter_payload diff or a note")
ErrSuggestionLifecycleInvalid = errors.New("suggest-changes is only valid for update / complete lifecycles")
)
// 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}
}