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= 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"}) }