feat(models,services,handlers): Slice B.5 Go rename + JSON envelope dual-emit (t-paliad-305 / m/paliad#93)
Adds the Slice B.5 canonical Go names (SequencingRule, ProceduralEvent,
LegalSource, SequencingRuleService) without breaking any existing
call-site, and dual-emits / dual-accepts the two JSON envelope key
renames on /admin/api/rules with a Deprecation header.
* internal/models/models.go —
- type SequencingRule = DeadlineRule (alias; same struct, same db /
json tags). DeadlineRule remains the underlying type for now —
deferred hard-rename keeps the slice small.
- type ProceduralEvent struct mirroring paliad.procedural_events
(id, code, name, name_en, description, event_kind,
primary_party_default, legal_source_id, concept_id,
lifecycle_state, draft_of, published_at, is_active, timestamps).
Used by future code that needs the PE identity row alone.
- type LegalSource struct mirroring paliad.legal_sources (citation,
jurisdiction, pretty_de / pretty_en — both nullable per mig 136).
* internal/services/deadline_rule_service.go —
- type SequencingRuleService = DeadlineRuleService (alias).
- var NewSequencingRuleService = NewDeadlineRuleService (constructor
alias). Internal callers can adopt either name.
* internal/services/rule_editor_service.go —
- CreateRuleInput gains Code + EventKind fields tagged
json:"code" / json:"event_kind". CoalesceCanonicalKeys() folds
canonical → legacy after json.Decode so the rest of the service
keeps using SubmissionCode / EventType. Canonical wins when
both are sent.
- RulePatch gains EventKind field with the same fold.
* internal/handlers/admin_rules.go —
- adminRuleResponse wraps *models.DeadlineRule and adds Code +
EventKind fields alongside the legacy SubmissionCode /
EventType. Outputs both keys per response for one
deprecation-window slice.
- wrapRuleResponse / wrapRuleListResponse helpers.
- adminRuleDeprecationHeaders emits IETF Deprecation + Link/Sunset
headers on every Rule-bearing response so clients see the
migration signal in transit.
- All 8 Rule-returning handlers (List, Get, Create, Patch, Clone,
Publish, Archive, Restore) now wrap their result and add the
headers.
- Create + Patch handlers call CoalesceCanonicalKeys after decode
so legacy AND canonical request bodies are both accepted.
Scope decisions (documented in commit):
- Type renames use aliases instead of a hard 200-LOC rename. Same
semantics, no call-site churn. A future cleanup slice can flip
the underlying type definitions when convenient.
- ProceduralEvent + LegalSource are NEW structs (not aliases) since
they represent new conceptual rows; no legacy callers exist yet.
- Frontend admin .tsx i18n key rebinds (mentioned in parent task
brief B.5 deliverable list) are deferred — i18n keys themselves
already exist from Slice A (t-paliad-262); rebinding only changes
which key the .tsx file looks up. Pulling this into B.5 ballooned
scope; flagging as a small follow-up slice or B.6 sibling.
- Only /admin/api/rules emits dual keys today. Other handlers that
surface rule rows (Schriftsätze list, deadlines join) continue to
emit the legacy keys via models.DeadlineRule's existing JSON tags
— they're read paths, not the editor surface, and the deprecation
signal is most important where clients write.
Build + vet clean. TestMigrations_NoDuplicateSlot passes.
This commit is contained in:
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/models"
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
)
|
||||
|
||||
@@ -25,6 +26,60 @@ import (
|
||||
// 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.
|
||||
type adminRuleResponse struct {
|
||||
*models.DeadlineRule
|
||||
Code *string `json:"code,omitempty"`
|
||||
EventKind *string `json:"event_kind,omitempty"`
|
||||
}
|
||||
|
||||
// wrapRuleResponse builds the dual-emit wrapper from a service result.
|
||||
// Same values, two keys per concept — no semantic change.
|
||||
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.
|
||||
func wrapRuleListResponse(rows []models.DeadlineRule) []adminRuleResponse {
|
||||
out := make([]adminRuleResponse, len(rows))
|
||||
for i := range rows {
|
||||
out[i] = wrapRuleResponse(&rows[i])
|
||||
}
|
||||
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 {
|
||||
@@ -73,7 +128,8 @@ func handleAdminListRules(w http.ResponseWriter, r *http.Request) {
|
||||
writeRuleEditorError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, rows)
|
||||
adminRuleDeprecationHeaders(w)
|
||||
writeJSON(w, http.StatusOK, wrapRuleListResponse(rows))
|
||||
}
|
||||
|
||||
// GET /admin/api/rules/{id}
|
||||
@@ -91,7 +147,8 @@ func handleAdminGetRule(w http.ResponseWriter, r *http.Request) {
|
||||
writeRuleEditorError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, row)
|
||||
adminRuleDeprecationHeaders(w)
|
||||
writeJSON(w, http.StatusOK, wrapRuleResponse(row))
|
||||
}
|
||||
|
||||
// POST /admin/api/rules — create draft.
|
||||
@@ -108,12 +165,15 @@ func handleAdminCreateRule(w http.ResponseWriter, r *http.Request) {
|
||||
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
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, row)
|
||||
adminRuleDeprecationHeaders(w)
|
||||
writeJSON(w, http.StatusCreated, wrapRuleResponse(row))
|
||||
}
|
||||
|
||||
// PATCH /admin/api/rules/{id} — partial update of a draft.
|
||||
@@ -134,12 +194,15 @@ func handleAdminPatchRule(w http.ResponseWriter, r *http.Request) {
|
||||
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
|
||||
}
|
||||
writeJSON(w, http.StatusOK, row)
|
||||
adminRuleDeprecationHeaders(w)
|
||||
writeJSON(w, http.StatusOK, wrapRuleResponse(row))
|
||||
}
|
||||
|
||||
// POST /admin/api/rules/{id}/clone-as-draft
|
||||
@@ -161,7 +224,8 @@ func handleAdminCloneAsDraft(w http.ResponseWriter, r *http.Request) {
|
||||
writeRuleEditorError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, row)
|
||||
adminRuleDeprecationHeaders(w)
|
||||
writeJSON(w, http.StatusCreated, wrapRuleResponse(row))
|
||||
}
|
||||
|
||||
// POST /admin/api/rules/{id}/publish
|
||||
@@ -183,7 +247,8 @@ func handleAdminPublishRule(w http.ResponseWriter, r *http.Request) {
|
||||
writeRuleEditorError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, row)
|
||||
adminRuleDeprecationHeaders(w)
|
||||
writeJSON(w, http.StatusOK, wrapRuleResponse(row))
|
||||
}
|
||||
|
||||
// POST /admin/api/rules/{id}/archive
|
||||
@@ -205,7 +270,8 @@ func handleAdminArchiveRule(w http.ResponseWriter, r *http.Request) {
|
||||
writeRuleEditorError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, row)
|
||||
adminRuleDeprecationHeaders(w)
|
||||
writeJSON(w, http.StatusOK, wrapRuleResponse(row))
|
||||
}
|
||||
|
||||
// POST /admin/api/rules/{id}/restore
|
||||
@@ -227,7 +293,8 @@ func handleAdminRestoreRule(w http.ResponseWriter, r *http.Request) {
|
||||
writeRuleEditorError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, row)
|
||||
adminRuleDeprecationHeaders(w)
|
||||
writeJSON(w, http.StatusOK, wrapRuleResponse(row))
|
||||
}
|
||||
|
||||
// GET /admin/api/rules/{id}/audit?offset=N&limit=M
|
||||
|
||||
@@ -553,6 +553,51 @@ type Party struct {
|
||||
// scans, hydration, projection service) continues to compile.
|
||||
type DeadlineRule = litigationplanner.Rule
|
||||
|
||||
// SequencingRule is the Slice B.5 (t-paliad-305) canonical name for what
|
||||
// the legacy schema called a "deadline rule". Alias to DeadlineRule so
|
||||
// existing call-sites compile unchanged while new code can adopt the
|
||||
// procedural-event vocabulary. Same struct, same db / json tags.
|
||||
type SequencingRule = DeadlineRule
|
||||
|
||||
// ProceduralEvent mirrors paliad.procedural_events — the "what kind of
|
||||
// step is this in the proceeding" identity row. New struct introduced
|
||||
// in Slice B.5 (t-paliad-305) for code that needs the procedural-event
|
||||
// columns alone. Most consumers still pull the merged shape via
|
||||
// SequencingRule through the paliad.deadline_rules_unified view; this
|
||||
// struct unlocks per-PE reads/writes without going through the view.
|
||||
type ProceduralEvent struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
Code string `db:"code" json:"code"`
|
||||
Name string `db:"name" json:"name"`
|
||||
NameEN string `db:"name_en" json:"name_en"`
|
||||
Description *string `db:"description" json:"description,omitempty"`
|
||||
EventKind *string `db:"event_kind" json:"event_kind,omitempty"`
|
||||
PrimaryPartyDefault *string `db:"primary_party_default" json:"primary_party_default,omitempty"`
|
||||
LegalSourceID *uuid.UUID `db:"legal_source_id" json:"legal_source_id,omitempty"`
|
||||
ConceptID *uuid.UUID `db:"concept_id" json:"concept_id,omitempty"`
|
||||
LifecycleState string `db:"lifecycle_state" json:"lifecycle_state"`
|
||||
DraftOf *uuid.UUID `db:"draft_of" json:"draft_of,omitempty"`
|
||||
PublishedAt *time.Time `db:"published_at" json:"published_at,omitempty"`
|
||||
IsActive bool `db:"is_active" json:"is_active"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
// LegalSource mirrors paliad.legal_sources — the source-of-law citation
|
||||
// rows that procedural events anchor against. pretty_de / pretty_en are
|
||||
// nullable on disk; readers fall back to
|
||||
// internal/services/submission_vars.go:legalSourcePretty when missing.
|
||||
type LegalSource struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
Citation string `db:"citation" json:"citation"`
|
||||
Jurisdiction string `db:"jurisdiction" json:"jurisdiction"`
|
||||
PrettyDE *string `db:"pretty_de" json:"pretty_de,omitempty"`
|
||||
PrettyEN *string `db:"pretty_en" json:"pretty_en,omitempty"`
|
||||
Notes *string `db:"notes" json:"notes,omitempty"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
// DeadlineRuleAudit is one row of paliad.deadline_rule_audit — the
|
||||
// append-only audit log for every change to paliad.deadline_rules.
|
||||
// Written by the AFTER-trigger (raw create / update / delete) and by
|
||||
|
||||
@@ -10,12 +10,24 @@ import (
|
||||
"mgit.msbls.de/m/paliad/internal/models"
|
||||
)
|
||||
|
||||
// DeadlineRuleService reads paliad.deadline_rules + paliad.proceeding_types.
|
||||
// Rules are static reference data; no visibility check needed.
|
||||
// DeadlineRuleService reads paliad.deadline_rules_unified (mig 139 view
|
||||
// projecting paliad.sequencing_rules + procedural_events +
|
||||
// legal_sources back to the legacy column shape after mig 140 dropped
|
||||
// the underlying table) + paliad.proceeding_types. Rules are static
|
||||
// reference data; no visibility check needed.
|
||||
type DeadlineRuleService struct {
|
||||
db *sqlx.DB
|
||||
}
|
||||
|
||||
// SequencingRuleService is the Slice B.5 (t-paliad-305) canonical name
|
||||
// for DeadlineRuleService. Alias preserves every existing call-site
|
||||
// while new code can adopt the procedural-event vocabulary.
|
||||
type SequencingRuleService = DeadlineRuleService
|
||||
|
||||
// NewSequencingRuleService is the canonical constructor name; alias to
|
||||
// NewDeadlineRuleService for now. Both return the same underlying type.
|
||||
var NewSequencingRuleService = NewDeadlineRuleService
|
||||
|
||||
// NewDeadlineRuleService wires the service to the pool.
|
||||
func NewDeadlineRuleService(db *sqlx.DB) *DeadlineRuleService {
|
||||
return &DeadlineRuleService{db: db}
|
||||
|
||||
@@ -76,7 +76,13 @@ type RulePatch struct {
|
||||
NameEN *string `json:"name_en,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
PrimaryParty *string `json:"primary_party,omitempty"`
|
||||
// EventType is the legacy JSON key; EventKind is the Slice B.5
|
||||
// canonical name. Decoder accepts either — coalescePatchKeys()
|
||||
// resolves the canonical to the legacy field if only EventKind
|
||||
// was sent. Same uuid wire shape; emit-side wraps via
|
||||
// adminRuleResponse to expose both keys for one slice.
|
||||
EventType *string `json:"event_type,omitempty"`
|
||||
EventKind *string `json:"event_kind,omitempty"`
|
||||
DurationValue *int `json:"duration_value,omitempty"`
|
||||
DurationUnit *string `json:"duration_unit,omitempty"`
|
||||
Timing *string `json:"timing,omitempty"`
|
||||
@@ -101,6 +107,24 @@ type RulePatch struct {
|
||||
ConceptID *uuid.UUID `json:"concept_id,omitempty"`
|
||||
}
|
||||
|
||||
// CoalesceCanonicalKeys folds the Slice B.5 (t-paliad-305) canonical
|
||||
// JSON aliases into the legacy field positions so the rest of the
|
||||
// service can keep using the existing field names. Canonical wins
|
||||
// when both are sent.
|
||||
//
|
||||
// json:"event_kind" → EventType (legacy)
|
||||
//
|
||||
// Called by the handler immediately after json.Decode. New code can
|
||||
// adopt the canonical naming; legacy callers continue to work.
|
||||
func (p *RulePatch) CoalesceCanonicalKeys() {
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
if p.EventKind != nil {
|
||||
p.EventType = p.EventKind
|
||||
}
|
||||
}
|
||||
|
||||
// CreateRuleInput is the create payload — a full rule row in draft
|
||||
// state. Required fields enforce schema NOT-NULL on insert (name,
|
||||
// name_en, duration_value, duration_unit).
|
||||
@@ -111,9 +135,16 @@ type CreateRuleInput struct {
|
||||
TriggerEventID *int64 `json:"trigger_event_id,omitempty"`
|
||||
ParentID *uuid.UUID `json:"parent_id,omitempty"`
|
||||
ConceptID *uuid.UUID `json:"concept_id,omitempty"`
|
||||
// SubmissionCode is the legacy JSON key; Code is the Slice B.5
|
||||
// canonical name. Decoder accepts either — CoalesceCanonicalKeys()
|
||||
// folds Code → SubmissionCode if only the canonical was sent.
|
||||
SubmissionCode *string `json:"submission_code,omitempty"`
|
||||
Code *string `json:"code,omitempty"`
|
||||
PrimaryParty *string `json:"primary_party,omitempty"`
|
||||
// EventType is the legacy JSON key; EventKind is the Slice B.5
|
||||
// canonical name. Same dual-accept pattern as SubmissionCode/Code.
|
||||
EventType *string `json:"event_type,omitempty"`
|
||||
EventKind *string `json:"event_kind,omitempty"`
|
||||
DurationValue int `json:"duration_value"`
|
||||
DurationUnit string `json:"duration_unit"`
|
||||
Timing *string `json:"timing,omitempty"`
|
||||
@@ -135,6 +166,24 @@ type CreateRuleInput struct {
|
||||
SequenceOrder int `json:"sequence_order"`
|
||||
}
|
||||
|
||||
// CoalesceCanonicalKeys folds the Slice B.5 (t-paliad-305) canonical
|
||||
// JSON aliases into the legacy field positions. Canonical wins when
|
||||
// both are sent. Called by the handler immediately after json.Decode.
|
||||
//
|
||||
// json:"code" → SubmissionCode (legacy)
|
||||
// json:"event_kind" → EventType (legacy)
|
||||
func (in *CreateRuleInput) CoalesceCanonicalKeys() {
|
||||
if in == nil {
|
||||
return
|
||||
}
|
||||
if in.Code != nil {
|
||||
in.SubmissionCode = in.Code
|
||||
}
|
||||
if in.EventKind != nil {
|
||||
in.EventType = in.EventKind
|
||||
}
|
||||
}
|
||||
|
||||
// Create inserts a new rule as lifecycle_state='draft' with
|
||||
// published_at=NULL. The caller's reason is set on the session BEFORE
|
||||
// the INSERT so the mig 079 trigger writes an audit row with the
|
||||
|
||||
Reference in New Issue
Block a user