feat(t-paliad-138): approval API endpoints (policy CRUD + inbox + decisions)

Combined backend API for the upcoming policy-authoring page (commit 4)
and inbox + bell (commit 5). Registers:

Policy CRUD (admin-only via RequireAdminFunc gate):
- GET    /api/projects/{id}/approval-policies
- PUT    /api/projects/{id}/approval-policies/{entity_type}/{lifecycle}
- DELETE /api/projects/{id}/approval-policies/{entity_type}/{lifecycle}

Inbox (any authenticated user; service-layer query gates by visibility
+ role-tier match):
- GET /api/inbox/pending-mine     — requests I qualify to approve
- GET /api/inbox/mine             — requests I submitted
- GET /api/inbox/count            — bell badge count
- GET /api/approval-requests/{id} — one hydrated request

Decisions (caller authorization checked at service layer; the CHECK
constraint on approval_requests blocks self-approval as a second
defence):
- POST /api/approval-requests/{id}/approve
- POST /api/approval-requests/{id}/reject
- POST /api/approval-requests/{id}/revoke

Error mapping (writeApprovalError):
- ErrSelfApproval        → 403 self_approval_blocked
- ErrNoQualifiedApprover → 409 no_qualified_approver
- ErrConcurrentPending   → 409 concurrent_pending
- ErrNotApprover         → 403 not_authorized
- ErrRequestNotPending   → 409 request_not_pending

Frontend pages (the policy authoring tab on /projects/{id}/settings
and the /inbox page with bell) follow in subsequent commits — the
endpoints are usable via curl + admin tooling immediately.
This commit is contained in:
m
2026-05-06 15:30:28 +02:00
parent 10b3426086
commit fb6a07f4b7
3 changed files with 314 additions and 0 deletions

View File

@@ -0,0 +1,291 @@
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.ListPolicies(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: {"required_role": "associate"}
//
// 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")
var body struct {
RequiredRole string `json:"required_role"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
policy, err := dbSvc.approval.UpsertPolicy(r.Context(), projectID, uid, entityType, lifecycle, body.RequiredRole)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, policy)
}
// 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
}
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
}
entityType := r.PathValue("entity_type")
lifecycle := r.PathValue("lifecycle")
if err := dbSvc.approval.DeletePolicy(r.Context(), 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
}
writeJSON(w, http.StatusOK, rows)
}
// GET /api/inbox/mine — requests I submitted.
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
}
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.
func parseInboxFilter(r *http.Request) services.InboxFilter {
q := r.URL.Query()
f := services.InboxFilter{
Status: q.Get("status"),
EntityType: q.Get("entity_type"),
}
if pid := q.Get("project_id"); pid != "" {
if id, err := uuid.Parse(pid); err == nil {
f.ProjectID = &id
}
}
return f
}
// GET /api/approval-requests/{id} — one hydrated request.
func handleGetApprovalRequest(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
if _, ok := requireUser(w, r); !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(), 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"})
}
// writeApprovalError maps approval-flow errors to HTTP status codes.
func writeApprovalError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, services.ErrSelfApproval):
writeJSON(w, http.StatusForbidden, map[string]string{"error": "self_approval_blocked"})
case errors.Is(err, services.ErrNoQualifiedApprover):
writeJSON(w, http.StatusConflict, map[string]string{"error": "no_qualified_approver"})
case errors.Is(err, services.ErrConcurrentPending):
writeJSON(w, http.StatusConflict, map[string]string{"error": "concurrent_pending"})
case errors.Is(err, services.ErrNotApprover):
writeJSON(w, http.StatusForbidden, map[string]string{"error": "not_authorized"})
case errors.Is(err, services.ErrRequestNotPending):
writeJSON(w, http.StatusConflict, map[string]string{"error": "request_not_pending"})
default:
writeServiceError(w, err)
}
}

View File

@@ -97,6 +97,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
link: svc.Link, link: svc.Link,
event: svc.Event, event: svc.Event,
courts: svc.Courts, courts: svc.Courts,
approval: svc.Approval,
} }
} }
@@ -367,6 +368,27 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
protected.HandleFunc("POST /api/admin/event-types/merge", adminGate(users, handleAdminMergeEventTypes)) protected.HandleFunc("POST /api/admin/event-types/merge", adminGate(users, handleAdminMergeEventTypes))
protected.HandleFunc("POST /api/admin/event-types/{id}/promote", adminGate(users, handleAdminPromoteEventType)) protected.HandleFunc("POST /api/admin/event-types/{id}/promote", adminGate(users, handleAdminPromoteEventType))
protected.HandleFunc("POST /api/admin/event-types/{id}/restore", adminGate(users, handleAdminRestoreEventType)) protected.HandleFunc("POST /api/admin/event-types/{id}/restore", adminGate(users, handleAdminRestoreEventType))
// t-paliad-138 — approval-policy CRUD (admin only). The inbox
// + decision endpoints are NOT admin-only — they're below.
protected.HandleFunc("GET /api/projects/{id}/approval-policies",
adminGate(users, handleListApprovalPolicies))
protected.HandleFunc("PUT /api/projects/{id}/approval-policies/{entity_type}/{lifecycle}",
adminGate(users, handlePutApprovalPolicy))
protected.HandleFunc("DELETE /api/projects/{id}/approval-policies/{entity_type}/{lifecycle}",
adminGate(users, handleDeleteApprovalPolicy))
}
// t-paliad-138 — approval inbox + decision endpoints (any authenticated
// user; the service layer gates approve/reject by required-role match).
if svc != nil && svc.Approval != nil {
protected.HandleFunc("GET /api/inbox/pending-mine", handleListInboxPendingMine)
protected.HandleFunc("GET /api/inbox/mine", handleListInboxMine)
protected.HandleFunc("GET /api/inbox/count", handleInboxCount)
protected.HandleFunc("GET /api/approval-requests/{id}", handleGetApprovalRequest)
protected.HandleFunc("POST /api/approval-requests/{id}/approve", handleApproveApprovalRequest)
protected.HandleFunc("POST /api/approval-requests/{id}/reject", handleRejectApprovalRequest)
protected.HandleFunc("POST /api/approval-requests/{id}/revoke", handleRevokeApprovalRequest)
} }
// Catch-all 404 — runs for any authenticated path that no more-specific // Catch-all 404 — runs for any authenticated path that no more-specific

View File

@@ -42,6 +42,7 @@ type dbServices struct {
link *services.LinkService link *services.LinkService
event *services.EventService event *services.EventService
courts *services.CourtService courts *services.CourtService
approval *services.ApprovalService
} }
var dbSvc *dbServices var dbSvc *dbServices