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=&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": , "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) }