diff --git a/internal/db/migrations/151_dedupe_null_procedural_events.down.sql b/internal/db/migrations/151_dedupe_null_procedural_events.down.sql new file mode 100644 index 0000000..85e5f4d --- /dev/null +++ b/internal/db/migrations/151_dedupe_null_procedural_events.down.sql @@ -0,0 +1,31 @@ +-- 151_dedupe_null_procedural_events (down) — t-paliad-319 / m/paliad#144 +-- +-- Best-effort restore from paliad.procedural_events_pre_151 and +-- paliad.sequencing_rules_pre_151. Re-points the reparented +-- sequencing_rules back at their original procedural_event_id and +-- reactivates the archived duplicates with the lifecycle_state + +-- is_active they had before the up migration. +-- +-- Catastrophic-recovery path only; the normal revert is to leave the +-- dedupe in place (it is purely cosmetic). + +-- 1. Re-point sequencing_rules.procedural_event_id back to its +-- pre-mig-151 value. The snapshot row is keyed by sr.id so the +-- join is 1:1 and idempotent. +UPDATE paliad.sequencing_rules sr + SET procedural_event_id = s.original_procedural_event_id, + updated_at = now() + FROM paliad.sequencing_rules_pre_151 s + WHERE sr.id = s.id; + +-- 2. Reactivate the archived duplicates with their snapshot lifecycle. +UPDATE paliad.procedural_events pe + SET is_active = s.is_active, + lifecycle_state = s.lifecycle_state, + updated_at = now() + FROM paliad.procedural_events_pre_151 s + WHERE pe.id = s.id; + +-- 3. Drop the snapshot tables — the data is back in place. +DROP TABLE IF EXISTS paliad.sequencing_rules_pre_151; +DROP TABLE IF EXISTS paliad.procedural_events_pre_151; diff --git a/internal/db/migrations/151_dedupe_null_procedural_events.up.sql b/internal/db/migrations/151_dedupe_null_procedural_events.up.sql new file mode 100644 index 0000000..ca6933f --- /dev/null +++ b/internal/db/migrations/151_dedupe_null_procedural_events.up.sql @@ -0,0 +1,229 @@ +-- 151_dedupe_null_procedural_events — t-paliad-319 / m/paliad#144 +-- +-- Purpose: ~14 paliad.procedural_events rows with synthetic null.<8hex> +-- codes (minted by mig 136 from the legacy paliad.deadline_rules rows +-- whose submission_code was NULL) share user-visible names. The +-- /admin/procedural-events list shows multiple entries for the same legal +-- concept (worst offender: "Mängelbeseitigung / Zahlung" × 6). This +-- migration consolidates every name-group onto a single canonical row, +-- reparents the sequencing_rules pointing at the duplicates, and archives +-- the duplicates without deleting them. +-- +-- Scope verified live before write (Supabase MCP, 2026-05-26): +-- * 5 name-groups, 14 duplicate rows total (1 canonical + 1–5 dups per +-- group). Every duplicate has exactly 1 sequencing_rule pointing at it. +-- * 0 paliad.deadlines reference any duplicate. +-- * 0 procedural_events.draft_of references any duplicate. +-- * No audit trigger on procedural_events or sequencing_rules — only +-- the INSTEAD OF triggers on deadline_rules_unified (mig 140), which +-- do not fire on direct table writes. No set_config('paliad.audit_reason') +-- needed. +-- +-- Canonical selection: ROW_NUMBER() OVER (PARTITION BY name ORDER BY +-- created_at, id::text). Every duplicate in current data shares the same +-- created_at (mig 136 bulk insert), so the deterministic tiebreaker is +-- the UUID's lexicographic order. +-- +-- Hard constraints honoured: +-- * No deletions. Duplicates flip to is_active=false + +-- lifecycle_state='archived'. The rows stay in the table for audit. +-- * Reparent sequencing_rules.procedural_event_id duplicate → canonical +-- BEFORE archiving, so no FK ever points at an archived PE. +-- * Snapshot the affected procedural_events + sequencing_rules into +-- paliad.procedural_events_pre_151 / paliad.sequencing_rules_pre_151 +-- in the same TX, mirroring precedent (migs 091/093/095/098/140). +-- +-- Down: best-effort restore from the snapshots. See .down.sql. + +-- ---------------------------------------------------------------- +-- 1. Build the dedupe mapping (duplicate_id → canonical_id) in a +-- TEMP table used by every subsequent step. +-- ---------------------------------------------------------------- + +CREATE TEMP TABLE tmp_pe_dedupe ON COMMIT DROP AS +WITH dupe_names AS ( + SELECT name + FROM paliad.procedural_events + WHERE code LIKE 'null.%' + GROUP BY name + HAVING COUNT(*) > 1 +), +ranked AS ( + SELECT pe.id, + pe.code, + pe.name, + pe.created_at, + ROW_NUMBER() OVER ( + PARTITION BY pe.name + ORDER BY pe.created_at, pe.id::text + ) AS rn + FROM paliad.procedural_events pe + WHERE pe.code LIKE 'null.%' + AND pe.name IN (SELECT name FROM dupe_names) +), +canonicals AS ( + SELECT name, + id AS canonical_id, + code AS canonical_code + FROM ranked + WHERE rn = 1 +) +SELECT r.id AS duplicate_id, + r.code AS duplicate_code, + r.name, + c.canonical_id, + c.canonical_code + FROM ranked r + JOIN canonicals c ON c.name = r.name + WHERE r.rn > 1; + +-- ---------------------------------------------------------------- +-- 2. Snapshot. Captures the rows that change so .down has a clean +-- source of truth; mirrors the pre_091/093/095/098/140 precedent. +-- ---------------------------------------------------------------- + +CREATE TABLE paliad.procedural_events_pre_151 AS +SELECT pe.* + FROM paliad.procedural_events pe + WHERE pe.id IN (SELECT duplicate_id FROM tmp_pe_dedupe); + +COMMENT ON TABLE paliad.procedural_events_pre_151 IS + 'Snapshot (mig 151, t-paliad-319) of the null.* procedural_events ' + 'duplicates that were archived in favour of their canonical name-mate. ' + 'Read-only forensic + revert source. Mirrors precedent pre_091/093/' + '095/098/140.'; + +CREATE TABLE paliad.sequencing_rules_pre_151 AS +SELECT sr.id, + sr.procedural_event_id AS original_procedural_event_id + FROM paliad.sequencing_rules sr + WHERE sr.procedural_event_id IN (SELECT duplicate_id FROM tmp_pe_dedupe); + +COMMENT ON TABLE paliad.sequencing_rules_pre_151 IS + 'Snapshot (mig 151, t-paliad-319) of sequencing_rules.procedural_event_id ' + 'before reparenting from null.* duplicates onto their canonical PE. ' + 'Read-only forensic + revert source.'; + +-- ---------------------------------------------------------------- +-- 3. Audit log — per-row NOTICE so the migration output captures +-- exactly which duplicate folded into which canonical, including +-- the sr_count for the duplicate (always 1 in current data, but +-- the RAISE keeps the audit honest if the scope grows later). +-- ---------------------------------------------------------------- + +DO $$ +DECLARE + rec record; + v_dup_count int; + v_grp_count int; +BEGIN + SELECT COUNT(*), COUNT(DISTINCT name) + INTO v_dup_count, v_grp_count + FROM tmp_pe_dedupe; + + RAISE NOTICE '[mig 151] dedupe scope: % duplicate rows across % name-groups', + v_dup_count, v_grp_count; + + FOR rec IN + SELECT d.duplicate_id, + d.duplicate_code, + d.name, + d.canonical_id, + d.canonical_code, + (SELECT COUNT(*) + FROM paliad.sequencing_rules sr + WHERE sr.procedural_event_id = d.duplicate_id) AS sr_count + FROM tmp_pe_dedupe d + ORDER BY d.name, d.duplicate_id + LOOP + RAISE NOTICE '[mig 151] dup % (%) -> canonical % (%) — sr_count=%', + rec.duplicate_id, rec.duplicate_code, + rec.canonical_id, rec.canonical_code, + rec.sr_count; + RAISE NOTICE '[mig 151] name: %', rec.name; + END LOOP; +END $$; + +-- ---------------------------------------------------------------- +-- 4. Reparent sequencing_rules.procedural_event_id duplicate → canonical. +-- sequencing_rules_pe_proc_lifecycle_idx is non-unique, so collapsing +-- multiple sr onto one PE is by design. +-- ---------------------------------------------------------------- + +UPDATE paliad.sequencing_rules sr + SET procedural_event_id = d.canonical_id, + updated_at = now() + FROM tmp_pe_dedupe d + WHERE sr.procedural_event_id = d.duplicate_id; + +-- ---------------------------------------------------------------- +-- 5. Archive the duplicates. No deletion — audit trail preserved. +-- ---------------------------------------------------------------- + +UPDATE paliad.procedural_events pe + SET is_active = false, + lifecycle_state = 'archived', + updated_at = now() + WHERE pe.id IN (SELECT duplicate_id FROM tmp_pe_dedupe); + +-- ---------------------------------------------------------------- +-- 6. POST assertions. Any failure rolls the migration back. +-- ---------------------------------------------------------------- + +DO $$ +DECLARE + v_surviving_groups int; + v_expected_count int; + v_archived_count int; + v_orphan_sr int; +BEGIN + -- (a) Acceptance criterion 2: no name-group still has >1 active+ + -- published null.* row. + SELECT COUNT(*) INTO v_surviving_groups + FROM ( + SELECT name + FROM paliad.procedural_events + WHERE code LIKE 'null.%' + AND is_active = true + AND lifecycle_state = 'published' + GROUP BY name + HAVING COUNT(*) > 1 + ) s; + + IF v_surviving_groups > 0 THEN + RAISE EXCEPTION + '[mig 151] FAILED POST: % name-groups still have >1 active+published null.* rows', + v_surviving_groups; + END IF; + + -- (b) Every targeted duplicate is now archived. + SELECT COUNT(*) INTO v_expected_count FROM tmp_pe_dedupe; + + SELECT COUNT(*) INTO v_archived_count + FROM paliad.procedural_events pe + WHERE pe.id IN (SELECT duplicate_id FROM tmp_pe_dedupe) + AND pe.is_active = false + AND pe.lifecycle_state = 'archived'; + + IF v_archived_count <> v_expected_count THEN + RAISE EXCEPTION + '[mig 151] FAILED POST: archived %/% duplicates', + v_archived_count, v_expected_count; + END IF; + + -- (c) Acceptance criterion 4: no sequencing_rule still points at + -- an archived duplicate. + SELECT COUNT(*) INTO v_orphan_sr + FROM paliad.sequencing_rules sr + WHERE sr.procedural_event_id IN (SELECT duplicate_id FROM tmp_pe_dedupe); + + IF v_orphan_sr > 0 THEN + RAISE EXCEPTION + '[mig 151] FAILED POST: % sequencing_rules still point at archived PE duplicates', + v_orphan_sr; + END IF; + + RAISE NOTICE '[mig 151] OK — archived % duplicates across % name-groups; 0 orphan sequencing_rules', + v_archived_count, + (SELECT COUNT(DISTINCT name) FROM tmp_pe_dedupe); +END $$;