diff --git a/frontend/src/client/projects-detail.ts b/frontend/src/client/projects-detail.ts index 88c565a..5c66686 100644 --- a/frontend/src/client/projects-detail.ts +++ b/frontend/src/client/projects-detail.ts @@ -1421,10 +1421,17 @@ interface ProceedingTypeRow { let proceedingTypesCache: ProceedingTypeRow[] | null = null; +// loadProceedingTypes fetches active proceeding types for the project +// picker. Phase 3 Slice 5 (t-paliad-186) restricts project-binding to +// fristenrechner-category codes (design §3.F + m's Q2 ruling), so the +// picker only ever shows those — never the 7 legacy litigation codes +// (INF / REV / CCR / APM / APP / AMD / ZPO_CIVIL). The matching +// server-side service validation + DB trigger (mig 088) are the +// defence-in-depth backstops for any non-UI writer. async function loadProceedingTypes(): Promise { if (proceedingTypesCache) return proceedingTypesCache; try { - const resp = await fetch("/api/proceeding-types-db"); + const resp = await fetch("/api/proceeding-types-db?category=fristenrechner"); if (!resp.ok) return []; const rows = ((await resp.json()) ?? []) as ProceedingTypeRow[]; proceedingTypesCache = rows.filter((r) => r.is_active); diff --git a/internal/db/migrations/087_project_proceeding_type_remap.down.sql b/internal/db/migrations/087_project_proceeding_type_remap.down.sql new file mode 100644 index 0000000..6af0423 --- /dev/null +++ b/internal/db/migrations/087_project_proceeding_type_remap.down.sql @@ -0,0 +1,28 @@ +-- t-paliad-186 down — reverses 087_project_proceeding_type_remap.up.sql. +-- +-- "Revert" here means: NULL every project that the up-migration remapped +-- AND drop the 'proceeding_type_remap_null' project_events rows it +-- wrote. We cannot perfectly recover the litigation→fristenrechner +-- remap because the up-migration moved INF→UPC_INF (etc.) without +-- preserving the original code in a side column. Resetting to NULL is +-- the safe rollback — the operator can hand-remap a project if needed. +-- +-- Today this is a no-op on production data (0 live remaps). + +SELECT set_config( + 'paliad.audit_reason', + 'rollback 087: NULL projects.proceeding_type_id remapped by mig 087', + true); + +DELETE FROM paliad.project_events + WHERE event_type = 'proceeding_type_remap_null' + AND metadata->>'migration' = '087'; + +UPDATE paliad.projects + SET proceeding_type_id = NULL + WHERE proceeding_type_id IS NOT NULL + AND proceeding_type_id IN ( + SELECT id FROM paliad.proceeding_types + WHERE category = 'fristenrechner' + AND code IN ('UPC_INF', 'UPC_REV', 'UPC_APP') + ); diff --git a/internal/db/migrations/087_project_proceeding_type_remap.up.sql b/internal/db/migrations/087_project_proceeding_type_remap.up.sql new file mode 100644 index 0000000..5c0dec6 --- /dev/null +++ b/internal/db/migrations/087_project_proceeding_type_remap.up.sql @@ -0,0 +1,148 @@ +-- t-paliad-186 / Fristen Phase 3 Slice 5 Step F-1 — remap any project +-- still pointing at a litigation-category proceeding_types row to the +-- corresponding fristenrechner-category code (per design §3.F + m's +-- Q2 ruling: "I dont even get 'litigation corpus'"). +-- +-- Live-data reality: 11/11 projects carry proceeding_type_id IS NULL +-- today, so this migration is effectively a no-op on the production +-- corpus. It still ships defensively for any future test / staging / +-- imported data that might land with a litigation-category id before +-- the CHECK trigger (mig 088) catches the next write. +-- +-- Mapping (cross-checked against the live paliad.proceeding_types +-- catalog — 19 fristenrechner codes, 7 litigation codes): +-- +-- INF → UPC_INF (UPC infringement, canonical reading) +-- REV → UPC_REV (UPC revocation) +-- APP → UPC_APP (UPC appeal) +-- CCR → NULL (no UPC_CCR in the fristenrechner catalog +-- — flag for legal review per design §3.F) +-- APM → NULL (no UPC_APM — flag for legal review) +-- AMD → NULL (no UPC_AMD — flag for legal review) +-- ZPO_CIVIL → NULL (no fristenrechner analogue, design §3.F: +-- "litigation codes stay but become unused +-- for project-binding") +-- +-- Each NULL-remap leaves a paliad.project_events row with a +-- 'proceeding_type_remap_null' event so legal review can spot the +-- project + decide whether to pick a hand-mapped fristenrechner code. +-- Today no live project hits this branch — the events table stays +-- clean — but the audit hook is there for the day a litigation-coded +-- project lands. +-- +-- Idempotent: only rows still pointing at a litigation-category code +-- are touched. Re-running on a clean target is a no-op. +-- +-- Hard assertion at end: no paliad.projects row points at a +-- non-fristenrechner-category proceeding_types row post-mig. RAISE +-- EXCEPTION if violated — fails the migration loudly rather than +-- relying on mig 088's runtime trigger to catch the next write. +-- +-- Audit-reason wrapper: required by the mig 079 trigger when this +-- migration UPDATEs deadline_rules tangentially (it doesn't, but +-- set_config is harmless if no audited row mutates). + +SELECT set_config( + 'paliad.audit_reason', + 'mig 087: remap projects.proceeding_type_id from litigation→fristenrechner per design §3.F + Q2', + true); + +-- ============================================================================ +-- 1. Remap rows that point at litigation codes with a known UPC analogue. +-- ============================================================================ + +UPDATE paliad.projects p + SET proceeding_type_id = pt_new.id + FROM paliad.proceeding_types pt_old + JOIN paliad.proceeding_types pt_new + ON pt_new.code = CASE pt_old.code + WHEN 'INF' THEN 'UPC_INF' + WHEN 'REV' THEN 'UPC_REV' + WHEN 'APP' THEN 'UPC_APP' + END + AND pt_new.is_active = true + AND pt_new.category = 'fristenrechner' + WHERE p.proceeding_type_id = pt_old.id + AND pt_old.category = 'litigation' + AND pt_old.code IN ('INF', 'REV', 'APP'); + +-- ============================================================================ +-- 2. NULL-remap rows pointing at litigation codes with no fristenrechner +-- analogue. Record a paliad.project_events row so legal review can +-- follow up. +-- ============================================================================ + +-- Capture the projects we're about to NULL-remap into a temp table so +-- we can both UPDATE and INSERT events from the same set (without a +-- second SELECT that might race with the UPDATE). + +CREATE TEMP TABLE _mig_087_null_remaps ON COMMIT DROP AS +SELECT p.id AS project_id, + p.created_by AS actor, + pt_old.code AS old_code + FROM paliad.projects p + JOIN paliad.proceeding_types pt_old ON pt_old.id = p.proceeding_type_id + WHERE pt_old.category = 'litigation' + AND pt_old.code IN ('CCR', 'APM', 'AMD', 'ZPO_CIVIL'); + +UPDATE paliad.projects p + SET proceeding_type_id = NULL + FROM _mig_087_null_remaps r + WHERE p.id = r.project_id; + +INSERT INTO paliad.project_events + (id, project_id, event_type, title, description, event_date, created_by, metadata, created_at, updated_at) +SELECT gen_random_uuid(), + r.project_id, + 'proceeding_type_remap_null', + 'Verfahrenstyp zurückgesetzt (Soft-Merge Phase 3)', + 'proceeding_type_id wurde auf NULL gesetzt — ' + || r.old_code + || ' hat kein Fristenrechner-Pendant. Bitte manuell einen passenden Code wählen.', + now(), + r.actor, + jsonb_build_object( + 'migration', '087', + 'old_code', r.old_code, + 'reason', 'project soft-merge: no fristenrechner analogue' + ), + now(), + now() + FROM _mig_087_null_remaps r; + +-- ============================================================================ +-- 3. Hard assertion: every non-NULL proceeding_type_id on projects now +-- references a fristenrechner-category row. +-- ============================================================================ + +DO $$ +DECLARE + n_total int; + n_null int; + n_fristen int; + n_non_fristen int; +BEGIN + SELECT count(*) INTO n_total FROM paliad.projects; + SELECT count(*) FILTER (WHERE proceeding_type_id IS NULL) + INTO n_null FROM paliad.projects; + SELECT count(*) + INTO n_fristen + FROM paliad.projects p + JOIN paliad.proceeding_types pt ON pt.id = p.proceeding_type_id + WHERE pt.category = 'fristenrechner'; + SELECT count(*) + INTO n_non_fristen + FROM paliad.projects p + JOIN paliad.proceeding_types pt ON pt.id = p.proceeding_type_id + WHERE pt.category <> 'fristenrechner'; + + RAISE NOTICE 'mig 087: projects total=%, NULL=%, fristenrechner=%, other=%', + n_total, n_null, n_fristen, n_non_fristen; + + IF n_non_fristen > 0 THEN + RAISE EXCEPTION 'mig 087: % projects still point at non-fristenrechner-category ' + 'proceeding_type_ids — soft-merge incomplete. Investigate ' + 'and either extend the remap or add a hand-mapped code.', + n_non_fristen; + END IF; +END $$; 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.'; diff --git a/internal/handlers/deadline_rules_db.go b/internal/handlers/deadline_rules_db.go index 7838277..0aa20ae 100644 --- a/internal/handlers/deadline_rules_db.go +++ b/internal/handlers/deadline_rules_db.go @@ -34,16 +34,23 @@ func handleListDeadlineRules(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, rules) } -// GET /api/proceeding-types-db +// GET /api/proceeding-types-db?category= +// +// Lists active proceeding types from the DB. Optional `category` query +// param filters the result set (e.g. ?category=fristenrechner is the +// shape the project-create / project-edit pickers use after Phase 3 +// Slice 5 — design §3.F + m's Q2 ruling restricts project-binding to +// fristenrechner-category codes). Empty / missing param returns every +// active row. // -// Lists active proceeding types from the DB. // (Distinct route name from the existing in-memory /api/tools/proceeding-types // endpoint to avoid path conflicts during the Phase B → Phase C transition.) func handleListProceedingTypesDB(w http.ResponseWriter, r *http.Request) { if !requireDB(w) { return } - types, err := dbSvc.rules.ListProceedingTypes(r.Context()) + category := r.URL.Query().Get("category") + types, err := dbSvc.rules.ListProceedingTypesByCategory(r.Context(), category) if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to list proceeding types"}) return diff --git a/internal/handlers/projects.go b/internal/handlers/projects.go index 453f849..d3e9c60 100644 --- a/internal/handlers/projects.go +++ b/internal/handlers/projects.go @@ -90,6 +90,13 @@ func writeServiceError(w http.ResponseWriter, err error) { writeJSON(w, http.StatusForbidden, map[string]string{"error": err.Error()}) case errors.Is(err, services.ErrInvalidInput): writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + case errors.Is(err, services.ErrInvalidProceedingTypeCategory): + // Phase 3 Slice 5 (t-paliad-186). Bilingual user-facing message + // matches what the project-form copy expects so the toast reads + // naturally without an i18n round-trip in the handler. + writeJSON(w, http.StatusBadRequest, map[string]string{ + "error": "Verfahrenstyp muss ein Fristenrechner-Typ sein / proceeding type must be a Fristenrechner type", + }) case errors.Is(err, services.ErrEventTypeSlugTaken): writeJSON(w, http.StatusConflict, map[string]string{"error": err.Error()}) default: diff --git a/internal/services/deadline_rule_service.go b/internal/services/deadline_rule_service.go index 8344027..1f90422 100644 --- a/internal/services/deadline_rule_service.go +++ b/internal/services/deadline_rule_service.go @@ -237,13 +237,36 @@ func (s *DeadlineRuleService) ListByTriggerEvent(ctx context.Context, triggerEve // ListProceedingTypes returns active proceeding types ordered by sort_order. func (s *DeadlineRuleService) ListProceedingTypes(ctx context.Context) ([]models.ProceedingType, error) { + return s.ListProceedingTypesByCategory(ctx, "") +} + +// ListProceedingTypesByCategory returns active proceeding types +// ordered by sort_order, optionally filtered to a single category. An +// empty category returns every active row (preserves the legacy +// ListProceedingTypes behaviour). +// +// Phase 3 Slice 5 (t-paliad-186): the project-create / project-edit +// pickers pass category='fristenrechner' so users never see retired +// litigation codes when binding a project to a proceeding (design §3.F). +func (s *DeadlineRuleService) ListProceedingTypesByCategory(ctx context.Context, category string) ([]models.ProceedingType, error) { var types []models.ProceedingType + if category == "" { + if err := s.db.SelectContext(ctx, &types, + `SELECT `+proceedingTypeColumns+` + FROM paliad.proceeding_types + WHERE is_active = true + ORDER BY sort_order`); err != nil { + return nil, fmt.Errorf("list proceeding types: %w", err) + } + return types, nil + } if err := s.db.SelectContext(ctx, &types, `SELECT `+proceedingTypeColumns+` FROM paliad.proceeding_types WHERE is_active = true - ORDER BY sort_order`); err != nil { - return nil, fmt.Errorf("list proceeding types: %w", err) + AND category = $1 + ORDER BY sort_order`, category); err != nil { + return nil, fmt.Errorf("list proceeding types by category %q: %w", category, err) } return types, nil } diff --git a/internal/services/project_service.go b/internal/services/project_service.go index 6cc7f25..78b6ac6 100644 --- a/internal/services/project_service.go +++ b/internal/services/project_service.go @@ -44,6 +44,13 @@ var ( ErrForbidden = errors.New("forbidden") // ErrInvalidInput signals a bad request (empty required field etc.). ErrInvalidInput = errors.New("invalid input") + // ErrInvalidProceedingTypeCategory signals that the caller supplied + // a proceeding_type_id pointing at a non-fristenrechner-category row. + // Phase 3 Slice 5 soft-merge (t-paliad-186, design §3.F): only + // fristenrechner-category codes may bind to a project. Handlers + // surface this as a 400 with a bilingual friendly message; the + // 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") ) // ProjectType values enumerated on the projects.type CHECK constraint. @@ -816,6 +823,9 @@ func (s *ProjectService) Create(ctx context.Context, userID uuid.UUID, input Cre if err := validateProjectStatus(status); err != nil { return nil, err } + if err := s.validateProceedingTypeCategory(ctx, input.ProceedingTypeID); err != nil { + return nil, err + } tx, err := s.db.BeginTxx(ctx, nil) if err != nil { @@ -982,6 +992,9 @@ func (s *ProjectService) Update(ctx context.Context, userID, id uuid.UUID, input appendSetSkippable("case_number", *input.CaseNumber) } if input.ProceedingTypeID != nil { + if err := s.validateProceedingTypeCategory(ctx, input.ProceedingTypeID); err != nil { + return nil, err + } appendSetSkippable("proceeding_type_id", *input.ProceedingTypeID) } if input.OurSide != nil { @@ -1067,6 +1080,33 @@ func (s *ProjectService) Update(ctx context.Context, userID, id uuid.UUID, input return s.GetByID(ctx, userID, id) } +// validateProceedingTypeCategory enforces the Phase 3 Slice 5 invariant +// (t-paliad-186, design §3.F + m's Q2 ruling): a project may only bind +// 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 +// 400 with a bilingual user-facing message. +func (s *ProjectService) validateProceedingTypeCategory(ctx context.Context, ptID *int) error { + if ptID == nil { + return nil + } + var category sql.NullString + if err := s.db.GetContext(ctx, &category, + `SELECT category FROM paliad.proceeding_types WHERE id = $1`, *ptID); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("%w: proceeding_type_id=%d not found", ErrInvalidInput, *ptID) + } + return fmt.Errorf("lookup proceeding_type category: %w", err) + } + if !category.Valid || category.String != "fristenrechner" { + return fmt.Errorf("%w: proceeding_type_id=%d has category=%q", + ErrInvalidProceedingTypeCategory, *ptID, category.String) + } + return nil +} + // Delete archives the Project (soft-delete, status='archived'). Partner/admin only. // Hard-delete cascades through FK; we prefer archival for audit. func (s *ProjectService) Delete(ctx context.Context, userID, id uuid.UUID) error { diff --git a/internal/services/project_service_test.go b/internal/services/project_service_test.go new file mode 100644 index 0000000..db1c44c --- /dev/null +++ b/internal/services/project_service_test.go @@ -0,0 +1,148 @@ +package services + +import ( + "context" + "errors" + "os" + "testing" + + "github.com/google/uuid" + "github.com/jmoiron/sqlx" + _ "github.com/lib/pq" + + "mgit.msbls.de/m/paliad/internal/db" +) + +// TestProjectService_ProceedingTypeCategoryGuard exercises the Phase 3 +// Slice 5 (t-paliad-186) "fristenrechner-category only" invariant on +// paliad.projects.proceeding_type_id from three angles: +// +// 1. Migration smoke: post-mig 087, no project points at a +// non-fristenrechner-category proceeding_types row. +// +// 2. ProjectService.Create returns ErrInvalidProceedingTypeCategory +// when handed a litigation-category id. The server-side service +// guard fires BEFORE the DB write hits the trigger from mig 088. +// +// 3. The mig 088 trigger rejects a raw INSERT that bypasses the Go +// service layer (defence-in-depth). A litigation-category id +// INSERT via plain SQL must raise EXCEPTION. +// +// 4. Passing a fristenrechner-category id (UPC_INF) succeeds. +// +// Skipped when TEST_DATABASE_URL is unset, mirroring audit_service_test.go. +func TestProjectService_ProceedingTypeCategoryGuard(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() + + // ----------------------------------------------------------------- + // 1. Migration smoke — no project points at a litigation-category code. + // ----------------------------------------------------------------- + var leaked int + if err := pool.GetContext(ctx, &leaked, ` + SELECT count(*) + FROM paliad.projects p + JOIN paliad.proceeding_types pt ON pt.id = p.proceeding_type_id + WHERE pt.category <> 'fristenrechner'`); err != nil { + t.Fatalf("count leaked refs: %v", err) + } + if leaked != 0 { + t.Errorf("%d projects still reference non-fristenrechner proceeding_types — mig 087 incomplete", leaked) + } + + // ----------------------------------------------------------------- + // 2 + 4. ProjectService.Create guard — typed error on litigation id, + // success on fristenrechner id. + // ----------------------------------------------------------------- + var litigationID int + if err := pool.GetContext(ctx, &litigationID, + `SELECT id FROM paliad.proceeding_types + WHERE category = 'litigation' AND code = 'INF' AND is_active = true`); err != nil { + t.Fatalf("look up INF id: %v", err) + } + var fristenrechnerID int + if err := pool.GetContext(ctx, &fristenrechnerID, + `SELECT id FROM paliad.proceeding_types + WHERE category = 'fristenrechner' AND code = 'UPC_INF' AND is_active = true`); err != nil { + t.Fatalf("look up UPC_INF id: %v", err) + } + + users := NewUserService(pool) + svc := NewProjectService(pool, users) + + // Seed a user so Create has a creator with a paliad.users row. + 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, 'slice5-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, 'slice5-guard-test@hlc.com', 'Slice5 Guard', 'munich', 'associate', 'de')`, + userID); err != nil { + t.Fatalf("seed paliad.users: %v", err) + } + + // 2. Litigation-category id → ErrInvalidProceedingTypeCategory. + _, err = svc.Create(ctx, userID, CreateProjectInput{ + Type: ProjectTypeProject, + Title: "Slice 5 — litigation-id reject", + ProceedingTypeID: &litigationID, + }) + if err == nil { + t.Error("Create with litigation-category proceeding_type_id should fail, but succeeded") + } else if !errors.Is(err, ErrInvalidProceedingTypeCategory) { + t.Errorf("expected ErrInvalidProceedingTypeCategory, got %v", err) + } + + // 4. Fristenrechner-category id → success. + created, err := svc.Create(ctx, userID, CreateProjectInput{ + Type: ProjectTypeProject, + Title: "Slice 5 — fristenrechner-id accept", + ProceedingTypeID: &fristenrechnerID, + }) + if err != nil { + t.Fatalf("Create with fristenrechner-category proceeding_type_id: %v", err) + } + if created.ProceedingTypeID == nil || *created.ProceedingTypeID != fristenrechnerID { + t.Errorf("created project proceeding_type_id = %v, want %d", created.ProceedingTypeID, fristenrechnerID) + } + + // ----------------------------------------------------------------- + // 3. mig 088 trigger — raw INSERT bypassing Go service must raise. + // ----------------------------------------------------------------- + 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, 'Slice 5 — trigger bypass', 'active', $2, + $3, '{}'::jsonb, now(), now())`, + rawID, userID, litigationID) + if err == nil { + t.Error("raw INSERT with litigation-category proceeding_type_id should have raised; got nil") + } +}