diff --git a/internal/db/migrations/098_submission_codes_prefix_and_rename.down.sql b/internal/db/migrations/098_submission_codes_prefix_and_rename.down.sql new file mode 100644 index 0000000..68c7f3f --- /dev/null +++ b/internal/db/migrations/098_submission_codes_prefix_and_rename.down.sql @@ -0,0 +1,162 @@ +-- Reverses mig 098. Restores the pre-098 submission codes on +-- paliad.deadline_rules, renames the column back to `code`, recreates +-- the deadline_search matview against the restored column, then drops +-- the snapshot table. +-- +-- audit_reason wrapper required by the mig 079 audit trigger. + +SELECT set_config( + 'paliad.audit_reason', + 'mig 098 (down): revert t-paliad-209 workstream B — restore paliad.deadline_rules.code values from deadline_rules_pre_098 snapshot and rename submission_code → code; matview deadline_search rebuilt against the restored column.', + true); + +-- ============================================================================= +-- 1. Drop the matview so the column rename can succeed. +-- ============================================================================= + +DROP MATERIALIZED VIEW IF EXISTS paliad.deadline_search; + +-- ============================================================================= +-- 2. Rename the column back. Guarded so a down run on a DB where the +-- up never ran (or where the column is already named `code`) is a +-- no-op rather than an error. +-- ============================================================================= + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'paliad' + AND table_name = 'deadline_rules' + AND column_name = 'submission_code' + ) THEN + ALTER TABLE paliad.deadline_rules + RENAME COLUMN submission_code TO code; + END IF; +END $$; + +-- ============================================================================= +-- 3. Restore code values from the pre_098 snapshot. The snapshot was +-- captured at the first up-migration run; if the table is missing +-- (down run before up), the restore is a no-op. +-- ============================================================================= + +DO $$ +DECLARE + v_snap_exists boolean; +BEGIN + SELECT EXISTS ( + SELECT 1 + FROM information_schema.tables + WHERE table_schema = 'paliad' + AND table_name = 'deadline_rules_pre_098' + ) INTO v_snap_exists; + + IF NOT v_snap_exists THEN + RAISE NOTICE + 'mig 098 (down): snapshot table paliad.deadline_rules_pre_098 missing — nothing to restore'; + RETURN; + END IF; + + UPDATE paliad.deadline_rules dr + SET code = snap.code + FROM paliad.deadline_rules_pre_098 snap + WHERE dr.id = snap.id + AND dr.code <> snap.code; +END $$; + +-- ============================================================================= +-- 4. Recreate the deadline_search matview against the restored column. +-- Identical body to mig 051 §4, reproduced here so the down leaves +-- the schema in the same shape mig 051 created. +-- ============================================================================= + +CREATE MATERIALIZED VIEW paliad.deadline_search AS +SELECT + 'rule'::text AS kind, + 'r:' || dr.id::text AS row_key, + dc.id AS concept_id, + dc.slug AS concept_slug, + dc.name_de AS concept_name_de, + dc.name_en AS concept_name_en, + dc.description AS concept_description, + dc.aliases AS concept_aliases, + dc.party AS concept_party, + dc.category AS concept_category, + dc.sort_order AS concept_sort_order, + dr.id AS rule_id, + NULL::bigint AS trigger_event_id, + pt.code AS proceeding_code, + pt.name AS proceeding_name_de, + pt.name_en AS proceeding_name_en, + pt.jurisdiction AS jurisdiction, + pt.display_order AS proceeding_display_order, + dr.code AS rule_local_code, + dr.name AS rule_name_de, + dr.name_en AS rule_name_en, + dr.legal_source AS legal_source, + dr.rule_code AS rule_code, + dr.duration_value, + dr.duration_unit, + dr.timing, + COALESCE(dr.primary_party, dc.party) AS effective_party + FROM paliad.deadline_rules dr + JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id + JOIN paliad.deadline_concepts dc ON dc.id = dr.concept_id + WHERE dr.is_active + AND pt.is_active + AND pt.category = 'fristenrechner' + +UNION ALL + +SELECT + 'trigger'::text, + 't:' || te.id::text, + dc.id, + dc.slug, + dc.name_de, + dc.name_en, + dc.description, + dc.aliases, + dc.party, + dc.category, + dc.sort_order, + NULL::uuid, + te.id, + NULL::text, + NULL::text, + NULL::text, + 'cross-cutting'::text, + 9999::int AS proceeding_display_order, + te.code, + te.name_de, + te.name, + NULL::text, + NULL::text, + NULL::int, + NULL::text, + NULL::text, + dc.party + FROM paliad.trigger_events te + JOIN paliad.deadline_concepts dc ON dc.slug = te.concept_id + WHERE te.is_active; + +CREATE UNIQUE INDEX deadline_search_row_key ON paliad.deadline_search (row_key); +CREATE INDEX deadline_search_concept_id ON paliad.deadline_search (concept_id); +CREATE INDEX deadline_search_proc_code ON paliad.deadline_search (proceeding_code); +CREATE INDEX deadline_search_legal_source ON paliad.deadline_search (legal_source); +CREATE INDEX deadline_search_effective_party ON paliad.deadline_search (effective_party); +CREATE INDEX deadline_search_legal_source_trgm ON paliad.deadline_search USING gin (legal_source gin_trgm_ops); +CREATE INDEX deadline_search_concept_de_trgm ON paliad.deadline_search USING gin (concept_name_de gin_trgm_ops); +CREATE INDEX deadline_search_concept_en_trgm ON paliad.deadline_search USING gin (concept_name_en gin_trgm_ops); +CREATE INDEX deadline_search_rule_de_trgm ON paliad.deadline_search USING gin (rule_name_de gin_trgm_ops); +CREATE INDEX deadline_search_rule_en_trgm ON paliad.deadline_search USING gin (rule_name_en gin_trgm_ops); +CREATE INDEX deadline_search_rule_code_trgm ON paliad.deadline_search USING gin (rule_code gin_trgm_ops); + +-- ============================================================================= +-- 5. Drop the snapshot table so a re-applied up captures a fresh +-- snapshot of the current state. +-- ============================================================================= + +DROP TABLE IF EXISTS paliad.deadline_rules_pre_098; diff --git a/internal/db/migrations/098_submission_codes_prefix_and_rename.up.sql b/internal/db/migrations/098_submission_codes_prefix_and_rename.up.sql new file mode 100644 index 0000000..4894221 --- /dev/null +++ b/internal/db/migrations/098_submission_codes_prefix_and_rename.up.sql @@ -0,0 +1,268 @@ +-- t-paliad-209 / workstream B — submission-code prefix + rename. +-- +-- m's 2026-05-18 call: the `paliad.deadline_rules.code` field is a +-- SUBMISSION identifier (the event/filing within a proceeding), not the +-- legal-citation rule code (which lives in `rule_code` / `legal_source`). +-- Two cleanups land here: +-- +-- 1. DATA — prefix every existing submission code with its proceeding +-- code so submission codes carry the full hierarchical shape +-- (e.g. `inf.soc` on `upc.inf.cfi` → `upc.inf.cfi.soc`, +-- `de_inf.klage` on `de.inf.lg` → `de.inf.lg.klage`). +-- Algorithm: keep the proceeding-code prefix as-is, strip the +-- old single-segment prefix (everything before the first dot in +-- `dr.code`) and replace it with the proceeding's full `code`. +-- +-- 2. SCHEMA — rename `paliad.deadline_rules.code` → `submission_code` +-- so future devs don't conflate it with `rule_code` (legal +-- citation) or `proceeding_types.code`. Explicit name encodes the +-- semantic taxonomy ratified in +-- docs/design-proceeding-code-taxonomy-2026-05-18.md §0.1. +-- +-- Materialized-view dependency: `paliad.deadline_search` (mig 051) has +-- `dr.code AS rule_local_code` baked into its SELECT list. Postgres +-- rejects RENAME COLUMN when a matview's column list still resolves +-- via the old name — so the matview is dropped before the rename and +-- recreated against `submission_code` afterwards, with every index +-- reproduced. The mig 047 / 051 indexes are reproduced verbatim here. +-- +-- IDs and FKs are untouched. `deadline_rules.proceeding_type_id` / +-- `parent_id` / `spawn_proceeding_type_id` reference ids; no +-- code-string FK exists on submission codes (the parent_id chain is on +-- UUID `id`, not the code string), so the data UPDATE doesn't risk +-- breaking joins. +-- +-- Idempotent: +-- * The data UPDATE is gated `WHERE dr.code NOT LIKE pt.code || '.%'` +-- — rows already prefixed with their proceeding code (i.e. the +-- migration ran before) are skipped. +-- * The rename is wrapped in a DO block that checks column existence, +-- so a second run is a no-op. +-- * Snapshot table uses CREATE TABLE IF NOT EXISTS. +-- * Matview drop/recreate is DROP IF EXISTS + CREATE. +-- +-- audit_reason wrapper required by the mig 079 audit trigger. + +SELECT set_config( + 'paliad.audit_reason', + 'mig 098: t-paliad-209 workstream B — prefix every paliad.deadline_rules.code with its proceeding code, then rename code → submission_code; matview deadline_search rebuilt against the new column. See docs/design-proceeding-code-taxonomy-2026-05-18.md and the t-paliad-209 task brief.', + true); + +-- ============================================================================= +-- 1. Backup snapshot of paliad.deadline_rules BEFORE the prefix + rename. +-- Captures the rows as they are; serves as the source for the down +-- migration and the permanent audit anchor. +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS paliad.deadline_rules_pre_098 AS +SELECT *, now() AS snapshotted_at + FROM paliad.deadline_rules; + +COMMENT ON TABLE paliad.deadline_rules_pre_098 IS + 'Snapshot of paliad.deadline_rules taken before mig 098 prefixed ' + 'every `code` with its proceeding code and renamed the column to ' + '`submission_code` (t-paliad-209, 2026-05-18). Source-of-truth ' + 'for the down migration; persists post-rename as the permanent ' + 'audit record.'; + +-- ============================================================================= +-- 2. Drop the deadline_search materialized view. It bakes `dr.code AS +-- rule_local_code` into its SELECT list (mig 051 §4), and Postgres +-- refuses to rename a column that a matview's column list still +-- resolves via the old name. The matview is recreated verbatim in §5 +-- against the renamed column. +-- ============================================================================= + +DROP MATERIALIZED VIEW IF EXISTS paliad.deadline_search; + +-- ============================================================================= +-- 3. Data UPDATE — prefix every submission code with its proceeding +-- code. Algorithm: +-- * proceeding_code = pt.code +-- * suffix = portion of dr.code after the first '.' +-- * new code = proceeding_code || '.' || suffix +-- +-- regexp_replace('inf.soc', '^[^.]+\.', '') = 'soc' +-- regexp_replace('de_inf_bgh.revision', ...) = 'revision' +-- +-- The WHERE clause skips rows that already start with `pt.code || '.'` +-- so re-running the migration is a no-op on already-prefixed rows. +-- Archived rows (proceeding `_archived_litigation`) get the same +-- treatment — they end up as `_archived_litigation.`. The +-- shape regex in §6 only inspects active+published rows, so the +-- archived form sits outside the constraint by design. +-- ============================================================================= + +UPDATE paliad.deadline_rules dr + SET code = pt.code || '.' || regexp_replace(dr.code, '^[^.]+\.', '') + FROM paliad.proceeding_types pt + WHERE pt.id = dr.proceeding_type_id + AND dr.code IS NOT NULL + AND position('.' in dr.code) > 0 + AND dr.code NOT LIKE pt.code || '.%'; + +-- ============================================================================= +-- 4. Rename the column. Guarded in a DO block so a second run (e.g. a +-- fresh DB built up to mig 098 from an empty schema, or a manual +-- re-apply) is a no-op rather than a hard error. +-- ============================================================================= + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'paliad' + AND table_name = 'deadline_rules' + AND column_name = 'code' + ) THEN + ALTER TABLE paliad.deadline_rules + RENAME COLUMN code TO submission_code; + END IF; +END $$; + +-- ============================================================================= +-- 5. Recreate the deadline_search matview against the renamed column. +-- Column list reproduced verbatim from mig 051 §4 with the single +-- edit: `dr.code AS rule_local_code` → `dr.submission_code AS +-- rule_local_code`. All indexes from mig 051 are reproduced too. +-- ============================================================================= + +CREATE MATERIALIZED VIEW paliad.deadline_search AS +SELECT + 'rule'::text AS kind, + 'r:' || dr.id::text AS row_key, + dc.id AS concept_id, + dc.slug AS concept_slug, + dc.name_de AS concept_name_de, + dc.name_en AS concept_name_en, + dc.description AS concept_description, + dc.aliases AS concept_aliases, + dc.party AS concept_party, + dc.category AS concept_category, + dc.sort_order AS concept_sort_order, + dr.id AS rule_id, + NULL::bigint AS trigger_event_id, + pt.code AS proceeding_code, + pt.name AS proceeding_name_de, + pt.name_en AS proceeding_name_en, + pt.jurisdiction AS jurisdiction, + pt.display_order AS proceeding_display_order, + dr.submission_code AS rule_local_code, + dr.name AS rule_name_de, + dr.name_en AS rule_name_en, + dr.legal_source AS legal_source, + dr.rule_code AS rule_code, + dr.duration_value, + dr.duration_unit, + dr.timing, + COALESCE(dr.primary_party, dc.party) AS effective_party + FROM paliad.deadline_rules dr + JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id + JOIN paliad.deadline_concepts dc ON dc.id = dr.concept_id + WHERE dr.is_active + AND pt.is_active + AND pt.category = 'fristenrechner' + +UNION ALL + +SELECT + 'trigger'::text, + 't:' || te.id::text, + dc.id, + dc.slug, + dc.name_de, + dc.name_en, + dc.description, + dc.aliases, + dc.party, + dc.category, + dc.sort_order, + NULL::uuid, + te.id, + NULL::text, + NULL::text, + NULL::text, + 'cross-cutting'::text, + 9999::int AS proceeding_display_order, + te.code, + te.name_de, + te.name, + NULL::text, + NULL::text, + NULL::int, + NULL::text, + NULL::text, + dc.party + FROM paliad.trigger_events te + JOIN paliad.deadline_concepts dc ON dc.slug = te.concept_id + WHERE te.is_active; + +CREATE UNIQUE INDEX deadline_search_row_key ON paliad.deadline_search (row_key); +CREATE INDEX deadline_search_concept_id ON paliad.deadline_search (concept_id); +CREATE INDEX deadline_search_proc_code ON paliad.deadline_search (proceeding_code); +CREATE INDEX deadline_search_legal_source ON paliad.deadline_search (legal_source); +CREATE INDEX deadline_search_effective_party ON paliad.deadline_search (effective_party); +CREATE INDEX deadline_search_legal_source_trgm ON paliad.deadline_search USING gin (legal_source gin_trgm_ops); +CREATE INDEX deadline_search_concept_de_trgm ON paliad.deadline_search USING gin (concept_name_de gin_trgm_ops); +CREATE INDEX deadline_search_concept_en_trgm ON paliad.deadline_search USING gin (concept_name_en gin_trgm_ops); +CREATE INDEX deadline_search_rule_de_trgm ON paliad.deadline_search USING gin (rule_name_de gin_trgm_ops); +CREATE INDEX deadline_search_rule_en_trgm ON paliad.deadline_search USING gin (rule_name_en gin_trgm_ops); +CREATE INDEX deadline_search_rule_code_trgm ON paliad.deadline_search USING gin (rule_code gin_trgm_ops); + +-- ============================================================================= +-- 6. Hard assertions. Half-applied migrations would leave the rule +-- corpus inconsistent; gate on the shape of every active+published +-- row and on column existence so this fails loudly rather than +-- leaving the schema in a half-renamed state. +-- ============================================================================= + +DO $$ +DECLARE + v_bad_shape integer; + v_null_codes integer; + v_col_exists boolean; +BEGIN + -- 6.1 Every active+published row has the proceeding-code-prefixed + -- 4+-segment shape. Archived rows (`_archived_litigation` ones) + -- keep their shorter shape by design — they're carved out. + SELECT count(*) INTO v_bad_shape + FROM paliad.deadline_rules + WHERE is_active = true + AND lifecycle_state = 'published' + AND submission_code !~ '^[a-z_]+\.[a-z_]+\.[a-z_]+\.[a-z_]+(\..*)?$'; + IF v_bad_shape <> 0 THEN + RAISE EXCEPTION + 'mig 098: expected every active+published deadline_rules row to match the 4+-segment submission_code shape, got % violators', + v_bad_shape; + END IF; + + -- 6.2 No NULL submission_code on active+published rows. The column + -- is nullable for legacy reasons, but every live row should + -- carry a code after the prefix step. + SELECT count(*) INTO v_null_codes + FROM paliad.deadline_rules + WHERE is_active = true + AND lifecycle_state = 'published' + AND submission_code IS NULL; + IF v_null_codes <> 0 THEN + RAISE EXCEPTION + 'mig 098: expected 0 NULL submission_code on active+published rows, got %', + v_null_codes; + END IF; + + -- 6.3 Column was actually renamed. Catches the case where the DO + -- guard in §4 short-circuited because the schema hadn't yet + -- been migrated. + SELECT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'paliad' + AND table_name = 'deadline_rules' + AND column_name = 'submission_code' + ) INTO v_col_exists; + IF NOT v_col_exists THEN + RAISE EXCEPTION + 'mig 098: column paliad.deadline_rules.submission_code missing after rename — half-applied migration'; + END IF; +END $$;