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:
@@ -325,6 +325,67 @@ func handleRevokeApprovalRequest(w http.ResponseWriter, r *http.Request) {
|
||||
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) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
|
||||
@@ -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}/reject", handleRejectApprovalRequest)
|
||||
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
|
||||
// every authenticated user (NOT admin-gated) so deadline +
|
||||
|
||||
@@ -170,6 +170,18 @@ func mapApprovalError(w http.ResponseWriter, err error) bool {
|
||||
"message": "Die Anfrage ist nicht mehr offen.",
|
||||
})
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user