feat(fristenrechner): Slice S1 — backend ?kind=events + /follow-ups (m/paliad#146)
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:
65
internal/handlers/fristenrechner_followups.go
Normal file
65
internal/handlers/fristenrechner_followups.go
Normal 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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
404
internal/services/fristenrechner_followups.go
Normal file
404
internal/services/fristenrechner_followups.go
Normal 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
|
||||
}
|
||||
|
||||
205
internal/services/fristenrechner_followups_test.go
Normal file
205
internal/services/fristenrechner_followups_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
257
internal/services/fristenrechner_search_events.go
Normal file
257
internal/services/fristenrechner_search_events.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user