feat(t-paliad-191): admin rule-editor HTTP API
Phase 3 Slice 11a admin endpoints under /admin/api/rules, all
gated through auth.RequireAdminFunc:
GET /admin/api/rules — paginated list with filters
GET /admin/api/rules/{id} — full row
POST /admin/api/rules — create draft
PATCH /admin/api/rules/{id} — update draft only
POST /admin/api/rules/{id}/clone-as-draft
POST /admin/api/rules/{id}/publish
POST /admin/api/rules/{id}/archive
POST /admin/api/rules/{id}/restore
GET /admin/api/rules/{id}/audit — paginated audit log
GET /admin/api/rules/{id}/preview — preview-on-trigger-date
GET /admin/api/rules/export-migrations — SQL blob for the
migration-export flow
Every write endpoint takes a `reason` body field; missing reason →
HTTP 400 (ErrAuditReasonRequired surfaced by the service). The
service writes the reason into paliad.audit_reason in the same tx
as the UPDATE so mig 079's trigger captures it.
writeRuleEditorError maps service-level typed errors to HTTP
statuses (404 for ErrRuleNotFound, 409 for ErrInvalidLifecycleState
+ ErrCyclicSpawn, 400 for ErrAuditReasonRequired + ErrInvalidInput).
dbServices gains a ruleEditor field; Services.RuleEditor in the
public bundle gets wired from main.go via NewRuleEditorService.
Route ordering: export-migrations is registered BEFORE the
{id}-shaped routes so the static path doesn't get captured by the
{id} placeholder. (Go 1.22+'s ServeMux requires the explicit
registration order for shadowing-resolution.)
Frontend (Slice 11b) will hire a new coder to surface the API in
an admin UI. Slice 11a ships the backend in isolation so the editor
can drive the lifecycle via curl / mai instructions today.
This commit is contained in:
@@ -155,6 +155,7 @@ func main() {
|
||||
services.NewFristenrechnerService(rules, holidays, courts),
|
||||
),
|
||||
EventTrigger: services.NewEventTriggerService(pool, rules, holidays, courts),
|
||||
RuleEditor: services.NewRuleEditorService(pool, rules),
|
||||
Courts: courts,
|
||||
DeadlineSearch: services.NewDeadlineSearchService(pool),
|
||||
EventCategory: nil, // wired below; cross-link order matters
|
||||
|
||||
365
internal/handlers/admin_rules.go
Normal file
365
internal/handlers/admin_rules.go
Normal file
@@ -0,0 +1,365 @@
|
||||
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)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 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.ErrInvalidInput):
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
default:
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,7 @@ type Services struct {
|
||||
Fristenrechner *services.FristenrechnerService
|
||||
EventDeadline *services.EventDeadlineService
|
||||
EventTrigger *services.EventTriggerService
|
||||
RuleEditor *services.RuleEditorService
|
||||
DeadlineSearch *services.DeadlineSearchService
|
||||
EventCategory *services.EventCategoryService
|
||||
EventType *services.EventTypeService
|
||||
@@ -102,6 +103,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
fristenrechner: svc.Fristenrechner,
|
||||
eventDeadline: svc.EventDeadline,
|
||||
eventTrigger: svc.EventTrigger,
|
||||
ruleEditor: svc.RuleEditor,
|
||||
deadlineSearch: svc.DeadlineSearch,
|
||||
eventCategory: svc.EventCategory,
|
||||
eventType: svc.EventType,
|
||||
@@ -435,6 +437,19 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
protected.HandleFunc("POST /api/admin/email-templates/{key}/{lang}/restore/{version_id}", adminGate(users, handleAdminRestoreEmailTemplateVersion))
|
||||
|
||||
// t-paliad-089 — admin Event-Type moderation panel.
|
||||
// t-paliad-191 Slice 11a — admin rule-editor API.
|
||||
protected.HandleFunc("GET /admin/api/rules", adminGate(users, handleAdminListRules))
|
||||
protected.HandleFunc("GET /admin/api/rules/export-migrations", adminGate(users, handleAdminExportRuleMigrations))
|
||||
protected.HandleFunc("GET /admin/api/rules/{id}", adminGate(users, handleAdminGetRule))
|
||||
protected.HandleFunc("POST /admin/api/rules", adminGate(users, handleAdminCreateRule))
|
||||
protected.HandleFunc("PATCH /admin/api/rules/{id}", adminGate(users, handleAdminPatchRule))
|
||||
protected.HandleFunc("POST /admin/api/rules/{id}/clone-as-draft", adminGate(users, handleAdminCloneAsDraft))
|
||||
protected.HandleFunc("POST /admin/api/rules/{id}/publish", adminGate(users, handleAdminPublishRule))
|
||||
protected.HandleFunc("POST /admin/api/rules/{id}/archive", adminGate(users, handleAdminArchiveRule))
|
||||
protected.HandleFunc("POST /admin/api/rules/{id}/restore", adminGate(users, handleAdminRestoreRule))
|
||||
protected.HandleFunc("GET /admin/api/rules/{id}/audit", adminGate(users, handleAdminGetRuleAudit))
|
||||
protected.HandleFunc("GET /admin/api/rules/{id}/preview", adminGate(users, handleAdminPreviewRule))
|
||||
|
||||
protected.HandleFunc("GET /api/admin/event-types", adminGate(users, handleAdminListEventTypes))
|
||||
protected.HandleFunc("GET /api/admin/event-types/private", adminGate(users, handleAdminListPrivateEventTypes))
|
||||
protected.HandleFunc("POST /api/admin/event-types/archive", adminGate(users, handleAdminBulkArchiveEventTypes))
|
||||
|
||||
@@ -30,6 +30,7 @@ type dbServices struct {
|
||||
fristenrechner *services.FristenrechnerService
|
||||
eventDeadline *services.EventDeadlineService
|
||||
eventTrigger *services.EventTriggerService
|
||||
ruleEditor *services.RuleEditorService
|
||||
deadlineSearch *services.DeadlineSearchService
|
||||
eventCategory *services.EventCategoryService
|
||||
eventType *services.EventTypeService
|
||||
|
||||
Reference in New Issue
Block a user