feat(approvals): t-paliad-216 POST /api/approval-requests/{id}/suggest-changes

Wires the HTTP handler for the new action. Body shape:

    {"counter_payload": { ...allowlist fields... }, "note": "..."}

Returns 200 {"status": "ok", "new_request_id": "<uuid>"} on success.

Error mapping (via mapApprovalError):
    400 suggestion_requires_change   — ErrSuggestionRequiresChange
    400 suggestion_lifecycle_invalid — ErrSuggestionLifecycleInvalid
    403 self_approval_blocked        — ErrSelfApproval
    403 not_authorized               — ErrNotApprover
    404                              — not visible / not found (service)
    409 request_not_pending          — ErrRequestNotPending
    409 no_qualified_approver        — ErrNoQualifiedApprover

Route registered alongside the existing approve / reject / revoke trio
in handlers.go.
This commit is contained in:
mAi
2026-05-20 09:48:33 +02:00
parent 705e1a2e79
commit fb2896c836
3 changed files with 74 additions and 0 deletions

View File

@@ -325,6 +325,67 @@ func handleRevokeApprovalRequest(w http.ResponseWriter, r *http.Request) {
handleApprovalDecision(w, r, "revoke") handleApprovalDecision(w, r, "revoke")
} }
// suggestChangesBody is the JSON body for POST /api/approval-requests/{id}/suggest-changes.
// counter_payload is an entity-shaped jsonb of the approver's edited
// values (allowlist enforced server-side); note is the optional free-text
// explanation. The service rejects the call with
// ErrSuggestionRequiresChange when both are no-ops (counter is identical
// to the old row's payload AND note is empty).
type suggestChangesBody struct {
CounterPayload map[string]any `json:"counter_payload"`
Note string `json:"note"`
}
// POST /api/approval-requests/{id}/suggest-changes — t-paliad-216.
//
// In one transaction: close the pending request as 'changes_requested'
// (with the caller's note + counter_payload on the row), revert the entity
// from pre_image, then spawn a NEW pending approval_request authored by
// the caller carrying the counter_payload. Returns the new request id.
//
// Status mapping (see writeApprovalError → mapApprovalError):
//
// 400 suggestion_requires_change — counter == old payload AND no note
// 400 suggestion_lifecycle_invalid — old row's lifecycle ∉ (update, complete)
// 403 self_approval_blocked — caller == old row's requested_by
// 403 not_authorized — caller doesn't satisfy canApprove
// 404 — request not found / not visible
// 409 request_not_pending — old row already decided
// 409 no_qualified_approver — deadlock on the new row
func handleSuggestChangesApprovalRequest(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
}
var body suggestChangesBody
if r.Body != nil && r.ContentLength > 0 {
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{
"code": "invalid_body",
"message": "Ungültiger Body.",
})
return
}
}
newID, err := dbSvc.approval.SuggestChanges(r.Context(), requestID, uid, body.CounterPayload, body.Note)
if err != nil {
writeApprovalError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]string{
"status": "ok",
"new_request_id": newID.String(),
})
}
func handleApprovalDecision(w http.ResponseWriter, r *http.Request, action string) { func handleApprovalDecision(w http.ResponseWriter, r *http.Request, action string) {
if !requireDB(w) { if !requireDB(w) {
return return

View File

@@ -565,6 +565,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
protected.HandleFunc("POST /api/approval-requests/{id}/approve", handleApproveApprovalRequest) 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}/reject", handleRejectApprovalRequest)
protected.HandleFunc("POST /api/approval-requests/{id}/revoke", handleRevokeApprovalRequest) protected.HandleFunc("POST /api/approval-requests/{id}/revoke", handleRevokeApprovalRequest)
protected.HandleFunc("POST /api/approval-requests/{id}/suggest-changes", handleSuggestChangesApprovalRequest)
// t-paliad-154 — form-time effective policy lookup. Reachable by // t-paliad-154 — form-time effective policy lookup. Reachable by
// every authenticated user (NOT admin-gated) so deadline + // every authenticated user (NOT admin-gated) so deadline +

View File

@@ -170,6 +170,18 @@ func mapApprovalError(w http.ResponseWriter, err error) bool {
"message": "Die Anfrage ist nicht mehr offen.", "message": "Die Anfrage ist nicht mehr offen.",
}) })
return true return true
case errors.Is(err, services.ErrSuggestionRequiresChange):
writeJSON(w, http.StatusBadRequest, map[string]string{
"code": "suggestion_requires_change",
"message": "Ein Vorschlag braucht entweder geänderte Werte oder einen Kommentar.",
})
return true
case errors.Is(err, services.ErrSuggestionLifecycleInvalid):
writeJSON(w, http.StatusBadRequest, map[string]string{
"code": "suggestion_lifecycle_invalid",
"message": "Änderungen vorschlagen ist nur für Update- und Complete-Anfragen möglich.",
})
return true
} }
return false return false
} }