diff --git a/internal/db/migrations/139_deadline_rules_unified_view.down.sql b/internal/db/migrations/139_deadline_rules_unified_view.down.sql new file mode 100644 index 0000000..a668982 --- /dev/null +++ b/internal/db/migrations/139_deadline_rules_unified_view.down.sql @@ -0,0 +1,7 @@ +-- 139_deadline_rules_unified_view (down) — Slice B.3, t-paliad-305 +-- +-- Drops the view. The underlying paliad.sequencing_rules / +-- procedural_events / legal_sources tables are untouched (they own the +-- data — the view is just a projection). + +DROP VIEW IF EXISTS paliad.deadline_rules_unified; diff --git a/internal/db/migrations/139_deadline_rules_unified_view.up.sql b/internal/db/migrations/139_deadline_rules_unified_view.up.sql new file mode 100644 index 0000000..e76abe6 --- /dev/null +++ b/internal/db/migrations/139_deadline_rules_unified_view.up.sql @@ -0,0 +1,122 @@ +-- 139_deadline_rules_unified_view — Slice B.3 read cutover (t-paliad-305 / m/paliad#93) +-- +-- Creates paliad.deadline_rules_unified — a Postgres VIEW that +-- re-projects paliad.sequencing_rules + paliad.procedural_events + +-- paliad.legal_sources back into the legacy paliad.deadline_rules +-- column shape. +-- +-- Why a view instead of rewriting every SELECT in Go: +-- +-- - 19 read sites across 11 service files reference +-- paliad.deadline_rules. Rewriting each by hand multiplies the +-- opportunity for off-by-one bugs in the JOIN. +-- - The view has the same column names + types as the legacy table, +-- so the change in Go is a 1-token substitution per query +-- (FROM paliad.deadline_rules → FROM paliad.deadline_rules_unified) +-- with no struct or scanner changes. +-- - When B.4 drops paliad.deadline_rules, this view stays — it +-- becomes the canonical legacy-shape reader for any code that +-- hasn't been migrated to direct sr/pe/ls reads. +-- +-- Column mapping (per design §4.2): +-- - id, proceeding_type_id, parent_id, primary_party, duration_*, +-- timing, sequence_order, is_spawn/court_set/bilateral, priority, +-- rule_code, rule_codes, deadline_notes(_en), condition_expr, +-- choices_offered, applies_to_target, trigger_event_id, +-- spawn_proceeding_type_id, anchor_alt, alt_duration_*, +-- alt_rule_code, combine_op, lifecycle_state, draft_of, +-- published_at, is_active, created_at, updated_at, spawn_label +-- → from paliad.sequencing_rules +-- - submission_code → procedural_events.code +-- - name, name_en, description→ procedural_events +-- - event_type → procedural_events.event_kind (renamed) +-- - concept_id → procedural_events +-- - legal_source → legal_sources.citation (via legal_source_id FK) +-- +-- The view is READ-ONLY by default. Writes still go to the underlying +-- tables — RuleEditorService is refactored in the same slice to write +-- directly to sr/pe/ls. paliad.deadline_rules is FROZEN from B.3 onward +-- (no new writes); the dual-write helper from B.2 is decommissioned. + +-- The CHECK constraint on sequencing_rules.primary_party doesn't exist +-- yet (mig 135 only constrained deadline_rules.primary_party). The view +-- inherits whatever value sr.primary_party carries; mig 136's backfill +-- set sr.primary_party = dr.primary_party so the canonical four-value +-- vocab is already in place. A later slice can add the same CHECK to +-- sequencing_rules itself. + +CREATE OR REPLACE VIEW paliad.deadline_rules_unified AS +SELECT + sr.id, + sr.proceeding_type_id, + sr.parent_id, + pe.code AS submission_code, + pe.name, + pe.name_en, + pe.description, + sr.primary_party, + pe.event_kind AS event_type, + sr.duration_value, + sr.duration_unit, + sr.timing, + sr.alt_duration_value, + sr.alt_duration_unit, + sr.alt_rule_code, + sr.anchor_alt, + sr.combine_op, + sr.rule_code, + sr.deadline_notes, + sr.deadline_notes_en, + sr.sequence_order, + sr.is_spawn, + sr.spawn_label, + sr.spawn_proceeding_type_id, + sr.is_bilateral, + sr.is_court_set, + sr.priority, + sr.condition_expr, + pe.concept_id, + ls.citation AS legal_source, + sr.trigger_event_id, + sr.rule_codes, + sr.choices_offered, + sr.applies_to_target, + sr.lifecycle_state, + sr.draft_of, + sr.published_at, + sr.is_active, + sr.created_at, + sr.updated_at + FROM paliad.sequencing_rules sr + JOIN paliad.procedural_events pe ON pe.id = sr.procedural_event_id + LEFT JOIN paliad.legal_sources ls ON ls.id = pe.legal_source_id; + +COMMENT ON VIEW paliad.deadline_rules_unified IS + 'Slice B.3 (mig 139, t-paliad-305): legacy-shape projection over ' + 'sequencing_rules + procedural_events + legal_sources. Read-only — ' + 'writes go directly to the three underlying tables via ' + 'RuleEditorService. Survives B.4 destructive drop of ' + 'paliad.deadline_rules; the view will then be the only ' + 'legacy-shape reader.'; + +-- Post-apply integrity check: confirm the view's row count matches the +-- live sequencing_rules row count. A mismatch would indicate either a +-- mid-deploy race (rare) or a JOIN issue (the LEFT JOIN to legal_sources +-- never drops rows, the INNER JOIN to procedural_events drops sr rows +-- whose procedural_event_id is NULL — but that column is NOT NULL on +-- the table so it can't happen). Belt-and-braces. +DO $$ +DECLARE + v_view_count int; + v_sr_count int; +BEGIN + SELECT COUNT(*) INTO v_view_count FROM paliad.deadline_rules_unified; + SELECT COUNT(*) INTO v_sr_count FROM paliad.sequencing_rules; + IF v_view_count <> v_sr_count THEN + RAISE EXCEPTION '[mig 139] FAILED POST: view row count % does not match sequencing_rules row count %. ' + 'Possible cause: a sequencing_rules row references a procedural_event_id that does not exist (NOT NULL FK should prevent this).', + v_view_count, v_sr_count; + END IF; + RAISE NOTICE '[mig 139] view OK — deadline_rules_unified rows = % (= sequencing_rules)', + v_view_count; +END $$; diff --git a/internal/handlers/submissions.go b/internal/handlers/submissions.go index a4c0278..5c0fd5f 100644 --- a/internal/handlers/submissions.go +++ b/internal/handlers/submissions.go @@ -200,7 +200,7 @@ func loadSubmissionCatalog(ctx context.Context, projectProceedingTypeID *int) ([ pt.code AS proceeding_code, pt.name AS proceeding_name, pt.name_en AS proceeding_name_en - FROM paliad.deadline_rules dr + FROM paliad.deadline_rules_unified dr JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id WHERE dr.is_active = true AND dr.lifecycle_state = 'published' diff --git a/internal/services/deadline_rule_service.go b/internal/services/deadline_rule_service.go index 4f0127b..31aea8a 100644 --- a/internal/services/deadline_rule_service.go +++ b/internal/services/deadline_rule_service.go @@ -55,13 +55,13 @@ func (s *DeadlineRuleService) List(ctx context.Context, proceedingTypeID *int) ( if proceedingTypeID != nil { err = s.db.SelectContext(ctx, &rules, `SELECT `+ruleColumns+` - FROM paliad.deadline_rules + FROM paliad.deadline_rules_unified WHERE proceeding_type_id = $1 AND is_active = true ORDER BY sequence_order`, *proceedingTypeID) } else { err = s.db.SelectContext(ctx, &rules, `SELECT `+ruleColumns+` - FROM paliad.deadline_rules + FROM paliad.deadline_rules_unified WHERE is_active = true ORDER BY proceeding_type_id, sequence_order`) } @@ -100,7 +100,7 @@ func (s *DeadlineRuleService) hydrateConceptDefaultEventTypes(ctx context.Contex } query, args, err := sqlx.In( `SELECT dr.id AS rule_id, j.event_type_id - FROM paliad.deadline_rules dr + FROM paliad.deadline_rules_unified dr JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id JOIN paliad.deadline_concept_event_types j ON j.concept_id = dr.concept_id @@ -152,7 +152,7 @@ func (s *DeadlineRuleService) GetRuleTree(ctx context.Context, proceedingTypeCod var rules []models.DeadlineRule if err := s.db.SelectContext(ctx, &rules, `SELECT `+ruleColumns+` - FROM paliad.deadline_rules + FROM paliad.deadline_rules_unified WHERE proceeding_type_id = $1 AND is_active = true ORDER BY sequence_order`, pt.ID); err != nil { return nil, fmt.Errorf("list rules for %q: %w", proceedingTypeCode, err) @@ -175,10 +175,10 @@ func (s *DeadlineRuleService) GetFullTimeline(ctx context.Context, proceedingTyp var rules []models.DeadlineRule err := s.db.SelectContext(ctx, &rules, ` WITH RECURSIVE tree AS ( - SELECT * FROM paliad.deadline_rules + SELECT * FROM paliad.deadline_rules_unified WHERE proceeding_type_id = $1 AND parent_id IS NULL AND is_active = true UNION ALL - SELECT dr.* FROM paliad.deadline_rules dr + SELECT dr.* FROM paliad.deadline_rules_unified dr JOIN tree t ON dr.parent_id = t.id WHERE dr.is_active = true ) @@ -196,7 +196,7 @@ func (s *DeadlineRuleService) GetByIDs(ctx context.Context, ids []uuid.UUID) ([] } query, args, err := sqlx.In( `SELECT `+ruleColumns+` - FROM paliad.deadline_rules + FROM paliad.deadline_rules_unified WHERE id IN (?) AND is_active = true ORDER BY sequence_order`, ids) if err != nil { @@ -264,7 +264,7 @@ func (s *DeadlineRuleService) ListByTriggerEvent(ctx context.Context, triggerEve var rules []models.DeadlineRule if err := s.db.SelectContext(ctx, &rules, `SELECT `+ruleColumns+` - FROM paliad.deadline_rules + FROM paliad.deadline_rules_unified WHERE trigger_event_id = $1 AND is_active = true ORDER BY sequence_order`, triggerEventID); err != nil { @@ -292,7 +292,7 @@ func (s *DeadlineRuleService) ListByProceedingTypeIDs(ctx context.Context, ids [ } query, args, err := sqlx.In( `SELECT `+ruleColumns+` - FROM paliad.deadline_rules + FROM paliad.deadline_rules_unified WHERE proceeding_type_id IN (?) AND is_active = true ORDER BY proceeding_type_id, sequence_order`, ids) @@ -327,7 +327,7 @@ func (s *DeadlineRuleService) ListByConcept(ctx context.Context, conceptID uuid. var rules []models.DeadlineRule if err := s.db.SelectContext(ctx, &rules, `SELECT `+ruleColumns+` - FROM paliad.deadline_rules + FROM paliad.deadline_rules_unified WHERE concept_id = $1 AND is_active = true ORDER BY proceeding_type_id NULLS LAST, sequence_order`, conceptID); err != nil { diff --git a/internal/services/deadline_service.go b/internal/services/deadline_service.go index 42b9c3e..c219065 100644 --- a/internal/services/deadline_service.go +++ b/internal/services/deadline_service.go @@ -272,7 +272,7 @@ func (s *DeadlineService) ListVisibleForUser(ctx context.Context, userID uuid.UU ar.requester_kind AS requester_kind FROM paliad.deadlines f JOIN paliad.projects p ON p.id = f.project_id - LEFT JOIN paliad.deadline_rules r ON r.id = f.rule_id + LEFT JOIN paliad.deadline_rules_unified r ON r.id = f.rule_id LEFT JOIN paliad.approval_requests ar ON ar.id = f.pending_request_id WHERE ` + strings.Join(conds, " AND ") + ` ORDER BY f.due_date ASC, f.created_at DESC` diff --git a/internal/services/event_deadline_service.go b/internal/services/event_deadline_service.go index b36471f..a3367f3 100644 --- a/internal/services/event_deadline_service.go +++ b/internal/services/event_deadline_service.go @@ -168,7 +168,7 @@ func (s *EventDeadlineService) Calculate(ctx context.Context, triggerEventID int COALESCE(timing, 'after') AS timing, deadline_notes, deadline_notes_en, alt_duration_value, alt_duration_unit, combine_op, rule_codes - FROM paliad.deadline_rules + FROM paliad.deadline_rules_unified WHERE trigger_event_id = $1 AND is_active = true ORDER BY sequence_order`, triggerEventID) if err != nil { diff --git a/internal/services/export_service.go b/internal/services/export_service.go index 98ef7a4..b55e498 100644 --- a/internal/services/export_service.go +++ b/internal/services/export_service.go @@ -1138,7 +1138,7 @@ func personalSheetQueries(actorID uuid.UUID) []sheetQuery { }, { SheetName: "ref__deadline_rules", - SQL: `SELECT * FROM paliad.deadline_rules ORDER BY id`, + SQL: `SELECT * FROM paliad.deadline_rules_unified ORDER BY id`, }, { SheetName: "ref__deadline_concepts", @@ -1518,7 +1518,7 @@ SELECT 'partner_unit_default'::text AS source, {SheetName: "ref__proceeding_types", SQL: `SELECT * FROM paliad.proceeding_types ORDER BY id`}, {SheetName: "ref__event_types", SQL: `SELECT * FROM paliad.event_types ORDER BY id`}, {SheetName: "ref__event_categories", SQL: `SELECT * FROM paliad.event_categories ORDER BY id`}, - {SheetName: "ref__deadline_rules", SQL: `SELECT * FROM paliad.deadline_rules ORDER BY id`}, + {SheetName: "ref__deadline_rules", SQL: `SELECT * FROM paliad.deadline_rules_unified ORDER BY id`}, {SheetName: "ref__deadline_concepts", SQL: `SELECT * FROM paliad.deadline_concepts ORDER BY id`}, {SheetName: "ref__courts", SQL: `SELECT * FROM paliad.courts ORDER BY id`}, {SheetName: "ref__countries", SQL: `SELECT * FROM paliad.countries ORDER BY code`}, @@ -1621,7 +1621,7 @@ func orgSheetQueries() []sheetQuery { {SheetName: "ref__deadline_concept_event_types", SQL: `SELECT * FROM paliad.deadline_concept_event_types ORDER BY concept_id, event_type_id`}, {SheetName: "ref__deadline_concepts", SQL: `SELECT * FROM paliad.deadline_concepts ORDER BY id`}, {SheetName: "ref__deadline_event_types", SQL: `SELECT * FROM paliad.deadline_event_types ORDER BY rule_id, event_type_id`}, - {SheetName: "ref__deadline_rules", SQL: `SELECT * FROM paliad.deadline_rules ORDER BY id`}, + {SheetName: "ref__deadline_rules", SQL: `SELECT * FROM paliad.deadline_rules_unified ORDER BY id`}, {SheetName: "ref__event_categories", SQL: `SELECT * FROM paliad.event_categories ORDER BY id`}, {SheetName: "ref__event_category_concepts", SQL: `SELECT * FROM paliad.event_category_concepts ORDER BY category_id, concept_id`}, {SheetName: "ref__event_types", SQL: `SELECT * FROM paliad.event_types ORDER BY id`}, diff --git a/internal/services/fristenrechner.go b/internal/services/fristenrechner.go index 8384f81..7dfa767 100644 --- a/internal/services/fristenrechner.go +++ b/internal/services/fristenrechner.go @@ -169,7 +169,7 @@ func (c *paliadCatalog) LoadRuleByID(ctx context.Context, ruleID string) (*model var rule models.DeadlineRule err := c.rules.db.GetContext(ctx, &rule, `SELECT `+ruleColumns+` - FROM paliad.deadline_rules + FROM paliad.deadline_rules_unified WHERE id = $1 AND is_active = true`, ruleID) if errors.Is(err, sql.ErrNoRows) { return nil, lp.ErrUnknownRule @@ -200,7 +200,7 @@ func (c *paliadCatalog) LoadRuleByCode(ctx context.Context, proceedingCode, subm var rule models.DeadlineRule err = c.rules.db.GetContext(ctx, &rule, `SELECT `+ruleColumns+` - FROM paliad.deadline_rules + FROM paliad.deadline_rules_unified WHERE proceeding_type_id = $1 AND submission_code = $2 AND is_active = true`, pt.ID, submissionCode) if errors.Is(err, sql.ErrNoRows) { @@ -311,7 +311,7 @@ func (c *paliadCatalog) LookupEvents(ctx context.Context, axes lp.EventLookupAxe pt.trigger_event_label_de AS pt_trigger_event_label_de, pt.trigger_event_label_en AS pt_trigger_event_label_en, pt.appeal_target AS pt_appeal_target - FROM paliad.deadline_rules dr + FROM paliad.deadline_rules_unified dr JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id WHERE ` + strings.Join(where, "\n AND ") + ` ORDER BY dr.proceeding_type_id, dr.sequence_order` diff --git a/internal/services/projection_service.go b/internal/services/projection_service.go index b14752d..b77af2e 100644 --- a/internal/services/projection_service.go +++ b/internal/services/projection_service.go @@ -1767,7 +1767,7 @@ func (s *ProjectionService) lookupRuleBySubmissionCode(ctx context.Context, ptID var rule models.DeadlineRule err := s.db.GetContext(ctx, &rule, `SELECT `+ruleColumns+` - FROM paliad.deadline_rules + FROM paliad.deadline_rules_unified WHERE proceeding_type_id = $1 AND submission_code = $2 AND is_active = true`, ptID, code) if errors.Is(err, sql.ErrNoRows) { @@ -1784,7 +1784,7 @@ func (s *ProjectionService) lookupRuleByID(ctx context.Context, id uuid.UUID) (* var rule models.DeadlineRule err := s.db.GetContext(ctx, &rule, `SELECT `+ruleColumns+` - FROM paliad.deadline_rules + FROM paliad.deadline_rules_unified WHERE id = $1`, id) if err != nil { return nil, fmt.Errorf("lookup rule by id: %w", err) diff --git a/internal/services/rule_editor_orphans.go b/internal/services/rule_editor_orphans.go index bea1e55..e0328fc 100644 --- a/internal/services/rule_editor_orphans.go +++ b/internal/services/rule_editor_orphans.go @@ -117,7 +117,7 @@ func (s *RuleEditorService) ListOrphans(ctx context.Context) ([]Orphan, error) { } if err := s.db.SelectContext(ctx, &cs, ` SELECT id, rule_code, name, name_en - FROM paliad.deadline_rules + FROM paliad.deadline_rules_unified WHERE id = ANY($1::uuid[])`, pq.Array(uuidStrs)); err != nil { return nil, fmt.Errorf("list orphan candidate rules: %w", err) } diff --git a/internal/services/rule_editor_service.go b/internal/services/rule_editor_service.go index e9c224d..e7c7787 100644 --- a/internal/services/rule_editor_service.go +++ b/internal/services/rule_editor_service.go @@ -636,7 +636,7 @@ func (s *RuleEditorService) ListRules(ctx context.Context, f ListRulesFilter) ([ where = "WHERE " + strings.Join(conds, " AND ") } query := `SELECT ` + ruleColumns + ` - FROM paliad.deadline_rules + FROM paliad.deadline_rules_unified ` + where + ` ORDER BY proceeding_type_id NULLS LAST, sequence_order LIMIT ` + addArg(f.Limit) + ` OFFSET ` + addArg(f.Offset) @@ -656,7 +656,7 @@ func (s *RuleEditorService) GetByID(ctx context.Context, id uuid.UUID) (*models. 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) + `SELECT `+ruleColumns+` FROM paliad.deadline_rules_unified WHERE id = $1`, id) if errors.Is(err, sql.ErrNoRows) { return nil, ErrRuleNotFound } @@ -715,7 +715,7 @@ func (s *RuleEditorService) validateSpawnNoCycle(ctx context.Context, ruleID *uu visited[current] = true var nexts []sql.NullInt64 q := `SELECT DISTINCT spawn_proceeding_type_id::bigint - FROM paliad.deadline_rules + FROM paliad.deadline_rules_unified WHERE proceeding_type_id = $1 AND is_spawn = true AND spawn_proceeding_type_id IS NOT NULL diff --git a/internal/services/submission_vars.go b/internal/services/submission_vars.go index 7ca326d..0acd770 100644 --- a/internal/services/submission_vars.go +++ b/internal/services/submission_vars.go @@ -243,7 +243,7 @@ func (s *SubmissionVarsService) loadPublishedRule(ctx context.Context, submissio var rule models.DeadlineRule err := s.db.GetContext(ctx, &rule, `SELECT `+ruleColumns+` - FROM paliad.deadline_rules + FROM paliad.deadline_rules_unified WHERE submission_code = $1 AND lifecycle_state = 'published' AND is_active = true