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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user