The Litigation Builder triplet renders /api/tools/fristenrechner output
verbatim and never applied the pre-existing filterByDetailMode pass that
the legacy /tools/verfahrensablauf page uses. With the engine fix
(3c840c0 — pkg/litigationplanner default IncludeOptional=false + trigger
event semantic anchoring) already in main, optional rules are dropped
server-side but rules with an unsatisfied trigger_event_id surface as
IsConditional. Without filterByDetailMode those still rendered as
"abhängig von ..." cards on the triplet, polluting m's "naked
proceeding with options but not always displayed" mental model.
upc.inf.cfi went from 7 mandatory backbone events to 29 visible cards
(22 conditional noise — Lodging of translations, Mängelbeseitigung,
Antrag auf Verweisung, Wiedereinsetzung, ...). Live BEFORE/AFTER
captured in exports/screenshots/.
Fix layers:
- Go handler (internal/handlers/fristenrechner.go): accept
includeOptional + triggerEventAnchors from request body and
forward to services.CalcOptions. Default zero values match the
engine defaults (suppress optionals + no fabricated dates for
trigger_event_id rules), so the wire is unchanged when callers
don't set them.
- TS calc surface (frontend/src/client/views/verfahrensablauf-core.ts):
add the same two fields to CalcParams + forward in the fetch body;
surface rulesAwaitingAnchor on DeadlineResponse mirroring
Timeline.RulesAwaitingAnchor.
- Builder triplet (frontend/src/client/builder.ts hydrateTriplet):
apply filterByDetailMode(detailgrad) before renderColumnsBody, with
detailgrad sourced from the proceeding row. "selected" (default)
drops conditional + optional rules; "all_options" passes
includeOptional=true so the engine returns the optional rules the
user can opt into.
- Legacy /tools/verfahrensablauf (frontend/src/client/verfahrensablauf.ts):
pass includeOptional based on detailMode + a small hasOptionalOptIn
helper so per-rule rule:<uuid>=true deviations still surface their
optional rule even in "selected" mode (the engine has no rule:<uuid>
awareness; without the opt-in the user's pick would silently no-op).
Tests:
- frontend/src/client/views/verfahrensablauf-core.test.ts: pin the
fetch body shape - includeOptional=true and triggerEventAnchors={...}
round-trip through the request; empty/default values are omitted so
the wire stays minimal.
bun build + bun test (269 pass) + go vet + go test
./internal/handlers/... ./pkg/litigationplanner/... all clean.
(m/paliad#153)
364 lines
15 KiB
Go
364 lines
15 KiB
Go
package handlers
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"mgit.msbls.de/m/paliad/internal/models"
|
|
"mgit.msbls.de/m/paliad/internal/services"
|
|
)
|
|
|
|
// U4 (m/paliad#151) — legacy /tools/fristenrechner and
|
|
// /tools/verfahrensablauf folded into /tools/procedures via hard 301
|
|
// redirects. Per m's Q11 divergence in the design (no 2-week dual-ship
|
|
// window), bookmarks resolve via Location preservation of query params;
|
|
// no `?legacy=1` escape, no in-product affordance points back at the
|
|
// retired URLs after the merge.
|
|
|
|
func redirectToProcedures(w http.ResponseWriter, r *http.Request) {
|
|
loc := "/tools/procedures"
|
|
if raw := r.URL.RawQuery; raw != "" {
|
|
loc += "?" + raw
|
|
}
|
|
http.Redirect(w, r, loc, http.StatusMovedPermanently)
|
|
}
|
|
|
|
// handleFristenrechnerPage — kept as a registration name for the legacy
|
|
// URL so bookmarks (and the existing Sidebar history a former user may
|
|
// have cached) keep resolving. 301s to /tools/procedures.
|
|
func handleFristenrechnerPage(w http.ResponseWriter, r *http.Request) {
|
|
redirectToProcedures(w, r)
|
|
}
|
|
|
|
// handleVerfahrensablaufPage — symmetrical 301 to /tools/procedures.
|
|
func handleVerfahrensablaufPage(w http.ResponseWriter, r *http.Request) {
|
|
redirectToProcedures(w, r)
|
|
}
|
|
|
|
// Unified procedural-events tool page (m/paliad#151, design
|
|
// docs/design-unified-procedural-events-tool-2026-05-27.md). Consolidates
|
|
// Fristenrechner Mode A + Mode B + result + Verfahrensablauf into a
|
|
// single surface at /tools/procedures. No DB dependency — the page
|
|
// itself is static HTML; per-tab data flows over the existing
|
|
// /api/tools/fristenrechner/* endpoints.
|
|
func handleProceduresPage(w http.ResponseWriter, r *http.Request) {
|
|
http.ServeFile(w, r, "dist/procedures.html")
|
|
}
|
|
|
|
// POST /api/tools/fristenrechner — calculate the UI timeline for a proceeding.
|
|
//
|
|
// Phase C: routes through FristenrechnerService which pulls rules from
|
|
// paliad.deadline_rules. When DATABASE_URL is unset, returns 503; the page
|
|
// itself still renders because it's static HTML.
|
|
func handleFristenrechnerAPI(w http.ResponseWriter, r *http.Request) {
|
|
if dbSvc == nil || dbSvc.fristenrechner == nil {
|
|
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
|
|
"error": "Fristenrechner ist vorübergehend nicht verfügbar (keine Datenbank).",
|
|
})
|
|
return
|
|
}
|
|
var req struct {
|
|
ProceedingType string `json:"proceedingType"`
|
|
TriggerDate string `json:"triggerDate"`
|
|
PriorityDate string `json:"priorityDate,omitempty"`
|
|
Flags []string `json:"flags,omitempty"`
|
|
AnchorOverrides map[string]string `json:"anchorOverrides,omitempty"`
|
|
CourtID string `json:"courtId,omitempty"`
|
|
// t-paliad-265: per-event-card choices. Two parallel inputs:
|
|
// - ProjectID lets the server pull persisted choices from
|
|
// paliad.project_event_choices (project-bound /tools/fristenrechner).
|
|
// - PerCardChoices lets the unbound /tools/verfahrensablauf
|
|
// send an inline-CSV-decoded list straight off the URL
|
|
// without persisting. When both are present the inline list
|
|
// wins (what-if exploration overrides the saved state).
|
|
ProjectID string `json:"projectId,omitempty"`
|
|
PerCardChoices []services.UpsertEventChoiceInput `json:"perCardChoices,omitempty"`
|
|
// t-paliad-290 (m/paliad#122): re-surface previously-hidden
|
|
// optional cards. When true the calculator marks skipped rows
|
|
// with UIDeadline.IsHidden instead of dropping them; descendants
|
|
// stay in the result list. Default false preserves the legacy
|
|
// suppression. HiddenCount on the response is independent.
|
|
IncludeHidden bool `json:"includeHidden,omitempty"`
|
|
// Slice B1 / m/paliad#124 §18.1: narrows the unified UPC
|
|
// Berufung (upc.apl) timeline to the rule subset whose
|
|
// applies_to_target contains the requested slug. Empty = no
|
|
// filter. Valid values: endentscheidung | kostenentscheidung
|
|
// | anordnung | schadensbemessung | bucheinsicht. Unknown
|
|
// slugs are silently dropped (no filter) so a stale frontend
|
|
// chip doesn't 400 the request.
|
|
AppealTarget string `json:"appealTarget,omitempty"`
|
|
// t-paliad-348 / yoUPC#178 — surface the engine's two new
|
|
// CalcOptions axes to the HTTP boundary:
|
|
//
|
|
// IncludeOptional: when true, priority='optional' rules
|
|
// surface on the timeline. Default false matches the
|
|
// engine's default (mandatory backbone only).
|
|
// TriggerEventAnchors: per-event-code anchor dates the
|
|
// engine consults for rules carrying trigger_event_id.
|
|
// When a rule's anchor is absent the engine renders the
|
|
// rule as IsConditional rather than fabricating a date
|
|
// off the proceeding's trigger date.
|
|
IncludeOptional bool `json:"includeOptional,omitempty"`
|
|
TriggerEventAnchors map[string]string `json:"triggerEventAnchors,omitempty"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Ungültige Anfrage"})
|
|
return
|
|
}
|
|
if req.ProceedingType == "" || req.TriggerDate == "" {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "proceedingType und triggerDate sind erforderlich"})
|
|
return
|
|
}
|
|
|
|
// Fold per-card choices into the CalcOptions addendum. The inline
|
|
// PerCardChoices wins over the persisted ProjectID lookup when both
|
|
// are non-empty.
|
|
var addendum services.CalcOptionsAddendum
|
|
if len(req.PerCardChoices) > 0 {
|
|
choices := make([]models.ProjectEventChoice, 0, len(req.PerCardChoices))
|
|
for _, c := range req.PerCardChoices {
|
|
choices = append(choices, models.ProjectEventChoice{
|
|
SubmissionCode: c.SubmissionCode,
|
|
ChoiceKind: c.ChoiceKind,
|
|
ChoiceValue: c.ChoiceValue,
|
|
})
|
|
}
|
|
addendum = services.ToCalcOptionsAddendum(choices)
|
|
} else if req.ProjectID != "" && dbSvc.eventChoice != nil {
|
|
if pid, err := uuid.Parse(req.ProjectID); err == nil {
|
|
if uid, ok := requireUser(w, r); ok {
|
|
if choices, err := dbSvc.eventChoice.ListForProject(r.Context(), uid, pid); err == nil {
|
|
addendum = services.ToCalcOptionsAddendum(choices)
|
|
}
|
|
// Visibility-filtered lookup: a non-visible project
|
|
// returns ErrNotVisible from ListForProject; in that
|
|
// case we project without per-card overlays rather
|
|
// than 404 — the timeline itself is non-PII data.
|
|
}
|
|
}
|
|
}
|
|
|
|
resp, err := dbSvc.fristenrechner.Calculate(r.Context(), req.ProceedingType, req.TriggerDate, services.CalcOptions{
|
|
PriorityDateStr: req.PriorityDate,
|
|
Flags: req.Flags,
|
|
AnchorOverrides: req.AnchorOverrides,
|
|
CourtID: req.CourtID,
|
|
PerCardAppellant: addendum.PerCardAppellant,
|
|
SkipRules: addendum.SkipRules,
|
|
IncludeCCRFor: addendum.IncludeCCRFor,
|
|
IncludeHidden: req.IncludeHidden,
|
|
AppealTarget: req.AppealTarget,
|
|
IncludeOptional: req.IncludeOptional,
|
|
TriggerEventAnchors: req.TriggerEventAnchors,
|
|
})
|
|
if err != nil {
|
|
if errors.Is(err, services.ErrUnknownProceedingType) {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "unbekannter Verfahrenstyp: " + req.ProceedingType})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
// POST /api/tools/fristenrechner/calculate-rule — single-rule calc for
|
|
// the v4 (t-paliad-136 Phase B) result-card click flow.
|
|
//
|
|
// Body: { ruleId? } OR { proceedingCode, ruleLocalCode }, plus
|
|
// triggerDate (YYYY-MM-DD, required) and flags? (string array,
|
|
// optional condition_flag inputs).
|
|
//
|
|
// Returns a RuleCalculation (see services.RuleCalculation) — the rule
|
|
// metadata + computed dueDate / originalDate / adjustmentReason. Used by
|
|
// the result-card calc panel; distinct from the full-timeline endpoint
|
|
// at POST /api/tools/fristenrechner.
|
|
func handleFristenrechnerCalculateRule(w http.ResponseWriter, r *http.Request) {
|
|
if dbSvc == nil || dbSvc.fristenrechner == nil {
|
|
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
|
|
"error": "Fristenrechner ist vorübergehend nicht verfügbar (keine Datenbank).",
|
|
})
|
|
return
|
|
}
|
|
var req struct {
|
|
RuleID string `json:"ruleId"`
|
|
ProceedingCode string `json:"proceedingCode"`
|
|
RuleLocalCode string `json:"ruleLocalCode"`
|
|
TriggerDate string `json:"triggerDate"`
|
|
Flags []string `json:"flags,omitempty"`
|
|
CourtID string `json:"courtId,omitempty"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Ungültige Anfrage"})
|
|
return
|
|
}
|
|
if req.TriggerDate == "" {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "triggerDate ist erforderlich"})
|
|
return
|
|
}
|
|
if req.RuleID == "" && (req.ProceedingCode == "" || req.RuleLocalCode == "") {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{
|
|
"error": "Entweder ruleId oder (proceedingCode + ruleLocalCode) ist erforderlich",
|
|
})
|
|
return
|
|
}
|
|
|
|
resp, err := dbSvc.fristenrechner.CalculateRule(r.Context(), services.CalcRuleParams{
|
|
RuleID: req.RuleID,
|
|
ProceedingCode: req.ProceedingCode,
|
|
RuleLocalCode: req.RuleLocalCode,
|
|
TriggerDate: req.TriggerDate,
|
|
Flags: req.Flags,
|
|
CourtID: req.CourtID,
|
|
})
|
|
if err != nil {
|
|
switch {
|
|
case errors.Is(err, services.ErrUnknownRule):
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "unbekannte Regel"})
|
|
case errors.Is(err, services.ErrUnknownProceedingType):
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "unbekannter Verfahrenstyp: " + req.ProceedingCode})
|
|
default:
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
}
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
// GET /api/tools/proceeding-types — metadata list for the wizard buttons.
|
|
// Returns 503 with an empty array when DATABASE_URL is unset so the page
|
|
// still renders (buttons are server-rendered from tsx and don't depend on
|
|
// this endpoint for existence, only for dynamic list updates).
|
|
//
|
|
// Optional query params (Fristenrechner overhaul S3, m/paliad#146):
|
|
// jurisdiction - "UPC" | "DE" | "EPA" | "DPMA". Narrows the chip
|
|
// pool to one jurisdiction. Empty = any.
|
|
// kind - "proceeding" | "phase" | "side_action" | "meta".
|
|
// Narrows to one structural kind from the taxonomy
|
|
// cleanup (m/paliad#147, mig 153). Mode A passes
|
|
// "proceeding" to exclude phase / side_action / meta
|
|
// rows. Empty = any.
|
|
func handleProceedingTypes(w http.ResponseWriter, r *http.Request) {
|
|
if dbSvc == nil || dbSvc.fristenrechner == nil {
|
|
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
|
|
"error": "Verfahrenstypen vorübergehend nicht verfügbar (keine Datenbank).",
|
|
})
|
|
return
|
|
}
|
|
opts := services.ProceedingListOptions{
|
|
Jurisdiction: strings.ToUpper(strings.TrimSpace(r.URL.Query().Get("jurisdiction"))),
|
|
Kind: strings.TrimSpace(r.URL.Query().Get("kind")),
|
|
EventKind: strings.TrimSpace(r.URL.Query().Get("event_kind")),
|
|
}
|
|
types, err := dbSvc.fristenrechner.ListProceedings(r.Context(), opts)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "konnte Verfahrenstypen nicht laden"})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, types)
|
|
}
|
|
|
|
// GET /api/tools/trigger-events — list active UPC trigger events for the
|
|
// "Was kommt nach…" mode picker. Sorted alphabetically by name.
|
|
func handleTriggerEventsList(w http.ResponseWriter, r *http.Request) {
|
|
if dbSvc == nil || dbSvc.eventDeadline == nil {
|
|
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
|
|
"error": "Trigger-Ereignisse vorübergehend nicht verfügbar (keine Datenbank).",
|
|
})
|
|
return
|
|
}
|
|
events, err := dbSvc.eventDeadline.ListTriggerEvents(r.Context())
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "konnte Trigger-Ereignisse nicht laden"})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, events)
|
|
}
|
|
|
|
// POST /api/tools/event-deadlines — compute all deadlines flowing from a
|
|
// trigger event + date. Body: {"triggerEventId": <int>, "triggerDate": "YYYY-MM-DD"}.
|
|
//
|
|
// DEPRECATED (m/paliad#149 Phase 2 P4 partial, t-paliad-331). This route
|
|
// serves the 73 orphan globals (sequencing_rules with proceeding_type_id
|
|
// IS NULL, addressed only via trigger_event_id). The route is held live
|
|
// until those 73 are reparented onto real proceeding-type chains via
|
|
// /admin/procedural-events (editorial work; tracked separately).
|
|
//
|
|
// Once the orphan count hits zero, the planned final-P4 lands:
|
|
// - DROP TABLE paliad.trigger_events
|
|
// - ALTER TABLE paliad.sequencing_rules DROP COLUMN trigger_event_id
|
|
// - remove this handler + EventDeadlineService + the 5 read sites
|
|
// enumerated in the design (deadline_rule_service.go:226,
|
|
// event_deadline_service.go:79+244, event_type_service.go:40+414,
|
|
// export_service.go:1680, cmd/gen-upc-snapshot/main.go:185-202).
|
|
//
|
|
// The Deprecation + Sunset response headers below let callers see the
|
|
// signal without breaking — see RFC 8594 / RFC 9745.
|
|
func handleEventDeadlinesCalculate(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Deprecation", "true")
|
|
w.Header().Set("Link", `<https://mgit.msbls.de/m/paliad/issues/149>; rel="deprecation"; type="text/html"`)
|
|
if dbSvc == nil || dbSvc.eventDeadline == nil {
|
|
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
|
|
"error": "Fristenrechner ist vorübergehend nicht verfügbar (keine Datenbank).",
|
|
})
|
|
return
|
|
}
|
|
var req struct {
|
|
TriggerEventID int64 `json:"triggerEventId"`
|
|
TriggerDate string `json:"triggerDate"`
|
|
CourtID string `json:"courtId"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Ungültige Anfrage"})
|
|
return
|
|
}
|
|
if req.TriggerEventID <= 0 || req.TriggerDate == "" {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "triggerEventId und triggerDate sind erforderlich"})
|
|
return
|
|
}
|
|
resp, err := dbSvc.eventDeadline.Calculate(r.Context(), req.TriggerEventID, req.TriggerDate, req.CourtID)
|
|
if err != nil {
|
|
if errors.Is(err, services.ErrUnknownTriggerEvent) {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "unbekanntes Trigger-Ereignis"})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
// GET /api/tools/courts — list active courts for the Fristenrechner court
|
|
// picker. Optional ?courtType=UPC-LD filter narrows to a single tier so the
|
|
// UI can render only the courts compatible with the selected proceeding.
|
|
// Returns the deadline-computation slice (id, code, names, country, regime,
|
|
// court_type, sort_order) — NOT the full Gerichtsverzeichnis catalog. The
|
|
// rich addresses / phone / languages payload still lives at /api/courts.
|
|
func handleCourtsList(w http.ResponseWriter, r *http.Request) {
|
|
if dbSvc == nil || dbSvc.courts == nil {
|
|
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
|
|
"error": "Gerichte vorübergehend nicht verfügbar (keine Datenbank).",
|
|
})
|
|
return
|
|
}
|
|
courtType := r.URL.Query().Get("courtType")
|
|
var (
|
|
courts []services.Court
|
|
err error
|
|
)
|
|
if courtType != "" {
|
|
courts, err = dbSvc.courts.ByCourtType(courtType)
|
|
} else {
|
|
courts, err = dbSvc.courts.All()
|
|
}
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "konnte Gerichte nicht laden"})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, courts)
|
|
}
|