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.
593 lines
18 KiB
Go
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)
|
|
}
|