Surfaces the 3-segment proceeding code (e.g. upc.inf.cfi) on the admin
rules list so the 4 legitimately-distinct same-named groups are
visually disambiguated without opening each row's edit page.
Specifically helps with:
- "Antrag auf Patentänderung" × 4 (distinct proceeding_type_ids)
- "Beginn des Hauptsacheverfahrens" × 2
- "Berufungsbegründung-R.220.1" × 2
- "Berufungsschrift-R.220.1" × 2
(The 6× "Mängelbeseitigung / Zahlung" identical clones are dedup'd by
mig 152 in the sibling commit; this column lets m verify the dedupe
landed and confirms the remaining same-named groups are intentional.)
* internal/services/rule_editor_service.go —
- LoadProceedingTypeCodes(ctx, rows) — batch SELECT id, code FROM
paliad.proceeding_types WHERE id = ANY(...) for every distinct
non-NULL proceeding_type_id in rows. Returns id → code map.
Single round-trip, firm-wide reference data (no RLS / visibility
gate). Used only by the LIST endpoint; GetByID etc. don't need it.
* internal/handlers/admin_rules.go —
- adminRuleResponse gains ProceedingTypeCode *string field
(json:"proceeding_type_code,omitempty"). Populated by
wrapRuleListResponse from the id → code map.
- handleAdminListRules calls LoadProceedingTypeCodes after fetching
rows, passes the map to wrapRuleListResponse.
* frontend/src/admin-rules-list.tsx —
- Adds Proceeding column header in position 2 (between Submission
Code and Legal Citation) per paliadin's "Place between submission-
code and the existing columns" spec. Binds to canonical i18n
key admin.procedural_events.col.proceeding (added below).
- Drops the legacy Verfahrenstyp column at position 4 — the new
code-only column at position 2 replaces it; the old column
showed `code · name` which duplicates the new content.
* frontend/src/client/admin-rules-list.ts —
- Rule type gains proceeding_type_code?: string | null.
- New proceedingCodeCell(r) helper: prefers server-side
proceeding_type_code, falls back to dropdown-lookup
proceedingLabel for defense-in-depth on older API responses
(the old behaviour broke for rules whose proceeding_type_id
pointed at non-fristenrechner category proceedings; the new
column never has that bug because the join is server-side).
- Row rendering: new <td class="admin-rules-col-proceeding"><code>
proceedingCodeCell(r) </code></td> in column 2.
* frontend/src/client/i18n.ts —
- admin.procedural_events.col.proceeding alias added for DE +
EN ("Verfahren" / "Proceeding"). Mirror style of the other
canonical aliases from Slice A.
* frontend/src/i18n-keys.ts —
- Generated key union extended with
"admin.procedural_events.col.proceeding".
Build + vet clean. No new SQL — proceeding_types is firm-wide
reference data and the join uses an existing primary key.
577 lines
20 KiB
Go
577 lines
20 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.
|
|
//
|
|
// ProceedingTypeCode (t-paliad-321) is the joined paliad.proceeding_types.code
|
|
// for the row's proceeding_type_id. NULL on event-rooted rules. Lets the
|
|
// /admin/procedural-events list disambiguate same-named rules at a glance
|
|
// (e.g. "Berufungsbegründung" rows differ only by proceeding code).
|
|
type adminRuleResponse struct {
|
|
*models.DeadlineRule
|
|
Code *string `json:"code,omitempty"`
|
|
EventKind *string `json:"event_kind,omitempty"`
|
|
ProceedingTypeCode *string `json:"proceeding_type_code,omitempty"`
|
|
}
|
|
|
|
// wrapRuleResponse builds the dual-emit wrapper from a service result.
|
|
// Same values, two keys per concept — no semantic change. Pass a non-nil
|
|
// ptCode to populate the proceeding_type_code field; nil leaves it
|
|
// absent (e.g. on event-rooted rules with NULL proceeding_type_id).
|
|
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. ptCodes is an
|
|
// optional id → code lookup populated by handleAdminListRules from a
|
|
// single batch query against paliad.proceeding_types; nil leaves
|
|
// every row's proceeding_type_code empty (the LIST endpoint always
|
|
// passes a populated map; other callers don't need it).
|
|
func wrapRuleListResponse(rows []models.DeadlineRule, ptCodes map[int]string) []adminRuleResponse {
|
|
out := make([]adminRuleResponse, len(rows))
|
|
for i := range rows {
|
|
out[i] = wrapRuleResponse(&rows[i])
|
|
if ptCodes != nil && rows[i].ProceedingTypeID != nil {
|
|
if code, ok := ptCodes[*rows[i].ProceedingTypeID]; ok {
|
|
out[i].ProceedingTypeCode = &code
|
|
}
|
|
}
|
|
}
|
|
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
|
|
}
|
|
// t-paliad-321: batch-fetch proceeding_type.code for every rule
|
|
// row that carries a non-NULL proceeding_type_id, so the LIST
|
|
// response can show a Proceeding column without an N+1 join.
|
|
ptCodes, err := dbSvc.ruleEditor.LoadProceedingTypeCodes(r.Context(), rows)
|
|
if err != nil {
|
|
writeRuleEditorError(w, err)
|
|
return
|
|
}
|
|
adminRuleDeprecationHeaders(w)
|
|
writeJSON(w, http.StatusOK, wrapRuleListResponse(rows, ptCodes))
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|