diff --git a/internal/models/models.go b/internal/models/models.go index 5fe5707..d29fd71 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -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": ""} + // {"op":"and"|"or", "args":[, ...]} + // {"op":"not", "args":[]} + // 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 diff --git a/internal/services/deadline_rule_service.go b/internal/services/deadline_rule_service.go index 06bc8e8..9b4167c 100644 --- a/internal/services/deadline_rule_service.go +++ b/internal/services/deadline_rule_service.go @@ -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` diff --git a/internal/services/deadline_rule_service_test.go b/internal/services/deadline_rule_service_test.go new file mode 100644 index 0000000..44d25ee --- /dev/null +++ b/internal/services/deadline_rule_service_test.go @@ -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) + } +}