From 275cbd5e513ecc18eb5129c262285504ecacb2fb Mon Sep 17 00:00:00 2001 From: mAi Date: Fri, 15 May 2026 01:01:17 +0200 Subject: [PATCH] =?UTF-8?q?feat(t-paliad-186):=20mig=20088=20=E2=80=94=20f?= =?UTF-8?q?ristenrechner-category=20trigger?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 Slice 5 Step F-2. BEFORE INSERT/UPDATE trigger on paliad.projects rejects any write that binds proceeding_type_id to a non-fristenrechner-category proceeding_types row. NULL is allowed. PostgreSQL CHECK constraints can't reference other tables, so this is the only way to evaluate the (proceeding_types.category = 'fristenrechner') predicate per row without restructuring the existing FK relationship. Trigger trades narrower FK + partial-unique-index approach for keeping the existing schema reference (mig 027) untouched. Slice 9 or later may drop this trigger when the litigation category is fully retired. Error message is bilingual (German + English) so the Go handler can either surface it verbatim OR — preferably — intercept the typed service error first and emit a clean i18n string. mig 088 is defence-in-depth; the Go service-layer validation is the primary path. Idempotent: CREATE OR REPLACE FUNCTION + DROP TRIGGER IF EXISTS before CREATE TRIGGER. --- ...088_project_proceeding_type_check.down.sql | 5 ++ .../088_project_proceeding_type_check.up.sql | 90 +++++++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 internal/db/migrations/088_project_proceeding_type_check.down.sql create mode 100644 internal/db/migrations/088_project_proceeding_type_check.up.sql diff --git a/internal/db/migrations/088_project_proceeding_type_check.down.sql b/internal/db/migrations/088_project_proceeding_type_check.down.sql new file mode 100644 index 0000000..ff904df --- /dev/null +++ b/internal/db/migrations/088_project_proceeding_type_check.down.sql @@ -0,0 +1,5 @@ +-- t-paliad-186 down — reverses 088_project_proceeding_type_check.up.sql. + +DROP TRIGGER IF EXISTS projects_proceeding_type_category_check + ON paliad.projects; +DROP FUNCTION IF EXISTS paliad.projects_proceeding_type_category_check(); diff --git a/internal/db/migrations/088_project_proceeding_type_check.up.sql b/internal/db/migrations/088_project_proceeding_type_check.up.sql new file mode 100644 index 0000000..002bf4b --- /dev/null +++ b/internal/db/migrations/088_project_proceeding_type_check.up.sql @@ -0,0 +1,90 @@ +-- t-paliad-186 / Fristen Phase 3 Slice 5 Step F-2 — enforce +-- "fristenrechner-category only" on paliad.projects.proceeding_type_id +-- via a BEFORE INSERT/UPDATE trigger. PostgreSQL CHECK constraints +-- can't reference other tables, so a trigger is the only way to +-- evaluate the (proceeding_types.category = 'fristenrechner') +-- predicate per row. +-- +-- Why trigger over deferrable-FK-to-partial-index: a partial unique +-- index on proceeding_types where category='fristenrechner' would +-- let us reference it from a separate FK column, but the existing +-- FK on projects.proceeding_type_id → proceeding_types.id is +-- broad-category. Replacing it with a narrower FK would invalidate +-- the existing schema reference in mig 027. A trigger keeps the FK +-- in place and just adds the category predicate on top. +-- +-- Behaviour: +-- - INSERT/UPDATE with proceeding_type_id IS NULL: pass (NULL is allowed). +-- - INSERT/UPDATE with proceeding_type_id pointing at a +-- fristenrechner-category row: pass. +-- - INSERT/UPDATE with proceeding_type_id pointing at any other +-- category: RAISE EXCEPTION with a German + English message so the +-- handler / frontend can surface a friendly error. +-- - INSERT/UPDATE with proceeding_type_id pointing at a missing row: +-- the existing FK on the column rejects it before this trigger +-- even fires; nothing to do here. +-- +-- Removed when the litigation category is fully retired (Slice 9 or +-- later). Until then this is the runtime guard for any writer that +-- bypasses the Go service-layer validation. +-- +-- Idempotent: re-applying the migration drops + recreates the trigger. + +CREATE OR REPLACE FUNCTION paliad.projects_proceeding_type_category_check() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +DECLARE + v_category text; +BEGIN + IF NEW.proceeding_type_id IS NULL THEN + RETURN NEW; + END IF; + + SELECT category INTO v_category + FROM paliad.proceeding_types + WHERE id = NEW.proceeding_type_id; + + -- The FK on the column guarantees v_category is non-NULL when the + -- id resolves — but defensive against a future FK relax-and-replace. + IF v_category IS NULL THEN + RAISE EXCEPTION + 'paliad.projects.proceeding_type_id = % does not resolve to a ' + 'proceeding_types row — FK constraint should have caught this.', + NEW.proceeding_type_id; + END IF; + + IF v_category <> 'fristenrechner' THEN + RAISE EXCEPTION + 'paliad.projects.proceeding_type_id must reference a ' + 'fristenrechner-category proceeding_types row (got category=''%''). ' + 'Verfahrenstyp muss ein Fristenrechner-Typ sein (Kategorie=''%''). ' + 'Slice 5 (Phase 3 soft-merge per design §3.F) retires the ' + '''litigation'' category for project-binding; pick a UPC_*, ' + 'DE_*, EPA_*, DPMA_* or EP_GRANT code instead.', + v_category, v_category; + END IF; + + RETURN NEW; +END; +$$; + +COMMENT ON FUNCTION paliad.projects_proceeding_type_category_check() IS + 'BEFORE INSERT/UPDATE trigger function enforcing the Phase 3 Slice 5 ' + 'invariant: paliad.projects.proceeding_type_id may only reference ' + 'fristenrechner-category proceeding_types rows. NULL is allowed.'; + +DROP TRIGGER IF EXISTS projects_proceeding_type_category_check + ON paliad.projects; + +CREATE TRIGGER projects_proceeding_type_category_check + BEFORE INSERT OR UPDATE OF proceeding_type_id ON paliad.projects + FOR EACH ROW + EXECUTE FUNCTION paliad.projects_proceeding_type_category_check(); + +COMMENT ON TRIGGER projects_proceeding_type_category_check ON paliad.projects IS + 'Phase 3 Slice 5 (t-paliad-186) runtime guard for the projects ' + 'soft-merge — rejects any INSERT/UPDATE that would bind a project ' + 'to a non-fristenrechner-category proceeding_type. The Go service ' + 'layer also enforces this with a typed error; this trigger is the ' + 'defence-in-depth backstop.';