Merge: t-paliad-186 — Fristen Phase 3 Slice 5 (projects soft-merge to fristenrechner codes only)
This commit is contained in:
@@ -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<ProceedingTypeRow[]> {
|
||||
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);
|
||||
|
||||
@@ -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')
|
||||
);
|
||||
148
internal/db/migrations/087_project_proceeding_type_remap.up.sql
Normal file
148
internal/db/migrations/087_project_proceeding_type_remap.up.sql
Normal file
@@ -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 $$;
|
||||
@@ -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();
|
||||
@@ -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.';
|
||||
@@ -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=<value>
|
||||
//
|
||||
// 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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
148
internal/services/project_service_test.go
Normal file
148
internal/services/project_service_test.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user