Files
paliad/internal/services/rule_editor_service.go
mAi 6acb1167dd
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
feat(admin): add proceeding-type column to /admin/procedural-events list (t-paliad-321 / m/paliad#144)
Surfaces the 3-segment proceeding code (e.g. upc.inf.cfi) on the admin
rules list so the 4 legitimately-distinct same-named groups are
visually disambiguated without opening each row's edit page.

Specifically helps with:
- "Antrag auf Patentänderung" × 4 (distinct proceeding_type_ids)
- "Beginn des Hauptsacheverfahrens" × 2
- "Berufungsbegründung-R.220.1" × 2
- "Berufungsschrift-R.220.1" × 2

(The 6× "Mängelbeseitigung / Zahlung" identical clones are dedup'd by
mig 152 in the sibling commit; this column lets m verify the dedupe
landed and confirms the remaining same-named groups are intentional.)

* internal/services/rule_editor_service.go —
  - LoadProceedingTypeCodes(ctx, rows) — batch SELECT id, code FROM
    paliad.proceeding_types WHERE id = ANY(...) for every distinct
    non-NULL proceeding_type_id in rows. Returns id → code map.
    Single round-trip, firm-wide reference data (no RLS / visibility
    gate). Used only by the LIST endpoint; GetByID etc. don't need it.

* internal/handlers/admin_rules.go —
  - adminRuleResponse gains ProceedingTypeCode *string field
    (json:"proceeding_type_code,omitempty"). Populated by
    wrapRuleListResponse from the id → code map.
  - handleAdminListRules calls LoadProceedingTypeCodes after fetching
    rows, passes the map to wrapRuleListResponse.

* frontend/src/admin-rules-list.tsx —
  - Adds Proceeding column header in position 2 (between Submission
    Code and Legal Citation) per paliadin's "Place between submission-
    code and the existing columns" spec. Binds to canonical i18n
    key admin.procedural_events.col.proceeding (added below).
  - Drops the legacy Verfahrenstyp column at position 4 — the new
    code-only column at position 2 replaces it; the old column
    showed `code · name` which duplicates the new content.

* frontend/src/client/admin-rules-list.ts —
  - Rule type gains proceeding_type_code?: string | null.
  - New proceedingCodeCell(r) helper: prefers server-side
    proceeding_type_code, falls back to dropdown-lookup
    proceedingLabel for defense-in-depth on older API responses
    (the old behaviour broke for rules whose proceeding_type_id
    pointed at non-fristenrechner category proceedings; the new
    column never has that bug because the join is server-side).
  - Row rendering: new <td class="admin-rules-col-proceeding"><code>
    proceedingCodeCell(r) </code></td> in column 2.

* frontend/src/client/i18n.ts —
  - admin.procedural_events.col.proceeding alias added for DE +
    EN ("Verfahren" / "Proceeding"). Mirror style of the other
    canonical aliases from Slice A.

* frontend/src/i18n-keys.ts —
  - Generated key union extended with
    "admin.procedural_events.col.proceeding".

Build + vet clean. No new SQL — proceeding_types is firm-wide
reference data and the join uses an existing primary key.
2026-05-26 21:27:00 +02:00

860 lines
34 KiB
Go

package services
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"github.com/lib/pq"
"mgit.msbls.de/m/paliad/internal/models"
lp "mgit.msbls.de/m/paliad/pkg/litigationplanner"
)
// RuleEditorService owns the admin-only rule lifecycle for Phase 3
// Slice 11a (t-paliad-191). m's Q5 option C ruling: "C please — I need
// to see these things. Admin only, ofc."
//
// Lifecycle (mig 078 lifecycle_state enum):
//
// - draft — admin work-in-progress. Calculator does NOT include
// these in any user-facing surface (the SELECT filters
// lifecycle_state='published' or the equivalent). The
// admin previewer is the only reader.
// - published — live, calculator-visible, the corpus the rest of
// Paliad runs on.
// - archived — historical, kept for audit. The Restore op flips
// archived → published; the Publish flow archives
// the cloned-from source so each rule_code has at
// most one live row.
//
// All writes set paliad.audit_reason via set_config in the same tx
// before the UPDATE so the mig 079 audit trigger captures the
// rationale forever. The reason is mandatory on every write.
//
// Spawn cycle guard: edits that change spawn_proceeding_type_id are
// pre-validated against the global rule graph. A draft that would
// create a cycle when published returns ErrCyclicSpawn rather than
// allowing the write — the guard fires server-side before the row
// hits the DB.
type RuleEditorService struct {
db *sqlx.DB
rules *DeadlineRuleService
}
// NewRuleEditorService wires the service to its dependencies.
func NewRuleEditorService(db *sqlx.DB, rules *DeadlineRuleService) *RuleEditorService {
return &RuleEditorService{db: db, rules: rules}
}
// Typed errors surfaced to handlers (mapped to HTTP statuses).
var (
// ErrRuleNotFound — UUID didn't resolve to an existing row.
ErrRuleNotFound = errors.New("rule not found")
// ErrInvalidLifecycleState — caller asked for a transition that
// the current lifecycle_state doesn't allow (e.g. PATCH a
// published row, Publish a non-draft row, Restore a non-archived
// row, etc.). 409 Conflict in the handler.
ErrInvalidLifecycleState = errors.New("invalid lifecycle state for this operation")
// ErrAuditReasonRequired — write came in without a non-empty
// reason. 400 in the handler.
ErrAuditReasonRequired = errors.New("audit_reason required for rule-editor writes")
)
// RulePatch is the partial-update payload for UpdateDraft.
// Only fields the editor allows to change are exposed; system-managed
// fields (id, created_at, lifecycle_state itself, draft_of,
// published_at) are NOT in this struct — lifecycle transitions go
// through the dedicated methods.
type RulePatch struct {
Name *string `json:"name,omitempty"`
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"`
AltDurationValue *int `json:"alt_duration_value,omitempty"`
AltDurationUnit *string `json:"alt_duration_unit,omitempty"`
AltRuleCode *string `json:"alt_rule_code,omitempty"`
AnchorAlt *string `json:"anchor_alt,omitempty"`
CombineOp *string `json:"combine_op,omitempty"`
RuleCode *string `json:"rule_code,omitempty"`
LegalSource *string `json:"legal_source,omitempty"`
DeadlineNotes *string `json:"deadline_notes,omitempty"`
DeadlineNotesEn *string `json:"deadline_notes_en,omitempty"`
Priority *string `json:"priority,omitempty"`
IsCourtSet *bool `json:"is_court_set,omitempty"`
IsSpawn *bool `json:"is_spawn,omitempty"`
SpawnLabel *string `json:"spawn_label,omitempty"`
SpawnProceedingTypeID *int `json:"spawn_proceeding_type_id,omitempty"`
TriggerEventID *int64 `json:"trigger_event_id,omitempty"`
ConditionExpr json.RawMessage `json:"condition_expr,omitempty"`
SequenceOrder *int `json:"sequence_order,omitempty"`
ParentID *uuid.UUID `json:"parent_id,omitempty"`
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).
type CreateRuleInput struct {
Name string `json:"name"`
NameEN string `json:"name_en"`
ProceedingTypeID *int `json:"proceeding_type_id,omitempty"`
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"`
AltDurationValue *int `json:"alt_duration_value,omitempty"`
AltDurationUnit *string `json:"alt_duration_unit,omitempty"`
AltRuleCode *string `json:"alt_rule_code,omitempty"`
AnchorAlt *string `json:"anchor_alt,omitempty"`
CombineOp *string `json:"combine_op,omitempty"`
RuleCode *string `json:"rule_code,omitempty"`
LegalSource *string `json:"legal_source,omitempty"`
DeadlineNotes *string `json:"deadline_notes,omitempty"`
DeadlineNotesEn *string `json:"deadline_notes_en,omitempty"`
Priority string `json:"priority"`
IsCourtSet bool `json:"is_court_set"`
IsSpawn bool `json:"is_spawn"`
SpawnLabel *string `json:"spawn_label,omitempty"`
SpawnProceedingTypeID *int `json:"spawn_proceeding_type_id,omitempty"`
ConditionExpr json.RawMessage `json:"condition_expr,omitempty"`
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
// rationale.
func (s *RuleEditorService) Create(ctx context.Context, input CreateRuleInput, reason string) (*models.DeadlineRule, error) {
if strings.TrimSpace(reason) == "" {
return nil, ErrAuditReasonRequired
}
if strings.TrimSpace(input.Name) == "" || strings.TrimSpace(input.NameEN) == "" {
return nil, fmt.Errorf("%w: name + name_en required on create", ErrInvalidInput)
}
if strings.TrimSpace(input.Priority) == "" {
input.Priority = "mandatory"
}
// Slice B3 (m/paliad#124 §18.3, mig 135): canonical four-value
// primary_party vocab. Pre-validate so the user gets a
// user-friendly error before the DB CHECK fires with the raw
// constraint-violation message.
if input.PrimaryParty != nil && !lp.IsValidPrimaryParty(*input.PrimaryParty) {
return nil, fmt.Errorf(
"%w: primary_party=%q is not one of %v",
ErrInvalidInput, *input.PrimaryParty, lp.PrimaryParties,
)
}
if err := s.validateSpawnNoCycle(ctx, nil, input.SpawnProceedingTypeID, input.ProceedingTypeID); err != nil {
return nil, err
}
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
if err := setAuditReasonTx(ctx, tx, reason); err != nil {
return nil, err
}
id := uuid.New()
// Slice 9 (t-paliad-195) dropped is_mandatory / is_optional /
// condition_flag / condition_rule_id from the schema. The INSERT
// here writes the live shape only — priority + condition_expr
// + is_court_set are the new gates.
if _, err := tx.ExecContext(ctx,
`INSERT INTO paliad.deadline_rules_unified
(id, proceeding_type_id, trigger_event_id, parent_id, concept_id, submission_code,
name, name_en, description, primary_party, event_type,
duration_value, duration_unit, timing,
alt_duration_value, alt_duration_unit, alt_rule_code, anchor_alt, combine_op,
rule_code, legal_source, deadline_notes, deadline_notes_en,
priority, is_court_set, is_spawn, spawn_label, spawn_proceeding_type_id,
condition_expr, sequence_order,
is_active,
lifecycle_state, draft_of, published_at,
created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NULL, $9, $10,
$11, $12, $13,
$14, $15, $16, $17, $18,
$19, $20, $21, $22,
$23, $24, $25, $26, $27,
$28, $29,
true,
'draft', NULL, NULL,
now(), now())`,
id, input.ProceedingTypeID, input.TriggerEventID, input.ParentID, input.ConceptID, input.SubmissionCode,
input.Name, input.NameEN, input.PrimaryParty, input.EventType,
input.DurationValue, input.DurationUnit, input.Timing,
input.AltDurationValue, input.AltDurationUnit, input.AltRuleCode, input.AnchorAlt, input.CombineOp,
input.RuleCode, input.LegalSource, input.DeadlineNotes, input.DeadlineNotesEn,
input.Priority, input.IsCourtSet, input.IsSpawn, input.SpawnLabel, input.SpawnProceedingTypeID,
nullableJSON(input.ConditionExpr), input.SequenceOrder,
); err != nil {
return nil, fmt.Errorf("insert rule: %w", err)
}
// Slice B.4 (mig 140, t-paliad-305): write routes through the
// INSTEAD OF triggers on paliad.deadline_rules_unified, which fan
// out into legal_sources + procedural_events + sequencing_rules.
// No Go-side mirror call needed — the INSERT above already landed
// the parallel rows.
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit create: %w", err)
}
return s.getByID(ctx, id)
}
// UpdateDraft applies a partial patch to a rule in lifecycle_state=
// 'draft'. Published or archived rows cannot be patched directly —
// the caller must CloneAsDraft first.
func (s *RuleEditorService) UpdateDraft(ctx context.Context, id uuid.UUID, patch RulePatch, reason string) (*models.DeadlineRule, error) {
if strings.TrimSpace(reason) == "" {
return nil, ErrAuditReasonRequired
}
current, err := s.getByID(ctx, id)
if err != nil {
return nil, err
}
if current.LifecycleState != "draft" {
return nil, fmt.Errorf("%w: rule %s is %s, must be draft to patch (clone first)",
ErrInvalidLifecycleState, id, current.LifecycleState)
}
// Slice B3 (m/paliad#124 §18.3, mig 135): pre-validate the
// patch's primary_party so the user gets a user-friendly error
// before the DB CHECK fires with the raw constraint-violation
// message. Patch field is *string — nil means "don't change",
// dereferenced empty string means "set to NULL" (handled below
// in buildPatchSets).
if patch.PrimaryParty != nil && !lp.IsValidPrimaryParty(*patch.PrimaryParty) {
return nil, fmt.Errorf(
"%w: primary_party=%q is not one of %v",
ErrInvalidInput, *patch.PrimaryParty, lp.PrimaryParties,
)
}
// Spawn cycle guard: if the patch sets spawn_proceeding_type_id,
// validate against the global graph BEFORE the UPDATE so we can
// surface the cycle clearly instead of relying on a runtime
// projection failure.
if patch.SpawnProceedingTypeID != nil {
if err := s.validateSpawnNoCycle(ctx, &id, patch.SpawnProceedingTypeID, current.ProceedingTypeID); err != nil {
return nil, err
}
}
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
if err := setAuditReasonTx(ctx, tx, reason); err != nil {
return nil, err
}
sets, args := buildPatchSets(patch)
if len(sets) == 0 {
return current, nil // no-op patch; don't fire the audit trigger
}
sets = append(sets, fmt.Sprintf("updated_at = $%d", len(args)+1))
args = append(args, time.Now().UTC())
args = append(args, id)
q := fmt.Sprintf(
`UPDATE paliad.deadline_rules_unified SET %s WHERE id = $%d AND lifecycle_state = 'draft'`,
strings.Join(sets, ", "), len(args))
if _, err := tx.ExecContext(ctx, q, args...); err != nil {
return nil, fmt.Errorf("update rule draft: %w", err)
}
// Slice B.4 (mig 140, t-paliad-305): INSTEAD OF trigger handles the
// new-table writes — the UPDATE above is already fan-out.
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit update: %w", err)
}
return s.getByID(ctx, id)
}
// CloneAsDraft creates a new lifecycle_state='draft' row that's a
// deep-copy of the source rule (published or archived), with draft_of
// pointing back at the source. Lets editors propose changes to live
// rules without mutating the live row.
func (s *RuleEditorService) CloneAsDraft(ctx context.Context, id uuid.UUID, reason string) (*models.DeadlineRule, error) {
if strings.TrimSpace(reason) == "" {
return nil, ErrAuditReasonRequired
}
src, err := s.getByID(ctx, id)
if err != nil {
return nil, err
}
if src.LifecycleState == "draft" {
return nil, fmt.Errorf("%w: rule %s is already a draft", ErrInvalidLifecycleState, id)
}
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
if err := setAuditReasonTx(ctx, tx, reason); err != nil {
return nil, err
}
newID := uuid.New()
if _, err := tx.ExecContext(ctx,
`INSERT INTO paliad.deadline_rules_unified
(id, proceeding_type_id, trigger_event_id, parent_id, concept_id, submission_code,
name, name_en, description, primary_party, event_type,
duration_value, duration_unit, timing,
alt_duration_value, alt_duration_unit, alt_rule_code, anchor_alt, combine_op,
rule_code, legal_source, deadline_notes, deadline_notes_en,
priority, is_court_set, is_spawn, spawn_label, spawn_proceeding_type_id,
condition_expr, sequence_order,
is_active,
lifecycle_state, draft_of, published_at,
created_at, updated_at)
SELECT $1, proceeding_type_id, trigger_event_id, parent_id, concept_id, submission_code,
name, name_en, description, primary_party, event_type,
duration_value, duration_unit, timing,
alt_duration_value, alt_duration_unit, alt_rule_code, anchor_alt, combine_op,
rule_code, legal_source, deadline_notes, deadline_notes_en,
priority, is_court_set, is_spawn, spawn_label, spawn_proceeding_type_id,
condition_expr, sequence_order,
is_active,
'draft', $2, NULL,
now(), now()
FROM paliad.deadline_rules_unified
WHERE id = $2`,
newID, id,
); err != nil {
return nil, fmt.Errorf("clone rule as draft: %w", err)
}
// Slice B.4 (mig 140, t-paliad-305): INSTEAD OF INSERT trigger
// mints the synthetic 'null.<8hex>' code when submission_code is
// NULL (matching mig 136 + the legacy dual-write helper's
// expression).
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit clone: %w", err)
}
return s.getByID(ctx, newID)
}
// Publish flips a draft to published, sets published_at=now(), and —
// if the draft was cloned from a published peer — archives that peer
// so each rule_code has at most one live row.
func (s *RuleEditorService) Publish(ctx context.Context, id uuid.UUID, reason string) (*models.DeadlineRule, error) {
if strings.TrimSpace(reason) == "" {
return nil, ErrAuditReasonRequired
}
current, err := s.getByID(ctx, id)
if err != nil {
return nil, err
}
if current.LifecycleState != "draft" {
return nil, fmt.Errorf("%w: only drafts can be published (rule %s is %s)",
ErrInvalidLifecycleState, id, current.LifecycleState)
}
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
if err := setAuditReasonTx(ctx, tx, reason); err != nil {
return nil, err
}
now := time.Now().UTC()
if _, err := tx.ExecContext(ctx,
`UPDATE paliad.deadline_rules_unified
SET lifecycle_state = 'published',
published_at = $1,
updated_at = $1
WHERE id = $2 AND lifecycle_state = 'draft'`,
now, id,
); err != nil {
return nil, fmt.Errorf("publish draft: %w", err)
}
// Archive the peer this draft was cloned from, if any.
if current.DraftOf != nil {
if _, err := tx.ExecContext(ctx,
`UPDATE paliad.deadline_rules_unified
SET lifecycle_state = 'archived',
updated_at = $1
WHERE id = $2 AND lifecycle_state = 'published'`,
now, *current.DraftOf,
); err != nil {
return nil, fmt.Errorf("archive cloned-from source: %w", err)
}
}
// Slice B.4 (mig 140, t-paliad-305): both UPDATEs above route via
// the INSTEAD OF UPDATE trigger, which mirrors the lifecycle flip
// onto procedural_events + sequencing_rules in the same TX.
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit publish: %w", err)
}
return s.getByID(ctx, id)
}
// Archive flips lifecycle_state to 'archived'. Both published and
// draft rules can be archived (a draft might be abandoned without
// publishing).
func (s *RuleEditorService) Archive(ctx context.Context, id uuid.UUID, reason string) (*models.DeadlineRule, error) {
return s.flipLifecycle(ctx, id, "archived", []string{"published", "draft"}, reason)
}
// Restore flips lifecycle_state from 'archived' to 'published'. Used
// when an editor undoes a previous archive.
func (s *RuleEditorService) Restore(ctx context.Context, id uuid.UUID, reason string) (*models.DeadlineRule, error) {
return s.flipLifecycle(ctx, id, "published", []string{"archived"}, reason)
}
func (s *RuleEditorService) flipLifecycle(ctx context.Context, id uuid.UUID, target string, allowed []string, reason string) (*models.DeadlineRule, error) {
if strings.TrimSpace(reason) == "" {
return nil, ErrAuditReasonRequired
}
current, err := s.getByID(ctx, id)
if err != nil {
return nil, err
}
if !containsString(allowed, current.LifecycleState) {
return nil, fmt.Errorf("%w: rule %s is %s, cannot flip to %s (allowed: %v)",
ErrInvalidLifecycleState, id, current.LifecycleState, target, allowed)
}
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
if err := setAuditReasonTx(ctx, tx, reason); err != nil {
return nil, err
}
now := time.Now().UTC()
// published_at is set on the published flip (Restore from archived)
// but NOT touched on Archive — preserving the original publication
// timestamp helps audit reads ("when was this rule first live?").
if target == "published" {
if _, err := tx.ExecContext(ctx,
`UPDATE paliad.deadline_rules_unified
SET lifecycle_state = $1,
published_at = COALESCE(published_at, $2),
updated_at = $2
WHERE id = $3`,
target, now, id,
); err != nil {
return nil, fmt.Errorf("flip lifecycle to %s: %w", target, err)
}
} else {
if _, err := tx.ExecContext(ctx,
`UPDATE paliad.deadline_rules_unified
SET lifecycle_state = $1, updated_at = $2
WHERE id = $3`,
target, now, id,
); err != nil {
return nil, fmt.Errorf("flip lifecycle to %s: %w", target, err)
}
}
// Slice B.4 (mig 140, t-paliad-305): INSTEAD OF UPDATE trigger
// mirrors the lifecycle flip onto sr + pe.
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit flip: %w", err)
}
return s.getByID(ctx, id)
}
// Preview runs the unified calculator with the given draft rule
// substituted for its published peer (or appended if it's a net-new
// draft with no peer). No DB write, no audit log; pure simulation
// for the editor's "what would this rule do on date X?" affordance.
//
// Implements design §4.5 + Q-H-4 option (a): in-memory override
// passed to Calculate. The peer-discovery walks draft_of → published
// chain; if the draft has no peer, the rule is appended so its
// effect lights up against the rest of the proceeding's rules.
func (s *RuleEditorService) Preview(ctx context.Context, fristen *FristenrechnerService, id uuid.UUID, triggerDate string, flags []string, courtID string) (*UIResponse, error) {
draft, err := s.getByID(ctx, id)
if err != nil {
return nil, err
}
if draft.LifecycleState != "draft" {
return nil, fmt.Errorf("%w: preview only operates on drafts (rule %s is %s)",
ErrInvalidLifecycleState, id, draft.LifecycleState)
}
if draft.ProceedingTypeID == nil {
return nil, fmt.Errorf("%w: draft has no proceeding_type_id — preview needs a proceeding context", ErrInvalidInput)
}
// Resolve proceeding code for the Calculate call.
var proceedingCode string
if err := s.db.GetContext(ctx, &proceedingCode,
`SELECT code FROM paliad.proceeding_types WHERE id = $1 AND is_active = true`,
*draft.ProceedingTypeID); err != nil {
return nil, fmt.Errorf("resolve proceeding code: %w", err)
}
// The override slice carries the draft itself; Calculate substitutes
// any rule with matching .ID in the proceeding's rule list. If the
// draft is cloned-from a published row (draft_of != NULL), the
// override replaces THAT row's effect — Calculate sees the draft's
// fields in place of the published row, but the draft's own ID is
// what shows up in the result. Net-new drafts (draft_of NULL) get
// appended so they take effect as new rules.
overrides := []models.DeadlineRule{*draft}
if draft.DraftOf != nil {
// Make the draft's ID match the peer's so the override
// substitutes in place. Saves a callback into Calculate
// changing the rule_id seen in the response.
dup := *draft
dup.ID = *draft.DraftOf
overrides[0] = dup
}
return fristen.Calculate(ctx, proceedingCode, triggerDate, CalcOptions{
Flags: flags,
CourtID: courtID,
RuleOverrides: overrides,
})
}
// RuleAuditEntry mirrors the paliad.deadline_rule_audit row + a friendly
// changed_by display name from paliad.users (NULL on system writes).
// Distinct from services.AuditEntry (the cross-source union for the
// site-wide audit panel) — this one is rule-editor-specific.
type RuleAuditEntry struct {
models.DeadlineRuleAudit
ChangedByDisplayName *string `db:"changed_by_display_name" json:"changed_by_display_name,omitempty"`
}
// ListAudit returns paliad.deadline_rule_audit rows for a single rule,
// newest first, with optional offset/limit pagination.
func (s *RuleEditorService) ListAudit(ctx context.Context, ruleID uuid.UUID, offset, limit int) ([]RuleAuditEntry, error) {
if limit <= 0 || limit > 200 {
limit = 50
}
if offset < 0 {
offset = 0
}
var rows []RuleAuditEntry
if err := s.db.SelectContext(ctx, &rows, `
SELECT a.id, a.rule_id, a.changed_by, a.changed_at, a.action,
a.before_json, a.after_json, a.reason, a.migration_exported,
u.display_name AS changed_by_display_name
FROM paliad.deadline_rule_audit a
LEFT JOIN paliad.users u ON u.id = a.changed_by
WHERE a.rule_id = $1
ORDER BY a.changed_at DESC
LIMIT $2 OFFSET $3`, ruleID, limit, offset); err != nil {
return nil, fmt.Errorf("list audit for rule %s: %w", ruleID, err)
}
return rows, nil
}
// ListRules returns paginated rules for the admin list view, with
// optional filters: proceeding_type_id, lifecycle_state, trigger_event_id,
// and a fuzzy "q" (matches name OR name_en OR rule_code, ILIKE).
type ListRulesFilter struct {
ProceedingTypeID *int
TriggerEventID *int64
LifecycleState string
Query string
Offset int
Limit int
}
func (s *RuleEditorService) ListRules(ctx context.Context, f ListRulesFilter) ([]models.DeadlineRule, error) {
if f.Limit <= 0 || f.Limit > 500 {
f.Limit = 100
}
if f.Offset < 0 {
f.Offset = 0
}
var (
conds []string
args []any
)
addArg := func(v any) string {
args = append(args, v)
return fmt.Sprintf("$%d", len(args))
}
if f.ProceedingTypeID != nil {
conds = append(conds, "proceeding_type_id = "+addArg(*f.ProceedingTypeID))
}
if f.TriggerEventID != nil {
conds = append(conds, "trigger_event_id = "+addArg(*f.TriggerEventID))
}
if f.LifecycleState != "" {
conds = append(conds, "lifecycle_state = "+addArg(f.LifecycleState))
}
if strings.TrimSpace(f.Query) != "" {
q := "%" + f.Query + "%"
conds = append(conds,
"(name ILIKE "+addArg(q)+" OR name_en ILIKE "+addArg(q)+" OR rule_code ILIKE "+addArg(q)+")")
}
where := ""
if len(conds) > 0 {
where = "WHERE " + strings.Join(conds, " AND ")
}
query := `SELECT ` + ruleColumns + `
FROM paliad.deadline_rules_unified
` + where + `
ORDER BY proceeding_type_id NULLS LAST, sequence_order
LIMIT ` + addArg(f.Limit) + ` OFFSET ` + addArg(f.Offset)
var rows []models.DeadlineRule
if err := s.db.SelectContext(ctx, &rows, query, args...); err != nil {
return nil, fmt.Errorf("list rules: %w", err)
}
return rows, nil
}
// LoadProceedingTypeCodes returns an id → code map for every distinct
// non-NULL proceeding_type_id present in rows. Single SELECT against
// paliad.proceeding_types (firm-wide reference data, no RLS). Used by
// /admin/api/procedural-events to enrich the LIST response with a
// proceeding_type_code field so the admin UI can disambiguate
// same-named rules at a glance (t-paliad-321).
func (s *RuleEditorService) LoadProceedingTypeCodes(ctx context.Context, rows []models.DeadlineRule) (map[int]string, error) {
seen := map[int]bool{}
var ids []int
for _, r := range rows {
if r.ProceedingTypeID != nil && !seen[*r.ProceedingTypeID] {
seen[*r.ProceedingTypeID] = true
ids = append(ids, *r.ProceedingTypeID)
}
}
if len(ids) == 0 {
return nil, nil
}
type pair struct {
ID int `db:"id"`
Code string `db:"code"`
}
var pairs []pair
if err := s.db.SelectContext(ctx, &pairs,
`SELECT id, code FROM paliad.proceeding_types WHERE id = ANY($1)`,
pq.Array(ids),
); err != nil {
return nil, fmt.Errorf("load proceeding_type codes: %w", err)
}
out := make(map[int]string, len(pairs))
for _, p := range pairs {
out[p.ID] = p.Code
}
return out, nil
}
// GetByID returns a single rule. Exported so the handler can call it
// directly without round-tripping through ListRules.
func (s *RuleEditorService) GetByID(ctx context.Context, id uuid.UUID) (*models.DeadlineRule, error) {
return s.getByID(ctx, id)
}
func (s *RuleEditorService) getByID(ctx context.Context, id uuid.UUID) (*models.DeadlineRule, error) {
var r models.DeadlineRule
err := s.db.GetContext(ctx, &r,
`SELECT `+ruleColumns+` FROM paliad.deadline_rules_unified WHERE id = $1`, id)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrRuleNotFound
}
if err != nil {
return nil, fmt.Errorf("get rule %s: %w", id, err)
}
return &r, nil
}
// =============================================================================
// Internal helpers
// =============================================================================
// setAuditReasonTx writes the audit reason into the session-local
// paliad.audit_reason setting via set_config(name, value, is_local=true).
// The mig 079 trigger reads it via current_setting('paliad.audit_reason', true).
func setAuditReasonTx(ctx context.Context, tx *sqlx.Tx, reason string) error {
if _, err := tx.ExecContext(ctx,
`SELECT set_config('paliad.audit_reason', $1, true)`, reason); err != nil {
return fmt.Errorf("set audit_reason: %w", err)
}
return nil
}
// validateSpawnNoCycle checks that spawning from `sourceProceedingID`
// (the rule's proceeding) into `targetProceedingID` doesn't create a
// cycle in the global rule graph. Reuses the design §6 cycle-guard
// semantics: walk the target's spawn rules transitively; if any of
// them spawn back to sourceProceedingID (or to a proceeding already in
// the chain), refuse.
//
// Skipped when either side is nil (no spawn intent or no source
// context). The ruleID parameter is used to exclude the rule itself
// from the walk so an edit that already had a spawn doesn't see
// itself as the cycle source.
func (s *RuleEditorService) validateSpawnNoCycle(ctx context.Context, ruleID *uuid.UUID, target *int, source *int) error {
if target == nil || source == nil {
return nil
}
if *target == *source {
return fmt.Errorf("%w: cannot spawn into the same proceeding", ErrCyclicSpawn)
}
// Walk the target proceeding's spawn rules. If any of them have a
// spawn_proceeding_type_id equal to source, that's the cycle.
visited := map[int]bool{*source: true}
queue := []int{*target}
maxHops := maxSpawnDepth
for len(queue) > 0 && maxHops > 0 {
maxHops--
current := queue[0]
queue = queue[1:]
if visited[current] {
return fmt.Errorf("%w: edit would create a cycle through proceeding %d",
ErrCyclicSpawn, current)
}
visited[current] = true
var nexts []sql.NullInt64
q := `SELECT DISTINCT spawn_proceeding_type_id::bigint
FROM paliad.deadline_rules_unified
WHERE proceeding_type_id = $1
AND is_spawn = true
AND spawn_proceeding_type_id IS NOT NULL
AND is_active = true
AND lifecycle_state IN ('published', 'draft')`
args := []any{current}
if ruleID != nil {
q += " AND id <> $2"
args = append(args, *ruleID)
}
if err := s.db.SelectContext(ctx, &nexts, q, args...); err != nil {
return fmt.Errorf("walk spawn graph from %d: %w", current, err)
}
for _, n := range nexts {
if !n.Valid {
continue
}
queue = append(queue, int(n.Int64))
}
}
if maxHops == 0 {
return fmt.Errorf("%w: spawn graph walk exceeded max depth %d", ErrCyclicSpawn, maxSpawnDepth)
}
return nil
}
// buildPatchSets walks the RulePatch and produces (SET clauses, args)
// for the UPDATE statement. Order is stable (per-field) so the
// generated SQL stays diff-friendly. Returns empty slices when the
// patch is empty (caller short-circuits without writing).
func buildPatchSets(p RulePatch) (sets []string, args []any) {
add := func(col string, val any) {
args = append(args, val)
sets = append(sets, fmt.Sprintf("%s = $%d", col, len(args)))
}
if p.Name != nil { add("name", *p.Name) }
if p.NameEN != nil { add("name_en", *p.NameEN) }
if p.Description != nil { add("description", *p.Description) }
if p.PrimaryParty != nil { add("primary_party", *p.PrimaryParty) }
if p.EventType != nil { add("event_type", *p.EventType) }
if p.DurationValue != nil { add("duration_value", *p.DurationValue) }
if p.DurationUnit != nil { add("duration_unit", *p.DurationUnit) }
if p.Timing != nil { add("timing", *p.Timing) }
if p.AltDurationValue != nil { add("alt_duration_value", *p.AltDurationValue) }
if p.AltDurationUnit != nil { add("alt_duration_unit", *p.AltDurationUnit) }
if p.AltRuleCode != nil { add("alt_rule_code", *p.AltRuleCode) }
if p.AnchorAlt != nil { add("anchor_alt", *p.AnchorAlt) }
if p.CombineOp != nil { add("combine_op", *p.CombineOp) }
if p.RuleCode != nil { add("rule_code", *p.RuleCode) }
if p.LegalSource != nil { add("legal_source", *p.LegalSource) }
if p.DeadlineNotes != nil { add("deadline_notes", *p.DeadlineNotes) }
if p.DeadlineNotesEn != nil { add("deadline_notes_en", *p.DeadlineNotesEn) }
if p.Priority != nil { add("priority", *p.Priority) }
if p.IsCourtSet != nil { add("is_court_set", *p.IsCourtSet) }
if p.IsSpawn != nil { add("is_spawn", *p.IsSpawn) }
if p.SpawnLabel != nil { add("spawn_label", *p.SpawnLabel) }
if p.SpawnProceedingTypeID != nil { add("spawn_proceeding_type_id", *p.SpawnProceedingTypeID) }
if p.TriggerEventID != nil { add("trigger_event_id", *p.TriggerEventID) }
if p.ConditionExpr != nil { add("condition_expr", nullableJSON(p.ConditionExpr)) }
if p.SequenceOrder != nil { add("sequence_order", *p.SequenceOrder) }
if p.ParentID != nil { add("parent_id", *p.ParentID) }
if p.ConceptID != nil { add("concept_id", *p.ConceptID) }
return sets, args
}
// nullableJSON returns nil for empty / "null" raw so the SQL driver
// writes NULL into the jsonb column, otherwise the byte slice itself.
func nullableJSON(b json.RawMessage) any {
if len(b) == 0 || string(b) == "null" {
return nil
}
return []byte(b)
}