Merge: t-paliad-292 — Slice B3: primary_party CHECK constraint + IsValidPrimaryParty helper (mig 135 audit-first) (m/paliad#124 §18.3)
This commit is contained in:
8
internal/db/migrations/135_primary_party_check.down.sql
Normal file
8
internal/db/migrations/135_primary_party_check.down.sql
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
-- 135_primary_party_check — DOWN
|
||||||
|
--
|
||||||
|
-- Drops the CHECK constraint added in 135.up. No data revert needed
|
||||||
|
-- — the column stays text, the four-value vocab is enforced only by
|
||||||
|
-- application code thereafter.
|
||||||
|
|
||||||
|
ALTER TABLE paliad.deadline_rules
|
||||||
|
DROP CONSTRAINT IF EXISTS deadline_rules_primary_party_chk;
|
||||||
92
internal/db/migrations/135_primary_party_check.up.sql
Normal file
92
internal/db/migrations/135_primary_party_check.up.sql
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
-- 135_primary_party_check — Slice B3, m/paliad#124 §18.3
|
||||||
|
--
|
||||||
|
-- Tightens paliad.deadline_rules.primary_party from free-text to a
|
||||||
|
-- CHECK constraint over the canonical four-value vocabulary
|
||||||
|
-- (claimant / defendant / court / both). NULL stays valid for the
|
||||||
|
-- 78 cross-cutting orphan concept seeds (Wiedereinsetzung,
|
||||||
|
-- Versäumnisurteil-Einspruch, Schriftsatznachreichung,
|
||||||
|
-- Weiterbehandlung) — they have no proceeding_type_id binding so
|
||||||
|
-- they're outside the calculator's path; loosening the CHECK to
|
||||||
|
-- "IS NULL OR IN (…)" keeps them valid without backfill gymnastics.
|
||||||
|
--
|
||||||
|
-- Audit-first: the DO block RAISEs NOTICE for every non-conforming
|
||||||
|
-- row before adding the CHECK, and RAISEs EXCEPTION if any dirty
|
||||||
|
-- rows are found so the operator can decide a manual cleanup path.
|
||||||
|
-- Live audit (Supabase, 2026-05-26 §18.0) confirmed zero dirty rows
|
||||||
|
-- on the current corpus: 26 claimant + 26 defendant + 38 court +
|
||||||
|
-- 63 both + 78 NULL = 231 total, all in the canonical vocab. The
|
||||||
|
-- audit pass stays in the migration for safety against future drift
|
||||||
|
-- (e.g. a rule editor write that bypassed the application-layer
|
||||||
|
-- validation hook this slice also adds).
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
rec record;
|
||||||
|
dirty_count int := 0;
|
||||||
|
BEGIN
|
||||||
|
RAISE NOTICE '[mig 135] primary_party audit pass — non-conforming rows:';
|
||||||
|
FOR rec IN
|
||||||
|
SELECT dr.id, dr.name, dr.primary_party,
|
||||||
|
pt.code AS proceeding_code
|
||||||
|
FROM paliad.deadline_rules dr
|
||||||
|
LEFT JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
|
||||||
|
WHERE dr.is_active = true
|
||||||
|
AND dr.primary_party IS NOT NULL
|
||||||
|
AND dr.primary_party NOT IN ('claimant', 'defendant', 'court', 'both')
|
||||||
|
ORDER BY pt.code NULLS LAST, dr.name
|
||||||
|
LOOP
|
||||||
|
RAISE NOTICE '[mig 135] % % primary_party=% (rule=%)',
|
||||||
|
COALESCE(rec.proceeding_code, '<orphan>'),
|
||||||
|
rec.name,
|
||||||
|
rec.primary_party,
|
||||||
|
rec.id;
|
||||||
|
dirty_count := dirty_count + 1;
|
||||||
|
END LOOP;
|
||||||
|
IF dirty_count > 0 THEN
|
||||||
|
RAISE EXCEPTION '[mig 135] FAILED — % rule(s) carry non-canonical primary_party values. '
|
||||||
|
'Manual cleanup required: update each row to one of '
|
||||||
|
'''claimant'', ''defendant'', ''court'', ''both'', or NULL. '
|
||||||
|
'See the NOTICE lines above for the offending rows.', dirty_count;
|
||||||
|
END IF;
|
||||||
|
RAISE NOTICE '[mig 135] audit clean — proceeding with CHECK constraint';
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------
|
||||||
|
-- Add the CHECK constraint. NULL stays valid; the four canonical
|
||||||
|
-- values are the only allowed non-NULL forms.
|
||||||
|
-- ---------------------------------------------------------------
|
||||||
|
|
||||||
|
ALTER TABLE paliad.deadline_rules
|
||||||
|
ADD CONSTRAINT deadline_rules_primary_party_chk
|
||||||
|
CHECK (
|
||||||
|
primary_party IS NULL
|
||||||
|
OR primary_party IN ('claimant', 'defendant', 'court', 'both')
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON CONSTRAINT deadline_rules_primary_party_chk
|
||||||
|
ON paliad.deadline_rules IS
|
||||||
|
'Slice B3 (mig 135, m/paliad#124 §18.3) — canonical four-value '
|
||||||
|
'vocab for primary_party (claimant / defendant / court / both). '
|
||||||
|
'NULL allowed for cross-cutting orphan concept seeds (78 rows in '
|
||||||
|
'live corpus as of mig 135). See pkg/litigationplanner.PrimaryParties '
|
||||||
|
'for the in-code vocabulary.';
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------
|
||||||
|
-- Post-migration distribution check — informational NOTICE only.
|
||||||
|
-- ---------------------------------------------------------------
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
rec record;
|
||||||
|
BEGIN
|
||||||
|
RAISE NOTICE '[mig 135] post: primary_party distribution after constraint add:';
|
||||||
|
FOR rec IN
|
||||||
|
SELECT COALESCE(primary_party, '<NULL>') AS party, COUNT(*) AS n
|
||||||
|
FROM paliad.deadline_rules
|
||||||
|
WHERE is_active = true
|
||||||
|
GROUP BY primary_party
|
||||||
|
ORDER BY party
|
||||||
|
LOOP
|
||||||
|
RAISE NOTICE '[mig 135] % count=%', rec.party, rec.n;
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
@@ -15,16 +15,6 @@ import (
|
|||||||
lp "mgit.msbls.de/m/paliad/pkg/litigationplanner"
|
lp "mgit.msbls.de/m/paliad/pkg/litigationplanner"
|
||||||
)
|
)
|
||||||
|
|
||||||
// isValidPartyForLookup mirrors the four-value primary_party vocab the
|
|
||||||
// engine knows about. B2 inlines this check; B3 will add a canonical
|
|
||||||
// lp.IsValidPrimaryParty + tighten the column to a CHECK constraint.
|
|
||||||
func isValidPartyForLookup(s string) bool {
|
|
||||||
switch s {
|
|
||||||
case "claimant", "defendant", "court", "both":
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// FristenrechnerService renders the Paliad public Fristenrechner's
|
// FristenrechnerService renders the Paliad public Fristenrechner's
|
||||||
// response shape from DB-stored rules. Post-Slice-A (t-paliad-298) it
|
// response shape from DB-stored rules. Post-Slice-A (t-paliad-298) it
|
||||||
@@ -268,7 +258,7 @@ func (c *paliadCatalog) LookupEvents(ctx context.Context, axes lp.EventLookupAxe
|
|||||||
jurisdiction = ""
|
jurisdiction = ""
|
||||||
}
|
}
|
||||||
party := axes.Party
|
party := axes.Party
|
||||||
if party != "" && !isValidPartyForLookup(party) {
|
if party != "" && !lp.IsValidPrimaryParty(party) {
|
||||||
party = ""
|
party = ""
|
||||||
}
|
}
|
||||||
appealTarget := axes.AppealTarget
|
appealTarget := axes.AppealTarget
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"github.com/jmoiron/sqlx"
|
"github.com/jmoiron/sqlx"
|
||||||
|
|
||||||
"mgit.msbls.de/m/paliad/internal/models"
|
"mgit.msbls.de/m/paliad/internal/models"
|
||||||
|
lp "mgit.msbls.de/m/paliad/pkg/litigationplanner"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RuleEditorService owns the admin-only rule lifecycle for Phase 3
|
// RuleEditorService owns the admin-only rule lifecycle for Phase 3
|
||||||
@@ -148,6 +149,16 @@ func (s *RuleEditorService) Create(ctx context.Context, input CreateRuleInput, r
|
|||||||
if strings.TrimSpace(input.Priority) == "" {
|
if strings.TrimSpace(input.Priority) == "" {
|
||||||
input.Priority = "mandatory"
|
input.Priority = "mandatory"
|
||||||
}
|
}
|
||||||
|
// Slice B3 (m/paliad#124 §18.3, mig 135): canonical four-value
|
||||||
|
// primary_party vocab. Pre-validate so the user gets a
|
||||||
|
// user-friendly error before the DB CHECK fires with the raw
|
||||||
|
// constraint-violation message.
|
||||||
|
if input.PrimaryParty != nil && !lp.IsValidPrimaryParty(*input.PrimaryParty) {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"%w: primary_party=%q is not one of %v",
|
||||||
|
ErrInvalidInput, *input.PrimaryParty, lp.PrimaryParties,
|
||||||
|
)
|
||||||
|
}
|
||||||
if err := s.validateSpawnNoCycle(ctx, nil, input.SpawnProceedingTypeID, input.ProceedingTypeID); err != nil {
|
if err := s.validateSpawnNoCycle(ctx, nil, input.SpawnProceedingTypeID, input.ProceedingTypeID); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -220,6 +231,19 @@ func (s *RuleEditorService) UpdateDraft(ctx context.Context, id uuid.UUID, patch
|
|||||||
ErrInvalidLifecycleState, id, current.LifecycleState)
|
ErrInvalidLifecycleState, id, current.LifecycleState)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Slice B3 (m/paliad#124 §18.3, mig 135): pre-validate the
|
||||||
|
// patch's primary_party so the user gets a user-friendly error
|
||||||
|
// before the DB CHECK fires with the raw constraint-violation
|
||||||
|
// message. Patch field is *string — nil means "don't change",
|
||||||
|
// dereferenced empty string means "set to NULL" (handled below
|
||||||
|
// in buildPatchSets).
|
||||||
|
if patch.PrimaryParty != nil && !lp.IsValidPrimaryParty(*patch.PrimaryParty) {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"%w: primary_party=%q is not one of %v",
|
||||||
|
ErrInvalidInput, *patch.PrimaryParty, lp.PrimaryParties,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Spawn cycle guard: if the patch sets spawn_proceeding_type_id,
|
// Spawn cycle guard: if the patch sets spawn_proceeding_type_id,
|
||||||
// validate against the global graph BEFORE the UPDATE so we can
|
// validate against the global graph BEFORE the UPDATE so we can
|
||||||
// surface the cycle clearly instead of relying on a runtime
|
// surface the cycle clearly instead of relying on a runtime
|
||||||
|
|||||||
68
pkg/litigationplanner/primary_party_test.go
Normal file
68
pkg/litigationplanner/primary_party_test.go
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
package litigationplanner
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// TestIsValidPrimaryParty pins the four-value vocab + NULL-equivalent
|
||||||
|
// behaviour the rule-editor's B3 validation hook depends on. Empty
|
||||||
|
// string is "no value supplied" = valid (NULL maps to empty on the
|
||||||
|
// wire). Non-empty must match one of the four canonical values.
|
||||||
|
func TestIsValidPrimaryParty(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
in string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"", true},
|
||||||
|
{"claimant", true},
|
||||||
|
{"defendant", true},
|
||||||
|
{"court", true},
|
||||||
|
{"both", true},
|
||||||
|
{"Claimant", false}, // case-sensitive
|
||||||
|
{"clamant", false}, // typo
|
||||||
|
{"applicant", false}, // not in vocab
|
||||||
|
{"foo", false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := IsValidPrimaryParty(c.in); got != c.want {
|
||||||
|
t.Errorf("IsValidPrimaryParty(%q) = %v, want %v", c.in, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPrimaryPartiesOrder pins the canonical chip order (admin UI
|
||||||
|
// renders these as a select; reordering would break user muscle
|
||||||
|
// memory). Update both the slice + this test together if the order
|
||||||
|
// genuinely needs to change.
|
||||||
|
func TestPrimaryPartiesOrder(t *testing.T) {
|
||||||
|
want := []string{"claimant", "defendant", "court", "both"}
|
||||||
|
if len(PrimaryParties) != len(want) {
|
||||||
|
t.Fatalf("PrimaryParties has %d entries, want %d", len(PrimaryParties), len(want))
|
||||||
|
}
|
||||||
|
for i, p := range PrimaryParties {
|
||||||
|
if p != want[i] {
|
||||||
|
t.Errorf("PrimaryParties[%d] = %q, want %q", i, p, want[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIsValidAppealTarget is sibling-of: same shape, ensures the B1
|
||||||
|
// helper has the same NULL-equivalent semantic.
|
||||||
|
func TestIsValidAppealTarget(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
in string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"", true},
|
||||||
|
{"endentscheidung", true},
|
||||||
|
{"kostenentscheidung", true},
|
||||||
|
{"anordnung", true},
|
||||||
|
{"schadensbemessung", true},
|
||||||
|
{"bucheinsicht", true},
|
||||||
|
{"foo", false},
|
||||||
|
{"Endentscheidung", false}, // case-sensitive
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := IsValidAppealTarget(c.in); got != c.want {
|
||||||
|
t.Errorf("IsValidAppealTarget(%q) = %v, want %v", c.in, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -551,6 +551,50 @@ var AppealTargets = []string{
|
|||||||
AppealTargetBucheinsicht,
|
AppealTargetBucheinsicht,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PrimaryParty* are the canonical four-value vocabulary for
|
||||||
|
// paliad.deadline_rules.primary_party (Slice B3, m/paliad#124 §18.3,
|
||||||
|
// mig 135). The DB CHECK constraint enforces the same set; the
|
||||||
|
// application-layer helper IsValidPrimaryParty lets the rule editor
|
||||||
|
// surface a friendly 400 before the DB error fires.
|
||||||
|
//
|
||||||
|
// NULL is also valid in the DB (for the 78 orphan cross-cutting
|
||||||
|
// concept seeds — Wiedereinsetzung, Versäumnisurteil-Einspruch,
|
||||||
|
// Schriftsatznachreichung, Weiterbehandlung). The helper treats the
|
||||||
|
// empty string as "no value supplied" = valid; non-empty strings must
|
||||||
|
// match one of the four canonical values.
|
||||||
|
const (
|
||||||
|
PrimaryPartyClaimant = "claimant"
|
||||||
|
PrimaryPartyDefendant = "defendant"
|
||||||
|
PrimaryPartyCourt = "court"
|
||||||
|
PrimaryPartyBoth = "both"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PrimaryParties is the canonical ordered list for validation +
|
||||||
|
// admin-UI rendering. Order matches the rule-editor select; do not
|
||||||
|
// reorder without coordinating with the frontend.
|
||||||
|
var PrimaryParties = []string{
|
||||||
|
PrimaryPartyClaimant,
|
||||||
|
PrimaryPartyDefendant,
|
||||||
|
PrimaryPartyCourt,
|
||||||
|
PrimaryPartyBoth,
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsValidPrimaryParty returns true for empty (NULL-equivalent) or any
|
||||||
|
// of the four canonical values. Used by the rule-editor to validate
|
||||||
|
// writes before they hit the DB CHECK — produces a user-friendly 400
|
||||||
|
// instead of a raw constraint-violation error.
|
||||||
|
func IsValidPrimaryParty(s string) bool {
|
||||||
|
if s == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, p := range PrimaryParties {
|
||||||
|
if p == s {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// IsValidAppealTarget returns true for empty (no filter requested) or
|
// IsValidAppealTarget returns true for empty (no filter requested) or
|
||||||
// any of the five canonical slugs. The engine uses this to gate the
|
// any of the five canonical slugs. The engine uses this to gate the
|
||||||
// CalcOptions.AppealTarget filter — an unknown slug is silently
|
// CalcOptions.AppealTarget filter — an unknown slug is silently
|
||||||
|
|||||||
Reference in New Issue
Block a user