Mode B "🧭 Geführt" — the guided 3-5 row wizard defined in
docs/design-fristenrechner-overhaul-2026-05-26.md §3.2. Lands the
user on a single procedural_event (the trigger), then transitions
to the shared §4 result view.
Frontend:
* `fristenrechner-wizard.ts` — row stack with R1..R5:
R1 Was ist passiert? (event_kind, always asked)
R2 Vor welchem Gericht? (jurisdiction, skip if R1 narrows)
R3 In welchem Verfahren? (proceeding_type, auto-skip when
narrowed pool has 1 option)
R4 Welches Schriftstück? (procedural_event, landing)
R5 Welche Seite vertreten Sie? (party, only when follow-ups
differ by primary_party)
Row badges per §11.Q3: R1+R2 = Filter, R3+R4+R5 = Qualifier.
R5 has NO "Beide" option per §11.Q8 — Mode B is the file-mode
where perspective is a qualifier.
* Project prefill — derives R3 + R2 jurisdiction from
project.proceeding_type, R5 from project.our_side. Annotates
pre-filled rows with "aus Akte" tag and implicit rows with
"implizit" tag per §11.Q10 ("erhalten" annotation when a pick is
carried across an upstream change).
* R4-to-result transition — after R4 the wizard fetches /follow-
ups (no dates) to inspect primary_party variance. If both
claimant and defendant rules exist AND R5 isn't already set,
swaps the loading row for the R5 chip picker. Otherwise jumps
straight to mountResultView.
* URL state — `?mode=wizard&kind=…&forum=…&pt=…&r4=…&party=…`
keeps deep-link / back-nav consistent (the launchResult step
sets `event=` so the result view picks up).
* `fristenrechner-result.ts` mountModeShell now dispatches the
"wizard" tab to the wizard module (was a coming-soon
placeholder).
* 18 i18n keys added (DE + EN parity), 145-line CSS block for the
wizard row stack with Filter / Qualifier badge styling and
"aus Akte" annotation chip.
Backend:
* `ProceedingListOptions.EventKind` adds an EXISTS subquery
filter on `paliad.sequencing_rules` ⨯ `paliad.procedural_events`
so Mode B R3 chips only show proceedings whose event roster
contains at least one event of the requested kind (design
§6.3). Endpoint param: `event_kind=` on
/api/tools/proceeding-types.
Test updates:
* `TestListProceedings` switched from SKIP-when-column-missing to
asserting the live filter — mig 153 has landed, `kind` column
is in place. New subtests: kind=proceeding includes
upc.inf.cfi and excludes the phase row upc.cfi.interim;
event_kind=filing narrows to proceedings with filing events.
* `fristenrechner-wizard.test.ts` covers
`followUpsDifferByParty` — the R5 trigger predicate. 7 cases:
asymmetric → true; uniform / both / court / empty → false.
Verified — bun build clean (2971 i18n keys), 256 frontend tests
pass (incl. 7 new), go build + vet clean, live-DB
TestListProceedings passes all 6 subtests against mig 153 data.
726 lines
27 KiB
Go
726 lines
27 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"mgit.msbls.de/m/paliad/internal/models"
|
|
lp "mgit.msbls.de/m/paliad/pkg/litigationplanner"
|
|
)
|
|
|
|
|
|
// FristenrechnerService renders the Paliad public Fristenrechner's
|
|
// response shape from DB-stored rules. Post-Slice-A (t-paliad-298) it
|
|
// is a thin adapter: the compute engine + types live in
|
|
// pkg/litigationplanner, and FristenrechnerService just wires the
|
|
// Postgres-backed Catalog + HolidayCalendar + CourtRegistry
|
|
// implementations and delegates Calculate / CalculateRule across the
|
|
// boundary.
|
|
//
|
|
// The package owns the wire shape (Timeline / TimelineEntry); paliad's
|
|
// historical aliases (UIResponse / UIDeadline) keep call-sites
|
|
// unchanged.
|
|
type FristenrechnerService struct {
|
|
rules *DeadlineRuleService
|
|
holidays *HolidayService
|
|
courts *CourtService
|
|
|
|
catalog lp.Catalog
|
|
}
|
|
|
|
// NewFristenrechnerService wires the service to its dependencies.
|
|
func NewFristenrechnerService(rules *DeadlineRuleService, holidays *HolidayService, courts *CourtService) *FristenrechnerService {
|
|
s := &FristenrechnerService{rules: rules, holidays: holidays, courts: courts}
|
|
s.catalog = &paliadCatalog{rules: rules}
|
|
return s
|
|
}
|
|
|
|
// Type aliases keep call-sites byte-identical with the pre-Slice-A
|
|
// shape. The wire JSON tags are owned by the package.
|
|
// (AdjustmentReason + HolidayDTO are aliased in holidays.go.)
|
|
type (
|
|
UIResponse = lp.Timeline
|
|
UIDeadline = lp.TimelineEntry
|
|
CalcOptions = lp.CalcOptions
|
|
CalcRuleParams = lp.CalcRuleParams
|
|
RuleCalculation = lp.RuleCalculation
|
|
RuleCalculationRule = lp.RuleCalculationRule
|
|
RuleCalculationProceeding = lp.RuleCalculationProceeding
|
|
SubTrackRouting = lp.SubTrackRouting
|
|
)
|
|
|
|
// Sentinel errors. Re-exported as package-level vars so handlers that
|
|
// errors.Is(..., services.ErrUnknownProceedingType) continue to work.
|
|
var (
|
|
ErrUnknownProceedingType = lp.ErrUnknownProceedingType
|
|
ErrUnknownRule = lp.ErrUnknownRule
|
|
)
|
|
|
|
// Calculate delegates to litigationplanner.Calculate with paliad's
|
|
// Postgres-backed Catalog / HolidayCalendar / CourtRegistry implementations.
|
|
func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, triggerDateStr string, opts CalcOptions) (*UIResponse, error) {
|
|
return lp.Calculate(ctx, proceedingCode, triggerDateStr, opts, s.catalog, s.holidays, s.courts)
|
|
}
|
|
|
|
// CalculateRule delegates to litigationplanner.CalculateRule. Distinct
|
|
// from Calculate: no parent-chain walk, no full-timeline rendering —
|
|
// just one date out.
|
|
func (s *FristenrechnerService) CalculateRule(ctx context.Context, params CalcRuleParams) (*RuleCalculation, error) {
|
|
return lp.CalculateRule(ctx, params, s.catalog, s.holidays, s.courts)
|
|
}
|
|
|
|
// ListFristenrechnerTypes returns the proceeding types that populate
|
|
// the Fristenrechner UI (category='fristenrechner'), ordered by
|
|
// sort_order. Stays on the service because the response is a paliad-
|
|
// specific surface (the wire shape FristenrechnerType is owned by the
|
|
// package but the SQL filter is paliad-side).
|
|
func (s *FristenrechnerService) ListFristenrechnerTypes(ctx context.Context) ([]lp.FristenrechnerType, error) {
|
|
return s.ListProceedings(ctx, ProceedingListOptions{})
|
|
}
|
|
|
|
// ProceedingListOptions narrows ListProceedings. Empty values = no
|
|
// filter on that axis. Added for the Fristenrechner overhaul S3
|
|
// (m/paliad#146): Mode A's "Verfahren" filter chip strip needs to scope
|
|
// the proceeding pool by the user's Forum pick (jurisdiction) and by
|
|
// kind='proceeding' to exclude the phase / side_action / meta rows
|
|
// landed in the taxonomy cleanup (m/paliad#147, mig 153).
|
|
type ProceedingListOptions struct {
|
|
// Jurisdiction narrows to one jurisdiction code (UPC / DE / EPA /
|
|
// DPMA). Empty = any.
|
|
Jurisdiction string
|
|
// Kind narrows to one structural kind (proceeding / phase /
|
|
// side_action / meta). Mode A passes "proceeding" to exclude the
|
|
// phase / side_action / meta rows from the chip strip. Empty = any.
|
|
//
|
|
// Filter referenced before mig 153 lands the column → callers
|
|
// pre-mig get a "column kind does not exist" error from Postgres.
|
|
// Sequenced per docs/design-proceeding-types-taxonomy-2026-05-26.md
|
|
// §7 option (c): mig 153 merges to main before the S3 PR ships.
|
|
Kind string
|
|
// EventKind narrows to proceedings that have at least one published
|
|
// sequencing rule anchored on a procedural event of the requested
|
|
// kind ("filing" | "hearing" | "decision" | "order"). Powers the
|
|
// Fristenrechner overhaul Mode B R3 wizard row (§3.2): after R1
|
|
// picks an event_kind, R3 should only chip proceedings whose event
|
|
// roster contains at least one event of that kind. Empty = no
|
|
// event-kind narrowing.
|
|
EventKind string
|
|
}
|
|
|
|
// ListProceedings returns the proceeding_types chips the Fristenrechner
|
|
// overhaul Mode A renders in its filter strip. Filters apply
|
|
// progressively: pre-mig 153 Kind=="" is the safe default; post-mig 153
|
|
// Mode A passes Kind="proceeding" to exclude the phase / side_action /
|
|
// meta rows.
|
|
func (s *FristenrechnerService) ListProceedings(ctx context.Context, opts ProceedingListOptions) ([]lp.FristenrechnerType, error) {
|
|
where := []string{
|
|
"category = 'fristenrechner'",
|
|
"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("jurisdiction = $%d", opts.Jurisdiction)
|
|
}
|
|
if opts.Kind != "" {
|
|
add("kind = $%d", opts.Kind)
|
|
}
|
|
if opts.EventKind != "" {
|
|
add(`EXISTS (
|
|
SELECT 1 FROM paliad.sequencing_rules sr
|
|
JOIN paliad.procedural_events pe ON pe.id = sr.procedural_event_id
|
|
WHERE sr.proceeding_type_id = paliad.proceeding_types.id
|
|
AND sr.is_active = true AND sr.lifecycle_state = 'published'
|
|
AND pe.is_active = true AND pe.lifecycle_state = 'published'
|
|
AND pe.event_kind = $%d
|
|
)`, opts.EventKind)
|
|
}
|
|
query := `SELECT code, name, name_en, jurisdiction
|
|
FROM paliad.proceeding_types
|
|
WHERE ` + strings.Join(where, " AND ") + `
|
|
ORDER BY sort_order`
|
|
|
|
rows, err := s.rules.db.QueryxContext(ctx, query, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list proceedings: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []lp.FristenrechnerType
|
|
for rows.Next() {
|
|
var t lp.FristenrechnerType
|
|
var juris sql.NullString
|
|
if err := rows.Scan(&t.Code, &t.Name, &t.NameEN, &juris); err != nil {
|
|
return nil, err
|
|
}
|
|
if juris.Valid {
|
|
t.Group = juris.String
|
|
}
|
|
out = append(out, t)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// FristenrechnerType is paliad's local alias for lp.FristenrechnerType
|
|
// so historical call-sites (services.FristenrechnerType) keep working.
|
|
type FristenrechnerType = lp.FristenrechnerType
|
|
|
|
// ---------------------------------------------------------------------
|
|
// paliadCatalog is the paliad-side litigationplanner.Catalog adapter.
|
|
// Wraps DeadlineRuleService to expose proceeding + rule lookups against
|
|
// paliad.proceeding_types + paliad.deadline_rules.
|
|
// ---------------------------------------------------------------------
|
|
|
|
type paliadCatalog struct {
|
|
rules *DeadlineRuleService
|
|
}
|
|
|
|
// proceedingTypeColumns is canonically defined in
|
|
// deadline_rule_service.go; the catalog adapter reuses it via the
|
|
// shared package-level const.
|
|
|
|
// LoadProceeding returns the proceeding-type metadata + rules. The
|
|
// ProjectHint is currently ignored on paliad's side (per m's 2026-05-26
|
|
// decision dropping the Slice E user-authored rules); kept on the
|
|
// interface for forward-compat.
|
|
func (c *paliadCatalog) LoadProceeding(ctx context.Context, code string, _ lp.ProjectHint) (*models.ProceedingType, []models.DeadlineRule, error) {
|
|
var pt models.ProceedingType
|
|
err := c.rules.db.GetContext(ctx, &pt,
|
|
`SELECT `+proceedingTypeColumns+`
|
|
FROM paliad.proceeding_types
|
|
WHERE code = $1 AND is_active = true`, code)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, nil, lp.ErrUnknownProceedingType
|
|
}
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("resolve proceeding %q: %w", code, err)
|
|
}
|
|
rules, err := c.rules.List(ctx, &pt.ID)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return &pt, rules, nil
|
|
}
|
|
|
|
// LoadProceedingByID is the resolver for a rule's parent proceeding.
|
|
func (c *paliadCatalog) LoadProceedingByID(ctx context.Context, id int) (*models.ProceedingType, error) {
|
|
var pt models.ProceedingType
|
|
err := c.rules.db.GetContext(ctx, &pt,
|
|
`SELECT `+proceedingTypeColumns+`
|
|
FROM paliad.proceeding_types
|
|
WHERE id = $1`, id)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, lp.ErrUnknownProceedingType
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("resolve proceeding by id %d: %w", id, err)
|
|
}
|
|
return &pt, nil
|
|
}
|
|
|
|
// LoadRuleByID resolves a rule UUID to the rule row.
|
|
func (c *paliadCatalog) LoadRuleByID(ctx context.Context, ruleID string) (*models.DeadlineRule, error) {
|
|
var rule models.DeadlineRule
|
|
err := c.rules.db.GetContext(ctx, &rule,
|
|
`SELECT `+ruleColumns+`
|
|
FROM paliad.deadline_rules_unified
|
|
WHERE id = $1 AND is_active = true`, ruleID)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, lp.ErrUnknownRule
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("resolve rule by id %q: %w", ruleID, err)
|
|
}
|
|
if err := c.rules.hydrateConceptDefaultEventTypes(ctx, []models.DeadlineRule{rule}); err != nil {
|
|
return nil, err
|
|
}
|
|
return &rule, nil
|
|
}
|
|
|
|
// LoadRuleByCode resolves a rule by (proceedingCode, submissionCode)
|
|
// + returns the parent proceeding for use in the response identity.
|
|
func (c *paliadCatalog) LoadRuleByCode(ctx context.Context, proceedingCode, submissionCode string) (*models.DeadlineRule, *models.ProceedingType, error) {
|
|
var pt models.ProceedingType
|
|
err := c.rules.db.GetContext(ctx, &pt,
|
|
`SELECT `+proceedingTypeColumns+`
|
|
FROM paliad.proceeding_types
|
|
WHERE code = $1 AND is_active = true`, proceedingCode)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, nil, lp.ErrUnknownProceedingType
|
|
}
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("resolve proceeding %q: %w", proceedingCode, err)
|
|
}
|
|
var rule models.DeadlineRule
|
|
err = c.rules.db.GetContext(ctx, &rule,
|
|
`SELECT `+ruleColumns+`
|
|
FROM paliad.deadline_rules_unified
|
|
WHERE proceeding_type_id = $1 AND submission_code = $2 AND is_active = true`,
|
|
pt.ID, submissionCode)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, nil, lp.ErrUnknownRule
|
|
}
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("resolve rule %q in %q: %w", submissionCode, proceedingCode, err)
|
|
}
|
|
if err := c.rules.hydrateConceptDefaultEventTypes(ctx, []models.DeadlineRule{rule}); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return &rule, &pt, nil
|
|
}
|
|
|
|
// LoadRulesByTriggerEvent lists Pipeline-C trigger-event-rooted rules.
|
|
func (c *paliadCatalog) LoadRulesByTriggerEvent(ctx context.Context, triggerEventID int64) ([]models.DeadlineRule, error) {
|
|
return c.rules.ListByTriggerEvent(ctx, triggerEventID)
|
|
}
|
|
|
|
// LoadTriggerEventsByIDs bulk-loads paliad.trigger_events rows for
|
|
// the conditional-label override (t-paliad-294 / m/paliad#126).
|
|
func (c *paliadCatalog) LoadTriggerEventsByIDs(ctx context.Context, ids []int64) (map[int64]models.TriggerEvent, error) {
|
|
return c.rules.LoadTriggerEventsByIDs(ctx, ids)
|
|
}
|
|
|
|
// LookupEvents queries paliad.deadline_rules for rules matching the
|
|
// requested axes, then walks the parent_id graph in Go to honour the
|
|
// requested depth. Slice B2 (m/paliad#124 §18.2).
|
|
//
|
|
// Filter axes apply at the SQL layer:
|
|
// - Jurisdiction: WHERE paliad.proceeding_types.jurisdiction = $X
|
|
// - ProceedingTypeID: WHERE deadline_rules.proceeding_type_id = $X
|
|
// - Party: WHERE deadline_rules.primary_party = $X
|
|
// - EventCategoryID: EXISTS subquery on
|
|
// paliad.event_category_concepts joined via concept_id
|
|
// - AppealTarget: WHERE $X = ANY(deadline_rules.applies_to_target)
|
|
//
|
|
// Depth is applied post-fetch: for EventLookupDepthNext, anchor rules
|
|
// (matched directly) are returned at depth=1 + their immediate
|
|
// children (parent_id IN matched-set) at depth=2. For
|
|
// EventLookupDepthAllFollowing, the parent_id walk continues
|
|
// recursively. The walk stays within the per-proceeding rule set
|
|
// (cross-proceeding spawn following is handled by the engine, not by
|
|
// LookupEvents).
|
|
//
|
|
// "published + active" gate: lifecycle_state='published' AND
|
|
// is_active=true (matches LoadProceeding's WHERE clause).
|
|
func (c *paliadCatalog) LookupEvents(ctx context.Context, axes lp.EventLookupAxes, depth lp.EventLookupDepth) ([]lp.EventMatch, error) {
|
|
// Validate axis values up front; unknown values fall through as
|
|
// "no filter on this axis" so a stale frontend chip doesn't
|
|
// silently drop the entire result set.
|
|
jurisdiction := axes.Jurisdiction
|
|
if jurisdiction != "" && jurisdiction != "UPC" && jurisdiction != "DE" &&
|
|
jurisdiction != "EPA" && jurisdiction != "DPMA" {
|
|
jurisdiction = ""
|
|
}
|
|
party := axes.Party
|
|
if party != "" && !lp.IsValidPrimaryParty(party) {
|
|
party = ""
|
|
}
|
|
appealTarget := axes.AppealTarget
|
|
if appealTarget != "" && !lp.IsValidAppealTarget(appealTarget) {
|
|
appealTarget = ""
|
|
}
|
|
|
|
// Build the WHERE clause progressively. Each axis adds a $N
|
|
// placeholder + appends to the args slice.
|
|
where := []string{
|
|
"dr.is_active = true",
|
|
"dr.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 jurisdiction != "" {
|
|
add("pt.jurisdiction = $%d", jurisdiction)
|
|
}
|
|
if axes.ProceedingTypeID != nil {
|
|
add("dr.proceeding_type_id = $%d", *axes.ProceedingTypeID)
|
|
}
|
|
if party != "" {
|
|
add("dr.primary_party = $%d", party)
|
|
}
|
|
if axes.EventCategoryID != nil {
|
|
// Junction-table EXISTS: the rule's concept_id must appear in
|
|
// paliad.event_category_concepts with the matching
|
|
// event_category_id.
|
|
add(`EXISTS (
|
|
SELECT 1 FROM paliad.event_category_concepts ecc
|
|
WHERE ecc.event_category_id = $%d
|
|
AND ecc.concept_id = dr.concept_id
|
|
)`, *axes.EventCategoryID)
|
|
}
|
|
if appealTarget != "" {
|
|
add("$%d = ANY(dr.applies_to_target)", appealTarget)
|
|
}
|
|
|
|
query := `
|
|
SELECT ` + ruleColumns + `,
|
|
pt.id AS pt_id, pt.code AS pt_code, pt.name AS pt_name,
|
|
pt.name_en AS pt_name_en, pt.description AS pt_description,
|
|
pt.jurisdiction AS pt_jurisdiction, pt.category AS pt_category,
|
|
pt.default_color AS pt_default_color, pt.sort_order AS pt_sort_order,
|
|
pt.is_active AS pt_is_active,
|
|
pt.trigger_event_label_de AS pt_trigger_event_label_de,
|
|
pt.trigger_event_label_en AS pt_trigger_event_label_en,
|
|
pt.appeal_target AS pt_appeal_target
|
|
FROM paliad.deadline_rules_unified dr
|
|
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
|
|
WHERE ` + strings.Join(where, "\n AND ") + `
|
|
ORDER BY dr.proceeding_type_id, dr.sequence_order`
|
|
|
|
var rows []lookupEventsRow
|
|
if err := c.rules.db.SelectContext(ctx, &rows, query, args...); err != nil {
|
|
return nil, fmt.Errorf("lookup events: %w", err)
|
|
}
|
|
|
|
if len(rows) == 0 {
|
|
return []lp.EventMatch{}, nil
|
|
}
|
|
|
|
// matchedIDs is the set of rule IDs that satisfied the axes (the
|
|
// "anchor" matches at depth=1). For EventLookupDepthNext we add
|
|
// their direct children. For EventLookupDepthAllFollowing we walk
|
|
// the parent_id chain transitively.
|
|
matchedIDs := make(map[uuid.UUID]bool, len(rows))
|
|
anchorMatch := make(map[uuid.UUID]bool, len(rows))
|
|
rowByID := make(map[uuid.UUID]lookupEventsRow, len(rows))
|
|
for _, r := range rows {
|
|
matchedIDs[r.ID] = true
|
|
anchorMatch[r.ID] = true
|
|
rowByID[r.ID] = r
|
|
}
|
|
|
|
// For depth control we need the full per-proceeding rule corpus
|
|
// (so we can find children whose parent_id ∈ matchedIDs even when
|
|
// those children don't match the axes themselves). Skip this when
|
|
// depth is empty (treated as "anchors only" — undocumented but
|
|
// useful as a degenerate case).
|
|
expandFromCorpus := func(corpus []models.DeadlineRule, joinedFor map[int]lookupEventsRow) {
|
|
// We loop until no new descendants are added (transitive
|
|
// closure under parent_id ∈ matchedIDs). EventLookupDepthNext
|
|
// stops after one pass; AllFollowing iterates to fixpoint.
|
|
for {
|
|
grew := false
|
|
for _, r := range corpus {
|
|
if r.ParentID == nil {
|
|
continue
|
|
}
|
|
if !matchedIDs[*r.ParentID] {
|
|
continue
|
|
}
|
|
if matchedIDs[r.ID] {
|
|
continue
|
|
}
|
|
matchedIDs[r.ID] = true
|
|
j := joinedFor[*r.ProceedingTypeID]
|
|
rowByID[r.ID] = lookupEventsRow{
|
|
DeadlineRule: r,
|
|
PTID: j.PTID,
|
|
PTCode: j.PTCode,
|
|
PTName: j.PTName,
|
|
PTNameEN: j.PTNameEN,
|
|
PTDescription: j.PTDescription,
|
|
PTJurisdiction: j.PTJurisdiction,
|
|
PTCategory: j.PTCategory,
|
|
PTDefaultColor: j.PTDefaultColor,
|
|
PTSortOrder: j.PTSortOrder,
|
|
PTIsActive: j.PTIsActive,
|
|
PTTriggerEventLabelDE: j.PTTriggerEventLabelDE,
|
|
PTTriggerEventLabelEN: j.PTTriggerEventLabelEN,
|
|
PTAppealTarget: j.PTAppealTarget,
|
|
}
|
|
grew = true
|
|
}
|
|
if !grew || depth == lp.EventLookupDepthNext {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
if depth == lp.EventLookupDepthNext || depth == lp.EventLookupDepthAllFollowing {
|
|
// Load the proceeding-scoped corpus for every proceeding_type
|
|
// that appeared in the anchor set. The walk needs full
|
|
// visibility into each proceeding's rule tree so it can
|
|
// resolve parent_id chains.
|
|
procIDs := make(map[int]struct{})
|
|
joinedFor := make(map[int]lookupEventsRow)
|
|
for _, r := range rows {
|
|
procIDs[r.PTID] = struct{}{}
|
|
if _, ok := joinedFor[r.PTID]; !ok {
|
|
joinedFor[r.PTID] = r
|
|
}
|
|
}
|
|
for ptID := range procIDs {
|
|
corpus, err := c.rules.List(ctx, &ptID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("lookup events: load proceeding %d corpus: %w", ptID, err)
|
|
}
|
|
expandFromCorpus(corpus, joinedFor)
|
|
}
|
|
}
|
|
|
|
// depths[id] = sequence-depth from the closest anchor ancestor.
|
|
// Anchors are depth=1; their direct children are depth=2; etc.
|
|
depths := computeDepths(rowByID, anchorMatch)
|
|
|
|
// Compose the result slice ordered by (PTID, sequence_order).
|
|
type withKey struct {
|
|
match lp.EventMatch
|
|
key int64
|
|
}
|
|
items := make([]withKey, 0, len(matchedIDs))
|
|
for id := range matchedIDs {
|
|
r := rowByID[id]
|
|
var parentRuleID *uuid.UUID
|
|
if r.ParentID != nil && matchedIDs[*r.ParentID] {
|
|
p := *r.ParentID
|
|
parentRuleID = &p
|
|
}
|
|
items = append(items, withKey{
|
|
match: lp.EventMatch{
|
|
Rule: r.DeadlineRule,
|
|
ProceedingType: lp.ProceedingType{
|
|
ID: r.PTID,
|
|
Code: r.PTCode,
|
|
Name: r.PTName,
|
|
NameEN: r.PTNameEN,
|
|
Description: r.PTDescription,
|
|
Jurisdiction: r.PTJurisdiction,
|
|
Category: r.PTCategory,
|
|
DefaultColor: r.PTDefaultColor,
|
|
SortOrder: r.PTSortOrder,
|
|
IsActive: r.PTIsActive,
|
|
TriggerEventLabelDE: r.PTTriggerEventLabelDE,
|
|
TriggerEventLabelEN: r.PTTriggerEventLabelEN,
|
|
AppealTarget: r.PTAppealTarget,
|
|
},
|
|
Priority: r.Priority,
|
|
DepthFromAnchor: depths[id],
|
|
ParentRuleID: parentRuleID,
|
|
},
|
|
key: int64(r.PTID)*1_000_000 + int64(r.SequenceOrder),
|
|
})
|
|
}
|
|
sort.Slice(items, func(a, b int) bool { return items[a].key < items[b].key })
|
|
out := make([]lp.EventMatch, len(items))
|
|
for i, it := range items {
|
|
out[i] = it.match
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// lookupEventsRow is the joined SELECT shape for LookupEvents — one
|
|
// deadline_rules row plus its proceeding_types parent columns. Kept
|
|
// at package scope so computeDepths can reference it.
|
|
type lookupEventsRow struct {
|
|
models.DeadlineRule
|
|
PTID int `db:"pt_id"`
|
|
PTCode string `db:"pt_code"`
|
|
PTName string `db:"pt_name"`
|
|
PTNameEN string `db:"pt_name_en"`
|
|
PTDescription *string `db:"pt_description"`
|
|
PTJurisdiction *string `db:"pt_jurisdiction"`
|
|
PTCategory *string `db:"pt_category"`
|
|
PTDefaultColor string `db:"pt_default_color"`
|
|
PTSortOrder int `db:"pt_sort_order"`
|
|
PTIsActive bool `db:"pt_is_active"`
|
|
PTTriggerEventLabelDE *string `db:"pt_trigger_event_label_de"`
|
|
PTTriggerEventLabelEN *string `db:"pt_trigger_event_label_en"`
|
|
PTAppealTarget *string `db:"pt_appeal_target"`
|
|
}
|
|
|
|
// computeDepths walks from each rule up the parent_id chain until it
|
|
// hits an anchor match (or runs out). The depth of the anchor is 1;
|
|
// each step away adds one. Rules whose entire chain has no anchor
|
|
// (defensive — shouldn't happen given the expand-from-corpus walk
|
|
// only adds children of matched parents) get depth=1.
|
|
//
|
|
// Iteration-bounded by the corpus size to prevent infinite loops on
|
|
// hypothetical parent_id cycles (mig 134 + the schema CHECKs already
|
|
// preclude cycles, but the bound is cheap insurance).
|
|
func computeDepths(
|
|
rowByID map[uuid.UUID]lookupEventsRow,
|
|
anchors map[uuid.UUID]bool,
|
|
) map[uuid.UUID]int {
|
|
depths := make(map[uuid.UUID]int, len(rowByID))
|
|
for id := range rowByID {
|
|
if anchors[id] {
|
|
depths[id] = 1
|
|
continue
|
|
}
|
|
// Walk parents until we find an anchor or run out.
|
|
d := 1
|
|
cur := id
|
|
maxIter := len(rowByID) + 1
|
|
for i := 0; i < maxIter; i++ {
|
|
r := rowByID[cur]
|
|
if r.ParentID == nil {
|
|
break
|
|
}
|
|
d++
|
|
cur = *r.ParentID
|
|
if anchors[cur] {
|
|
break
|
|
}
|
|
}
|
|
depths[id] = d
|
|
}
|
|
return depths
|
|
}
|
|
|
|
// LoadScenarios lists scenarios visible to the caller (Slice D,
|
|
// m/paliad#124 §5, mig 145). RLS on paliad.scenarios enforces:
|
|
// project-scoped rows require paliad.can_see_project(project_id);
|
|
// abstract rows require created_by = auth.uid(). The filter narrows
|
|
// the SELECT (project_id-bound, abstract-for-user, or all).
|
|
func (c *paliadCatalog) LoadScenarios(ctx context.Context, filter lp.ScenarioFilter) ([]lp.Scenario, error) {
|
|
where := []string{}
|
|
args := []any{}
|
|
add := func(clause string, val any) {
|
|
args = append(args, val)
|
|
where = append(where, fmt.Sprintf(clause, len(args)))
|
|
}
|
|
if filter.ProjectID != nil {
|
|
add("project_id = $%d", *filter.ProjectID)
|
|
}
|
|
if filter.AbstractForUser != nil {
|
|
where = append(where, "project_id IS NULL")
|
|
add("created_by = $%d", *filter.AbstractForUser)
|
|
}
|
|
query := `SELECT id, project_id, name, description, spec,
|
|
created_by, created_at, updated_at
|
|
FROM paliad.scenarios`
|
|
if len(where) > 0 {
|
|
query += " WHERE " + strings.Join(where, " AND ")
|
|
}
|
|
query += " ORDER BY created_at DESC"
|
|
|
|
var rows []lp.Scenario
|
|
if err := c.rules.db.SelectContext(ctx, &rows, query, args...); err != nil {
|
|
return nil, fmt.Errorf("load scenarios: %w", err)
|
|
}
|
|
return rows, nil
|
|
}
|
|
|
|
// MatchScenario returns the scenario with the given id, or
|
|
// lp.ErrUnknownScenario if not visible / not found. RLS gates
|
|
// visibility; a not-found result could mean "doesn't exist" OR
|
|
// "exists but you can't see it" — either way the caller treats it
|
|
// as unknown.
|
|
func (c *paliadCatalog) MatchScenario(ctx context.Context, id uuid.UUID) (*lp.Scenario, error) {
|
|
var s lp.Scenario
|
|
err := c.rules.db.GetContext(ctx, &s,
|
|
`SELECT id, project_id, name, description, spec,
|
|
created_by, created_at, updated_at
|
|
FROM paliad.scenarios
|
|
WHERE id = $1`, id)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, lp.ErrUnknownScenario
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("match scenario %q: %w", id, err)
|
|
}
|
|
return &s, nil
|
|
}
|
|
|
|
// _ proves paliadCatalog satisfies lp.Catalog at compile time.
|
|
var _ lp.Catalog = (*paliadCatalog)(nil)
|
|
|
|
// Ensure HolidayService satisfies lp.HolidayCalendar at compile time.
|
|
// HolidayService.AdjustForNonWorkingDaysWithReason returns the
|
|
// AdjustmentReason via paliad's internal type — since lp.AdjustmentReason
|
|
// is now the canonical definition and AdjustmentReason inside services
|
|
// is aliased to it, the signatures align verbatim.
|
|
var _ lp.HolidayCalendar = (*HolidayService)(nil)
|
|
|
|
// Ensure CourtService satisfies lp.CourtRegistry at compile time.
|
|
var _ lp.CourtRegistry = (*CourtService)(nil)
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Helpers used by sibling services (event_trigger_service,
|
|
// event_deadline_service). Re-exported as thin wrappers so the existing
|
|
// call-sites in those services continue to compile without an import
|
|
// rewrite. A future slice can collapse them onto direct lp.* imports.
|
|
// ---------------------------------------------------------------------
|
|
|
|
// applyDuration delegates to litigationplanner.ApplyDuration.
|
|
func applyDuration(base time.Time, value int, unit, timing, country, regime string, holidays *HolidayService) (raw, adjusted time.Time, didAdjust bool, reason *AdjustmentReason) {
|
|
return lp.ApplyDuration(base, value, unit, timing, country, regime, holidays)
|
|
}
|
|
|
|
// addWorkingDays delegates to litigationplanner.AddWorkingDays.
|
|
//
|
|
//nolint:unused // referenced for forward-compat with sibling services
|
|
func addWorkingDays(from time.Time, n int, country, regime string, holidays *HolidayService) time.Time {
|
|
return lp.AddWorkingDays(from, n, country, regime, holidays)
|
|
}
|
|
|
|
// evalConditionExpr delegates to litigationplanner.EvalConditionExpr.
|
|
func evalConditionExpr(expr []byte, flags map[string]struct{}) bool {
|
|
return lp.EvalConditionExpr(expr, flags)
|
|
}
|
|
|
|
// hasConditionExpr delegates to litigationplanner.HasConditionExpr.
|
|
func hasConditionExpr(expr models.NullableJSON) bool {
|
|
return lp.HasConditionExpr(expr)
|
|
}
|
|
|
|
// extractFlagsFromExpr delegates to litigationplanner.ExtractFlagsFromExpr.
|
|
//
|
|
//nolint:unused // retained for sibling services that may want it
|
|
func extractFlagsFromExpr(expr models.NullableJSON) []string {
|
|
return lp.ExtractFlagsFromExpr(expr)
|
|
}
|
|
|
|
// allFlagsSet delegates to litigationplanner.AllFlagsSet. Retained for
|
|
// the paliad-side test suite that asserts the helper's contract.
|
|
func allFlagsSet(required []string, set map[string]struct{}) bool {
|
|
return lp.AllFlagsSet(required, set)
|
|
}
|
|
|
|
// wireFlagsFromPriority delegates to
|
|
// litigationplanner.WireFlagsFromPriority. Retained for the paliad-side
|
|
// test suite that asserts the priority → (isMandatory, isOptional)
|
|
// mapping.
|
|
func wireFlagsFromPriority(priority string) (isMandatory, isOptional bool) {
|
|
return lp.WireFlagsFromPriority(priority)
|
|
}
|
|
|
|
// sortDeadlinesByDurationWithinTriggerGroup is the paliad-side wrapper
|
|
// retained for the t-paliad-296 sort tests. Delegates to the
|
|
// package-internal sort over the lp.TimelineEntry shape — which is
|
|
// just an alias for UIDeadline, so callers pass []UIDeadline directly.
|
|
func sortDeadlinesByDurationWithinTriggerGroup(
|
|
deadlines []UIDeadline,
|
|
ruleByID map[uuid.UUID]models.DeadlineRule,
|
|
) {
|
|
lp.SortDeadlinesByDurationWithinTriggerGroup(deadlines, ruleByID)
|
|
}
|
|
|
|
// DefaultsForJurisdiction delegates to
|
|
// litigationplanner.DefaultsForJurisdiction. Public re-export so
|
|
// handlers (deadline_rules_db.go) can keep using
|
|
// services.DefaultsForJurisdiction without an import-rewrite.
|
|
func DefaultsForJurisdiction(jurisdiction *string) (country, regime string) {
|
|
return lp.DefaultsForJurisdiction(jurisdiction)
|
|
}
|
|
|
|
// applyRuleOverrides delegates to litigationplanner.ApplyRuleOverrides.
|
|
//
|
|
//nolint:unused // retained for sibling services that may want it
|
|
func applyRuleOverrides(src, overrides []models.DeadlineRule) []models.DeadlineRule {
|
|
return lp.ApplyRuleOverrides(src, overrides)
|
|
}
|