feat(litigationplanner): primary_party CHECK constraint + IsValidPrimaryParty helper (Slice B3, m/paliad#124 §18.3)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled

Tightens paliad.deadline_rules.primary_party from free-text to a CHECK
constraint over the canonical four-value vocab (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.

Migration 135 (audit-first):
  - DO block RAISEs NOTICE for every non-conforming row + RAISEs
    EXCEPTION if any dirty rows exist (manual cleanup required).
    Live audit (Supabase, 2026-05-26 §18.0) confirmed zero dirty rows
    on the current corpus; the audit pass stays in the migration as
    safety against future drift.
  - ALTER TABLE … ADD CONSTRAINT deadline_rules_primary_party_chk
    CHECK (primary_party IS NULL OR primary_party IN
           ('claimant', 'defendant', 'court', 'both'))
  - Post-migration distribution NOTICE so the operator sees the
    final per-value count.
  - Down = DROP CONSTRAINT. No data revert needed.

Package additions (pkg/litigationplanner):
  - PrimaryParty* constants (PrimaryPartyClaimant / Defendant / Court
    / Both) + PrimaryParties[] ordered list + IsValidPrimaryParty(s)
    predicate. Empty string is "no value supplied" = valid (NULL maps
    to empty on the wire); non-empty must match one of the four
    canonical values.
  - Sibling unit tests (primary_party_test.go) pin the four-value
    vocab + the chip order + IsValidAppealTarget's matching shape.

Rule-editor validation hook (rule_editor_service.go):
  - Create() validates input.PrimaryParty before INSERT.
  - UpdateDraft() validates patch.PrimaryParty before UPDATE.
  - Both surface a user-friendly 400 with the canonical vocab listed
    instead of leaking the raw PG CHECK constraint-violation message.
  - Uses errors.Is(err, ErrInvalidInput) so handler 400 routing
    continues to work.

services/fristenrechner.go cleanup:
  - The B2-inlined isValidPartyForLookup helper is replaced with the
    canonical lp.IsValidPrimaryParty. No behaviour change.

No frontend changes — the rule-editor's primary_party UI already
constrains to the four values via a select; the validation hook is
defense-in-depth.

Audit:
  - go build + go test (incl. new lp unit tests) all green
  - Pre-migration audit confirmed: 26 claimant + 26 defendant + 38
    court + 63 both + 78 NULL = 231 total, all in canonical vocab
  - event_categories.party (text[] array, narrower semantic) is
    NOT touched in this migration per the design doc's
    "out of scope, separate follow-up" decision
This commit is contained in:
mAi
2026-05-26 13:58:33 +02:00
parent db8e8ba6fd
commit 989941c648
6 changed files with 237 additions and 11 deletions

View 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)
}
}
}