Slice 11b backend addition for the orphan-resolution flow in the
/admin/rules UI. The Slice 10 fuzzy-match backfill (mig 089) staged
legacy paliad.deadlines rows the matcher could not bind to a unique
deadline_rule into paliad.deadline_rule_backfill_orphans. This adds
the two endpoints the editor needs to surface and resolve them:
GET /admin/api/orphans — unresolved staging rows,
hydrated with the candidate
rule rows in one round-trip.
POST /admin/api/orphans/{id}/resolve — picks a rule_id from the
candidate set, writes it onto
the deadline, and flips
resolved_at + resolved_rule_id
on the staging row in a single
tx.
The methods live on RuleEditorService because they share the same admin
surface and audit semantics; resolved_rule_id + resolved_at on the
staging row is the audit trail (mig 089 COMMENT). reason is captured
into paliad.audit_reason in the same tx so any future audit trigger on
paliad.deadlines picks it up automatically.
Typed errors:
ErrOrphanAlreadyResolved → 409 in handler
ErrOrphanCandidateMismatch → 400 in handler
Route ordering matches Slice 11a's pattern: the static path is
registered alongside the existing /admin/api/rules family inside the
adminGate block in handlers.go.
441 lines
13 KiB
Go
441 lines
13 KiB
Go
package handlers
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"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.
|
|
|
|
// 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
|
|
}
|
|
writeJSON(w, http.StatusOK, 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
|
|
}
|
|
writeJSON(w, http.StatusOK, 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
|
|
}
|
|
row, err := dbSvc.ruleEditor.Create(r.Context(), body.CreateRuleInput, body.Reason)
|
|
if err != nil {
|
|
writeRuleEditorError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, 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
|
|
}
|
|
row, err := dbSvc.ruleEditor.UpdateDraft(r.Context(), id, body.RulePatch, body.Reason)
|
|
if err != nil {
|
|
writeRuleEditorError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, 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
|
|
}
|
|
writeJSON(w, http.StatusCreated, 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
|
|
}
|
|
writeJSON(w, http.StatusOK, 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
|
|
}
|
|
writeJSON(w, http.StatusOK, 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
|
|
}
|
|
writeJSON(w, http.StatusOK, 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)
|
|
}
|
|
|
|
// GET /admin/api/rules/export-migrations?since=<audit_id>
|
|
func handleAdminExportRuleMigrations(w http.ResponseWriter, r *http.Request) {
|
|
if dbSvc == nil || dbSvc.ruleEditor == nil {
|
|
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "rule editor unavailable"})
|
|
return
|
|
}
|
|
since := r.URL.Query().Get("since")
|
|
out, err := dbSvc.ruleEditor.ExportMigrationsSince(r.Context(), since)
|
|
if err != nil {
|
|
writeRuleEditorError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, out)
|
|
}
|
|
|
|
// =============================================================================
|
|
// 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")
|
|
}
|
|
|
|
func handleAdminRulesExportPage(w http.ResponseWriter, r *http.Request) {
|
|
http.ServeFile(w, r, "dist/admin-rules-export.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"})
|
|
}
|