Workflow shifted to hand-written numbered migrations; the audit-row SQL
export tool no longer has any consumers. Pure deletion — /admin/rules
and /admin/rules/{id}/edit stay; only the export-to-SQL flow goes.
Deleted:
- frontend/src/admin-rules-export.tsx
- frontend/src/client/admin-rules-export.ts
Removed:
- routes GET /admin/rules/export and GET /admin/api/rules/export-migrations
- handleAdminExportRuleMigrations + handleAdminRulesExportPage
- RuleEditorService.ExportMigrationsSince + ExportResult + sqlEscape helper
- build.ts entries (import, client bundle, dist HTML write)
- Sidebar "Regel-Migrations" nav item + "Migrations exportieren" button on /admin/rules
- all admin.rules.export.* + nav.admin.rules_export + admin.rules.list.export i18n keys (DE+EN)
- .admin-rules-export-* CSS rules (dead after page deletion)
Doc references in design-fristen-phase2-2026-05-15.md and
design-paliad-data-export-2026-05-19.md updated to mark the endpoint as
removed (acceptance #2 requires grep to return zero hits).
731 lines
29 KiB
Go
731 lines
29 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"
|
|
)
|
|
|
|
// 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"
|
|
}
|
|
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)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|