feat(t-paliad-182): models + service compat-read for unified rules
Phase 3 Slice 1 Go-side of mig 078–080. Compat-mode reads: the
service selects BOTH the legacy shape (is_mandatory, is_optional,
condition_flag, condition_rule_id) and the new shape (priority,
condition_expr, is_court_set, trigger_event_id,
spawn_proceeding_type_id, combine_op, lifecycle_state, draft_of,
published_at). Existing callers stay on the legacy fields until
Slice 4 cuts the calculator over.
Adds:
- DeadlineRule field block for the nine Phase 3 columns. NULLable
jsonb (condition_expr) uses NullableJSON to dodge the
json.RawMessage NULL-scan trap (see Project.Metadata note from
t-paliad-138 dogfood).
- Project.InstanceLevel *string.
- DeadlineRuleAudit row struct (id, rule_id, changed_by,
changed_at, action, before_json, after_json, reason,
migration_exported).
- ruleColumns const extended to project every new column.
Test (TEST_DATABASE_URL-gated, mirrors audit_service_test.go):
1. ruleColumns SELECT scans cleanly — every new column populates
its Go field.
2. Migration defaults land: priority='mandatory',
is_court_set=false, lifecycle_state='published' on every
pre-Slice-1 row.
3. Audit trigger writes one row on UPDATE WITH paliad.audit_reason
set, captures before+after JSON + reason.
4. Audit trigger RAISES on UPDATE WITHOUT paliad.audit_reason —
Slice 2 backfills fail loudly if they forget to set it.
5. paliad.projects.instance_level accepts NULL + first/appeal/
cassation, rejects 'final'.
Build clean, full test suite green (live DB test skipped locally).
This commit is contained in:
@@ -171,6 +171,16 @@ type Project struct {
|
||||
// sibling under the same patent (§4.4 of the design doc).
|
||||
CounterclaimOf *uuid.UUID `db:"counterclaim_of" json:"counterclaim_of,omitempty"`
|
||||
|
||||
// InstanceLevel is the procedural instance the project sits at:
|
||||
// 'first' (default) | 'appeal' | 'cassation'. Combined with the
|
||||
// proceeding code + jurisdiction by FristenrechnerService to pick
|
||||
// the effective proceeding (DE_INF + appeal → DE_INF_OLG, etc.).
|
||||
// NULL = unset / not applicable; the calculator treats NULL as
|
||||
// 'first'. Backfill happens via the project-detail picker UI
|
||||
// (Phase 3 Slice 8); this column ships in Slice 1 ahead of the
|
||||
// service rewrite (mig 080, t-paliad-182).
|
||||
InstanceLevel *string `db:"instance_level" json:"instance_level,omitempty"`
|
||||
|
||||
Metadata json.RawMessage `db:"metadata" json:"metadata"`
|
||||
AISummary *string `db:"ai_summary" json:"ai_summary,omitempty"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
@@ -500,6 +510,100 @@ type DeadlineRule struct {
|
||||
IsActive bool `db:"is_active" json:"is_active"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Phase 3 unified-rule columns (mig 078, t-paliad-182).
|
||||
// Populated by Slice 2 backfill; readers are compat-mode (read
|
||||
// both shapes) until Slice 4 cuts the calculator over and Slice 9
|
||||
// drops the legacy columns above (IsMandatory, IsOptional,
|
||||
// ConditionFlag, ConditionRuleID).
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
// TriggerEventID points at paliad.trigger_events when this rule is
|
||||
// event-rooted (Pipeline C unification, design §2.5). NULL on
|
||||
// proceeding-rooted rules. Exactly one of (proceeding_type_id,
|
||||
// trigger_event_id) is set after Slice 3.
|
||||
TriggerEventID *int64 `db:"trigger_event_id" json:"trigger_event_id,omitempty"`
|
||||
|
||||
// SpawnProceedingTypeID is the cross-proceeding spawn target —
|
||||
// when is_spawn=true and this is non-NULL, the calculator follows
|
||||
// the FK and emits the target proceeding's root rule chain. Slice
|
||||
// 7 backfills the 8 live is_spawn=true rows.
|
||||
SpawnProceedingTypeID *int `db:"spawn_proceeding_type_id" json:"spawn_proceeding_type_id,omitempty"`
|
||||
|
||||
// CombineOp is 'max' or 'min' for composite-rule arithmetic
|
||||
// (R.198 / R.213: "31d OR 20 working_days, whichever is longer").
|
||||
// NULL = single-anchor arithmetic.
|
||||
CombineOp *string `db:"combine_op" json:"combine_op,omitempty"`
|
||||
|
||||
// ConditionExpr is the jsonb gating expression replacing
|
||||
// ConditionFlag (design §2.4). Grammar:
|
||||
// {"flag": "<name>"}
|
||||
// {"op":"and"|"or", "args":[<node>, ...]}
|
||||
// {"op":"not", "args":[<node>]}
|
||||
// NULL or {} = unconditional. NullableJSON so a NULL column scans
|
||||
// cleanly (the row mishap that hid approval rows from the inbox
|
||||
// must not recur on rule rows).
|
||||
ConditionExpr NullableJSON `db:"condition_expr" json:"condition_expr,omitempty"`
|
||||
|
||||
// Priority is the 4-way unified enum replacing
|
||||
// (IsMandatory, IsOptional). Values: 'mandatory' (default),
|
||||
// 'recommended', 'optional', 'informational'. Backfilled in
|
||||
// Slice 2; legacy callers read IsMandatory + IsOptional until
|
||||
// Slice 4 cuts them over.
|
||||
Priority string `db:"priority" json:"priority"`
|
||||
|
||||
// IsCourtSet replaces the runtime heuristic
|
||||
// (primary_party='court' OR event_type IN ('hearing','decision',
|
||||
// 'order')). Backfilled in Slice 2; legacy callers read the
|
||||
// heuristic until Slice 4.
|
||||
IsCourtSet bool `db:"is_court_set" json:"is_court_set"`
|
||||
|
||||
// LifecycleState drives the rule-editor flow (design §4.2):
|
||||
// 'draft' (admin work-in-progress) | 'published' (live, calculator-
|
||||
// visible) | 'archived' (historical, retained for audit). Every
|
||||
// pre-Slice-1 row defaults to 'published' via the migration.
|
||||
LifecycleState string `db:"lifecycle_state" json:"lifecycle_state"`
|
||||
|
||||
// DraftOf points at the published rule this draft will replace on
|
||||
// publish. NULL on published / archived rows. NULL also on net-
|
||||
// new drafts that have no prior published peer.
|
||||
DraftOf *uuid.UUID `db:"draft_of" json:"draft_of,omitempty"`
|
||||
|
||||
// PublishedAt records when the row entered LifecycleState='published'.
|
||||
// NULL while draft, set on publish, retained through archive.
|
||||
// Distinct from UpdatedAt (moves on every edit).
|
||||
PublishedAt *time.Time `db:"published_at" json:"published_at,omitempty"`
|
||||
}
|
||||
|
||||
// DeadlineRuleAudit is one row of paliad.deadline_rule_audit — the
|
||||
// append-only audit log for every change to paliad.deadline_rules.
|
||||
// Written by the AFTER-trigger (raw create / update / delete) and by
|
||||
// the Go rule-editor service (semantic publish / archive / restore).
|
||||
// See migration 079 and design-fristen-phase2-2026-05-15.md §2.8.
|
||||
type DeadlineRuleAudit struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
RuleID uuid.UUID `db:"rule_id" json:"rule_id"`
|
||||
ChangedBy *uuid.UUID `db:"changed_by" json:"changed_by,omitempty"`
|
||||
ChangedAt time.Time `db:"changed_at" json:"changed_at"`
|
||||
|
||||
// Action is one of: create | update | delete (trigger-written) |
|
||||
// publish | archive | restore (Go-written by the rule editor).
|
||||
Action string `db:"action" json:"action"`
|
||||
|
||||
// BeforeJSON is the row state pre-change (NULL on 'create').
|
||||
// AfterJSON is the row state post-change (NULL on 'delete').
|
||||
BeforeJSON NullableJSON `db:"before_json" json:"before_json,omitempty"`
|
||||
AfterJSON NullableJSON `db:"after_json" json:"after_json,omitempty"`
|
||||
|
||||
// Reason is required on update / delete (the trigger raises if
|
||||
// paliad.audit_reason is unset). On create the trigger defaults
|
||||
// to 'create' so seed migrations don't need to bother.
|
||||
Reason string `db:"reason" json:"reason"`
|
||||
|
||||
// MigrationExported flips to true once the Slice 11b export
|
||||
// endpoint folds this delta into a checked-in .up.sql.
|
||||
MigrationExported bool `db:"migration_exported" json:"migration_exported"`
|
||||
}
|
||||
|
||||
// ProceedingType is one of INF/REV/CCR/APM/APP/AMD/ZPO_CIVIL (matter
|
||||
|
||||
@@ -21,12 +21,25 @@ func NewDeadlineRuleService(db *sqlx.DB) *DeadlineRuleService {
|
||||
return &DeadlineRuleService{db: db}
|
||||
}
|
||||
|
||||
// ruleColumns lists every column scanned into models.DeadlineRule.
|
||||
//
|
||||
// Compat-mode (t-paliad-182 Phase 3 Slice 1): the SELECT reads BOTH
|
||||
// the legacy shape (is_mandatory, is_optional, condition_flag,
|
||||
// condition_rule_id) and the unified Phase 3 shape (trigger_event_id,
|
||||
// spawn_proceeding_type_id, combine_op, condition_expr, priority,
|
||||
// is_court_set, lifecycle_state, draft_of, published_at). Existing
|
||||
// callers stay on the legacy fields; the new fields are NULL or carry
|
||||
// their migration default until Slice 2 backfills them. Slice 4 cuts
|
||||
// the calculator over to the new fields, Slice 9 drops the legacy
|
||||
// columns.
|
||||
const ruleColumns = `id, proceeding_type_id, parent_id, code, name, name_en,
|
||||
description, primary_party, event_type, is_mandatory, duration_value,
|
||||
duration_unit, timing, rule_code, deadline_notes, deadline_notes_en, sequence_order,
|
||||
condition_rule_id, condition_flag, alt_duration_value, alt_duration_unit, alt_rule_code,
|
||||
anchor_alt, concept_id, legal_source, is_spawn, spawn_label, is_optional, is_active,
|
||||
created_at, updated_at`
|
||||
created_at, updated_at,
|
||||
trigger_event_id, spawn_proceeding_type_id, combine_op, condition_expr,
|
||||
priority, is_court_set, lifecycle_state, draft_of, published_at`
|
||||
|
||||
const proceedingTypeColumns = `id, code, name, name_en, description, jurisdiction,
|
||||
category, default_color, sort_order, is_active`
|
||||
|
||||
216
internal/services/deadline_rule_service_test.go
Normal file
216
internal/services/deadline_rule_service_test.go
Normal file
@@ -0,0 +1,216 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/lib/pq"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/db"
|
||||
)
|
||||
|
||||
// TestDeadlineRuleService_UnifiedColumns_CompatRead exercises the Phase 3
|
||||
// Slice 1 (mig 078–080, t-paliad-182) additive-schema landing.
|
||||
//
|
||||
// What it validates:
|
||||
//
|
||||
// 1. Every Phase 3 column (trigger_event_id, spawn_proceeding_type_id,
|
||||
// combine_op, condition_expr, priority, is_court_set,
|
||||
// lifecycle_state, draft_of, published_at) is present on
|
||||
// paliad.deadline_rules after migrations apply and scans cleanly
|
||||
// into models.DeadlineRule.
|
||||
//
|
||||
// 2. The default migration values land: priority='mandatory',
|
||||
// is_court_set=false, lifecycle_state='published' on every pre-
|
||||
// Slice-1 row. New rows default the same way.
|
||||
//
|
||||
// 3. The audit trigger fires on UPDATE — exactly one
|
||||
// paliad.deadline_rule_audit row is written for an UPDATE that
|
||||
// supplies a reason via SET LOCAL paliad.audit_reason.
|
||||
//
|
||||
// 4. The audit trigger raises when paliad.audit_reason is unset on
|
||||
// UPDATE — Slice 2 backfills MUST set the reason or they fail
|
||||
// loudly.
|
||||
//
|
||||
// 5. paliad.projects.instance_level (mig 080) accepts NULL and the
|
||||
// three CHECK-allowed values, and rejects anything else.
|
||||
//
|
||||
// Skipped when TEST_DATABASE_URL is unset, mirroring audit_service_test.go.
|
||||
func TestDeadlineRuleService_UnifiedColumns_CompatRead(t *testing.T) {
|
||||
url := os.Getenv("TEST_DATABASE_URL")
|
||||
if url == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
|
||||
}
|
||||
if err := db.ApplyMigrations(url); err != nil {
|
||||
t.Fatalf("apply migrations: %v", err)
|
||||
}
|
||||
pool, err := sqlx.Connect("postgres", url)
|
||||
if err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
svc := NewDeadlineRuleService(pool)
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// 1. SELECT every column via the service's ruleColumns list. The list
|
||||
// must end the test green even though it now includes the Phase 3
|
||||
// columns; if a scan error pops up we know a column name or Go
|
||||
// type slipped.
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
rules, err := svc.List(ctx, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
if len(rules) == 0 {
|
||||
t.Fatal("no rules returned; seed-data missing?")
|
||||
}
|
||||
|
||||
// 2. Every pre-Slice-1 row should carry the migration defaults.
|
||||
for _, r := range rules {
|
||||
if r.Priority != "mandatory" {
|
||||
t.Errorf("rule %s: priority=%q, want default 'mandatory'", r.ID, r.Priority)
|
||||
}
|
||||
if r.IsCourtSet {
|
||||
t.Errorf("rule %s: is_court_set=true, want default false", r.ID)
|
||||
}
|
||||
if r.LifecycleState != "published" {
|
||||
t.Errorf("rule %s: lifecycle_state=%q, want default 'published'", r.ID, r.LifecycleState)
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// 3 + 4. Audit trigger behaviour. Use a throwaway row in its own tx
|
||||
// so SET LOCAL is scoped to this test.
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
// Pick any existing rule; we'll UPDATE its updated_at field with a
|
||||
// no-op-equivalent change (twice — once with reason, once without).
|
||||
target := rules[0]
|
||||
|
||||
// Count the audit rows for this rule before we touch it.
|
||||
var beforeCount int
|
||||
if err := pool.GetContext(ctx, &beforeCount,
|
||||
`SELECT count(*) FROM paliad.deadline_rule_audit WHERE rule_id = $1`, target.ID); err != nil {
|
||||
t.Fatalf("count audit rows pre-update: %v", err)
|
||||
}
|
||||
|
||||
// 3a. UPDATE WITH reason set — should succeed and write one audit row.
|
||||
tx, err := pool.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("begin tx: %v", err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`SELECT set_config('paliad.audit_reason', 'test: compat-read audit smoke', true)`); err != nil {
|
||||
tx.Rollback()
|
||||
t.Fatalf("set audit reason: %v", err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE paliad.deadline_rules SET updated_at = now() WHERE id = $1`, target.ID); err != nil {
|
||||
tx.Rollback()
|
||||
t.Fatalf("update with reason: %v", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
t.Fatalf("commit update-with-reason tx: %v", err)
|
||||
}
|
||||
|
||||
var afterCount int
|
||||
if err := pool.GetContext(ctx, &afterCount,
|
||||
`SELECT count(*) FROM paliad.deadline_rule_audit WHERE rule_id = $1`, target.ID); err != nil {
|
||||
t.Fatalf("count audit rows post-update: %v", err)
|
||||
}
|
||||
if afterCount != beforeCount+1 {
|
||||
t.Errorf("audit-row count: before=%d, after=%d, want before+1", beforeCount, afterCount)
|
||||
}
|
||||
|
||||
// Look up the audit row we just wrote: latest by changed_at, action='update'.
|
||||
var (
|
||||
auditAction string
|
||||
auditReason string
|
||||
auditBefore json.RawMessage
|
||||
auditAfter json.RawMessage
|
||||
)
|
||||
if err := pool.QueryRowxContext(ctx,
|
||||
`SELECT action, reason, before_json, after_json
|
||||
FROM paliad.deadline_rule_audit
|
||||
WHERE rule_id = $1
|
||||
ORDER BY changed_at DESC
|
||||
LIMIT 1`, target.ID).Scan(&auditAction, &auditReason, &auditBefore, &auditAfter); err != nil {
|
||||
t.Fatalf("read latest audit row: %v", err)
|
||||
}
|
||||
if auditAction != "update" {
|
||||
t.Errorf("audit action=%q, want 'update'", auditAction)
|
||||
}
|
||||
if auditReason != "test: compat-read audit smoke" {
|
||||
t.Errorf("audit reason=%q, want the set_config value", auditReason)
|
||||
}
|
||||
if len(auditBefore) == 0 || len(auditAfter) == 0 {
|
||||
t.Errorf("audit before/after json missing: before=%q after=%q", auditBefore, auditAfter)
|
||||
}
|
||||
|
||||
// 4. UPDATE WITHOUT reason — trigger must raise.
|
||||
tx2, err := pool.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("begin tx2: %v", err)
|
||||
}
|
||||
_, err = tx2.ExecContext(ctx,
|
||||
`UPDATE paliad.deadline_rules SET updated_at = now() WHERE id = $1`, target.ID)
|
||||
tx2.Rollback()
|
||||
if err == nil {
|
||||
t.Error("UPDATE without paliad.audit_reason should have raised, but succeeded")
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// 5. paliad.projects.instance_level CHECK.
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
userID := uuid.New()
|
||||
projectID := uuid.New()
|
||||
cleanup := func() {
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE id = $1`, projectID)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.users WHERE id = $1`, userID)
|
||||
pool.ExecContext(ctx, `DELETE FROM auth.users WHERE id = $1`, userID)
|
||||
}
|
||||
cleanup()
|
||||
defer cleanup()
|
||||
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO auth.users (id, email) VALUES ($1, 'instance-level-test@hlc.com')`,
|
||||
userID); err != nil {
|
||||
t.Fatalf("seed auth.users: %v", err)
|
||||
}
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO paliad.users (id, email, display_name, office, role, lang)
|
||||
VALUES ($1, 'instance-level-test@hlc.com', 'Instance Test', 'munich', 'associate', 'de')`,
|
||||
userID); err != nil {
|
||||
t.Fatalf("seed paliad.users: %v", err)
|
||||
}
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO paliad.projects (id, type, path, title, status, created_by, instance_level)
|
||||
VALUES ($1, 'project', $1::text, 'Instance Test', 'active', $2, 'appeal')`,
|
||||
projectID, userID); err != nil {
|
||||
t.Fatalf("seed paliad.projects with instance_level='appeal': %v", err)
|
||||
}
|
||||
|
||||
// Update to each allowed value should succeed; bogus value must fail.
|
||||
for _, lvl := range []string{"first", "cassation", "appeal"} {
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`UPDATE paliad.projects SET instance_level = $1 WHERE id = $2`, lvl, projectID); err != nil {
|
||||
t.Errorf("update instance_level=%q: %v", lvl, err)
|
||||
}
|
||||
}
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`UPDATE paliad.projects SET instance_level = 'final' WHERE id = $1`, projectID); err == nil {
|
||||
t.Error("instance_level='final' should violate CHECK constraint, but succeeded")
|
||||
}
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`UPDATE paliad.projects SET instance_level = NULL WHERE id = $1`, projectID); err != nil {
|
||||
t.Errorf("NULL instance_level should be allowed: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user