package services import ( "context" "database/sql" "errors" "fmt" "time" "github.com/google/uuid" "github.com/jmoiron/sqlx" "github.com/lib/pq" ) // EventDeadlineService backs the "Was kommt nach…" Fristenrechner mode: // given a trigger event + date, return all deadlines that flow from it // with their computed due dates. Mirrors youpc.org's deadline-calc // shape (event-driven). // // Phase 3 Slice 3 (t-paliad-184) refactor: the math + rule SELECT moved // into FristenrechnerService.calculateByTriggerEvent (which reads from // the unified paliad.deadline_rules backed by mig 085's data-move). // EventDeadlineService.Calculate delegated and wrapped the unified // response in the legacy CalculateResponse shape, but still SELECTed // paliad.event_deadlines + paliad.event_deadline_rule_codes for the // per-row metadata (DurationValue, DurationUnit, Timing, Notes, RuleCodes, // alt_*, combine_op). // // Phase 3 Slice 9 follow-up A (t-paliad-199): EventDeadlineService now // reads source rows from paliad.deadline_rules directly — the // trigger_event_id IS NOT NULL filter scopes to the 77 Pipeline-C rows // mig 085 unified. Multi-code citations (the legacy // event_deadline_rule_codes junction) live in the new // paliad.deadline_rules.rule_codes text[] column populated by mig 092's // backfill. event_deadlines + event_deadline_rule_codes are dropped by // mig 092; the service no longer references either. // // Phase 3 Slice 4 (t-paliad-185) collapsed the prior on-service // applyDuration / addWorkingDays helpers into package-level functions // shared with FristenrechnerService — single source-of-truth for // timing / working_days / holiday-rollover arithmetic. type EventDeadlineService struct { db *sqlx.DB calc *DeadlineCalculator holidays *HolidayService courts *CourtService fristenrechner *FristenrechnerService } // NewEventDeadlineService wires the service to its dependencies. func NewEventDeadlineService(db *sqlx.DB, calc *DeadlineCalculator, holidays *HolidayService, courts *CourtService, fristenrechner *FristenrechnerService) *EventDeadlineService { return &EventDeadlineService{ db: db, calc: calc, holidays: holidays, courts: courts, fristenrechner: fristenrechner, } } // TriggerEventSummary is the shape returned to the picker UI: lightweight // (no description, no audit timestamps) and sorted alphabetically by name. type TriggerEventSummary struct { ID int64 `db:"id" json:"id"` Code string `db:"code" json:"code"` Name string `db:"name" json:"name"` NameDE string `db:"name_de" json:"name_de"` } // ErrUnknownTriggerEvent is returned when a request references a trigger // event that doesn't exist or is inactive. var ErrUnknownTriggerEvent = errors.New("unknown trigger event") // ListTriggerEvents returns active triggers for the picker, sorted by name. func (s *EventDeadlineService) ListTriggerEvents(ctx context.Context) ([]TriggerEventSummary, error) { var rows []TriggerEventSummary err := s.db.SelectContext(ctx, &rows, ` SELECT id, code, name, name_de FROM paliad.trigger_events WHERE is_active = true ORDER BY lower(name)`) if err != nil { return nil, fmt.Errorf("list trigger events: %w", err) } return rows, nil } // EventDeadlineResult is one computed deadline returned to the UI. // Bilingual title + bilingual notes + the rule codes attached to the // deadline + the computed due date (after weekend/holiday rollover). type EventDeadlineResult struct { ID int64 `json:"id"` Title string `json:"title"` TitleDE string `json:"titleDE"` DurationValue int `json:"durationValue"` DurationUnit string `json:"durationUnit"` Timing string `json:"timing"` Notes string `json:"notes,omitempty"` NotesEN string `json:"notesEN,omitempty"` RuleCodes []string `json:"ruleCodes"` DueDate string `json:"dueDate"` // YYYY-MM-DD, after holiday/weekend adjust OriginalDueDate string `json:"originalDueDate"` // YYYY-MM-DD, before adjust WasAdjusted bool `json:"wasAdjusted"` IsComposite bool `json:"isComposite,omitempty"` // true when alt_* + combine_op resolved this row CompositeNote string `json:"compositeNote,omitempty"` } // CalculateResponse is what the API hands back to the client. type CalculateResponse struct { TriggerEvent TriggerEventSummary `json:"triggerEvent"` TriggerDate string `json:"triggerDate"` Deadlines []EventDeadlineResult `json:"deadlines"` } // Calculate resolves all deadlines flowing from a trigger event + date. // // Phase 3 Slice 3 (t-paliad-184) delegated the rule SELECT + math to // FristenrechnerService.calculateByTriggerEvent — which reads from // paliad.deadline_rules WHERE trigger_event_id = X (the rows mig 085 // moved out of event_deadlines). // // Phase 3 Slice 9 follow-up A (t-paliad-199): the per-row metadata // SELECT now also reads from paliad.deadline_rules. Mig 092 dropped // paliad.event_deadlines + paliad.event_deadline_rule_codes after // backfilling the multi-code junction rows into // paliad.deadline_rules.rule_codes (text[]). The legacy // EventDeadlineResult shape is built by mapping fields: // // deadline_rules.name → EventDeadlineResult.TitleDE // deadline_rules.name_en → EventDeadlineResult.Title // deadline_rules.deadline_notes → EventDeadlineResult.Notes // deadline_rules.deadline_notes_en → EventDeadlineResult.NotesEN // deadline_rules.rule_codes → EventDeadlineResult.RuleCodes // deadline_rules.sequence_order → EventDeadlineResult.ID // (legacy event_deadlines.id semantic via mig 085's // sequence_order = 1000 + event_deadlines.id convention) // // The public /api/tools/event-deadlines wire shape is unchanged from // pre-Slice-9-followup-A — only the backing query changes. // // courtID may be empty for legacy callers — defaults to UPC München // (DE country, UPC regime) for the trigger-event surface. func (s *EventDeadlineService) Calculate(ctx context.Context, triggerEventID int64, triggerDateStr, courtID string) (*CalculateResponse, error) { var trig TriggerEventSummary err := s.db.GetContext(ctx, &trig, ` SELECT id, code, name, name_de FROM paliad.trigger_events WHERE id = $1 AND is_active = true`, triggerEventID) if errors.Is(err, sql.ErrNoRows) { return nil, ErrUnknownTriggerEvent } if err != nil { return nil, fmt.Errorf("load trigger event: %w", err) } // Source-of-truth columns the unified UIResponse drops (the // frontend still reads DurationValue/Unit/Timing literally to render // the "X days after" pill). Reading from paliad.deadline_rules with // trigger_event_id = $1 — the same row set FristenrechnerService. // calculateByTriggerEvent uses, so a join by rule.ID is exact. // COALESCE(timing, 'after') matches the column default. Pipeline-C // rows seeded by mig 085 always carry an explicit timing (the // source event_deadlines.timing was NOT NULL); the COALESCE guards // any future hand-edited rule that left the column NULL. var rows []eventDeadlineRuleRow err = s.db.SelectContext(ctx, &rows, ` SELECT id, sequence_order, name, name_en, duration_value, duration_unit, COALESCE(timing, 'after') AS timing, deadline_notes, deadline_notes_en, alt_duration_value, alt_duration_unit, combine_op, rule_codes FROM paliad.deadline_rules WHERE trigger_event_id = $1 AND is_active = true ORDER BY sequence_order`, triggerEventID) if err != nil { return nil, fmt.Errorf("load deadlines: %w", err) } byRuleID := make(map[uuid.UUID]eventDeadlineRuleRow, len(rows)) for _, r := range rows { byRuleID[r.ID] = r } // Delegate to the unified calculator. UIResponse comes back with the // adjusted/original dates + wasAdjusted; UIDeadline.RuleID is // rule.ID.String(), so we can merge precisely on the rule UUID // without relying on title_de string equality (the pre-Slice-9 // shape) — a fragile match if a rule's name ever diverges from its // source. unified, err := s.fristenrechner.Calculate(ctx, "", triggerDateStr, CalcOptions{ TriggerEventIDFilter: &triggerEventID, CourtID: courtID, }) if err != nil { return nil, err } // Holiday/regime resolution is cheap but happens up to N times in // the composite-recompute loop below; pull it out so we hit the // CourtService once per call. country, regime, cerr := s.courts.CountryRegime(courtID, CountryDE, RegimeUPC) if cerr != nil { return nil, cerr } triggerDate, terr := time.Parse("2006-01-02", triggerDateStr) if terr != nil { return nil, fmt.Errorf("invalid trigger date %q: %w", triggerDateStr, terr) } results := make([]EventDeadlineResult, 0, len(unified.Deadlines)) for _, d := range unified.Deadlines { ruleID, perr := uuid.Parse(d.RuleID) if perr != nil { // UIDeadline.RuleID is always rule.ID.String() — a non-UUID // here would mean a calculator bug. Skip defensively rather // than fail the request. continue } src, ok := byRuleID[ruleID] if !ok { // Defensive: a unified row exists for which no source // deadline_rules row matches by ID. Should be impossible // since both branches read the same rows; skip rather than // emit a broken row. continue } isComposite := src.CombineOp != nil && src.AltDurationValue != nil && src.AltDurationUnit != nil compositeNote := "" if isComposite { // Recompute which leg won by re-running applyDuration with // the source's exact inputs — cheaper than threading the // pick through the unified UIDeadline shape. _, baseAdj, _, _ := applyDuration(triggerDate, src.DurationValue, src.DurationUnit, src.Timing, country, regime, s.holidays) _, altAdj, _, _ := applyDuration(triggerDate, *src.AltDurationValue, *src.AltDurationUnit, src.Timing, country, regime, s.holidays) pickedUnit := src.DurationUnit switch *src.CombineOp { case "max": if altAdj.After(baseAdj) { pickedUnit = *src.AltDurationUnit } case "min": if altAdj.Before(baseAdj) { pickedUnit = *src.AltDurationUnit } } compositeNote = fmt.Sprintf("%s(%d %s, %d %s) → %s leg", *src.CombineOp, src.DurationValue, src.DurationUnit, *src.AltDurationValue, *src.AltDurationUnit, pickedUnit) } notes := "" if src.DeadlineNotes != nil { notes = *src.DeadlineNotes } notesEN := "" if src.DeadlineNotesEn != nil { notesEN = *src.DeadlineNotesEn } // rule_codes is NULL when the Pipeline-C rule had no junction // rows pre-mig-092 (7 of 77 deadlines). Emit an empty slice in // that case so the JSON contract stays `"ruleCodes": []` rather // than `null`. ruleCodes := []string(src.RuleCodes) if ruleCodes == nil { ruleCodes = []string{} } results = append(results, EventDeadlineResult{ // Legacy event_deadlines.id semantic: mig 085 set // sequence_order = 1000 + event_deadlines.id, so the // pre-Slice-9-followup-A integer IDs (1..206) round-trip // via sequence_order - 1000. Preserves the wire contract // for the existing 77 Pipeline-C rows; Pipeline-C rules // added by the rule editor get whatever sequence_order // the editor assigns (no event_deadlines counterpart). ID: int64(src.SequenceOrder - 1000), Title: src.NameEN, TitleDE: src.Name, DurationValue: src.DurationValue, DurationUnit: src.DurationUnit, Timing: src.Timing, Notes: notes, NotesEN: notesEN, RuleCodes: ruleCodes, DueDate: d.DueDate, OriginalDueDate: d.OriginalDate, WasAdjusted: d.WasAdjusted, IsComposite: isComposite, CompositeNote: compositeNote, }) } return &CalculateResponse{ TriggerEvent: trig, TriggerDate: triggerDateStr, Deadlines: results, }, nil } // eventDeadlineRuleRow is the package-private row shape used by // Calculate's SELECT against paliad.deadline_rules. Keeps optional // fields as pointers (nil = no composite alt-leg / no notes). rule_codes // is pq.StringArray so the text[] column scans cleanly; Pipeline-C // rules without junction rows have a NULL column and end up with a nil // slice (treated as "no codes"). type eventDeadlineRuleRow struct { ID uuid.UUID `db:"id"` SequenceOrder int `db:"sequence_order"` Name string `db:"name"` NameEN string `db:"name_en"` DurationValue int `db:"duration_value"` DurationUnit string `db:"duration_unit"` Timing string `db:"timing"` DeadlineNotes *string `db:"deadline_notes"` DeadlineNotesEn *string `db:"deadline_notes_en"` AltDurationValue *int `db:"alt_duration_value"` AltDurationUnit *string `db:"alt_duration_unit"` CombineOp *string `db:"combine_op"` RuleCodes pq.StringArray `db:"rule_codes"` }