#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.
207 lines
7.7 KiB
Go
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}
|
|
}
|