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", `; 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", `; 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", `; 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", `; rel="successor-version"`) http.Redirect(w, r, target, http.StatusMovedPermanently) } }