feat(t-paliad-186): service guard + ?category filter

Phase 3 Slice 5 Go-side: ErrInvalidProceedingTypeCategory typed
error + service-layer validation + handler-level mapping +
listing-side filter.

  - services.ErrInvalidProceedingTypeCategory: typed error so
    handlers can map to a 400 with a bilingual user-facing message
    distinct from generic ErrInvalidInput.

  - ProjectService.validateProceedingTypeCategory: looks up the
    referenced proceeding_types.category and rejects with the typed
    error if it's not 'fristenrechner'. Called from both Create and
    Update before any DB write.

  - DeadlineRuleService.ListProceedingTypesByCategory: extends the
    existing ListProceedingTypes with an optional category filter.
    Empty category passes through (legacy callers unaffected).

  - GET /api/proceeding-types-db?category=<value>: handler reads the
    query param and forwards it to the service. The project-create
    / project-edit pickers pass 'fristenrechner' so users never see
    retired litigation codes.

  - writeServiceError: maps ErrInvalidProceedingTypeCategory to
    HTTP 400 with a bilingual message ("Verfahrenstyp muss ein
    Fristenrechner-Typ sein / proceeding type must be a
    Fristenrechner type"). Distinct from generic ErrInvalidInput so
    the frontend can show a more helpful hint.

Defence-in-depth chain: frontend picker filter → service-layer
validation → DB trigger (mig 088). Each backstops the next.
This commit is contained in:
mAi
2026-05-15 01:01:28 +02:00
parent 275cbd5e51
commit 5b81f2159e
4 changed files with 82 additions and 5 deletions

View File

@@ -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

View File

@@ -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:

View File

@@ -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
}

View File

@@ -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 {