8 new endpoints under /api/admin/* (admin-gated) and /api/projects (gated
on per-user authentication for the form-time hint):
Admin APIs (gated by adminGate):
- GET /admin/approval-policies — page shell
- GET /api/admin/partner-units/{unit_id}/approval-policies — list unit defaults
- PUT /api/admin/partner-units/{unit_id}/approval-policies/{entity}/{lifecycle} — upsert unit default
- DELETE /api/admin/partner-units/{unit_id}/approval-policies/{entity}/{lifecycle} — clear unit default
- GET /api/admin/approval-policies/seeded — exists check (gates inbox nudge)
- GET /api/admin/approval-policies/matrix?project_id=... — 8 effective rows w/ attribution
- POST /api/admin/approval-policies/apply-to-descendants — bulk fanout
Form-time hint (NOT admin-gated — every user authoring a deadline /
appointment needs to know whether their save will trigger 4-eye):
- GET /api/projects/{id}/approval-policies/effective?entity_type=&lifecycle=
AuditService extension:
- New AuditSourcePolicyAuditLog source string.
- Fifth UNION ALL branch in auditUnionSQL queries paliad.policy_audit_log,
packs description as 'entity/lifecycle: old → new'. project_id forwarded
for project-scoped rows so /admin/audit-log filters work — but
policy_audit_log is NOT a /verlauf source (the verlauf SELECT in
ProjectService.ListProjectEvents reads project_events directly), so
Q8's no-leak constraint is preserved.
Build + go vet clean. The new handler functions register with the existing
adminGate / gateOnboarded patterns; no new middleware.
511 lines
15 KiB
Go
511 lines
15 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: {"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.UpsertProjectPolicy(r.Context(), uid, projectID, 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
|
|
}
|
|
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
|
|
}
|
|
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"})
|
|
}
|
|
|
|
// 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: {"required_role": "associate"}
|
|
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")
|
|
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.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.
|
|
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)
|
|
}
|
|
}
|