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:
291
internal/handlers/approvals.go
Normal file
291
internal/handlers/approvals.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user