Files
paliad/internal/handlers/approvals.go
mAi aa82434af9 fix(t-paliad-202): grey out inbox actions instead of erroring on illegal click
m's UX bug (2026-05-17, paliad.de prod): clicking Genehmigen/Ablehnen/
Zurückziehen on a row the viewer can't act on alerted ("Eigengenehmigung
nicht zulässig.", "Sie haben nicht die erforderliche Rolle.") after the
POST round-trip. m's ask: "approval that i cannot grant should have the
'Genehmigen' button greyed out... that would be better than showing an
error when I try."

Backend (internal/services/approval_service.go):
- ApprovalRequestView gains viewer_can_approve + viewer_is_requester
  booleans. Resolved server-side per caller — false on self-authored rows
  (caller == requester), true when the eligibility predicate matches.
- Extract the eligibility EXISTS-block into approvalEligibilitySQL const
  and reuse it in ListPendingForApprover (WHERE), PendingCountForUser
  (WHERE), and the new viewer_can_approve SELECT expression. Single
  source of truth for the gate, identical to canApprove.
- ListPendingForApprover, ListSubmittedByUser, and GetRequest all bind
  $1 = callerID so the SELECT computes the flags inline (one query, no
  N+1). GetRequest's signature grows a callerID arg; the handler passes
  the authenticated user.

Frontend (frontend/src/client/views/shape-list.ts):
- ApprovalDetail picks up the two booleans (optional — falsy is safe:
  it disables, never falsely enables).
- approvalActionBtn renders the button as before but flips
  btn.disabled + sets a tooltip via disabledReasonFor: approve/reject
  share the viewer_can_approve gate (self → self_approval tooltip;
  unauthorized → not_authorized); revoke needs viewer_is_requester.
- All three buttons still render on every pending row so users see
  what's possible — the disabled+tooltip combo explains what's not.

i18n + CSS:
- 3 new keys × DE/EN: approvals.disabled.{self_approval,
  not_authorized,revoke_not_requester}.
- .inbox-row-action:disabled neutralises the .btn-primary/danger/
  secondary variant via opacity + not-allowed + muted tokens.

Tests:
- internal/services/approval_service_test.go::TestApprovalService_ViewerFlags
  is a 4-case table-driven live-DB test (skips without TEST_DATABASE_URL):
  self-authored (false/true), eligible peer (true/false), non-eligible
  viewer (false/false), global_admin (true/false). Also asserts the flags
  on ListPendingForApprover + ListSubmittedByUser rows.

Defence-in-depth preserved: server still rejects illegal POSTs with the
same error contract, and the alert path stays in inbox.ts for the race
where state changes between render and click.
2026-05-17 12:44:29 +02:00

593 lines
18 KiB
Go

package handlers
// Approval workflow HTTP endpoints (t-paliad-138).
//
// Three groups of routes:
//
// 1. Policy CRUD (admin-only, gated at the route layer):
// GET /api/projects/{id}/approval-policies
// PUT /api/projects/{id}/approval-policies/{entity_type}/{lifecycle}
// DELETE /api/projects/{id}/approval-policies/{entity_type}/{lifecycle}
//
// 2. Inbox (any authenticated user — the service-layer query gates by
// project visibility + approver eligibility):
// GET /api/inbox/pending-mine — requests I can approve
// GET /api/inbox/mine — requests I submitted
// GET /api/inbox/count — bell badge count
// GET /api/approval-requests/{id} — one request hydrated
//
// 3. Decisions (any authenticated user — service layer gates the action):
// POST /api/approval-requests/{id}/approve
// POST /api/approval-requests/{id}/reject
// POST /api/approval-requests/{id}/revoke
import (
"encoding/json"
"errors"
"net/http"
"github.com/google/uuid"
"mgit.msbls.de/m/paliad/internal/models"
"mgit.msbls.de/m/paliad/internal/services"
)
// ============================================================================
// Policy CRUD (admin only — gated by RequireAdminFunc at registration).
// ============================================================================
// GET /api/projects/{id}/approval-policies
func handleListApprovalPolicies(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
if _, ok := requireUser(w, r); !ok {
return
}
projectID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid project id"})
return
}
rows, err := dbSvc.approval.ListProjectPolicies(r.Context(), projectID)
if err != nil {
writeServiceError(w, err)
return
}
if rows == nil {
rows = []models.ApprovalPolicy{} // ensure JSON [] not null
}
writeJSON(w, http.StatusOK, rows)
}
// PUT /api/projects/{id}/approval-policies/{entity_type}/{lifecycle}
//
// Body (split-grammar, t-paliad-160): {"requires_approval": bool, "min_role": "associate"|null}
// Body (legacy, dual-read window): {"required_role": "associate"|"none"}
//
// Semantics: upsert. Replaces any existing row for the same
// (project, entity_type, lifecycle) tuple.
func handlePutApprovalPolicy(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
projectID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid project id"})
return
}
entityType := r.PathValue("entity_type")
lifecycle := r.PathValue("lifecycle")
body, err := decodePolicyBody(r)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
var policy *models.ApprovalPolicy
if body.useSplit {
policy, err = dbSvc.approval.UpsertProjectPolicySplit(r.Context(), uid, projectID, entityType, lifecycle, body.requiresApproval, body.minRole)
} else {
policy, err = dbSvc.approval.UpsertProjectPolicy(r.Context(), uid, projectID, entityType, lifecycle, body.requiredRole)
}
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, policy)
}
// policyUpsertBody is the parsed payload for the policy PUT endpoints,
// supporting both the new split-grammar shape and the legacy single-string
// shape during the M1 dual-read window. Exactly one path is taken:
// - useSplit=true → call *Split with (requiresApproval, minRole).
// - useSplit=false → call legacy with requiredRole.
type policyUpsertBody struct {
useSplit bool
requiresApproval bool
minRole *string
requiredRole string
}
// decodePolicyBody parses either split-grammar or legacy payload. The
// presence of the "requires_approval" key wins — explicit absence falls
// back to the legacy required_role path.
func decodePolicyBody(r *http.Request) (policyUpsertBody, error) {
var raw map[string]json.RawMessage
if err := json.NewDecoder(r.Body).Decode(&raw); err != nil {
return policyUpsertBody{}, errors.New("invalid JSON")
}
if v, ok := raw["requires_approval"]; ok {
var b policyUpsertBody
b.useSplit = true
if err := json.Unmarshal(v, &b.requiresApproval); err != nil {
return policyUpsertBody{}, errors.New("requires_approval must be boolean")
}
if mr, ok := raw["min_role"]; ok && string(mr) != "null" {
var s string
if err := json.Unmarshal(mr, &s); err != nil {
return policyUpsertBody{}, errors.New("min_role must be string or null")
}
b.minRole = &s
}
return b, nil
}
if v, ok := raw["required_role"]; ok {
var s string
if err := json.Unmarshal(v, &s); err != nil {
return policyUpsertBody{}, errors.New("required_role must be string")
}
return policyUpsertBody{useSplit: false, requiredRole: s}, nil
}
return policyUpsertBody{}, errors.New("requires_approval or required_role required")
}
// DELETE /api/projects/{id}/approval-policies/{entity_type}/{lifecycle}
//
// Removes one policy row, reverting that lifecycle event back to the
// no-approval-needed default.
func handleDeleteApprovalPolicy(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
projectID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid project id"})
return
}
entityType := r.PathValue("entity_type")
lifecycle := r.PathValue("lifecycle")
if err := dbSvc.approval.DeleteProjectPolicy(r.Context(), uid, projectID, entityType, lifecycle); err != nil {
writeServiceError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// ============================================================================
// Inbox.
// ============================================================================
// GET /api/inbox/pending-mine — requests I'm qualified to approve.
func handleListInboxPendingMine(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
rows, err := dbSvc.approval.ListPendingForApprover(r.Context(), uid, parseInboxFilter(r))
if err != nil {
writeServiceError(w, err)
return
}
if rows == nil {
rows = []services.ApprovalRequestView{} // ensure JSON [] not null
}
writeJSON(w, http.StatusOK, rows)
}
// GET /api/inbox/mine — requests I submitted.
//
// Returns ALL statuses by default (pending, approved, rejected, revoked,
// superseded). Pass ?status=pending to narrow. Empty result is serialised
// as [] not null so the frontend doesn't trip on a null body and crash
// before rendering the empty state (t-paliad-160 §D regression hardening).
func handleListInboxMine(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
rows, err := dbSvc.approval.ListSubmittedByUser(r.Context(), uid, parseInboxFilter(r))
if err != nil {
writeServiceError(w, err)
return
}
if rows == nil {
rows = []services.ApprovalRequestView{} // ensure JSON [] not null
}
writeJSON(w, http.StatusOK, rows)
}
// GET /api/inbox/count — bell badge count for the sidebar.
func handleInboxCount(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
n, err := dbSvc.approval.PendingCountForUser(r.Context(), uid)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]int{"count": n})
}
// parseInboxFilter pulls common filter knobs off the query string.
//
// Status / EntityType pass through validation: an unrecognised value is
// silently dropped so a stray ?status=foo from a stale frontend build
// doesn't shadow every row out of the result set (t-paliad-160 §D
// regression hardening — defence-in-depth against the "Meine Anfragen
// is empty" report).
func parseInboxFilter(r *http.Request) services.InboxFilter {
q := r.URL.Query()
f := services.InboxFilter{}
if s := q.Get("status"); isValidInboxStatus(s) {
f.Status = s
}
if e := q.Get("entity_type"); e == services.EntityTypeDeadline || e == services.EntityTypeAppointment {
f.EntityType = e
}
if pid := q.Get("project_id"); pid != "" {
if id, err := uuid.Parse(pid); err == nil {
f.ProjectID = &id
}
}
return f
}
// isValidInboxStatus is the allowlist of accepted status filter values.
// Empty string passes through as "no filter".
func isValidInboxStatus(s string) bool {
switch s {
case "",
services.RequestStatusPending,
services.RequestStatusApproved,
services.RequestStatusRejected,
services.RequestStatusRevoked,
services.RequestStatusSuperseded:
return true
}
return false
}
// GET /api/approval-requests/{id} — one hydrated request.
func handleGetApprovalRequest(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
requestID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request id"})
return
}
row, err := dbSvc.approval.GetRequest(r.Context(), uid, requestID)
if err != nil {
writeServiceError(w, err)
return
}
if row == nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
return
}
writeJSON(w, http.StatusOK, row)
}
// ============================================================================
// Decisions.
// ============================================================================
type approvalDecisionBody struct {
Note string `json:"note"`
}
// POST /api/approval-requests/{id}/approve
func handleApproveApprovalRequest(w http.ResponseWriter, r *http.Request) {
handleApprovalDecision(w, r, "approve")
}
// POST /api/approval-requests/{id}/reject
func handleRejectApprovalRequest(w http.ResponseWriter, r *http.Request) {
handleApprovalDecision(w, r, "reject")
}
// POST /api/approval-requests/{id}/revoke
func handleRevokeApprovalRequest(w http.ResponseWriter, r *http.Request) {
handleApprovalDecision(w, r, "revoke")
}
func handleApprovalDecision(w http.ResponseWriter, r *http.Request, action string) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
requestID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request id"})
return
}
var body approvalDecisionBody
if r.Body != nil && r.ContentLength > 0 {
_ = json.NewDecoder(r.Body).Decode(&body) // body is optional
}
switch action {
case "approve":
err = dbSvc.approval.Approve(r.Context(), requestID, uid, body.Note)
case "reject":
err = dbSvc.approval.Reject(r.Context(), requestID, uid, body.Note)
case "revoke":
err = dbSvc.approval.Revoke(r.Context(), requestID, uid)
}
if err != nil {
writeApprovalError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
// GET /inbox — server-static page shell. Hydration is purely client-side
// (the bundle calls /api/inbox/pending-mine on load).
func handleInboxPage(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "dist/inbox.html")
}
// ============================================================================
// t-paliad-154 — admin approval-policy authoring page + APIs.
//
// Most endpoints below register under /api/admin and are admin-gated by the
// outer adminGate(users, ...) wrapper at handlers.go. The form-time hint
// endpoint at /api/projects/{id}/approval-policies/effective is the one
// exception — it's reachable by every authenticated user authoring a
// deadline/appointment so the form can render the "this needs 4-eye"
// banner before they save.
// ============================================================================
// GET /admin/approval-policies — server-static page shell.
func handleAdminApprovalPoliciesPage(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "dist/admin-approval-policies.html")
}
// GET /api/admin/partner-units/{unit_id}/approval-policies — list one
// partner unit's default policy rows.
func handleListUnitApprovalPolicies(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
if _, ok := requireUser(w, r); !ok {
return
}
unitID, err := uuid.Parse(r.PathValue("unit_id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid unit id"})
return
}
rows, err := dbSvc.approval.ListUnitPolicies(r.Context(), unitID)
if err != nil {
writeServiceError(w, err)
return
}
if rows == nil {
rows = []models.ApprovalPolicy{}
}
writeJSON(w, http.StatusOK, rows)
}
// PUT /api/admin/partner-units/{unit_id}/approval-policies/{entity_type}/{lifecycle}
//
// Body (split-grammar): {"requires_approval": bool, "min_role": "associate"|null}
// Body (legacy): {"required_role": "associate"|"none"}
func handlePutUnitApprovalPolicy(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
unitID, err := uuid.Parse(r.PathValue("unit_id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid unit id"})
return
}
entityType := r.PathValue("entity_type")
lifecycle := r.PathValue("lifecycle")
body, err := decodePolicyBody(r)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
var policy *models.ApprovalPolicy
if body.useSplit {
policy, err = dbSvc.approval.UpsertUnitPolicySplit(r.Context(), uid, unitID, entityType, lifecycle, body.requiresApproval, body.minRole)
} else {
policy, err = dbSvc.approval.UpsertUnitPolicy(r.Context(), uid, unitID, entityType, lifecycle, body.requiredRole)
}
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, policy)
}
// DELETE /api/admin/partner-units/{unit_id}/approval-policies/{entity_type}/{lifecycle}
func handleDeleteUnitApprovalPolicy(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
unitID, err := uuid.Parse(r.PathValue("unit_id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid unit id"})
return
}
entityType := r.PathValue("entity_type")
lifecycle := r.PathValue("lifecycle")
if err := dbSvc.approval.DeleteUnitPolicy(r.Context(), uid, unitID, entityType, lifecycle); err != nil {
writeServiceError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// GET /api/admin/approval-policies/seeded — has any policy been authored
// firm-wide? Used by /inbox to gate the admin "configure policies" nudge.
func handleApprovalPoliciesSeeded(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
if _, ok := requireUser(w, r); !ok {
return
}
any, err := dbSvc.approval.PoliciesExist(r.Context())
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]bool{"any": any})
}
// GET /api/admin/approval-policies/matrix?project_id=... — 8 effective
// policy rows for one project, with attribution chips.
func handleApprovalPoliciesMatrix(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
if _, ok := requireUser(w, r); !ok {
return
}
pidStr := r.URL.Query().Get("project_id")
if pidStr == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "project_id required"})
return
}
projectID, err := uuid.Parse(pidStr)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid project_id"})
return
}
rows, err := dbSvc.approval.GetEffectivePoliciesMatrix(r.Context(), projectID)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, rows)
}
// POST /api/admin/approval-policies/apply-to-descendants
//
// Body: {"source_project_id": uuid, "target_project_ids": [uuid, ...]}
//
// Copies the source's effective matrix down to every target as
// project-scoped rows. Targets must be actual descendants of source.
func handleApplyMatrixToDescendants(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
var body struct {
SourceProjectID uuid.UUID `json:"source_project_id"`
TargetProjectIDs []uuid.UUID `json:"target_project_ids"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
if body.SourceProjectID == uuid.Nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "source_project_id required"})
return
}
writes, err := dbSvc.approval.ApplyMatrixToDescendants(r.Context(), uid, body.SourceProjectID, body.TargetProjectIDs)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"writes": writes,
"targets": len(body.TargetProjectIDs),
})
}
// GET /api/projects/{id}/approval-policies/effective?entity_type=&lifecycle=
//
// Single-cell effective policy lookup. Used by the deadline + appointment
// new/edit forms to render the form-time 4-eye hint above the Speichern
// button (Q13 of the locked design). Reachable by every authenticated user
// (NOT admin-gated) — they need to know their save will trigger an
// approval request.
func handleProjectEffectivePolicy(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
if _, ok := requireUser(w, r); !ok {
return
}
projectID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid project id"})
return
}
q := r.URL.Query()
entityType := q.Get("entity_type")
lifecycle := q.Get("lifecycle")
if entityType == "" || lifecycle == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "entity_type and lifecycle required"})
return
}
row, err := dbSvc.approval.GetEffectivePolicyOne(r.Context(), projectID, entityType, lifecycle)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, row)
}
// writeApprovalError maps approval-flow errors to HTTP status codes. The
// code/message body shape is shared with mapApprovalError so the frontend
// has a single switch on `code` regardless of which endpoint surfaced
// the error (entity mutation vs explicit approve/reject/revoke decision).
func writeApprovalError(w http.ResponseWriter, err error) {
if mapApprovalError(w, err) {
return
}
writeServiceError(w, err)
}