feat(db): mig 153 — proceeding_types kind discriminator + ProjectService hardening
Adds a `kind` column to paliad.proceeding_types (proceeding / phase /
side_action / meta) so the Mode B R3 Fristenrechner wizard, the
projects.proceeding_type_id binding, and the pkg/litigationplanner
snapshot can filter to primary proceedings only.
Implements the ratified design from docs/design-proceeding-types-
taxonomy-2026-05-26.md (m greenlit 2026-05-27 09:57 after the 11-question
AskUserQuestion round-trip).
Mig 153 is purely additive — ADD COLUMN with a safe DEFAULT, UPDATEs
reclassify 23 non-primary rows (4 phase + 10 side_action + 9 meta), and
a BEFORE INSERT/UPDATE trigger on paliad.projects backstops the new
invariant. Pre-mig audit (Supabase MCP, 2026-05-27) confirmed zero
downstream pressure on the 23 reclassified rows.
- internal/db/migrations/153_proceeding_types_kind.up.sql + .down.sql
- snapshot to paliad.proceeding_types_pre_153 in the same TX
- set_config('paliad.audit_reason', …) defensively
- DO-block asserts 23 reclassified rows before the trigger ships
- Q9 carve-out: is_active=false on every phase/side_action/meta row
- new trigger paliad.projects_proceeding_type_kind_check on
paliad.projects.proceeding_type_id
- internal/services/project_service.go
- extend validateProceedingTypeCategory to also enforce
kind='proceeding' AND is_active=true; new typed error
ErrInvalidProceedingTypeKind
- single SELECT picks up category + kind + is_active
- internal/services/project_service_test.go
- TestProjectService_ProceedingTypeKindGuard covers service-layer
rejection, the active-but-non-proceeding edge, mig 153 trigger
backstop, and the kind='proceeding' happy path
- cmd/gen-upc-snapshot/main.go
- filter proceeding_types query to kind='proceeding' for forward-
compat (the embedded UPC snapshot JSON regen requires DATABASE_URL
access and will land in a follow-up; the current placeholder is
already empty of non-primary rows)
t-paliad-325 / m/paliad#147
This commit is contained in:
@@ -117,9 +117,13 @@ func run(ctx context.Context, pool *sqlx.DB, output, version, sourceLabel string
|
|||||||
return fmt.Errorf("mkdir output: %w", err)
|
return fmt.Errorf("mkdir output: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Proceeding types — UPC + active only. The unified upc.apl row
|
// 1. Proceeding types — UPC primaries only. The unified upc.apl row
|
||||||
// from B1 mig 134 is included; the 3 archived old appeal codes
|
// from B1 mig 134 is included; the 3 archived old appeal codes
|
||||||
// (is_active=false) are filtered out by the WHERE.
|
// (is_active=false) are filtered out by the is_active predicate.
|
||||||
|
// The kind='proceeding' predicate (mig 153, t-paliad-325) belts the
|
||||||
|
// is_active filter so phase/side_action/meta rows can't slip into
|
||||||
|
// the embedded catalog even if some future deploy re-activates one
|
||||||
|
// for an admin task.
|
||||||
var procs []litigationplanner.ProceedingType
|
var procs []litigationplanner.ProceedingType
|
||||||
if err := pool.SelectContext(ctx, &procs, `
|
if err := pool.SelectContext(ctx, &procs, `
|
||||||
SELECT id, code, name, name_en, description, jurisdiction,
|
SELECT id, code, name, name_en, description, jurisdiction,
|
||||||
@@ -127,7 +131,9 @@ func run(ctx context.Context, pool *sqlx.DB, output, version, sourceLabel string
|
|||||||
trigger_event_label_de, trigger_event_label_en,
|
trigger_event_label_de, trigger_event_label_en,
|
||||||
appeal_target
|
appeal_target
|
||||||
FROM paliad.proceeding_types
|
FROM paliad.proceeding_types
|
||||||
WHERE jurisdiction = 'UPC' AND is_active = true
|
WHERE jurisdiction = 'UPC'
|
||||||
|
AND is_active = true
|
||||||
|
AND kind = 'proceeding'
|
||||||
ORDER BY sort_order, id`); err != nil {
|
ORDER BY sort_order, id`); err != nil {
|
||||||
return fmt.Errorf("select proceeding_types: %w", err)
|
return fmt.Errorf("select proceeding_types: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
53
internal/db/migrations/153_proceeding_types_kind.down.sql
Normal file
53
internal/db/migrations/153_proceeding_types_kind.down.sql
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
-- 153_proceeding_types_kind.down — t-paliad-325 / m/paliad#147
|
||||||
|
--
|
||||||
|
-- Best-effort rollback of mig 153. Restores the pre-mig state of
|
||||||
|
-- paliad.proceeding_types from the same-TX snapshot, drops the kind
|
||||||
|
-- column, drops the backstop trigger.
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
SELECT set_config(
|
||||||
|
'paliad.audit_reason',
|
||||||
|
'mig 153 down: revert proceeding_types kind discriminator',
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ----------------------------------------------------------------
|
||||||
|
-- 1. Drop the backstop trigger + function.
|
||||||
|
-- ----------------------------------------------------------------
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS projects_proceeding_type_kind_check
|
||||||
|
ON paliad.projects;
|
||||||
|
|
||||||
|
DROP FUNCTION IF EXISTS paliad.projects_proceeding_type_kind_check();
|
||||||
|
|
||||||
|
-- ----------------------------------------------------------------
|
||||||
|
-- 2. Restore is_active flags from the snapshot. We only touch rows
|
||||||
|
-- whose is_active value diverged from the snapshot — i.e. the 23
|
||||||
|
-- rows that mig 153 §4 deactivated.
|
||||||
|
-- ----------------------------------------------------------------
|
||||||
|
|
||||||
|
UPDATE paliad.proceeding_types pt
|
||||||
|
SET is_active = pre.is_active
|
||||||
|
FROM paliad.proceeding_types_pre_153 pre
|
||||||
|
WHERE pt.id = pre.id
|
||||||
|
AND pt.is_active IS DISTINCT FROM pre.is_active;
|
||||||
|
|
||||||
|
-- ----------------------------------------------------------------
|
||||||
|
-- 3. Drop the kind column (cascades the index).
|
||||||
|
-- ----------------------------------------------------------------
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS paliad.proceeding_types_kind_active_idx;
|
||||||
|
|
||||||
|
ALTER TABLE paliad.proceeding_types
|
||||||
|
DROP COLUMN IF EXISTS kind;
|
||||||
|
|
||||||
|
-- ----------------------------------------------------------------
|
||||||
|
-- 4. Drop the snapshot table.
|
||||||
|
-- (The CHECK constraint on the kind column is dropped implicitly
|
||||||
|
-- when the column is dropped.)
|
||||||
|
-- ----------------------------------------------------------------
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS paliad.proceeding_types_pre_153;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
201
internal/db/migrations/153_proceeding_types_kind.up.sql
Normal file
201
internal/db/migrations/153_proceeding_types_kind.up.sql
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
-- 153_proceeding_types_kind — t-paliad-325 / m/paliad#147
|
||||||
|
--
|
||||||
|
-- Purpose: tag every paliad.proceeding_types row with a structural
|
||||||
|
-- classification so the Mode B R3 wizard (Fristenrechner overhaul,
|
||||||
|
-- m/paliad#146), the projects.proceeding_type_id binding, and the
|
||||||
|
-- pkg/litigationplanner snapshot can filter to primary proceedings
|
||||||
|
-- only — separating self-contained matters from CFI phases,
|
||||||
|
-- in-proceeding side-actions, and cross-cutting RoP/admin rows.
|
||||||
|
--
|
||||||
|
-- Design: docs/design-proceeding-types-taxonomy-2026-05-26.md
|
||||||
|
-- §0–§10 (m ratified 2026-05-27 09:52 via 11-question AskUserQuestion
|
||||||
|
-- batch; "proceed, sure" greenlight at 09:57).
|
||||||
|
--
|
||||||
|
-- This mig is purely additive: ALTER TABLE adds the kind column with
|
||||||
|
-- a safe DEFAULT, UPDATEs reclassify the 23 non-primary rows, and a
|
||||||
|
-- BEFORE INSERT/UPDATE trigger backstops the new
|
||||||
|
-- "projects.proceeding_type_id must point at kind='proceeding'"
|
||||||
|
-- invariant. The 23 rows being reclassified have zero downstream
|
||||||
|
-- consumers today (0 active sequencing_rules anchor, 0 spawn, 0
|
||||||
|
-- projects bind, 0 event_category_concepts reference) so no FK
|
||||||
|
-- reparenting is needed — verified via Supabase MCP 2026-05-27
|
||||||
|
-- before write.
|
||||||
|
--
|
||||||
|
-- Hard constraints honoured (mirrors precedent migs 091/093/095/098/
|
||||||
|
-- 140/151/152):
|
||||||
|
-- * No deletions. Non-primary rows flip is_active=false but stay in
|
||||||
|
-- the table for audit + future re-activation.
|
||||||
|
-- * Snapshot the affected proceeding_types into
|
||||||
|
-- paliad.proceeding_types_pre_153 in the same TX.
|
||||||
|
-- * set_config('paliad.audit_reason') is defensively called even
|
||||||
|
-- though no audit trigger fires on proceeding_types today; a
|
||||||
|
-- future audit trigger would inherit the reason automatically.
|
||||||
|
-- * Idempotent on re-apply — the ADD COLUMN uses IF NOT EXISTS
|
||||||
|
-- semantics through golang-migrate's tracker (mig only fires
|
||||||
|
-- once); the UPDATEs only touch rows that match the explicit ID
|
||||||
|
-- list from the ratified design §3.2 / §10.2.
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
SELECT set_config(
|
||||||
|
'paliad.audit_reason',
|
||||||
|
'mig 153: proceeding_types kind discriminator (m/paliad#147)',
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ----------------------------------------------------------------
|
||||||
|
-- 1. Snapshot the pre-mig state for audit + rollback safety.
|
||||||
|
-- Mirrors precedent: sequencing_rules_pre_151/_pre_152,
|
||||||
|
-- procedural_events_pre_151.
|
||||||
|
-- ----------------------------------------------------------------
|
||||||
|
|
||||||
|
CREATE TABLE paliad.proceeding_types_pre_153 AS
|
||||||
|
SELECT * FROM paliad.proceeding_types;
|
||||||
|
|
||||||
|
COMMENT ON TABLE paliad.proceeding_types_pre_153 IS
|
||||||
|
'Snapshot of paliad.proceeding_types taken in the same TX as '
|
||||||
|
'mig 153 (kind discriminator). Audit + rollback safety per the '
|
||||||
|
'precedent set by migs 091/093/095/098/140/151/152. Drop only '
|
||||||
|
'when the kind taxonomy has held in prod for at least one '
|
||||||
|
'release cycle and no rollback is anticipated.';
|
||||||
|
|
||||||
|
-- ----------------------------------------------------------------
|
||||||
|
-- 2. Add the kind column.
|
||||||
|
-- ----------------------------------------------------------------
|
||||||
|
|
||||||
|
ALTER TABLE paliad.proceeding_types
|
||||||
|
ADD COLUMN kind text NOT NULL DEFAULT 'proceeding'
|
||||||
|
CHECK (kind IN ('proceeding', 'phase', 'side_action', 'meta'));
|
||||||
|
|
||||||
|
COMMENT ON COLUMN paliad.proceeding_types.kind IS
|
||||||
|
'Structural classification — see docs/design-proceeding-types-taxonomy-2026-05-26.md §1. '
|
||||||
|
'proceeding = self-contained matter (own filing + deadline tree); '
|
||||||
|
'phase = stage inside a primary CFI proceeding; '
|
||||||
|
'side_action = application/order inside a proceeding; '
|
||||||
|
'meta = RoP mechanics, court admin, cross-cutting remedies.';
|
||||||
|
|
||||||
|
CREATE INDEX proceeding_types_kind_active_idx
|
||||||
|
ON paliad.proceeding_types(kind, is_active)
|
||||||
|
WHERE is_active = true;
|
||||||
|
|
||||||
|
-- ----------------------------------------------------------------
|
||||||
|
-- 3. Reclassify the 23 non-primary rows.
|
||||||
|
-- IDs per ratified design §3.2 / §10.2. m's Q2 carve-out keeps
|
||||||
|
-- upc.costs.cfi (176) as kind='proceeding' (defaults to that);
|
||||||
|
-- Q3.b keeps upc.pl.cfi (188) as kind='proceeding' (defaults).
|
||||||
|
-- ----------------------------------------------------------------
|
||||||
|
|
||||||
|
-- 3.1 Phases: 4 rows (Q2 carve-out drops upc.costs.cfi from the original 5).
|
||||||
|
UPDATE paliad.proceeding_types
|
||||||
|
SET kind = 'phase'
|
||||||
|
WHERE id IN (173, 174, 175, 185);
|
||||||
|
|
||||||
|
-- 3.2 Side-actions: 10 rows (§0.4 Group C).
|
||||||
|
UPDATE paliad.proceeding_types
|
||||||
|
SET kind = 'side_action'
|
||||||
|
WHERE id IN (178, 182, 177, 184, 165, 170, 180, 181, 187, 183);
|
||||||
|
|
||||||
|
-- 3.3 Meta / cross-cutting: 9 rows (§0.4 Group D incl. upc.reestablishment.rop).
|
||||||
|
UPDATE paliad.proceeding_types
|
||||||
|
SET kind = 'meta'
|
||||||
|
WHERE id IN (162, 161, 163, 168, 164, 166, 167, 186, 169);
|
||||||
|
|
||||||
|
-- 3.4 Defensive integrity check — every reclassified ID must have been
|
||||||
|
-- reached. If the live table drifted between design (2026-05-26)
|
||||||
|
-- and apply, this raises before the trigger ships.
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
expected int := 23;
|
||||||
|
actual int;
|
||||||
|
BEGIN
|
||||||
|
SELECT COUNT(*) INTO actual
|
||||||
|
FROM paliad.proceeding_types
|
||||||
|
WHERE kind <> 'proceeding';
|
||||||
|
IF actual <> expected THEN
|
||||||
|
RAISE EXCEPTION
|
||||||
|
'[mig 153] expected % rows reclassified to non-proceeding kind, found % — '
|
||||||
|
'live IDs drifted from the design. Abort.',
|
||||||
|
expected, actual;
|
||||||
|
END IF;
|
||||||
|
RAISE NOTICE '[mig 153] reclassified % rows: 4 phase + 10 side_action + 9 meta', actual;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- ----------------------------------------------------------------
|
||||||
|
-- 4. Per m's Q9: deactivate the non-primary rows so the admin list
|
||||||
|
-- surfaces only primaries. The kind column carries the semantic
|
||||||
|
-- info; is_active controls UI visibility. Reversible — flip
|
||||||
|
-- is_active back on if a row gains corpus.
|
||||||
|
-- ----------------------------------------------------------------
|
||||||
|
|
||||||
|
UPDATE paliad.proceeding_types
|
||||||
|
SET is_active = false
|
||||||
|
WHERE kind IN ('phase', 'side_action', 'meta');
|
||||||
|
|
||||||
|
-- ----------------------------------------------------------------
|
||||||
|
-- 5. Backstop trigger on projects.proceeding_type_id (§3.3 + Q8).
|
||||||
|
-- Complements mig 088's category check; rejects any
|
||||||
|
-- INSERT/UPDATE that would bind a project to a non-proceeding
|
||||||
|
-- kind. Independent from the category trigger so each invariant
|
||||||
|
-- can be dropped in isolation.
|
||||||
|
-- ----------------------------------------------------------------
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION paliad.projects_proceeding_type_kind_check()
|
||||||
|
RETURNS trigger
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_kind text;
|
||||||
|
BEGIN
|
||||||
|
IF NEW.proceeding_type_id IS NULL THEN
|
||||||
|
RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
SELECT kind INTO v_kind
|
||||||
|
FROM paliad.proceeding_types
|
||||||
|
WHERE id = NEW.proceeding_type_id;
|
||||||
|
|
||||||
|
IF v_kind IS NULL THEN
|
||||||
|
-- FK should have caught this; defensive for any future FK relax.
|
||||||
|
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_kind <> 'proceeding' THEN
|
||||||
|
RAISE EXCEPTION
|
||||||
|
'paliad.projects.proceeding_type_id must reference a kind=''proceeding'' '
|
||||||
|
'proceeding_types row (got kind=''%''). '
|
||||||
|
'Verfahrenstyp muss ein primäres Verfahren sein (kind=''%''). '
|
||||||
|
'Phasen, Nebenanträge und RoP-Querschnittsregeln sind keine '
|
||||||
|
'wählbaren Projekt-Verfahrenstypen.',
|
||||||
|
v_kind, v_kind
|
||||||
|
USING ERRCODE = '23514';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
COMMENT ON FUNCTION paliad.projects_proceeding_type_kind_check() IS
|
||||||
|
'BEFORE INSERT/UPDATE trigger function enforcing the mig 153 '
|
||||||
|
'invariant: paliad.projects.proceeding_type_id may only '
|
||||||
|
'reference kind=''proceeding'' proceeding_types rows. NULL is '
|
||||||
|
'allowed. Complements mig 088''s category check.';
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS projects_proceeding_type_kind_check
|
||||||
|
ON paliad.projects;
|
||||||
|
|
||||||
|
CREATE TRIGGER projects_proceeding_type_kind_check
|
||||||
|
BEFORE INSERT OR UPDATE OF proceeding_type_id ON paliad.projects
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION paliad.projects_proceeding_type_kind_check();
|
||||||
|
|
||||||
|
COMMENT ON TRIGGER projects_proceeding_type_kind_check ON paliad.projects IS
|
||||||
|
'mig 153 (t-paliad-325 / m/paliad#147) runtime guard — rejects '
|
||||||
|
'any INSERT/UPDATE that would bind a project to a phase/'
|
||||||
|
'side_action/meta proceeding_types row. The Go service layer '
|
||||||
|
'also enforces this with a typed error; this trigger is the '
|
||||||
|
'defence-in-depth backstop.';
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -58,6 +58,14 @@ var (
|
|||||||
// surface this as a 400 with a bilingual friendly message; the
|
// surface this as a 400 with a bilingual friendly message; the
|
||||||
// matching DB trigger (mig 088) is the defence-in-depth backstop.
|
// matching DB trigger (mig 088) is the defence-in-depth backstop.
|
||||||
ErrInvalidProceedingTypeCategory = errors.New("proceeding_type_id must reference a fristenrechner-category proceeding_types row")
|
ErrInvalidProceedingTypeCategory = errors.New("proceeding_type_id must reference a fristenrechner-category proceeding_types row")
|
||||||
|
// ErrInvalidProceedingTypeKind signals that the caller supplied a
|
||||||
|
// proceeding_type_id pointing at a non-primary row — i.e. a
|
||||||
|
// phase/side_action/meta row, or an inactive row. Mig 153
|
||||||
|
// (t-paliad-325, design §1) carved the taxonomy so only
|
||||||
|
// kind='proceeding' AND is_active=true rows may bind to a
|
||||||
|
// project. Handlers surface this as a 400; the matching DB
|
||||||
|
// trigger (mig 153) is the defence-in-depth backstop.
|
||||||
|
ErrInvalidProceedingTypeKind = errors.New("proceeding_type_id must reference an active kind='proceeding' proceeding_types row")
|
||||||
)
|
)
|
||||||
|
|
||||||
// ProjectType values enumerated on the projects.type CHECK constraint.
|
// ProjectType values enumerated on the projects.type CHECK constraint.
|
||||||
@@ -1165,29 +1173,47 @@ func (s *ProjectService) Update(ctx context.Context, userID, id uuid.UUID, input
|
|||||||
return s.GetByID(ctx, userID, id)
|
return s.GetByID(ctx, userID, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// validateProceedingTypeCategory enforces the Phase 3 Slice 5 invariant
|
// validateProceedingTypeCategory enforces the project-binding invariants
|
||||||
// (t-paliad-186, design §3.F + m's Q2 ruling): a project may only bind
|
// on paliad.projects.proceeding_type_id:
|
||||||
// to a fristenrechner-category proceeding_types row. NULL passes
|
|
||||||
// through; the matching DB trigger (mig 088) is the defence-in-depth
|
|
||||||
// backstop should this slip somehow.
|
|
||||||
//
|
//
|
||||||
// Surfaces ErrInvalidProceedingTypeCategory so handlers can map to a
|
// 1. Phase 3 Slice 5 (t-paliad-186, design §3.F): row must be
|
||||||
// 400 with a bilingual user-facing message.
|
// category='fristenrechner'. DB-side backstop: mig 088 trigger.
|
||||||
|
// Surfaces ErrInvalidProceedingTypeCategory.
|
||||||
|
//
|
||||||
|
// 2. Mig 153 (t-paliad-325, design §1 + m's Q8): row must be
|
||||||
|
// kind='proceeding' AND is_active=true. DB-side backstop: mig 153
|
||||||
|
// trigger. Surfaces ErrInvalidProceedingTypeKind. Rejects phase /
|
||||||
|
// side_action / meta rows and any deactivated row.
|
||||||
|
//
|
||||||
|
// NULL passes through. The Go layer fires first so handlers get typed
|
||||||
|
// errors; the DB triggers catch any writer that bypasses the service.
|
||||||
func (s *ProjectService) validateProceedingTypeCategory(ctx context.Context, ptID *int) error {
|
func (s *ProjectService) validateProceedingTypeCategory(ctx context.Context, ptID *int) error {
|
||||||
if ptID == nil {
|
if ptID == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
var category sql.NullString
|
var row struct {
|
||||||
if err := s.db.GetContext(ctx, &category,
|
Category sql.NullString `db:"category"`
|
||||||
`SELECT category FROM paliad.proceeding_types WHERE id = $1`, *ptID); err != nil {
|
Kind sql.NullString `db:"kind"`
|
||||||
|
IsActive bool `db:"is_active"`
|
||||||
|
}
|
||||||
|
if err := s.db.GetContext(ctx, &row,
|
||||||
|
`SELECT category, kind, is_active FROM paliad.proceeding_types WHERE id = $1`, *ptID); err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return fmt.Errorf("%w: proceeding_type_id=%d not found", ErrInvalidInput, *ptID)
|
return fmt.Errorf("%w: proceeding_type_id=%d not found", ErrInvalidInput, *ptID)
|
||||||
}
|
}
|
||||||
return fmt.Errorf("lookup proceeding_type category: %w", err)
|
return fmt.Errorf("lookup proceeding_type: %w", err)
|
||||||
}
|
}
|
||||||
if !category.Valid || category.String != "fristenrechner" {
|
if !row.Category.Valid || row.Category.String != "fristenrechner" {
|
||||||
return fmt.Errorf("%w: proceeding_type_id=%d has category=%q",
|
return fmt.Errorf("%w: proceeding_type_id=%d has category=%q",
|
||||||
ErrInvalidProceedingTypeCategory, *ptID, category.String)
|
ErrInvalidProceedingTypeCategory, *ptID, row.Category.String)
|
||||||
|
}
|
||||||
|
if !row.Kind.Valid || row.Kind.String != "proceeding" {
|
||||||
|
return fmt.Errorf("%w: proceeding_type_id=%d has kind=%q",
|
||||||
|
ErrInvalidProceedingTypeKind, *ptID, row.Kind.String)
|
||||||
|
}
|
||||||
|
if !row.IsActive {
|
||||||
|
return fmt.Errorf("%w: proceeding_type_id=%d is inactive",
|
||||||
|
ErrInvalidProceedingTypeKind, *ptID)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -163,6 +163,162 @@ func TestProjectService_ProceedingTypeCategoryGuard(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestProjectService_ProceedingTypeKindGuard exercises the mig 153
|
||||||
|
// (t-paliad-325 / m/paliad#147) "kind='proceeding' only" invariant on
|
||||||
|
// paliad.projects.proceeding_type_id from three angles:
|
||||||
|
//
|
||||||
|
// 1. ProjectService.Create returns ErrInvalidProceedingTypeKind when
|
||||||
|
// handed an id pointing at a kind='phase' / 'side_action' / 'meta'
|
||||||
|
// row (the Go service guard fires before the DB trigger).
|
||||||
|
//
|
||||||
|
// 2. ProjectService.Create returns ErrInvalidProceedingTypeKind when
|
||||||
|
// handed an id pointing at a row with is_active=false (mig 153 §4
|
||||||
|
// deactivated all non-primary rows so this is the same set of IDs;
|
||||||
|
// the test still independently asserts the is_active branch by
|
||||||
|
// re-activating a phase row inside the test and confirming the kind
|
||||||
|
// check still fires).
|
||||||
|
//
|
||||||
|
// 3. The mig 153 backstop trigger rejects a raw INSERT that bypasses
|
||||||
|
// the Go service layer (defence-in-depth). Bypasses mig 088's
|
||||||
|
// category trigger by also picking a fristenrechner-category row.
|
||||||
|
//
|
||||||
|
// 4. Passing a kind='proceeding' active id (upc.inf.cfi) still
|
||||||
|
// succeeds — proves the new guard doesn't break the happy path.
|
||||||
|
//
|
||||||
|
// Skipped when TEST_DATABASE_URL is unset, mirroring the rest of this
|
||||||
|
// file.
|
||||||
|
func TestProjectService_ProceedingTypeKindGuard(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()
|
||||||
|
|
||||||
|
// A row that is fristenrechner-category but kind != 'proceeding'.
|
||||||
|
// Picks the first phase row by id (deterministic). Falls back to any
|
||||||
|
// non-proceeding kind if no phase rows are present (post-data-drift
|
||||||
|
// hardening).
|
||||||
|
var phaseID int
|
||||||
|
if err := pool.GetContext(ctx, &phaseID,
|
||||||
|
`SELECT id FROM paliad.proceeding_types
|
||||||
|
WHERE category = 'fristenrechner' AND kind <> 'proceeding'
|
||||||
|
ORDER BY (kind = 'phase') DESC, id
|
||||||
|
LIMIT 1`); err != nil {
|
||||||
|
t.Fatalf("look up non-proceeding kind id: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A primary id for the happy-path case + raw-INSERT control.
|
||||||
|
var proceedingID int
|
||||||
|
if err := pool.GetContext(ctx, &proceedingID,
|
||||||
|
`SELECT id FROM paliad.proceeding_types
|
||||||
|
WHERE category = 'fristenrechner' AND kind = 'proceeding'
|
||||||
|
AND is_active = true AND code = $1`,
|
||||||
|
CodeUPCInfringement); err != nil {
|
||||||
|
t.Fatalf("look up %s id: %v", CodeUPCInfringement, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
users := NewUserService(pool)
|
||||||
|
svc := NewProjectService(pool, users)
|
||||||
|
|
||||||
|
userID := uuid.New()
|
||||||
|
cleanup := func() {
|
||||||
|
pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE created_by = $1`, userID)
|
||||||
|
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, 'mig153-guard-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, 'mig153-guard-test@hlc.com', 'Mig153 Guard', 'munich', 'associate', 'de')`,
|
||||||
|
userID); err != nil {
|
||||||
|
t.Fatalf("seed paliad.users: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Non-proceeding kind id → ErrInvalidProceedingTypeKind from the
|
||||||
|
// service guard. (The row is also is_active=false post-mig-153,
|
||||||
|
// but the kind check fires first.)
|
||||||
|
_, err = svc.Create(ctx, userID, CreateProjectInput{
|
||||||
|
Type: ProjectTypeProject,
|
||||||
|
Title: "Mig 153 — non-proceeding-kind reject",
|
||||||
|
ProceedingTypeID: &phaseID,
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Error("Create with kind!=proceeding proceeding_type_id should fail, but succeeded")
|
||||||
|
} else if !errors.Is(err, ErrInvalidProceedingTypeKind) {
|
||||||
|
t.Errorf("expected ErrInvalidProceedingTypeKind, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Re-activate the phase row in a savepoint so the kind check
|
||||||
|
// still fires (proves the kind branch isn't shadowed by the
|
||||||
|
// is_active branch).
|
||||||
|
if _, err := pool.ExecContext(ctx,
|
||||||
|
`UPDATE paliad.proceeding_types SET is_active = true WHERE id = $1`, phaseID); err != nil {
|
||||||
|
t.Fatalf("re-activate phase row: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
pool.ExecContext(ctx,
|
||||||
|
`UPDATE paliad.proceeding_types SET is_active = false WHERE id = $1`, phaseID)
|
||||||
|
})
|
||||||
|
|
||||||
|
_, err = svc.Create(ctx, userID, CreateProjectInput{
|
||||||
|
Type: ProjectTypeProject,
|
||||||
|
Title: "Mig 153 — active phase row still rejects on kind",
|
||||||
|
ProceedingTypeID: &phaseID,
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Error("Create with active kind=phase row should still fail on kind check; got nil")
|
||||||
|
} else if !errors.Is(err, ErrInvalidProceedingTypeKind) {
|
||||||
|
t.Errorf("expected ErrInvalidProceedingTypeKind, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. mig 153 trigger — raw INSERT bypassing Go service must raise.
|
||||||
|
// We use the active phase row (still re-activated from step 2)
|
||||||
|
// so we don't trip mig 088's category check first. Both triggers
|
||||||
|
// are independent; mig 153's must fire on a category=fristenrechner
|
||||||
|
// kind!=proceeding row.
|
||||||
|
rawID := uuid.New()
|
||||||
|
defer pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE id = $1`, rawID)
|
||||||
|
_, err = pool.ExecContext(ctx,
|
||||||
|
`INSERT INTO paliad.projects
|
||||||
|
(id, type, parent_id, path, title, status, created_by,
|
||||||
|
proceeding_type_id, metadata, created_at, updated_at)
|
||||||
|
VALUES ($1, 'project', NULL, $1::text, 'Mig 153 — trigger bypass', 'active', $2,
|
||||||
|
$3, '{}'::jsonb, now(), now())`,
|
||||||
|
rawID, userID, phaseID)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("raw INSERT with kind!=proceeding proceeding_type_id should have raised; got nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Happy path: kind='proceeding' active id → success.
|
||||||
|
created, err := svc.Create(ctx, userID, CreateProjectInput{
|
||||||
|
Type: ProjectTypeProject,
|
||||||
|
Title: "Mig 153 — primary proceeding accept",
|
||||||
|
ProceedingTypeID: &proceedingID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Create with kind=proceeding proceeding_type_id: %v", err)
|
||||||
|
}
|
||||||
|
if created.ProceedingTypeID == nil || *created.ProceedingTypeID != proceedingID {
|
||||||
|
t.Errorf("created proceeding_type_id = %v, want %d", created.ProceedingTypeID, proceedingID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestProjectService_InstanceLevel_Roundtrip covers the Phase 3 Slice 8
|
// TestProjectService_InstanceLevel_Roundtrip covers the Phase 3 Slice 8
|
||||||
// (t-paliad-189) instance_level data path: Create + Update both accept
|
// (t-paliad-189) instance_level data path: Create + Update both accept
|
||||||
// the four allowed shapes (first / appeal / cassation / NULL) and reject
|
// the four allowed shapes (first / appeal / cassation / NULL) and reject
|
||||||
|
|||||||
Reference in New Issue
Block a user