Mode B "🧭 Geführt" — the guided 3-5 row wizard defined in
docs/design-fristenrechner-overhaul-2026-05-26.md §3.2. Lands the
user on a single procedural_event (the trigger), then transitions
to the shared §4 result view.
Frontend:
* `fristenrechner-wizard.ts` — row stack with R1..R5:
R1 Was ist passiert? (event_kind, always asked)
R2 Vor welchem Gericht? (jurisdiction, skip if R1 narrows)
R3 In welchem Verfahren? (proceeding_type, auto-skip when
narrowed pool has 1 option)
R4 Welches Schriftstück? (procedural_event, landing)
R5 Welche Seite vertreten Sie? (party, only when follow-ups
differ by primary_party)
Row badges per §11.Q3: R1+R2 = Filter, R3+R4+R5 = Qualifier.
R5 has NO "Beide" option per §11.Q8 — Mode B is the file-mode
where perspective is a qualifier.
* Project prefill — derives R3 + R2 jurisdiction from
project.proceeding_type, R5 from project.our_side. Annotates
pre-filled rows with "aus Akte" tag and implicit rows with
"implizit" tag per §11.Q10 ("erhalten" annotation when a pick is
carried across an upstream change).
* R4-to-result transition — after R4 the wizard fetches /follow-
ups (no dates) to inspect primary_party variance. If both
claimant and defendant rules exist AND R5 isn't already set,
swaps the loading row for the R5 chip picker. Otherwise jumps
straight to mountResultView.
* URL state — `?mode=wizard&kind=…&forum=…&pt=…&r4=…&party=…`
keeps deep-link / back-nav consistent (the launchResult step
sets `event=` so the result view picks up).
* `fristenrechner-result.ts` mountModeShell now dispatches the
"wizard" tab to the wizard module (was a coming-soon
placeholder).
* 18 i18n keys added (DE + EN parity), 145-line CSS block for the
wizard row stack with Filter / Qualifier badge styling and
"aus Akte" annotation chip.
Backend:
* `ProceedingListOptions.EventKind` adds an EXISTS subquery
filter on `paliad.sequencing_rules` ⨯ `paliad.procedural_events`
so Mode B R3 chips only show proceedings whose event roster
contains at least one event of the requested kind (design
§6.3). Endpoint param: `event_kind=` on
/api/tools/proceeding-types.
Test updates:
* `TestListProceedings` switched from SKIP-when-column-missing to
asserting the live filter — mig 153 has landed, `kind` column
is in place. New subtests: kind=proceeding includes
upc.inf.cfi and excludes the phase row upc.cfi.interim;
event_kind=filing narrows to proceedings with filing events.
* `fristenrechner-wizard.test.ts` covers
`followUpsDifferByParty` — the R5 trigger predicate. 7 cases:
asymmetric → true; uniform / both / court / empty → false.
Verified — bun build clean (2971 i18n keys), 256 frontend tests
pass (incl. 7 new), go build + vet clean, live-DB
TestListProceedings passes all 6 subtests against mig 153 data.
317 lines
13 KiB
Go
317 lines
13 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"
|
|
)
|
|
|
|
// Fristenrechner page handler: serves the static HTML. No DB dependency.
|
|
//
|
|
// Back-compat: the pre-split sidebar entry for "Verfahrensablauf" pointed at
|
|
// /tools/fristenrechner?path=a. After the t-paliad-179 split, that landing is
|
|
// owned by /tools/verfahrensablauf. A naked ?path=a (no Akte context — i.e.
|
|
// no ?project=) is the bookmarked-legacy-entry case → 302 to the new route.
|
|
// ?project=<uuid>&path=a is the Akte-mode internal wizard pathway and stays
|
|
// on /tools/fristenrechner so the wizard state survives a refresh.
|
|
func handleFristenrechnerPage(w http.ResponseWriter, r *http.Request) {
|
|
q := r.URL.Query()
|
|
if q.Get("path") == "a" && q.Get("project") == "" {
|
|
http.Redirect(w, r, "/tools/verfahrensablauf", http.StatusFound)
|
|
return
|
|
}
|
|
http.ServeFile(w, r, "dist/fristenrechner.html")
|
|
}
|
|
|
|
// Verfahrensablauf page handler (t-paliad-179 Slice 1): the dedicated
|
|
// abstract-browse surface for procedural shape. No DB dependency — the page
|
|
// shell is static HTML; the calculator API still drives the timeline render.
|
|
func handleVerfahrensablaufPage(w http.ResponseWriter, r *http.Request) {
|
|
http.ServeFile(w, r, "dist/verfahrensablauf.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"`
|
|
}
|
|
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,
|
|
})
|
|
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"}.
|
|
func handleEventDeadlinesCalculate(w http.ResponseWriter, r *http.Request) {
|
|
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)
|
|
}
|