From 7ea415145f5247df7e01706d48bd70b8be106a2f Mon Sep 17 00:00:00 2001 From: mAi Date: Tue, 26 May 2026 22:01:10 +0200 Subject: [PATCH] =?UTF-8?q?feat(fristenrechner):=20Slice=20S1=20=E2=80=94?= =?UTF-8?q?=20backend=20=3Fkind=3Devents=20+=20/follow-ups=20(m/paliad#146?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two additive endpoints behind the Fristenrechner overhaul (design §6.1 + §6.2 in docs/design-fristenrechner-overhaul-2026-05-26.md): 1. GET /api/tools/fristenrechner/search?kind=events — returns procedural_events rows directly (not aggregated concept-cards), one hit per (event × proceeding_type) tuple. Trigram-ranked against name / name_en / code. Filters: jurisdiction, proc, event_kind, party. Powers Mode A's result list and Mode B's R4 landing chips. Default search shape unchanged. 2. GET /api/tools/fristenrechner/follow-ups?event=...&trigger_date=... — given a trigger event (by code or uuid) + date, returns the immediate follow-up sequencing rules with computed due dates via litigationplanner.CalculateRule. Each row carries priority / primary_party / is_court_set / is_spawn / has_condition / legal source / spawn target so the result view can group into Mandatory / Recommended / Optional / Conditional with the SPAWNED badge. party=claimant|defendant filters keep "both" rules visible. No schema changes — unified sequencing_rules already has every column needed. Live-DB tests cover the SoC follow-up shape, party narrowing, jurisdiction + event_kind filters, and the unknown- event sentinel. --- internal/handlers/fristenrechner_followups.go | 65 +++ internal/handlers/fristenrechner_search.go | 37 ++ internal/handlers/handlers.go | 1 + internal/services/fristenrechner_followups.go | 404 ++++++++++++++++++ .../services/fristenrechner_followups_test.go | 205 +++++++++ .../services/fristenrechner_search_events.go | 257 +++++++++++ 6 files changed, 969 insertions(+) create mode 100644 internal/handlers/fristenrechner_followups.go create mode 100644 internal/services/fristenrechner_followups.go create mode 100644 internal/services/fristenrechner_followups_test.go create mode 100644 internal/services/fristenrechner_search_events.go diff --git a/internal/handlers/fristenrechner_followups.go b/internal/handlers/fristenrechner_followups.go new file mode 100644 index 0000000..6856a78 --- /dev/null +++ b/internal/handlers/fristenrechner_followups.go @@ -0,0 +1,65 @@ +package handlers + +import ( + "errors" + "net/http" + "time" + + "mgit.msbls.de/m/paliad/internal/services" +) + +// GET /api/tools/fristenrechner/follow-ups — given a trigger event and +// a trigger date, return the immediate follow-up sequencing rules with +// their computed due dates (Fristenrechner overhaul S1, design §6.2). +// +// Query params: +// event - procedural_events.code OR procedural_events.id +// (uuid) OR sequencing_rules.id (uuid). Required. +// trigger_date - YYYY-MM-DD. Defaults to today when omitted, so the +// frontend can show a result preview before the user +// commits a date. +// party - "claimant" | "defendant" | "court" | "both". +// Optional; narrows follow-ups by primary_party +// (claimant/defendant filters keep "both" rules +// visible — they're bilateral procedural moves). +// court_id - paliad.courts.id (uuid); selects the holiday +// calendar for date adjustment. Optional. +func handleFristenrechnerFollowUps(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 + } + q := r.URL.Query() + eventRef := q.Get("event") + if eventRef == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{ + "error": "event ist erforderlich (procedural_events.code oder id)", + }) + return + } + triggerDate := q.Get("trigger_date") + if triggerDate == "" { + triggerDate = time.Now().Format("2006-01-02") + } + + resp, err := dbSvc.fristenrechner.LookupFollowUps( + r.Context(), + eventRef, + triggerDate, + q.Get("party"), + q.Get("court_id"), + ) + if err != nil { + if errors.Is(err, services.ErrUnknownProceduralEvent) { + writeJSON(w, http.StatusNotFound, map[string]string{ + "error": "Unbekanntes Ereignis: " + eventRef, + }) + return + } + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, resp) +} diff --git a/internal/handlers/fristenrechner_search.go b/internal/handlers/fristenrechner_search.go index 90d7f56..95cc3af 100644 --- a/internal/handlers/fristenrechner_search.go +++ b/internal/handlers/fristenrechner_search.go @@ -32,6 +32,10 @@ import ( // dpma). Trigger pills bypass this filter. // limit - max cards (default 12, max 30; in browse // modes default 200, max 500) +// kind - "events" switches to the events-shape +// response (Fristenrechner overhaul S1, +// design §6.1). The default concept-card +// shape is unchanged when kind is empty. // // Returns an empty cards array (not 400) when q is empty — that lets // the frontend boot the search input without a server round-trip. @@ -42,6 +46,10 @@ func handleFristenrechnerSearch(w http.ResponseWriter, r *http.Request) { }) return } + if r.URL.Query().Get("kind") == "events" { + handleFristenrechnerSearchEvents(w, r) + return + } q := r.URL.Query().Get("q") opts := services.SearchOptions{ Party: r.URL.Query().Get("party"), @@ -60,6 +68,35 @@ func handleFristenrechnerSearch(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, resp) } +// handleFristenrechnerSearchEvents serves the ?kind=events shape of +// /api/tools/fristenrechner/search (overhaul S1, design §6.1). Returns +// one hit per (procedural_event × proceeding_type) tuple, with a +// follow-up count and a trigram similarity score. +// +// Query params (additive to the legacy search params): +// q - free-text search against name / name_en / code +// jurisdiction - "UPC" | "DE" | "EPA" | "DPMA" +// proc - proceeding_type code +// event_kind - "filing" | "hearing" | "decision" | "order" +// party - primary_party of the anchor rule +// limit - max hits (default 50, max 200) +func handleFristenrechnerSearchEvents(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query().Get("q") + opts := services.EventSearchOptions{ + Jurisdiction: strings.ToUpper(strings.TrimSpace(r.URL.Query().Get("jurisdiction"))), + ProceedingTypeCode: r.URL.Query().Get("proc"), + EventKind: r.URL.Query().Get("event_kind"), + PrimaryParty: r.URL.Query().Get("party"), + Limit: parseLimit(r.URL.Query().Get("limit")), + } + resp, err := dbSvc.deadlineSearch.SearchEvents(r.Context(), q, opts) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "Ereignis-Suche fehlgeschlagen: " + err.Error()}) + return + } + writeJSON(w, http.StatusOK, resp) +} + // parseCSV splits a comma-separated query-string value into a slice of // trimmed non-empty entries. Empty input → nil. func parseCSV(raw string) []string { diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 6edaa19..d7daf45 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -307,6 +307,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc protected.HandleFunc("POST /api/tools/event-trigger", handleEventTriggerCalculate) protected.HandleFunc("GET /api/tools/courts", handleCourtsList) protected.HandleFunc("GET /api/tools/fristenrechner/search", handleFristenrechnerSearch) + protected.HandleFunc("GET /api/tools/fristenrechner/follow-ups", handleFristenrechnerFollowUps) protected.HandleFunc("GET /api/tools/fristenrechner/event-categories", handleFristenrechnerEventCategories) protected.HandleFunc("GET /downloads", handleDownloadsPage) protected.HandleFunc("GET /glossary", handleGlossaryPage) diff --git a/internal/services/fristenrechner_followups.go b/internal/services/fristenrechner_followups.go new file mode 100644 index 0000000..8b883d4 --- /dev/null +++ b/internal/services/fristenrechner_followups.go @@ -0,0 +1,404 @@ +package services + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + + "github.com/google/uuid" + + lp "mgit.msbls.de/m/paliad/pkg/litigationplanner" +) + +// ErrUnknownProceduralEvent is returned by LookupFollowUps when the +// requested procedural_event cannot be resolved (unknown id / unknown +// code / not active+published). Distinct from ErrUnknownTriggerEvent +// (which lives on the legacy Pipeline C / paliad.trigger_events path). +var ErrUnknownProceduralEvent = errors.New("unknown procedural event") + +// FollowUpsResponse is the wire shape for GET +// /api/tools/fristenrechner/follow-ups (Fristenrechner overhaul S1, +// design §6.2). Captures the locked trigger event + every immediate +// follow-up rule with its computed due date. +type FollowUpsResponse struct { + Trigger FollowUpTrigger `json:"trigger"` + TriggerDate string `json:"trigger_date"` + Party *string `json:"party,omitempty"` + FollowUps []FollowUpRule `json:"follow_ups"` +} + +// FollowUpTrigger is the locked trigger event identity returned by +// LookupFollowUps. +type FollowUpTrigger struct { + ID uuid.UUID `json:"id"` + Code string `json:"code"` + NameDE string `json:"name_de"` + NameEN string `json:"name_en"` + EventKind *string `json:"event_kind,omitempty"` + ProceedingType EventSearchPT `json:"proceeding_type"` + AnchorRuleID uuid.UUID `json:"anchor_rule_id"` +} + +// FollowUpRule is one follow-up deadline returned by LookupFollowUps. +// Carries the rule metadata + the computed due date (or the +// "wird vom Gericht bestimmt" / "abhängig von …" marker for rules whose +// date is undefined). +type FollowUpRule struct { + RuleID uuid.UUID `json:"rule_id"` + EventCode string `json:"event_code"` + TitleDE string `json:"title_de"` + TitleEN string `json:"title_en"` + Priority string `json:"priority"` + PrimaryParty *string `json:"primary_party,omitempty"` + DurationValue *int `json:"duration_value,omitempty"` + DurationUnit *string `json:"duration_unit,omitempty"` + Timing *string `json:"timing,omitempty"` + DueDate string `json:"due_date,omitempty"` + OriginalDueDate string `json:"original_due_date,omitempty"` + WasAdjusted bool `json:"was_adjusted,omitempty"` + IsCourtSet bool `json:"is_court_set"` + IsSpawn bool `json:"is_spawn"` + IsBilateral bool `json:"is_bilateral"` + HasCondition bool `json:"has_condition"` + RuleCode *string `json:"rule_code,omitempty"` + LegalSource *string `json:"legal_source,omitempty"` + LegalSourceDisplay *string `json:"legal_source_display,omitempty"` + LegalSourceURL *string `json:"legal_source_url,omitempty"` + NotesDE *string `json:"notes_de,omitempty"` + NotesEN *string `json:"notes_en,omitempty"` + SpawnLabel *string `json:"spawn_label,omitempty"` + SpawnProceedingCode *string `json:"spawn_proceeding_code,omitempty"` + ConceptID *uuid.UUID `json:"concept_id,omitempty"` +} + +// LookupFollowUps returns the follow-up rules anchored on a single +// procedural_event, with computed dates run through the holiday-aware +// litigationplanner.CalculateRule. Identifies the anchor by either the +// procedural_event.id (uuid) or its code; resolves the anchor rule +// (the sequencing_rule with procedural_event_id matching), then walks +// one hop down via parent_id to collect immediate follow-ups. +// +// When party is non-empty, follow-ups are filtered to rules whose +// primary_party matches OR is "both" (so a defendant filter still +// returns bilateral procedural moves like Vertraulichkeitsantrag- +// Erwiderung). +func (s *FristenrechnerService) LookupFollowUps( + ctx context.Context, + eventRef string, + triggerDateStr string, + party string, + courtID string, +) (*FollowUpsResponse, error) { + if eventRef == "" { + return nil, fmt.Errorf("eventRef required") + } + if triggerDateStr == "" { + return nil, fmt.Errorf("triggerDate required") + } + + anchor, err := s.resolveTriggerEvent(ctx, eventRef) + if err != nil { + return nil, err + } + + resp := &FollowUpsResponse{ + Trigger: anchor.Trigger, + TriggerDate: triggerDateStr, + FollowUps: []FollowUpRule{}, + } + if party != "" { + p := party + resp.Party = &p + } + + // Pull the proceeding_type metadata once so we can pass it + // downstream to populate the trigger card and to seed the + // CalculateRule lookup (which uses RuleID anyway). + rows, err := s.queryFollowUpRows(ctx, anchor.AnchorRuleID, party) + if err != nil { + return nil, err + } + + for _, r := range rows { + fr := FollowUpRule{ + RuleID: r.RuleID, + EventCode: r.EventCode, + TitleDE: r.NameDE, + TitleEN: r.NameEN, + Priority: r.Priority, + IsCourtSet: r.IsCourtSet, + IsSpawn: r.IsSpawn, + IsBilateral: r.IsBilateral, + HasCondition: r.HasCondition, + } + if r.PrimaryParty.Valid { + v := r.PrimaryParty.String + fr.PrimaryParty = &v + } + if r.DurationValue.Valid { + v := int(r.DurationValue.Int32) + fr.DurationValue = &v + } + if r.DurationUnit.Valid { + v := r.DurationUnit.String + fr.DurationUnit = &v + } + if r.Timing.Valid { + v := r.Timing.String + fr.Timing = &v + } + if r.RuleCode.Valid { + v := r.RuleCode.String + fr.RuleCode = &v + } + if r.LegalSource.Valid { + v := r.LegalSource.String + fr.LegalSource = &v + display := lp.FormatLegalSourceDisplay(v) + if display != "" { + fr.LegalSourceDisplay = &display + } + url := lp.BuildLegalSourceURL(v) + if url != "" { + fr.LegalSourceURL = &url + } + } + if r.NotesDE.Valid { + v := r.NotesDE.String + fr.NotesDE = &v + } + if r.NotesEN.Valid { + v := r.NotesEN.String + fr.NotesEN = &v + } + if r.SpawnLabel.Valid { + v := r.SpawnLabel.String + fr.SpawnLabel = &v + } + if r.SpawnProceedingCode.Valid { + v := r.SpawnProceedingCode.String + fr.SpawnProceedingCode = &v + } + if r.ConceptID != nil { + fr.ConceptID = r.ConceptID + } + + // Skip date computation for court-set / spawn rules — they don't + // project a calendar date here. + if !r.IsCourtSet && !r.IsSpawn { + calc, err := s.CalculateRule(ctx, lp.CalcRuleParams{ + RuleID: r.RuleID.String(), + TriggerDate: triggerDateStr, + CourtID: courtID, + }) + if err == nil { + fr.DueDate = calc.DueDate + fr.OriginalDueDate = calc.OriginalDate + fr.WasAdjusted = calc.WasAdjusted + } + // On error: leave the date fields empty — the frontend + // already handles missing dates as "abhängig von ..." style + // markers and a single bad rule shouldn't 500 the whole + // follow-up list. + } + + resp.FollowUps = append(resp.FollowUps, fr) + } + + return resp, nil +} + +// anchorResolution carries the resolver output: the trigger card metadata +// plus the anchor rule id (the sequencing_rule.id whose +// procedural_event_id equals the trigger event). +type anchorResolution struct { + Trigger FollowUpTrigger + AnchorRuleID uuid.UUID +} + +// resolveTriggerEvent looks up the trigger event by either uuid or code. +// Returns ErrUnknownTriggerEvent when no published+active anchor row +// matches. +func (s *FristenrechnerService) resolveTriggerEvent(ctx context.Context, ref string) (*anchorResolution, error) { + // Try uuid first; fall back to code lookup. + type row struct { + EventID uuid.UUID `db:"event_id"` + Code string `db:"code"` + NameDE string `db:"name_de"` + NameEN string `db:"name_en"` + EventKind sql.NullString `db:"event_kind"` + AnchorRuleID uuid.UUID `db:"anchor_rule_id"` + PTID int `db:"pt_id"` + PTCode string `db:"pt_code"` + PTNameDE string `db:"pt_name_de"` + PTNameEN string `db:"pt_name_en"` + PTJurisdiction sql.NullString `db:"pt_jurisdiction"` + } + + var r row + queryBase := ` + SELECT pe.id AS event_id, + pe.code, + pe.name AS name_de, + pe.name_en, + pe.event_kind, + sr.id AS anchor_rule_id, + pt.id AS pt_id, + pt.code AS pt_code, + pt.name AS pt_name_de, + pt.name_en AS pt_name_en, + pt.jurisdiction AS pt_jurisdiction + FROM paliad.sequencing_rules sr + JOIN paliad.procedural_events pe ON pe.id = sr.procedural_event_id + JOIN paliad.proceeding_types pt ON pt.id = sr.proceeding_type_id + WHERE sr.is_active = true + AND sr.lifecycle_state = 'published' + AND pe.is_active = true + AND pe.lifecycle_state = 'published' + AND pt.is_active = true + AND %s + ORDER BY pt.sort_order + LIMIT 1` + + if id, err := uuid.Parse(ref); err == nil { + // Treat as a procedural_event id OR a sequencing_rule id (the + // frontend may pass either — search returns event id but a + // concept-card-derived flow may pass the rule id). + err := s.rules.db.GetContext(ctx, &r, fmt.Sprintf(queryBase, "(pe.id = $1 OR sr.id = $1)"), id) + if err == nil { + goto found + } + if !errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("resolve trigger event by id: %w", err) + } + // fall through to code lookup + } + { + err := s.rules.db.GetContext(ctx, &r, fmt.Sprintf(queryBase, "pe.code = $1"), ref) + if err == nil { + goto found + } + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrUnknownProceduralEvent + } + return nil, fmt.Errorf("resolve trigger event by code: %w", err) + } + +found: + res := &anchorResolution{ + AnchorRuleID: r.AnchorRuleID, + Trigger: FollowUpTrigger{ + ID: r.EventID, + Code: r.Code, + NameDE: r.NameDE, + NameEN: r.NameEN, + AnchorRuleID: r.AnchorRuleID, + ProceedingType: EventSearchPT{ + ID: r.PTID, + Code: r.PTCode, + NameDE: r.PTNameDE, + NameEN: r.PTNameEN, + }, + }, + } + if r.EventKind.Valid { + v := r.EventKind.String + res.Trigger.EventKind = &v + } + if r.PTJurisdiction.Valid { + v := r.PTJurisdiction.String + res.Trigger.ProceedingType.Jurisdiction = &v + } + return res, nil +} + +// followUpRow is the joined SELECT shape for follow-up rules. +type followUpRow struct { + RuleID uuid.UUID `db:"rule_id"` + EventCode string `db:"event_code"` + NameDE string `db:"name_de"` + NameEN string `db:"name_en"` + Priority string `db:"priority"` + PrimaryParty sql.NullString `db:"primary_party"` + DurationValue sql.NullInt32 `db:"duration_value"` + DurationUnit sql.NullString `db:"duration_unit"` + Timing sql.NullString `db:"timing"` + IsCourtSet bool `db:"is_court_set"` + IsSpawn bool `db:"is_spawn"` + IsBilateral bool `db:"is_bilateral"` + HasCondition bool `db:"has_condition"` + RuleCode sql.NullString `db:"rule_code"` + LegalSource sql.NullString `db:"legal_source"` + NotesDE sql.NullString `db:"notes_de"` + NotesEN sql.NullString `db:"notes_en"` + SpawnLabel sql.NullString `db:"spawn_label"` + SpawnProceedingCode sql.NullString `db:"spawn_proceeding_code"` + ConceptID *uuid.UUID `db:"concept_id"` + SequenceOrder int `db:"sequence_order"` +} + +// queryFollowUpRows pulls the immediate-children rules of an anchor. +// Party filter is inclusive of "both" so bilateral moves stay visible +// when the user picks claimant or defendant. +func (s *FristenrechnerService) queryFollowUpRows( + ctx context.Context, + anchorRuleID uuid.UUID, + party string, +) ([]followUpRow, error) { + where := []string{ + "sr.parent_id = $1", + "sr.is_active = true", + "sr.lifecycle_state = 'published'", + "pe.is_active = true", + "pe.lifecycle_state = 'published'", + } + args := []any{anchorRuleID} + if party == "claimant" || party == "defendant" { + args = append(args, party) + where = append(where, fmt.Sprintf( + "(sr.primary_party = $%d OR sr.primary_party = 'both' OR sr.primary_party IS NULL)", + len(args))) + } else if party != "" { + // "court" / "both" — exact match + args = append(args, party) + where = append(where, fmt.Sprintf("sr.primary_party = $%d", len(args))) + } + + query := ` + SELECT sr.id AS rule_id, + pe.code AS event_code, + pe.name AS name_de, + pe.name_en, + sr.priority, + sr.primary_party, + sr.duration_value, + sr.duration_unit, + sr.timing, + sr.is_court_set, + sr.is_spawn, + sr.is_bilateral, + (sr.condition_expr IS NOT NULL) AS has_condition, + sr.rule_code, + ls.citation AS legal_source, + sr.deadline_notes AS notes_de, + sr.deadline_notes_en AS notes_en, + sr.spawn_label, + spt.code AS spawn_proceeding_code, + pe.concept_id, + sr.sequence_order + FROM paliad.sequencing_rules sr + JOIN paliad.procedural_events pe ON pe.id = sr.procedural_event_id + LEFT JOIN paliad.legal_sources ls ON ls.id = pe.legal_source_id + LEFT JOIN paliad.proceeding_types spt ON spt.id = sr.spawn_proceeding_type_id + WHERE ` + strings.Join(where, "\n AND ") + ` + ORDER BY sr.sequence_order, pe.code` + + var rows []followUpRow + if err := s.rules.db.SelectContext(ctx, &rows, query, args...); err != nil { + return nil, fmt.Errorf("load follow-up rows: %w", err) + } + return rows, nil +} + diff --git a/internal/services/fristenrechner_followups_test.go b/internal/services/fristenrechner_followups_test.go new file mode 100644 index 0000000..ca98aeb --- /dev/null +++ b/internal/services/fristenrechner_followups_test.go @@ -0,0 +1,205 @@ +package services + +import ( + "context" + "os" + "testing" + + "github.com/jmoiron/sqlx" + _ "github.com/lib/pq" + + "mgit.msbls.de/m/paliad/internal/db" +) + +// TestSearchEvents covers the ?kind=events response shape for the +// Fristenrechner overhaul S1 (design §6.1). Verified against live data: +// "Klageerhebung" must return upc.inf.cfi.soc (the canonical SoC +// procedural event) as the top hit, with the proceeding metadata +// populated and a non-zero follow_up_count. +// +// Skipped when TEST_DATABASE_URL is unset, mirroring the other live-DB +// tests in this package. +func TestSearchEvents(t *testing.T) { + url := os.Getenv("TEST_DATABASE_URL") + if url == "" { + t.Skip("TEST_DATABASE_URL not set — skipping live DB test") + } + if err := db.ApplyMigrations(url); err != nil { + t.Fatalf("apply migrations: %v", err) + } + pool, err := sqlx.Connect("postgres", url) + if err != nil { + t.Fatalf("connect: %v", err) + } + defer pool.Close() + ctx := context.Background() + + svc := NewDeadlineSearchService(pool) + + t.Run("Klageerhebung returns upc.inf.cfi.soc with follow-ups", func(t *testing.T) { + resp, err := svc.SearchEvents(ctx, "Klageerhebung", EventSearchOptions{Limit: 30}) + if err != nil { + t.Fatalf("search events: %v", err) + } + if len(resp.Events) == 0 { + t.Fatalf("no events returned for Klageerhebung") + } + var soc *EventSearchHit + for i := range resp.Events { + if resp.Events[i].Code == "upc.inf.cfi.soc" { + soc = &resp.Events[i] + break + } + } + if soc == nil { + t.Fatalf("upc.inf.cfi.soc not in event hits (got %d hits)", len(resp.Events)) + } + if soc.NameDE == "" { + t.Errorf("expected name_de populated, got empty") + } + if soc.ProceedingType.Code != "upc.inf.cfi" { + t.Errorf("expected proceeding upc.inf.cfi, got %q", soc.ProceedingType.Code) + } + if soc.FollowUpCount <= 0 { + t.Errorf("expected follow_up_count > 0 for SoC, got %d", soc.FollowUpCount) + } + if soc.EventKind == nil || *soc.EventKind != "filing" { + gotKind := "" + if soc.EventKind != nil { + gotKind = *soc.EventKind + } + t.Errorf("expected event_kind=filing, got %q", gotKind) + } + }) + + t.Run("jurisdiction filter narrows to UPC", func(t *testing.T) { + resp, err := svc.SearchEvents(ctx, "", EventSearchOptions{ + Jurisdiction: "UPC", + Limit: 200, + }) + if err != nil { + t.Fatalf("search events UPC: %v", err) + } + if len(resp.Events) == 0 { + t.Fatalf("expected UPC events, got 0") + } + for _, e := range resp.Events { + if e.ProceedingType.Jurisdiction == nil || *e.ProceedingType.Jurisdiction != "UPC" { + gotJ := "" + if e.ProceedingType.Jurisdiction != nil { + gotJ = *e.ProceedingType.Jurisdiction + } + t.Errorf("non-UPC event leaked: %s (jurisdiction=%q)", e.Code, gotJ) + } + } + }) + + t.Run("event_kind=filing narrows by kind", func(t *testing.T) { + resp, err := svc.SearchEvents(ctx, "", EventSearchOptions{ + EventKind: "filing", + Limit: 200, + }) + if err != nil { + t.Fatalf("search events filing: %v", err) + } + if len(resp.Events) == 0 { + t.Fatalf("expected filing events, got 0") + } + for _, e := range resp.Events { + if e.EventKind == nil || *e.EventKind != "filing" { + gotKind := "" + if e.EventKind != nil { + gotKind = *e.EventKind + } + t.Errorf("non-filing event leaked: %s (event_kind=%q)", e.Code, gotKind) + } + } + }) +} + +// TestLookupFollowUps covers the GET /api/tools/fristenrechner/follow-ups +// endpoint contract (overhaul S1, design §6.2). Verified against live +// data: looking up upc.inf.cfi.soc returns the four canonical follow-up +// rules (Klageerwiderung, CCR, Einspruch, Vertraulichkeits-Erwiderung), +// each with a computed due date or court-set marker. +func TestLookupFollowUps(t *testing.T) { + url := os.Getenv("TEST_DATABASE_URL") + if url == "" { + t.Skip("TEST_DATABASE_URL not set — skipping live DB test") + } + if err := db.ApplyMigrations(url); err != nil { + t.Fatalf("apply migrations: %v", err) + } + pool, err := sqlx.Connect("postgres", url) + if err != nil { + t.Fatalf("connect: %v", err) + } + defer pool.Close() + ctx := context.Background() + + holidays := NewHolidayService(pool) + courts := NewCourtService(pool) + rules := NewDeadlineRuleService(pool) + fr := NewFristenrechnerService(rules, holidays, courts) + + t.Run("SoC returns follow-ups with computed dates", func(t *testing.T) { + resp, err := fr.LookupFollowUps(ctx, "upc.inf.cfi.soc", "2026-05-20", "", "") + if err != nil { + t.Fatalf("lookup follow-ups: %v", err) + } + if resp.Trigger.Code != "upc.inf.cfi.soc" { + t.Errorf("trigger code = %q, want upc.inf.cfi.soc", resp.Trigger.Code) + } + if len(resp.FollowUps) == 0 { + t.Fatalf("expected follow-ups, got 0") + } + // At least the Klageerwiderung (sod) should be present and have a date. + var sod *FollowUpRule + for i := range resp.FollowUps { + if resp.FollowUps[i].EventCode == "upc.inf.cfi.sod" { + sod = &resp.FollowUps[i] + break + } + } + if sod == nil { + t.Fatalf("Klageerwiderung (upc.inf.cfi.sod) not in follow-ups") + } + if sod.DueDate == "" { + t.Errorf("expected due_date populated for sod, got empty") + } + if sod.Priority != "mandatory" { + t.Errorf("expected priority=mandatory for sod, got %q", sod.Priority) + } + // 3 months after 2026-05-20 (then weekend-adjusted) — sanity check + // only that something resembling 2026-08 came back. + if len(sod.DueDate) < 7 || sod.DueDate[:7] != "2026-08" { + t.Errorf("expected due_date in 2026-08, got %q", sod.DueDate) + } + }) + + t.Run("party=defendant narrows but keeps bilateral rules", func(t *testing.T) { + resp, err := fr.LookupFollowUps(ctx, "upc.inf.cfi.soc", "2026-05-20", "defendant", "") + if err != nil { + t.Fatalf("lookup follow-ups (defendant): %v", err) + } + if len(resp.FollowUps) == 0 { + t.Fatalf("expected defendant follow-ups, got 0") + } + for _, r := range resp.FollowUps { + if r.PrimaryParty == nil { + continue + } + p := *r.PrimaryParty + if p == "claimant" { + t.Errorf("claimant-only rule leaked under defendant filter: %s", r.EventCode) + } + } + }) + + t.Run("unknown event returns ErrUnknownProceduralEvent", func(t *testing.T) { + _, err := fr.LookupFollowUps(ctx, "no.such.event", "2026-05-20", "", "") + if err != ErrUnknownProceduralEvent { + t.Errorf("expected ErrUnknownProceduralEvent, got %v", err) + } + }) +} diff --git a/internal/services/fristenrechner_search_events.go b/internal/services/fristenrechner_search_events.go new file mode 100644 index 0000000..ea06ef6 --- /dev/null +++ b/internal/services/fristenrechner_search_events.go @@ -0,0 +1,257 @@ +package services + +import ( + "context" + "database/sql" + "fmt" + "strings" + + "github.com/google/uuid" +) + +// EventSearchHit is one ranked hit in the events-shape search response. +// Returned by FristenrechnerService.SearchEvents. +// +// One hit per (procedural_event, proceeding_type) tuple: a single event +// can appear in multiple proceedings (the data carries handful of +// procedural_event rows whose code is null.* and that are anchored by +// rules in different proceedings — those legacy stragglers surface as +// multiple hits, one per proceeding context). +type EventSearchHit struct { + EventID uuid.UUID `json:"id"` + Code string `json:"code"` + NameDE string `json:"name_de"` + NameEN string `json:"name_en"` + EventKind *string `json:"event_kind,omitempty"` + Description *string `json:"description,omitempty"` + PrimaryParty *string `json:"primary_party,omitempty"` + ProceedingType EventSearchPT `json:"proceeding_type"` + AnchorRuleID uuid.UUID `json:"anchor_rule_id"` + FollowUpCount int `json:"follow_up_count"` + ConceptID *uuid.UUID `json:"concept_id,omitempty"` + Score float64 `json:"score"` +} + +// EventSearchPT is the proceeding-type slice embedded in an EventSearchHit. +type EventSearchPT struct { + ID int `json:"id"` + Code string `json:"code"` + NameDE string `json:"name_de"` + NameEN string `json:"name_en"` + Jurisdiction *string `json:"jurisdiction,omitempty"` +} + +// EventSearchOptions is the filter set for SearchEvents. Empty values +// mean "no narrowing on this axis". +type EventSearchOptions struct { + // Jurisdiction filters by proceeding_types.jurisdiction + // ("UPC" | "DE" | "EPA" | "DPMA"). Empty = any. + Jurisdiction string + // ProceedingTypeCode narrows to one proceeding. Empty = any. + ProceedingTypeCode string + // EventKind filters by procedural_events.event_kind + // ("filing" | "hearing" | "decision" | "order"). Empty = any. + EventKind string + // PrimaryParty narrows by the anchor rule's primary_party + // ("claimant" | "defendant" | "court" | "both"). Empty = any. + PrimaryParty string + // Limit caps the result set; defaults to 50, max 200. + Limit int +} + +// EventSearchResponse is the wire shape for ?kind=events on the +// /api/tools/fristenrechner/search endpoint (design §6.1). +type EventSearchResponse struct { + Query string `json:"query"` + Filters EventSearchFilters `json:"filters"` + Events []EventSearchHit `json:"events"` + Total int `json:"total"` +} + +// EventSearchFilters is the filter echo returned to the client. +type EventSearchFilters struct { + Jurisdiction *string `json:"jurisdiction"` + ProceedingTypeCode *string `json:"proceeding_type_code"` + EventKind *string `json:"event_kind"` + PrimaryParty *string `json:"primary_party"` +} + +// SearchEvents implements the ?kind=events response shape (Fristenrechner +// overhaul S1, design §6.1). Returns one hit per (procedural_event × +// proceeding_type) tuple, ranked by trigram similarity against name / +// name_en / code. Empty q returns the unranked catalog filtered by the +// supplied facets. +func (s *DeadlineSearchService) SearchEvents(ctx context.Context, q string, opts EventSearchOptions) (*EventSearchResponse, error) { + limit := opts.Limit + if limit <= 0 { + limit = 50 + } + if limit > 200 { + limit = 200 + } + + qNorm := normalizeQuery(q) + + resp := &EventSearchResponse{ + Query: q, + Filters: buildEventFilters(opts), + Events: []EventSearchHit{}, + } + + where := []string{ + "sr.is_active = true", + "sr.lifecycle_state = 'published'", + "pe.is_active = true", + "pe.lifecycle_state = 'published'", + "pt.is_active = true", + } + args := []any{} + add := func(clause string, val any) { + args = append(args, val) + where = append(where, fmt.Sprintf(clause, len(args))) + } + if opts.Jurisdiction != "" { + add("pt.jurisdiction = $%d", opts.Jurisdiction) + } + if opts.ProceedingTypeCode != "" { + add("pt.code = $%d", opts.ProceedingTypeCode) + } + if opts.EventKind != "" { + add("pe.event_kind = $%d", opts.EventKind) + } + if opts.PrimaryParty != "" { + add("sr.primary_party = $%d", opts.PrimaryParty) + } + + // Trigram score over (name || name_en || code). Empty query collapses + // the score to 0 — keeps the SQL identical regardless of input mode. + scoreExpr := "0::float8" + if qNorm != "" { + args = append(args, qNorm) + scoreExpr = fmt.Sprintf( + `GREATEST(similarity(pe.name, $%[1]d), similarity(pe.name_en, $%[1]d), similarity(pe.code, $%[1]d))`, + len(args)) + // Drop hits with zero similarity so a typo doesn't return the + // whole catalog ranked at 0. + where = append(where, fmt.Sprintf( + `(pe.name %% $%[1]d OR pe.name_en %% $%[1]d OR pe.code %% $%[1]d)`, + len(args))) + } + + // follow_up_count: rules whose parent_id points at this anchor rule. + // Computed via correlated subquery; cheap at the 231-row scale. + query := ` + SELECT pe.id AS event_id, + pe.code, + pe.name AS name_de, + pe.name_en, + pe.event_kind, + pe.description, + sr.primary_party, + pe.concept_id, + sr.id AS anchor_rule_id, + pt.id AS pt_id, + pt.code AS pt_code, + pt.name AS pt_name_de, + pt.name_en AS pt_name_en, + pt.jurisdiction AS pt_jurisdiction, + (SELECT COUNT(*)::int + FROM paliad.sequencing_rules child + WHERE child.parent_id = sr.id + AND child.is_active = true + AND child.lifecycle_state = 'published') AS follow_up_count, + ` + scoreExpr + ` AS score + FROM paliad.sequencing_rules sr + JOIN paliad.procedural_events pe ON pe.id = sr.procedural_event_id + JOIN paliad.proceeding_types pt ON pt.id = sr.proceeding_type_id + WHERE ` + strings.Join(where, "\n AND ") + ` + ORDER BY score DESC, pt.sort_order, pe.code + LIMIT $` + fmt.Sprintf("%d", len(args)+1) + + args = append(args, limit) + + type row struct { + EventID uuid.UUID `db:"event_id"` + Code string `db:"code"` + NameDE string `db:"name_de"` + NameEN string `db:"name_en"` + EventKind sql.NullString `db:"event_kind"` + Description sql.NullString `db:"description"` + PrimaryParty sql.NullString `db:"primary_party"` + ConceptID *uuid.UUID `db:"concept_id"` + AnchorRuleID uuid.UUID `db:"anchor_rule_id"` + PTID int `db:"pt_id"` + PTCode string `db:"pt_code"` + PTNameDE string `db:"pt_name_de"` + PTNameEN string `db:"pt_name_en"` + PTJurisdiction sql.NullString `db:"pt_jurisdiction"` + FollowUpCount int `db:"follow_up_count"` + Score float64 `db:"score"` + } + + var rows []row + if err := s.db.SelectContext(ctx, &rows, query, args...); err != nil { + return nil, fmt.Errorf("search events: %w", err) + } + + hits := make([]EventSearchHit, 0, len(rows)) + for _, r := range rows { + hit := EventSearchHit{ + EventID: r.EventID, + Code: r.Code, + NameDE: r.NameDE, + NameEN: r.NameEN, + AnchorRuleID: r.AnchorRuleID, + FollowUpCount: r.FollowUpCount, + ConceptID: r.ConceptID, + Score: r.Score, + ProceedingType: EventSearchPT{ + ID: r.PTID, + Code: r.PTCode, + NameDE: r.PTNameDE, + NameEN: r.PTNameEN, + }, + } + if r.EventKind.Valid { + v := r.EventKind.String + hit.EventKind = &v + } + if r.Description.Valid { + v := r.Description.String + hit.Description = &v + } + if r.PrimaryParty.Valid { + v := r.PrimaryParty.String + hit.PrimaryParty = &v + } + if r.PTJurisdiction.Valid { + v := r.PTJurisdiction.String + hit.ProceedingType.Jurisdiction = &v + } + hits = append(hits, hit) + } + resp.Events = hits + resp.Total = len(hits) + return resp, nil +} + +func buildEventFilters(opts EventSearchOptions) EventSearchFilters { + f := EventSearchFilters{} + if opts.Jurisdiction != "" { + v := opts.Jurisdiction + f.Jurisdiction = &v + } + if opts.ProceedingTypeCode != "" { + v := opts.ProceedingTypeCode + f.ProceedingTypeCode = &v + } + if opts.EventKind != "" { + v := opts.EventKind + f.EventKind = &v + } + if opts.PrimaryParty != "" { + v := opts.PrimaryParty + f.PrimaryParty = &v + } + return f +}