Files
paliad/internal/services/rule_editor_service.go
mAi 989941c648
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(litigationplanner): primary_party CHECK constraint + IsValidPrimaryParty helper (Slice B3, m/paliad#124 §18.3)
Tightens paliad.deadline_rules.primary_party from free-text to a CHECK
constraint over the canonical four-value vocab (claimant / defendant /
court / both). NULL stays valid for the 78 cross-cutting orphan
concept seeds (Wiedereinsetzung, Versäumnisurteil-Einspruch,
Schriftsatznachreichung, Weiterbehandlung) — they have no
proceeding_type_id binding so they're outside the calculator's path;
loosening the CHECK to "IS NULL OR IN (…)" keeps them valid without
backfill gymnastics.

Migration 135 (audit-first):
  - DO block RAISEs NOTICE for every non-conforming row + RAISEs
    EXCEPTION if any dirty rows exist (manual cleanup required).
    Live audit (Supabase, 2026-05-26 §18.0) confirmed zero dirty rows
    on the current corpus; the audit pass stays in the migration as
    safety against future drift.
  - ALTER TABLE … ADD CONSTRAINT deadline_rules_primary_party_chk
    CHECK (primary_party IS NULL OR primary_party IN
           ('claimant', 'defendant', 'court', 'both'))
  - Post-migration distribution NOTICE so the operator sees the
    final per-value count.
  - Down = DROP CONSTRAINT. No data revert needed.

Package additions (pkg/litigationplanner):
  - PrimaryParty* constants (PrimaryPartyClaimant / Defendant / Court
    / Both) + PrimaryParties[] ordered list + IsValidPrimaryParty(s)
    predicate. Empty string is "no value supplied" = valid (NULL maps
    to empty on the wire); non-empty must match one of the four
    canonical values.
  - Sibling unit tests (primary_party_test.go) pin the four-value
    vocab + the chip order + IsValidAppealTarget's matching shape.

Rule-editor validation hook (rule_editor_service.go):
  - Create() validates input.PrimaryParty before INSERT.
  - UpdateDraft() validates patch.PrimaryParty before UPDATE.
  - Both surface a user-friendly 400 with the canonical vocab listed
    instead of leaking the raw PG CHECK constraint-violation message.
  - Uses errors.Is(err, ErrInvalidInput) so handler 400 routing
    continues to work.

services/fristenrechner.go cleanup:
  - The B2-inlined isValidPartyForLookup helper is replaced with the
    canonical lp.IsValidPrimaryParty. No behaviour change.

No frontend changes — the rule-editor's primary_party UI already
constrains to the four values via a select; the validation hook is
defense-in-depth.

Audit:
  - go build + go test (incl. new lp unit tests) all green
  - Pre-migration audit confirmed: 26 claimant + 26 defendant + 38
    court + 63 both + 78 NULL = 231 total, all in canonical vocab
  - event_categories.party (text[] array, narrower semantic) is
    NOT touched in this migration per the design doc's
    "out of scope, separate follow-up" decision
2026-05-26 13:58:33 +02:00

755 lines
30 KiB
Go

package services
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"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 *string `json:"event_type,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"`
}
// 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 *string `json:"submission_code,omitempty"`
PrimaryParty *string `json:"primary_party,omitempty"`
EventType *string `json:"event_type,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"`
}
// 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
(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)
}
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 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)
}
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
(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
WHERE id = $2`,
newID, id,
); err != nil {
return nil, fmt.Errorf("clone rule as draft: %w", err)
}
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
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
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)
}
}
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
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
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)
}
}
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
` + 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
}
// 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 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
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)
}