feat(fristenrechner): Slice S1 — backend ?kind=events + /follow-ups (m/paliad#146)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled

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.
This commit is contained in:
mAi
2026-05-26 22:01:10 +02:00
parent 109946edff
commit 7ea415145f
6 changed files with 969 additions and 0 deletions

View File

@@ -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)
}

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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 := "<nil>"
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 := "<nil>"
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 := "<nil>"
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)
}
})
}

View File

@@ -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
}