Files
paliad/internal/services/event_trigger_service.go
mAi 99a72a744f refactor(t-paliad-195): drop legacy fields from Go service surface
Phase 3 Slice 9 Go cleanup. With mig 091's column drops live, the
service layer stops reading + emitting the legacy shape:

  - models.DeadlineRule: drop IsMandatory, IsOptional, ConditionFlag,
    ConditionRuleID fields. Comment block flags Slice 9 as the
    closeout slice.
  - DeadlineRuleService.ruleColumns: SELECT no longer enumerates the
    dropped columns. The post-Slice-9 schema is the live shape.
  - FristenrechnerService.UIDeadline: drops IsMandatory + IsOptional
    fields. Frontend reads `priority` directly post-Slice-8; the
    legacy emit was kept "for one release" and that release is now.
  - evalConditionExpr signature: drops the conditionFlag fallback
    param. NULL / "null" expressions return true (unconditional);
    the legacy text[] fallback was the only reason for the second
    param. New helpers hasConditionExpr + extractFlagsFromExpr fill
    the gaps (alt-swap guard + RuleCalculation.FlagsRequired list).
  - FristenrechnerService.Calculate + calculateByTriggerEvent +
    EventTriggerService.Trigger: switched to the new (single-arg)
    evalConditionExpr; alt-swap guard now uses
    hasConditionExpr(r.ConditionExpr) instead of the dropped
    len(r.ConditionFlag) > 0 check.
  - FristenrechnerService.CalculateRule: RuleCalculationRule.IsMandatory
    derived from priority via wireFlagsFromPriority (kept for the
    result-card panel TS contract). FlagsRequired walks the jsonb
    gate tree to enumerate {"flag":"X"} leaves (replaces the
    dropped condition_flag enumeration).
  - RuleEditorService.Create + CloneAsDraft INSERT statements:
    dropped is_mandatory / is_optional / condition_flag from the
    column lists. Live shape only.

Test fixtures (projection_service_test.go, rule_editor_service_test.go,
fristenrechner_test.go) all updated to write the live shape on
seed; the evalConditionExpr table-driven test dropped its legacy
fallback cases (the fallback no longer exists) and now exercises
20 pure-jsonb scenarios across AND/OR/NOT compositions.

The deadline_rule_service_test backfill assertion lost its
(is_mandatory, is_optional) bucket cross-check (those columns are
gone); the priority-non-NULL invariant still holds via the CHECK
constraint. condition_flag cross-check now joins the pre-mig-091
snapshot table (when present) instead of the live row.
2026-05-15 17:53:31 +02:00

291 lines
9.7 KiB
Go

package services
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"mgit.msbls.de/m/paliad/internal/models"
)
// EventTriggerService backs POST /api/tools/event-trigger — Phase 3
// Slice 6 (t-paliad-187, design §5). Given an event-type or a concept
// (or both), it discovers the deadline rules triggered by the input
// and computes their dates via the unified Phase-3 helpers
// (applyDuration + evalConditionExpr).
//
// Distinct from the legacy /api/tools/event-deadlines surface (which
// is keyed exclusively on paliad.trigger_events bigints): this
// endpoint accepts either UUID paliad.event_types.id (Pipeline-C
// rules, via the trigger_event_id bridge on event_types) OR UUID
// paliad.deadline_concepts.id (Pipeline-A rules linked via the
// concept_id FK on deadline_rules). When both are passed the
// resulting rule set is UNIONed and deduped by rule.id.
//
// Distinct from FristenrechnerService.Calculate (proceeding-tree):
// no parent_id chain walk, no IsRootEvent / IsCourtSet
// classification, no AnchorOverrides — rules fire flat off the
// trigger date. The math, gate evaluation, and party-perspective
// filter all reuse Slice-4's unified helpers so the response shape
// stays calibrated against the proceeding-tree calculator.
type EventTriggerService struct {
db *sqlx.DB
rules *DeadlineRuleService
holidays *HolidayService
courts *CourtService
}
// NewEventTriggerService wires the service to its dependencies.
func NewEventTriggerService(db *sqlx.DB, rules *DeadlineRuleService, holidays *HolidayService, courts *CourtService) *EventTriggerService {
return &EventTriggerService{db: db, rules: rules, holidays: holidays, courts: courts}
}
// EventTriggerInput is the parsed request body. At least one of
// EventTypeID / ConceptID must be set (validated in Trigger).
type EventTriggerInput struct {
// EventTypeID resolves through paliad.event_types.id →
// trigger_event_id (bigint) → SELECT deadline_rules WHERE
// trigger_event_id matches. Nil = no event-type leg.
EventTypeID *uuid.UUID
// ConceptID matches deadline_rules.concept_id directly (the
// Pipeline-A cascade leaf semantic that the result-card click
// flow uses). Nil = no concept leg.
ConceptID *uuid.UUID
// TriggerDate is the anchor for the calculator. Required.
// Format: YYYY-MM-DD.
TriggerDate string
// Flags is the caller's flag set used by evalConditionExpr to
// gate / swap rules (e.g. with_ccr → alt-swap on flag-met).
Flags []string
// CourtID picks the (country, regime) tuple for non-working-day
// arithmetic. Empty falls back to DE / UPC (UPC München default).
CourtID string
// Perspective filters opposing-side rules out of the response.
// Empty = no filter (return rules for every party).
Perspective string
}
// Trigger discovers rules and computes their deadlines, returning
// the same UIResponse shape as FristenrechnerService.Calculate so
// the frontend can render with one renderer. Mutates no state.
func (s *EventTriggerService) Trigger(ctx context.Context, input EventTriggerInput) (*UIResponse, error) {
if input.EventTypeID == nil && input.ConceptID == nil {
return nil, fmt.Errorf("%w: event_type_id or concept_id required", ErrInvalidInput)
}
triggerDate, err := time.Parse("2006-01-02", input.TriggerDate)
if err != nil {
return nil, fmt.Errorf("%w: trigger_date must be YYYY-MM-DD (got %q)", ErrInvalidInput, input.TriggerDate)
}
// Pipeline-C rules originate from the UPC-flavoured corpus —
// default DE / UPC for the holiday calendar so this surface
// matches EventDeadlineService.Calculate's behaviour when the
// caller doesn't pick a specific court.
country, regime, err := s.courts.CountryRegime(input.CourtID, CountryDE, RegimeUPC)
if err != nil {
return nil, fmt.Errorf("resolve court %q: %w", input.CourtID, err)
}
rules, err := s.discoverRules(ctx, input.EventTypeID, input.ConceptID)
if err != nil {
return nil, err
}
flagSet := make(map[string]struct{}, len(input.Flags))
for _, f := range input.Flags {
flagSet[f] = struct{}{}
}
deadlines := make([]UIDeadline, 0, len(rules))
for _, r := range rules {
if !matchesPerspective(r.PrimaryParty, input.Perspective) {
continue
}
gateMet := evalConditionExpr([]byte(r.ConditionExpr), flagSet)
if !gateMet && r.AltDurationValue == nil {
continue
}
timing := ""
if r.Timing != nil {
timing = *r.Timing
}
// Legacy alt-swap (flag-keyed) is mutually exclusive with
// combine_op composite in the live corpus; the same guard
// FristenrechnerService.Calculate uses applies here.
durationValue := r.DurationValue
durationUnit := r.DurationUnit
if r.CombineOp == nil && gateMet && hasConditionExpr(r.ConditionExpr) && r.AltDurationValue != nil {
durationValue = *r.AltDurationValue
if r.AltDurationUnit != nil {
durationUnit = *r.AltDurationUnit
}
}
origDate, adjusted, wasAdj, reason := applyDuration(
triggerDate, durationValue, durationUnit, timing, country, regime, s.holidays,
)
if r.CombineOp != nil && r.AltDurationValue != nil && r.AltDurationUnit != nil {
altOrig, altAdj, altWasAdj, altReason := applyDuration(
triggerDate, *r.AltDurationValue, *r.AltDurationUnit, timing, country, regime, s.holidays,
)
switch *r.CombineOp {
case "max":
if altAdj.After(adjusted) {
origDate, adjusted, wasAdj, reason = altOrig, altAdj, altWasAdj, altReason
}
case "min":
if altAdj.Before(adjusted) {
origDate, adjusted, wasAdj, reason = altOrig, altAdj, altWasAdj, altReason
}
}
}
// Slice 9 (t-paliad-195): Priority is the canonical wire signal.
// Legacy IsMandatory/IsOptional fields dropped from UIDeadline
// along with the underlying column drop.
d := UIDeadline{
RuleID: r.ID.String(),
Name: r.Name,
NameEN: r.NameEN,
Priority: r.Priority,
ConditionExpr: json.RawMessage(r.ConditionExpr),
IsCourtSet: r.IsCourtSet,
DueDate: adjusted.Format("2006-01-02"),
OriginalDate: origDate.Format("2006-01-02"),
WasAdjusted: wasAdj,
AdjustmentReason: reason,
}
if r.Code != nil {
d.Code = *r.Code
}
if r.PrimaryParty != nil {
d.Party = *r.PrimaryParty
}
if r.RuleCode != nil {
d.RuleRef = *r.RuleCode
}
if r.LegalSource != nil {
d.LegalSource = *r.LegalSource
}
if r.DeadlineNotes != nil {
d.Notes = *r.DeadlineNotes
}
if r.DeadlineNotesEn != nil {
d.NotesEN = *r.DeadlineNotesEn
}
// Court-set rules surface IsCourtSet=true and clear the
// computed date — matches the proceeding-tree calculator's
// "wird vom Gericht bestimmt" rendering.
if r.IsCourtSet {
d.DueDate = ""
d.OriginalDate = ""
d.WasAdjusted = false
d.AdjustmentReason = nil
}
deadlines = append(deadlines, d)
}
return &UIResponse{
// Event-trigger responses don't carry proceeding metadata —
// the caller already has the event_type / concept context
// (they're in the request). Leaving these empty is the
// stable contract; FristenrechnerService.calculateByTriggerEvent
// (the Pipeline-C delegate) does the same.
ProceedingType: "",
ProceedingName: "",
TriggerDate: input.TriggerDate,
Deadlines: deadlines,
}, nil
}
// discoverRules returns the UNION of rules triggered by the
// event-type and concept inputs, deduped by rule.id. Either input
// may be nil — the corresponding branch is skipped.
func (s *EventTriggerService) discoverRules(ctx context.Context, eventTypeID, conceptID *uuid.UUID) ([]models.DeadlineRule, error) {
seen := make(map[uuid.UUID]struct{})
out := make([]models.DeadlineRule, 0, 16)
if eventTypeID != nil {
// event_types.trigger_event_id is nullable on the column but
// every active row in the corpus today carries a bigint here
// (the row is the bridge to the Pipeline-C corpus). NULL is
// possible for future hand-edited event_types; treat as "no
// rules triggered" rather than an error.
var triggerEventID sql.NullInt64
err := s.db.GetContext(ctx, &triggerEventID,
`SELECT trigger_event_id
FROM paliad.event_types
WHERE id = $1 AND archived_at IS NULL`, *eventTypeID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("%w: event_type_id=%s not found", ErrInvalidInput, *eventTypeID)
}
return nil, fmt.Errorf("lookup event_type: %w", err)
}
if triggerEventID.Valid {
byTrigger, err := s.rules.ListByTriggerEvent(ctx, triggerEventID.Int64)
if err != nil {
return nil, err
}
for _, r := range byTrigger {
if _, ok := seen[r.ID]; ok {
continue
}
seen[r.ID] = struct{}{}
out = append(out, r)
}
}
}
if conceptID != nil {
byConcept, err := s.rules.ListByConcept(ctx, *conceptID)
if err != nil {
return nil, err
}
for _, r := range byConcept {
if _, ok := seen[r.ID]; ok {
continue
}
seen[r.ID] = struct{}{}
out = append(out, r)
}
}
return out, nil
}
// matchesPerspective returns true iff a rule whose primary_party is
// `party` (may be nil/empty) should render under the given
// perspective filter. Empty perspective passes everything through.
// Rules without a party (NULL primary_party) always render — the
// caller didn't ask the system to take a side for these.
//
// The drop-only-on-explicit-mismatch policy keeps 'both' / 'court'
// / NULL rules visible and only filters claimant↔defendant pairs.
func matchesPerspective(party *string, perspective string) bool {
if perspective == "" || party == nil {
return true
}
switch perspective {
case "claimant":
return *party != "defendant"
case "defendant":
return *party != "claimant"
default:
// Unknown perspective: pass-through. Phase 3 Slice 8 will
// surface the allowed set; until then the API is forgiving.
return true
}
}