diff --git a/internal/handlers/approvals.go b/internal/handlers/approvals.go new file mode 100644 index 0000000..2c10fb7 --- /dev/null +++ b/internal/handlers/approvals.go @@ -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) + } +} diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 345ae92..82010ee 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -97,6 +97,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc link: svc.Link, event: svc.Event, 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/{id}/promote", adminGate(users, handleAdminPromoteEventType)) 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 diff --git a/internal/handlers/projects.go b/internal/handlers/projects.go index f406d94..6deb7ec 100644 --- a/internal/handlers/projects.go +++ b/internal/handlers/projects.go @@ -42,6 +42,7 @@ type dbServices struct { link *services.LinkService event *services.EventService courts *services.CourtService + approval *services.ApprovalService } var dbSvc *dbServices