Closes the procedural-events rename loop opened by m/paliad#93. The admin surface now lives under its canonical URL; the legacy paths remain reachable for one deprecation cycle via 301 redirects so bookmarks, audit-log entries, and curl scripts keep working. * internal/handlers/handlers.go — - Registers the 12 canonical routes under /admin/procedural-events* (page paths and JSON API). Same handlers — just the new URL slot. - Registers the 12 legacy /admin/rules* routes as 301 redirects. * internal/handlers/admin_rules.go — - redirectToProceduralEvents(dst) — fixed-destination redirect for paths without an {id}. - redirectToProceduralEventEdit — page redirect carrying the {id}. - redirectToProceduralEventAPI(suffix) — JSON API redirect carrying {id} + optional suffix (/clone-as-draft, /publish, /archive, /restore, /audit, /preview). Query string is preserved on every redirect. - All three helpers add the IETF Deprecation header + a Link header pointing at the successor-version path. * frontend internal nav + URL strings — Sidebar.tsx, admin.tsx, admin-rules-list.tsx, admin-rules-edit.tsx, client/admin-rules-list.ts, client/admin-rules-edit.ts: every `/admin/rules*` reference flipped to `/admin/procedural-events*`. In-app navigation now hits the canonical paths directly without a redirect round-trip; external callers keep working via the 301s. * frontend .tsx i18n rebind — 9 admin .tsx i18n bindings rebound to the canonical `admin.procedural_events.*` keys that already exist as aliases in i18n.ts (per Slice A from t-paliad-262). Specifically: admin.rules.list.title → admin.procedural_events.list.title admin.rules.list.heading → admin.procedural_events.list.heading admin.rules.list.new → admin.procedural_events.list.new admin.rules.col.submission_code → admin.procedural_events.col.code admin.rules.edit.title → admin.procedural_events.edit.title admin.rules.edit.breadcrumb → admin.procedural_events.edit.breadcrumb admin.rules.edit.field.submission_code → admin.procedural_events.edit.field.code admin.rules.edit.field.event_type → admin.procedural_events.edit.field.event_kind admin.rules.edit.field.parent → admin.procedural_events.edit.field.parent The remaining ~142 admin.rules.* keys do NOT yet have procedural_events aliases. Migrating them is a follow-up slice — each needs a new alias entry in i18n.ts (DE + EN) before the .tsx reference can be flipped. The 9 keys touched here are the most visible (page titles + edit-page field labels) so the admin UI immediately reads as "Verfahrensschritte" everywhere. * frontend/src/client/i18n.ts header comment updated to reflect that the URL rename has shipped (Slice B.6 done) and to flag the remaining i18n-key migration as the next step. Scope (documented, paliadin authorised): - "go everything" applied: backend routes + frontend nav + .tsx rebind of the 9 keys whose canonical aliases exist. - Full migration of all 142 admin.rules.* keys deferred — would require seeding ~142 new alias entries in i18n.ts (DE + EN) plus another 142 .tsx rebinds. Out of scope for tonight; flag as follow-up `feat(i18n): finish admin.rules.* → admin.procedural_events.* alias migration`. - 12 legacy /admin/rules routes still hit a handler (the redirect helper) — they don't 404 yet. Once a deprecation window passes with no traffic on the old paths, a future slice can drop them outright. Build + vet clean. TestMigrations_NoDuplicateSlot passes. This concludes the m/paliad#93 procedural-events rename slice train (Slices A through B.6). curie stays parked persistently for any follow-up the deploy / monitor cycle surfaces.
552 lines
18 KiB
Go
552 lines
18 KiB
Go
package handlers
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"mgit.msbls.de/m/paliad/internal/models"
|
|
"mgit.msbls.de/m/paliad/internal/services"
|
|
)
|
|
|
|
// Admin rule-editor endpoints — Phase 3 Slice 11a (t-paliad-191).
|
|
// Every handler in this file is wired through auth.RequireAdminFunc
|
|
// in handlers.go, so the handlers themselves assume the caller is a
|
|
// global_admin and only validate request shape.
|
|
//
|
|
// Every write endpoint takes an audit_reason field on the request
|
|
// body. The service layer sets paliad.audit_reason in the same tx
|
|
// before the UPDATE so mig 079's audit trigger captures the rationale
|
|
// forever. Missing reason → 400 (ErrAuditReasonRequired).
|
|
//
|
|
// Lifecycle invariants live in the service layer: ErrInvalidLifecycleState
|
|
// is mapped to 409 Conflict so the editor UI can show a clear "must
|
|
// clone first" hint.
|
|
|
|
// Slice B.5 (t-paliad-305) JSON envelope renames:
|
|
//
|
|
// - submission_code → code (procedural-event identifier)
|
|
// - event_type → event_kind (procedural-event taxonomy)
|
|
//
|
|
// Wire compatibility: every response emits BOTH the legacy and the
|
|
// canonical keys for one slice (see Deprecation HTTP header on the
|
|
// response). Input bodies accept either name on the request; the
|
|
// canonical key wins when both are present.
|
|
//
|
|
// adminRuleResponse wraps models.DeadlineRule (= litigationplanner.Rule)
|
|
// to add the canonical `code` + `event_kind` fields alongside the
|
|
// historical `submission_code` + `event_type` already on Rule's tags.
|
|
// The embedded *models.DeadlineRule carries every existing tag through
|
|
// json.Marshal unchanged; the wrapper only ADDS the two new keys.
|
|
type adminRuleResponse struct {
|
|
*models.DeadlineRule
|
|
Code *string `json:"code,omitempty"`
|
|
EventKind *string `json:"event_kind,omitempty"`
|
|
}
|
|
|
|
// wrapRuleResponse builds the dual-emit wrapper from a service result.
|
|
// Same values, two keys per concept — no semantic change.
|
|
func wrapRuleResponse(r *models.DeadlineRule) adminRuleResponse {
|
|
if r == nil {
|
|
return adminRuleResponse{}
|
|
}
|
|
return adminRuleResponse{
|
|
DeadlineRule: r,
|
|
Code: r.SubmissionCode,
|
|
EventKind: r.EventType,
|
|
}
|
|
}
|
|
|
|
// wrapRuleListResponse maps a slice of service results into the
|
|
// dual-emit wrapper. Used by the LIST endpoint.
|
|
func wrapRuleListResponse(rows []models.DeadlineRule) []adminRuleResponse {
|
|
out := make([]adminRuleResponse, len(rows))
|
|
for i := range rows {
|
|
out[i] = wrapRuleResponse(&rows[i])
|
|
}
|
|
return out
|
|
}
|
|
|
|
// adminRuleDeprecationHeaders writes the IETF "Deprecation" + "Sunset"
|
|
// HTTP headers signaling that the legacy `submission_code` /
|
|
// `event_type` JSON keys are being retired in favour of `code` /
|
|
// `event_kind`. RFC 8594 (Sunset) + draft-ietf-httpapi-deprecation-header.
|
|
// Clients should migrate within one slice cycle.
|
|
func adminRuleDeprecationHeaders(w http.ResponseWriter) {
|
|
w.Header().Set("Deprecation", `true; key="submission_code,event_type"`)
|
|
w.Header().Set("Link", `<https://mgit.msbls.de/m/paliad/issues/93>; rel="deprecation"`)
|
|
}
|
|
|
|
// GET /admin/api/rules — paginated list with filters.
|
|
func handleAdminListRules(w http.ResponseWriter, r *http.Request) {
|
|
if dbSvc == nil || dbSvc.ruleEditor == nil {
|
|
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "rule editor unavailable"})
|
|
return
|
|
}
|
|
q := r.URL.Query()
|
|
f := services.ListRulesFilter{
|
|
LifecycleState: q.Get("lifecycle_state"),
|
|
Query: q.Get("q"),
|
|
}
|
|
if v := q.Get("proceeding_type_id"); v != "" {
|
|
n, err := strconv.Atoi(v)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid proceeding_type_id"})
|
|
return
|
|
}
|
|
f.ProceedingTypeID = &n
|
|
}
|
|
if v := q.Get("trigger_event_id"); v != "" {
|
|
n, err := strconv.ParseInt(v, 10, 64)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid trigger_event_id"})
|
|
return
|
|
}
|
|
f.TriggerEventID = &n
|
|
}
|
|
if v := q.Get("offset"); v != "" {
|
|
n, err := strconv.Atoi(v)
|
|
if err != nil || n < 0 {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid offset"})
|
|
return
|
|
}
|
|
f.Offset = n
|
|
}
|
|
if v := q.Get("limit"); v != "" {
|
|
n, err := strconv.Atoi(v)
|
|
if err != nil || n < 0 {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid limit"})
|
|
return
|
|
}
|
|
f.Limit = n
|
|
}
|
|
rows, err := dbSvc.ruleEditor.ListRules(r.Context(), f)
|
|
if err != nil {
|
|
writeRuleEditorError(w, err)
|
|
return
|
|
}
|
|
adminRuleDeprecationHeaders(w)
|
|
writeJSON(w, http.StatusOK, wrapRuleListResponse(rows))
|
|
}
|
|
|
|
// GET /admin/api/rules/{id}
|
|
func handleAdminGetRule(w http.ResponseWriter, r *http.Request) {
|
|
if dbSvc == nil || dbSvc.ruleEditor == nil {
|
|
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "rule editor unavailable"})
|
|
return
|
|
}
|
|
id, ok := parseRuleID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
row, err := dbSvc.ruleEditor.GetByID(r.Context(), id)
|
|
if err != nil {
|
|
writeRuleEditorError(w, err)
|
|
return
|
|
}
|
|
adminRuleDeprecationHeaders(w)
|
|
writeJSON(w, http.StatusOK, wrapRuleResponse(row))
|
|
}
|
|
|
|
// POST /admin/api/rules — create draft.
|
|
func handleAdminCreateRule(w http.ResponseWriter, r *http.Request) {
|
|
if dbSvc == nil || dbSvc.ruleEditor == nil {
|
|
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "rule editor unavailable"})
|
|
return
|
|
}
|
|
var body struct {
|
|
services.CreateRuleInput
|
|
Reason string `json:"reason"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
|
return
|
|
}
|
|
// Slice B.5 (t-paliad-305): accept both legacy + canonical JSON keys.
|
|
body.CreateRuleInput.CoalesceCanonicalKeys()
|
|
row, err := dbSvc.ruleEditor.Create(r.Context(), body.CreateRuleInput, body.Reason)
|
|
if err != nil {
|
|
writeRuleEditorError(w, err)
|
|
return
|
|
}
|
|
adminRuleDeprecationHeaders(w)
|
|
writeJSON(w, http.StatusCreated, wrapRuleResponse(row))
|
|
}
|
|
|
|
// PATCH /admin/api/rules/{id} — partial update of a draft.
|
|
func handleAdminPatchRule(w http.ResponseWriter, r *http.Request) {
|
|
if dbSvc == nil || dbSvc.ruleEditor == nil {
|
|
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "rule editor unavailable"})
|
|
return
|
|
}
|
|
id, ok := parseRuleID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body struct {
|
|
services.RulePatch
|
|
Reason string `json:"reason"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
|
return
|
|
}
|
|
// Slice B.5 (t-paliad-305): accept both legacy + canonical JSON keys.
|
|
body.RulePatch.CoalesceCanonicalKeys()
|
|
row, err := dbSvc.ruleEditor.UpdateDraft(r.Context(), id, body.RulePatch, body.Reason)
|
|
if err != nil {
|
|
writeRuleEditorError(w, err)
|
|
return
|
|
}
|
|
adminRuleDeprecationHeaders(w)
|
|
writeJSON(w, http.StatusOK, wrapRuleResponse(row))
|
|
}
|
|
|
|
// POST /admin/api/rules/{id}/clone-as-draft
|
|
func handleAdminCloneAsDraft(w http.ResponseWriter, r *http.Request) {
|
|
if dbSvc == nil || dbSvc.ruleEditor == nil {
|
|
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "rule editor unavailable"})
|
|
return
|
|
}
|
|
id, ok := parseRuleID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
reason, ok := decodeReason(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
row, err := dbSvc.ruleEditor.CloneAsDraft(r.Context(), id, reason)
|
|
if err != nil {
|
|
writeRuleEditorError(w, err)
|
|
return
|
|
}
|
|
adminRuleDeprecationHeaders(w)
|
|
writeJSON(w, http.StatusCreated, wrapRuleResponse(row))
|
|
}
|
|
|
|
// POST /admin/api/rules/{id}/publish
|
|
func handleAdminPublishRule(w http.ResponseWriter, r *http.Request) {
|
|
if dbSvc == nil || dbSvc.ruleEditor == nil {
|
|
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "rule editor unavailable"})
|
|
return
|
|
}
|
|
id, ok := parseRuleID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
reason, ok := decodeReason(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
row, err := dbSvc.ruleEditor.Publish(r.Context(), id, reason)
|
|
if err != nil {
|
|
writeRuleEditorError(w, err)
|
|
return
|
|
}
|
|
adminRuleDeprecationHeaders(w)
|
|
writeJSON(w, http.StatusOK, wrapRuleResponse(row))
|
|
}
|
|
|
|
// POST /admin/api/rules/{id}/archive
|
|
func handleAdminArchiveRule(w http.ResponseWriter, r *http.Request) {
|
|
if dbSvc == nil || dbSvc.ruleEditor == nil {
|
|
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "rule editor unavailable"})
|
|
return
|
|
}
|
|
id, ok := parseRuleID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
reason, ok := decodeReason(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
row, err := dbSvc.ruleEditor.Archive(r.Context(), id, reason)
|
|
if err != nil {
|
|
writeRuleEditorError(w, err)
|
|
return
|
|
}
|
|
adminRuleDeprecationHeaders(w)
|
|
writeJSON(w, http.StatusOK, wrapRuleResponse(row))
|
|
}
|
|
|
|
// POST /admin/api/rules/{id}/restore
|
|
func handleAdminRestoreRule(w http.ResponseWriter, r *http.Request) {
|
|
if dbSvc == nil || dbSvc.ruleEditor == nil {
|
|
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "rule editor unavailable"})
|
|
return
|
|
}
|
|
id, ok := parseRuleID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
reason, ok := decodeReason(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
row, err := dbSvc.ruleEditor.Restore(r.Context(), id, reason)
|
|
if err != nil {
|
|
writeRuleEditorError(w, err)
|
|
return
|
|
}
|
|
adminRuleDeprecationHeaders(w)
|
|
writeJSON(w, http.StatusOK, wrapRuleResponse(row))
|
|
}
|
|
|
|
// GET /admin/api/rules/{id}/audit?offset=N&limit=M
|
|
func handleAdminGetRuleAudit(w http.ResponseWriter, r *http.Request) {
|
|
if dbSvc == nil || dbSvc.ruleEditor == nil {
|
|
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "rule editor unavailable"})
|
|
return
|
|
}
|
|
id, ok := parseRuleID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
offset, limit := 0, 0
|
|
q := r.URL.Query()
|
|
if v := q.Get("offset"); v != "" {
|
|
n, err := strconv.Atoi(v)
|
|
if err != nil || n < 0 {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid offset"})
|
|
return
|
|
}
|
|
offset = n
|
|
}
|
|
if v := q.Get("limit"); v != "" {
|
|
n, err := strconv.Atoi(v)
|
|
if err != nil || n < 0 {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid limit"})
|
|
return
|
|
}
|
|
limit = n
|
|
}
|
|
rows, err := dbSvc.ruleEditor.ListAudit(r.Context(), id, offset, limit)
|
|
if err != nil {
|
|
writeRuleEditorError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, rows)
|
|
}
|
|
|
|
// GET /admin/api/rules/{id}/preview?trigger_date=YYYY-MM-DD&flags=a,b&court_id=...
|
|
func handleAdminPreviewRule(w http.ResponseWriter, r *http.Request) {
|
|
if dbSvc == nil || dbSvc.ruleEditor == nil || dbSvc.fristenrechner == nil {
|
|
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "rule editor unavailable"})
|
|
return
|
|
}
|
|
id, ok := parseRuleID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
q := r.URL.Query()
|
|
triggerDate := q.Get("trigger_date")
|
|
if triggerDate == "" {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "trigger_date required"})
|
|
return
|
|
}
|
|
var flags []string
|
|
if v := q.Get("flags"); v != "" {
|
|
for _, f := range splitCSV(v) {
|
|
if f != "" {
|
|
flags = append(flags, f)
|
|
}
|
|
}
|
|
}
|
|
courtID := q.Get("court_id")
|
|
resp, err := dbSvc.ruleEditor.Preview(r.Context(), dbSvc.fristenrechner, id, triggerDate, flags, courtID)
|
|
if err != nil {
|
|
writeRuleEditorError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
// =============================================================================
|
|
// Page handlers — serve the static SPA shells. Auth + admin gate live
|
|
// at the route registration in handlers.go.
|
|
// =============================================================================
|
|
|
|
func handleAdminRulesListPage(w http.ResponseWriter, r *http.Request) {
|
|
http.ServeFile(w, r, "dist/admin-rules-list.html")
|
|
}
|
|
|
|
func handleAdminRulesEditPage(w http.ResponseWriter, r *http.Request) {
|
|
http.ServeFile(w, r, "dist/admin-rules-edit.html")
|
|
}
|
|
|
|
// =============================================================================
|
|
// helpers
|
|
// =============================================================================
|
|
|
|
func parseRuleID(w http.ResponseWriter, r *http.Request) (uuid.UUID, bool) {
|
|
id, err := uuid.Parse(r.PathValue("id"))
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
|
return uuid.Nil, false
|
|
}
|
|
return id, true
|
|
}
|
|
|
|
func decodeReason(w http.ResponseWriter, r *http.Request) (string, bool) {
|
|
var body struct {
|
|
Reason string `json:"reason"`
|
|
}
|
|
if r.ContentLength > 0 {
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
|
return "", false
|
|
}
|
|
}
|
|
return body.Reason, true
|
|
}
|
|
|
|
// writeRuleEditorError maps the service-level typed errors to HTTP statuses.
|
|
// Distinct from writeServiceError (projects path) because the rule
|
|
// editor's lifecycle errors map to 409 Conflict, which the project
|
|
// service doesn't use.
|
|
func writeRuleEditorError(w http.ResponseWriter, err error) {
|
|
switch {
|
|
case errors.Is(err, services.ErrRuleNotFound):
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "rule not found"})
|
|
case errors.Is(err, services.ErrAuditReasonRequired):
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{
|
|
"error": "audit_reason required",
|
|
"message": "Every rule-editor write must include a non-empty `reason` body field.",
|
|
})
|
|
case errors.Is(err, services.ErrInvalidLifecycleState):
|
|
writeJSON(w, http.StatusConflict, map[string]string{"error": err.Error()})
|
|
case errors.Is(err, services.ErrCyclicSpawn):
|
|
writeJSON(w, http.StatusConflict, map[string]string{"error": err.Error()})
|
|
case errors.Is(err, services.ErrOrphanAlreadyResolved):
|
|
writeJSON(w, http.StatusConflict, map[string]string{"error": err.Error()})
|
|
case errors.Is(err, services.ErrOrphanCandidateMismatch):
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
case errors.Is(err, services.ErrInvalidInput):
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
default:
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Orphan-resolution handlers — Slice 11b admin add-on.
|
|
// Lists the unresolved rows from paliad.deadline_rule_backfill_orphans
|
|
// (mig 089) and lets an admin hand-bind each to one of the matcher's
|
|
// candidate rule_ids. The resolve write lands in a single tx via the
|
|
// rule editor service so the deadline row + the staging row stay in
|
|
// sync; admin-only at the route layer.
|
|
// =============================================================================
|
|
|
|
// GET /admin/api/orphans
|
|
func handleAdminListOrphans(w http.ResponseWriter, r *http.Request) {
|
|
if dbSvc == nil || dbSvc.ruleEditor == nil {
|
|
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "rule editor unavailable"})
|
|
return
|
|
}
|
|
rows, err := dbSvc.ruleEditor.ListOrphans(r.Context())
|
|
if err != nil {
|
|
writeRuleEditorError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, rows)
|
|
}
|
|
|
|
// POST /admin/api/orphans/{id}/resolve body: {"rule_id": "...", "reason": "..."}
|
|
func handleAdminResolveOrphan(w http.ResponseWriter, r *http.Request) {
|
|
if dbSvc == nil || dbSvc.ruleEditor == nil {
|
|
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "rule editor unavailable"})
|
|
return
|
|
}
|
|
id, err := uuid.Parse(r.PathValue("id"))
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
|
return
|
|
}
|
|
var body struct {
|
|
RuleID string `json:"rule_id"`
|
|
Reason string `json:"reason"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
|
return
|
|
}
|
|
ruleID, err := uuid.Parse(body.RuleID)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid rule_id"})
|
|
return
|
|
}
|
|
if err := dbSvc.ruleEditor.ResolveOrphan(r.Context(), id, ruleID, body.Reason); err != nil {
|
|
writeRuleEditorError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "resolved"})
|
|
}
|
|
|
|
// Slice B.6 (t-paliad-305) — 301 redirect helpers for the legacy
|
|
// /admin/rules* paths. New canonical paths live under
|
|
// /admin/procedural-events; the redirects keep external bookmarks,
|
|
// audit-log entries, and curl scripts working through one
|
|
// deprecation cycle.
|
|
//
|
|
// Three flavours:
|
|
//
|
|
// * redirectToProceduralEvents(newPath) — fixed redirect target
|
|
// (used by the parameter-less paths /admin/rules and
|
|
// /admin/api/rules).
|
|
// * redirectToProceduralEventEdit — page path with {id}/edit suffix.
|
|
// * redirectToProceduralEventAPI(suffix) — JSON API paths that carry
|
|
// an {id} and optional suffix (/clone-as-draft, /publish, …).
|
|
//
|
|
// All emit 301 Moved Permanently — caches and browsers learn the new
|
|
// URL once and stop hitting the legacy path. The IETF Deprecation
|
|
// header is added so machine clients see the migration signal
|
|
// alongside the redirect.
|
|
|
|
// redirectToProceduralEvents returns an http.HandlerFunc that 301s to
|
|
// the supplied destination path. Query string is preserved.
|
|
func redirectToProceduralEvents(dst string) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
target := dst
|
|
if r.URL.RawQuery != "" {
|
|
target += "?" + r.URL.RawQuery
|
|
}
|
|
w.Header().Set("Deprecation", `true; path="/admin/rules"`)
|
|
w.Header().Set("Link", `</admin/procedural-events>; rel="successor-version"`)
|
|
http.Redirect(w, r, target, http.StatusMovedPermanently)
|
|
}
|
|
}
|
|
|
|
// redirectToProceduralEventEdit 301s GET /admin/rules/{id}/edit →
|
|
// /admin/procedural-events/{id}/edit.
|
|
func redirectToProceduralEventEdit(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
target := "/admin/procedural-events/" + id + "/edit"
|
|
if r.URL.RawQuery != "" {
|
|
target += "?" + r.URL.RawQuery
|
|
}
|
|
w.Header().Set("Deprecation", `true; path="/admin/rules/{id}/edit"`)
|
|
w.Header().Set("Link", `</admin/procedural-events/{id}/edit>; rel="successor-version"`)
|
|
http.Redirect(w, r, target, http.StatusMovedPermanently)
|
|
}
|
|
|
|
// redirectToProceduralEventAPI 301s /admin/api/rules/{id}[/suffix] →
|
|
// /admin/api/procedural-events/{id}[/suffix]. The optional suffix
|
|
// covers /clone-as-draft, /publish, /archive, /restore, /audit, /preview.
|
|
func redirectToProceduralEventAPI(suffix string) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
target := "/admin/api/procedural-events/" + id + suffix
|
|
if r.URL.RawQuery != "" {
|
|
target += "?" + r.URL.RawQuery
|
|
}
|
|
w.Header().Set("Deprecation", `true; path="/admin/api/rules/{id}`+suffix+`"`)
|
|
w.Header().Set("Link", `</admin/api/procedural-events/{id}`+suffix+`>; rel="successor-version"`)
|
|
http.Redirect(w, r, target, http.StatusMovedPermanently)
|
|
}
|
|
}
|