feat(approvals): t-paliad-216 — server-side hydration for back-link

Server-side additions so /inbox can render the suggest-changes back-link
without an extra client round-trip:

  - ApprovalRequestView gains NextRequestID. Hydrated via correlated
    subquery on previous_request_id; mig 103's partial index makes the
    lookup O(1) per row.
  - view_service.go approvalRowSubtitle picks up the changes_requested
    case ("Abgelehnt mit Vorschlag von <decider>").
  - filter_spec.go validRequestStatuses includes "changes_requested" so
    user-views can filter on it.
  - handlers/approvals.go isValidInboxStatus accepts "changes_requested"
    on the /api/inbox/{mine,pending-mine}?status= query. Test case added
    to TestParseInboxFilter_DropsUnknownStatus.
This commit is contained in:
mAi
2026-05-20 10:01:43 +02:00
parent 0fd02bf033
commit 0263a0e932
5 changed files with 28 additions and 11 deletions

View File

@@ -270,7 +270,8 @@ func isValidInboxStatus(s string) bool {
services.RequestStatusApproved,
services.RequestStatusRejected,
services.RequestStatusRevoked,
services.RequestStatusSuperseded:
services.RequestStatusSuperseded,
services.RequestStatusChangesRequested:
return true
}
return false

View File

@@ -135,6 +135,7 @@ func TestParseInboxFilter_DropsUnknownStatus(t *testing.T) {
{"rejected", "rejected"},
{"revoked", "revoked"},
{"superseded", "superseded"},
{"changes_requested", "changes_requested"}, // t-paliad-216
{"foo", ""}, // unknown — dropped
{"DROP+TABLE", ""}, // hostile — dropped
{"PENDING", ""}, // case mismatch — dropped (we don't normalise)

View File

@@ -1080,14 +1080,20 @@ func marshalJSONOrNull(m map[string]any) ([]byte, error) {
// server would reject, replacing the previous click-then-alert UX.
type ApprovalRequestView struct {
models.ApprovalRequest
ProjectTitle string `db:"project_title" json:"project_title"`
EntityTitle *string `db:"entity_title" json:"entity_title,omitempty"`
RequesterName string `db:"requester_name" json:"requester_name"`
RequesterEmail string `db:"requester_email" json:"requester_email"`
DeciderName *string `db:"decider_name" json:"decider_name,omitempty"`
DeciderEmail *string `db:"decider_email" json:"decider_email,omitempty"`
ViewerCanApprove bool `db:"viewer_can_approve" json:"viewer_can_approve"`
ViewerIsRequester bool `db:"viewer_is_requester" json:"viewer_is_requester"`
ProjectTitle string `db:"project_title" json:"project_title"`
EntityTitle *string `db:"entity_title" json:"entity_title,omitempty"`
RequesterName string `db:"requester_name" json:"requester_name"`
RequesterEmail string `db:"requester_email" json:"requester_email"`
DeciderName *string `db:"decider_name" json:"decider_name,omitempty"`
DeciderEmail *string `db:"decider_email" json:"decider_email,omitempty"`
ViewerCanApprove bool `db:"viewer_can_approve" json:"viewer_can_approve"`
ViewerIsRequester bool `db:"viewer_is_requester" json:"viewer_is_requester"`
// NextRequestID is the forward-pointer from a changes_requested row
// to the new pending row spawned by SuggestChanges (t-paliad-216).
// Hydrated via correlated subquery on previous_request_id; the
// partial index approval_requests_previous_idx keeps the lookup O(1).
// NULL on every row that hasn't been counter-proposed.
NextRequestID *uuid.UUID `db:"next_request_id" json:"next_request_id,omitempty"`
}
// approvalEligibilitySQL is the SELECT-and-WHERE-compatible boolean
@@ -1150,7 +1156,11 @@ const approvalRequestViewColumns = `
du.display_name AS decider_name,
du.email AS decider_email,
(ar.status = 'pending' AND ar.requested_by <> $1 AND ` + approvalEligibilitySQL + `) AS viewer_can_approve,
(ar.requested_by = $1) AS viewer_is_requester`
(ar.requested_by = $1) AS viewer_is_requester,
(SELECT nxt.id FROM paliad.approval_requests nxt
WHERE nxt.previous_request_id = ar.id
ORDER BY nxt.requested_at DESC
LIMIT 1) AS next_request_id`
const approvalRequestViewJoins = `
paliad.approval_requests ar

View File

@@ -203,7 +203,7 @@ var KnownProjectEventKinds = []string{
// filters and request-side status filters respectively.
var (
validEntityApprovalStatuses = []string{"approved", "pending", "legacy"}
validRequestStatuses = []string{"pending", "approved", "rejected", "revoked"}
validRequestStatuses = []string{"pending", "approved", "rejected", "revoked", "changes_requested"}
validApprovalEntityTypes = []string{"deadline", "appointment"}
validApprovalViewerRoles = []string{"approver_eligible", "self_requested", "any_visible"}
validDeadlineStatuses = []string{"pending", "completed"}

View File

@@ -569,6 +569,11 @@ func approvalRowSubtitle(r ApprovalRequestView) string {
return "Abgelehnt"
case "revoked":
return "Widerrufen"
case "changes_requested":
if r.DeciderName != nil {
return fmt.Sprintf("Abgelehnt mit Vorschlag von %s", *r.DeciderName)
}
return "Abgelehnt mit Vorschlag"
}
return r.Status
}