Files
paliad/internal/services/projection_service.go
mAi a905911cf4 fix(deadlines): restore /api/events deadline rail after mig 140 column drop (t-paliad-344)
Two SELECTs still referenced paliad.deadlines.rule_id after mig 140
(Slice B.4) dropped that column in favour of sequencing_rule_id:

  - internal/services/deadline_service.go:268 — DeadlineService.
    ListVisibleForUser. Powers /api/events?type=deadline (dashboard
    deadline rail, /deadlines page, every status bucket). Threw
    `pq: column f.rule_id does not exist` on every request → 500
    for any authenticated user hitting the dashboard.

  - internal/services/projection_service.go:1250 — collectActualsForOverrides.
    Same column on `paliad.deadlines d`. Logged once per projection
    pass (`ERROR service: projection: deadlines: ...`); aliased the
    rename to `rule_id` so the receiving struct tag still scans.

Live container logs confirmed the failure mode — a 60-row burst of
`pq: column f.rule_id does not exist at position 3:36 (42703)` starting
the minute the post-B0 container came up (mig 140 had applied to the
DB but the SELECT still used the dropped name). EXPLAIN against the
live schema after the edit plans cleanly; the LEFT JOIN to
paliad.deadline_rules_unified on sequencing_rule_id was already correct
(only the SELECT projection was stale).

Root cause: mig 140 commit (1129bab) renamed the JOIN to
`f.sequencing_rule_id` but left the SELECT clause on the older name.
The model tag is already `db:"sequencing_rule_id" json:"rule_id"`, so
the wire shape is unchanged — only the column reference flips.

bun build clean, go vet ./... clean, go test ./... green.
2026-05-28 00:47:08 +02:00

2258 lines
79 KiB
Go

package services
// ProjectionService composes the SmartTimeline read view for a project —
// the merged stream of past actuals (deadlines + appointments + opted-in
// project_events) plus future projections from the fristenrechner.
//
// Slice 1 (t-paliad-171) returned only actuals. Slice 2 (t-paliad-173)
// adds:
//
// - Future-projection rows (Kind="projected") via FristenrechnerService.
// - 7-event lookahead cap with ?lookahead=N override (1..50).
// - Predicted-overdue rows (past projected dates without an anchor)
// bypass the cap and surface as Status="predicted_overdue".
// - Dependency annotations (DependsOnRuleCode/Date/Name) on every row
// derived from a deadline_rule with a parent_id.
// - Anchor + skip write paths (RecordAnchor, RecordRuleSkipped).
//
// Slice 4 (t-paliad-175) adds parent-node lane aggregation (§5):
//
// - levelPolicy(projectType) returns the (kinds, statuses, lane_axis)
// triple per level — Case = full detail + CCR track; Patent = lanes
// per child case (deadlines + milestones, done+open+overdue);
// Litigation = lanes per child patent (milestones, done); Client =
// lanes per child litigation (milestones, done; opt-in via toggle).
// - Lanes []LaneInfo on the response envelope, LaneID on every event
// row — frontend buckets by lane for parallel-column rendering.
// - metadata.bubble_up=true on paliad.project_events overrides the
// kind/status filter at higher levels so structural milestones
// (counterclaim_created, third_party_intervention, scope_change,
// opt-in custom_milestone) survive the aggregation cull.
//
// See docs/design-smart-timeline-2026-05-08.md §5 + §6 + §9 + §10
// and m/paliad#31 for the layered requirements.
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"mgit.msbls.de/m/paliad/internal/models"
)
// DefaultLookaheadCap is the number of future projected rows surfaced by
// default. m/paliad#31: fixed across proceeding types in v1; "Mehr
// anzeigen" / "Weniger anzeigen" buttons control client-side override
// via the ?lookahead=N query parameter.
const DefaultLookaheadCap = 7
// ErrCyclicSpawn signals that the cross-proceeding spawn graph has a
// cycle reachable from a project's source proceeding (design §6.3,
// Slice 7 t-paliad-188). Surfaced when the visited-set DFS in
// expandCrossProceedingSpawns hits a proceeding_type_id already in the
// chain. ProjectionService.computeProjections degrades to "no spawned
// rows" rather than failing the whole SmartTimeline render.
var ErrCyclicSpawn = errors.New("cyclic cross-proceeding spawn")
// maxSpawnDepth caps recursive spawn expansion as a safety belt in
// addition to the visited-set guard. No legitimate spawn graph today
// reaches depth 4 (the live corpus has 6 spawn rules across 3 source
// proceedings → AMD / APP / CCR — each one-hop). Bump if real-world
// chains demand it; until then the cap is a backstop.
const maxSpawnDepth = 4
// MaxLookaheadCap caps the ?lookahead override so a misbehaving client
// can't request thousands of projected rows.
const MaxLookaheadCap = 50
// TimelineEvent is one row in the SmartTimeline merge. The struct is
// the wire contract of GET /api/projects/{id}/timeline; new slices
// extend it additively (new fields, never rename / repurpose).
//
// Provenance fields (DeadlineID, AppointmentID, ProjectEventID) — exactly
// one is non-nil for actual rows. All three are nil for Kind="projected"
// rows. Frontend deep-links via the populated id; projected rows expose
// DeadlineRuleID for the click-to-anchor affordance.
type TimelineEvent struct {
Kind string `json:"kind"` // "deadline" | "appointment" | "milestone" | "projected"
Status string `json:"status"` // "done" | "open" | "overdue" | "court_set" | "predicted" | "predicted_overdue" | "off_script"
Track string `json:"track"` // "parent" | "counterclaim" | "child:<project_id>" | "off_script"
// Date is nil for undated rows (court-set decisions, counterclaim-pending
// milestones). Undated rows sort to the end.
Date *time.Time `json:"date,omitempty"`
Title string `json:"title"`
Description string `json:"description,omitempty"`
RuleCode string `json:"rule_code,omitempty"`
DeadlineID *uuid.UUID `json:"deadline_id,omitempty"`
AppointmentID *uuid.UUID `json:"appointment_id,omitempty"`
ProjectEventID *uuid.UUID `json:"project_event_id,omitempty"`
// Click-to-anchor handle (Slice 2). Populated on Kind="projected"
// rows AND on actuals (Kind="deadline"/"appointment") that derive
// from a deadline_rule, so the frontend can show the rule chip on
// both alike. Party drives the side-of-the-table colour class.
DeadlineRuleID *uuid.UUID `json:"deadline_rule_id,omitempty"`
DeadlineRuleParty string `json:"deadline_rule_party,omitempty"`
// Reserved for Slice 3 (counterclaim sub-projects); populated when
// the row belongs to a child project rendered alongside the parent.
SubProjectID *uuid.UUID `json:"sub_project_id,omitempty"`
SubProjectTitle string `json:"sub_project_title,omitempty"`
// Dependency annotations (m/paliad#31 layer 2). Populated on rows
// derived from a deadline_rule with a non-NULL parent_id. The
// frontend renders "Folgt aus: <Name> (<Date>)" footer plus the
// "[Pfad anzeigen]" button when DependsOnRuleCode != "".
//
// DependsOnDate is nil when the parent has no anchored actual AND
// no projection (e.g. court-set parent that hasn't fired yet) —
// the UI renders "Datum offen" in that case.
DependsOnRuleCode string `json:"depends_on_rule_code,omitempty"`
DependsOnDate *time.Time `json:"depends_on_date,omitempty"`
DependsOnRuleName string `json:"depends_on_rule_name,omitempty"`
// LaneID buckets the row into a parallel column at parent-node levels
// (t-paliad-175 SmartTimeline Slice 4). At Case level, LaneID mirrors
// Track ("self" for the parent track, "counterclaim:<id>" for CCR
// children, "parent_context:<id>" for the CCR child's parent context).
// At Patent / Litigation / Client levels, LaneID is the direct-child
// project id under which this event originates — the frontend renders
// one column per lane and groups rows by LaneID.
LaneID string `json:"lane_id,omitempty"`
// ProjectEventType carries the underlying paliad.project_events.event_type
// for milestone rows (t-paliad-176). Empty for deadline / appointment /
// projected rows. The FilterBar's project_event_kind chip narrows the
// rendered list against this field; KnownProjectEventKinds in
// internal/services/filter_spec.go is the canonical vocabulary.
ProjectEventType string `json:"project_event_type,omitempty"`
// BubbleUp signals that a project_event milestone is marked to
// bubble up to higher-level SmartTimelines (t-paliad-175 §5.3 + §7.2).
// Read from metadata.bubble_up on the underlying paliad.project_events
// row. Default-on for structural milestones (counterclaim_created,
// third_party_intervention, scope_change), default-off for
// custom_milestone (user can override per entry via the form
// checkbox). At parent-node levels, rows with BubbleUp=true survive
// the levelPolicy kind/status filter unconditionally.
BubbleUp bool `json:"bubble_up,omitempty"`
// IsConditional marks projected rows whose anchor is uncertain —
// the projection layer mirrors UIDeadline.IsConditional from the
// fristenrechner so the SmartTimeline can render an "abhängig von
// <parent>" chip in place of the date column. When true, Date is
// nil and DependsOnRuleCode / DependsOnRuleName carry the parent
// reference (already populated by annotateDependsOn for projected
// rows; for conditional rows we additionally fall back to the
// UIDeadline-supplied ParentRule* when the parent has no
// computed date). Status is set to "conditional". (t-paliad-289)
IsConditional bool `json:"is_conditional,omitempty"`
}
// LaneInfo describes one column in the parent-node aggregated view.
// Returned alongside []TimelineEvent so the frontend knows which lanes
// to render, with what label, in what order. The id is opaque to the
// frontend (it just groups events by ev.LaneID == lane.ID); ProjectID
// lets the lane sub-header link through to the underlying project page.
type LaneInfo struct {
ID string `json:"id"`
Label string `json:"label"`
ProjectID string `json:"project_id,omitempty"`
// Primary marks the "primary" lane at Litigation level — the most-
// recently-active case per child patent (§5.1). Frontend can dim the
// non-primary lanes or rank them lower. Empty at other levels.
Primary bool `json:"primary,omitempty"`
}
// LevelPolicy is the (kinds, statuses, lane_axis) triple per project
// type returned by levelPolicy. The lane axis identifies which direct
// child type aggregates into lanes.
type LevelPolicy struct {
// Kinds is the allowed event kinds at this level. Empty = all.
Kinds []string
// Statuses is the allowed event statuses at this level. Empty = all.
Statuses []string
// LaneAxis identifies the lane grouping rule:
//
// "self_plus_ccr" — Case level: one lane for self + one per
// visible CCR sub-project.
// "child_case" — Patent level: one lane per direct child
// case (events come from each case subtree).
// "child_patent" — Litigation level: one lane per direct child
// patent (events from the primary case under
// each patent).
// "child_litigation" — Client level: one lane per direct child
// litigation (events from each litigation
// subtree).
LaneAxis string
}
// ResponseEnvelope is the wire shape of GET /api/projects/{id}/timeline
// from Slice 4 onward. Slices 1-3 returned []TimelineEvent directly;
// adding lanes [] forced the envelope. Frontend reads .events to
// preserve the per-row contract and .lanes to drive lane-grouped
// rendering at parent-node levels.
type ResponseEnvelope struct {
Events []TimelineEvent `json:"events"`
Lanes []LaneInfo `json:"lanes"`
}
// ProjectionOpts narrows the SmartTimeline read.
//
// IncludeAuditFull — when true, project_events are loaded WITHOUT the
// timeline_kind filter (every audit row, the legacy Verlauf list).
// Backs the "Audit-Log anzeigen" toggle in the timeline header.
//
// DirectOnly — when true, narrows to rows whose project_id exactly
// matches; default (false) aggregates the project + every descendant,
// matching the existing "Inkl. Unterprojekte" toggle behaviour on
// /projects/{id}.
//
// LookaheadCap — number of future projected rows to surface (Slice 2,
// m/paliad#31 layer 1). 0 = use DefaultLookaheadCap. Past predicted-
// overdue rows always bypass this cap. The handler clamps user input
// to [1, MaxLookaheadCap].
//
// Lang — language for DependsOnRuleName ("de" / "en"). Empty defaults
// to "de" (Paliad's frontend default — see CLAUDE.md "Frontend default
// language is German").
type ProjectionOpts struct {
IncludeAuditFull bool
DirectOnly bool
LookaheadCap int
Lang string
}
// ProjectionMeta summarises a projection result for the handler / frontend.
// Surfaced via X-Projection-* headers on the GET /timeline response so the
// wire shape stays []TimelineEvent (frozen from Slice 1) while the
// frontend still gets enough info to render "Mehr anzeigen".
type ProjectionMeta struct {
HasProjection bool `json:"has_projection"` // true when calculator was invoked
ProjectedTotal int `json:"projected_total"` // future predicted rows pre-cap (main track)
ProjectedShown int `json:"projected_shown"` // future predicted rows after cap (main track)
PredictedOverdue int `json:"predicted_overdue"` // overdue projection rows (main track, uncapped)
Lookahead int `json:"lookahead"` // applied cap value
// AvailableTracks lists the track tags present in the response — the
// chip selector (`[Track ▼]`) reads this to populate the dropdown.
// "parent" is always present; "counterclaim:<id>" is added when CCR
// children exist; "parent_context:<id>" is added when the viewed
// project is itself a CCR sub-project (t-paliad-174 §4.5).
AvailableTracks []string `json:"available_tracks"`
// Lanes describes the parallel-column layout at parent-node levels
// (t-paliad-175 SmartTimeline Slice 4 §5). At Case level, lanes
// mirror the available tracks (one entry for "self", one per visible
// CCR sub-project, one for parent_context when applicable). At
// Patent / Litigation / Client levels, lanes are the direct child
// projects under the lane axis. Empty when the response should
// render as a single-column flow (legacy behaviour).
Lanes []LaneInfo `json:"lanes"`
// SpawnCycleDropped is set when expandCrossProceedingSpawns detected
// a cycle in the spawn graph and degraded to "no spawned rows" rather
// than failing the projection. The SmartTimeline still renders; the
// caller can log + show a "Spawn-Auflösung übersprungen" banner so the
// editor knows which spawn rule to fix. Phase 3 Slice 7 (t-paliad-188).
SpawnCycleDropped bool `json:"spawn_cycle_dropped,omitempty"`
}
// ProjectionService composes the SmartTimeline.
type ProjectionService struct {
db *sqlx.DB
projects *ProjectService
deadlines *DeadlineService
appointments *AppointmentService
fristen *FristenrechnerService
rules *DeadlineRuleService
}
// NewProjectionService wires the read-side dependencies. fristen + rules
// are required for Slice 2 projection; pass them in even when the caller
// only intends to use actuals (the service short-circuits cleanly when
// the project has no proceeding_type set).
func NewProjectionService(
db *sqlx.DB,
projects *ProjectService,
deadlines *DeadlineService,
appointments *AppointmentService,
fristen *FristenrechnerService,
rules *DeadlineRuleService,
) *ProjectionService {
return &ProjectionService{
db: db,
projects: projects,
deadlines: deadlines,
appointments: appointments,
fristen: fristen,
rules: rules,
}
}
// For builds a SmartTimeline for one project. Returns rows + meta
// summarising the projection state. Visibility is delegated to the
// underlying services (DeadlineService / AppointmentService / the
// project_events query reuses the project visibility predicate), so
// this layer adds no new RLS surface.
//
// Sort: actuals before projections of the same date; projections sorted
// by date ASC (predicted_overdue first since they're in the past),
// undated rows last. See sortTimeline for the deterministic tiebreak.
//
// Level policy (t-paliad-175 Slice 4 §5):
// - Case (or unknown type) — full detail: own actuals + projection +
// parallel-track CCR children. Lanes mirror tracks ("self" + CCR).
// - Patent / Litigation / Client — lane-aggregated: load direct
// children matching the axis, gather their subtree events, apply
// the policy filter (kinds/statuses) with bubble_up override on
// project_events, tag every row with LaneID = direct-child id.
//
// Track composition (t-paliad-174 §4.5) survives at Case level:
// - The viewed project always emits Track="parent" rows.
// - Visible CCR sub-projects emit Track="counterclaim:<child_id>".
// - When the viewed project is itself a CCR, the parent emits
// Track="parent_context:<parent_id>" rows.
func (s *ProjectionService) For(ctx context.Context, userID, projectID uuid.UUID, opts ProjectionOpts) ([]TimelineEvent, ProjectionMeta, error) {
meta := ProjectionMeta{
Lookahead: applyLookaheadDefault(opts.LookaheadCap),
AvailableTracks: []string{"parent"},
Lanes: []LaneInfo{},
}
proj, err := s.projects.GetByID(ctx, userID, projectID)
if err != nil {
return nil, meta, err
}
policy := levelPolicy(proj.Type)
// DirectOnly collapses every level to a single-lane "self" view —
// no CCR sub-project lanes (Case level), no parent_context lane (CCR
// child viewpoint), no child-case / child-patent / child-litigation
// lanes (Patent / Litigation / Client levels). The level-policy
// kind/status filter still applies at higher levels so that, e.g., a
// Patent-level direct view doesn't suddenly leak off_script custom
// milestones that the aggregated view filters out (t-paliad-176).
if opts.DirectOnly {
return s.forDirectSelfOnly(ctx, userID, proj, policy, opts, meta)
}
// Patent / Litigation / Client levels — lane-aggregated rendering.
if policy.LaneAxis != "self_plus_ccr" {
return s.forAggregatedLevel(ctx, userID, proj, policy, opts, meta)
}
// Case level (and anything else without a known axis) — full detail
// flow: parent track + CCR sub-projects + parent_context for CCR
// children.
return s.forCaseLevel(ctx, userID, proj, opts, meta)
}
// forDirectSelfOnly handles every level when DirectOnly is requested
// (m/paliad#33). Renders this project's own actuals + (at Case level)
// projection only — no CCR / parent_context / child-case lanes. The
// policy's kind/status filter still applies at higher levels so the
// "Nur direkt" Patent view honours the same milestone-only contract as
// the aggregated default. Produces a single "self" lane.
func (s *ProjectionService) forDirectSelfOnly(
ctx context.Context,
userID uuid.UUID,
proj *models.Project,
policy LevelPolicy,
opts ProjectionOpts,
meta ProjectionMeta,
) ([]TimelineEvent, ProjectionMeta, error) {
includeProjection := policy.LaneAxis == "self_plus_ccr"
rows, mainMeta, err := s.loadProjectTrack(ctx, userID, proj, opts, "parent", nil, includeProjection)
if err != nil {
return nil, meta, err
}
meta.HasProjection = mainMeta.HasProjection
meta.ProjectedTotal = mainMeta.ProjectedTotal
meta.ProjectedShown = mainMeta.ProjectedShown
meta.PredictedOverdue = mainMeta.PredictedOverdue
allowKind := stringSet(policy.Kinds)
allowStatus := stringSet(policy.Statuses)
out := make([]TimelineEvent, 0, len(rows))
for i := range rows {
row := rows[i]
row.LaneID = "self"
if !rowSurvivesPolicy(row, allowKind, allowStatus) {
continue
}
out = append(out, row)
}
meta.Lanes = append(meta.Lanes, LaneInfo{
ID: "self",
Label: proj.Title,
ProjectID: proj.ID.String(),
})
sortTimeline(out)
return out, meta, nil
}
// forCaseLevel runs the original Slice-1-through-3 flow: parent track +
// CCR sub-projects (when this project is the parent) or parent_context
// (when this project is a CCR child). Lanes mirror tracks one-for-one
// at this level.
func (s *ProjectionService) forCaseLevel(
ctx context.Context,
userID uuid.UUID,
proj *models.Project,
opts ProjectionOpts,
meta ProjectionMeta,
) ([]TimelineEvent, ProjectionMeta, error) {
projectID := proj.ID
// --- Main project track (always present) ---------------------------
mainRows, mainMeta, err := s.loadProjectTrack(ctx, userID, proj, opts, "parent", nil, true)
if err != nil {
return nil, meta, err
}
meta.HasProjection = mainMeta.HasProjection
meta.ProjectedTotal = mainMeta.ProjectedTotal
meta.ProjectedShown = mainMeta.ProjectedShown
meta.PredictedOverdue = mainMeta.PredictedOverdue
for i := range mainRows {
mainRows[i].LaneID = "self"
}
meta.Lanes = append(meta.Lanes, LaneInfo{
ID: "self",
Label: proj.Title,
ProjectID: proj.ID.String(),
})
out := make([]TimelineEvent, 0, len(mainRows)+16)
out = append(out, mainRows...)
// --- CCR sub-project tracks (parent's view) ------------------------
if proj.CounterclaimOf == nil {
ccrChildren, err := s.projects.LoadCounterclaimChildrenVisible(ctx, userID, projectID)
if err != nil {
return nil, meta, fmt.Errorf("projection: ccr children: %w", err)
}
for i := range ccrChildren {
child := ccrChildren[i]
tag := "counterclaim:" + child.ID.String()
childRows, _, err := s.loadProjectTrack(ctx, userID, &child, opts, tag, &child, true)
if err != nil {
return nil, meta, fmt.Errorf("projection: ccr child %s: %w", child.ID, err)
}
for j := range childRows {
childRows[j].LaneID = tag
}
out = append(out, childRows...)
meta.AvailableTracks = append(meta.AvailableTracks, tag)
meta.Lanes = append(meta.Lanes, LaneInfo{
ID: tag,
Label: child.Title,
ProjectID: child.ID.String(),
})
}
}
// --- Parent context (CCR child's view) -----------------------------
if proj.CounterclaimOf != nil {
parent, err := s.projects.GetByID(ctx, userID, *proj.CounterclaimOf)
if err == nil && parent != nil {
tag := "parent_context:" + parent.ID.String()
parentRows, _, err := s.loadProjectTrack(ctx, userID, parent, opts, tag, parent, true)
if err != nil {
return nil, meta, fmt.Errorf("projection: parent context: %w", err)
}
for j := range parentRows {
parentRows[j].LaneID = tag
}
out = append(out, parentRows...)
meta.AvailableTracks = append(meta.AvailableTracks, tag)
meta.Lanes = append(meta.Lanes, LaneInfo{
ID: tag,
Label: parent.Title,
ProjectID: parent.ID.String(),
})
}
// Parent invisible to viewer (rare — usually CCR creator has
// access to both): silently omit; the CCR's own track still
// renders solo.
}
sortTimeline(out)
return out, meta, nil
}
// forAggregatedLevel handles Patent / Litigation / Client levels per
// §5: gather the direct children matching the policy's lane axis, run a
// per-lane loader on each subtree, apply the kind/status filter (with
// bubble_up override), and tag rows with LaneID = direct-child id.
//
// The projection calculator is disabled on lane-aggregated levels —
// at Patent / Litigation / Client we render only actuals + opted-in
// milestones, never the predicted future course (per §5.1 the future
// projection is a Case-level concern; surfacing it at higher levels
// would drown the user in noise).
func (s *ProjectionService) forAggregatedLevel(
ctx context.Context,
userID uuid.UUID,
proj *models.Project,
policy LevelPolicy,
opts ProjectionOpts,
meta ProjectionMeta,
) ([]TimelineEvent, ProjectionMeta, error) {
laneChildren, err := s.loadLaneChildren(ctx, userID, proj, policy)
if err != nil {
return nil, meta, err
}
out := make([]TimelineEvent, 0, len(laneChildren)*8)
allowKind := stringSet(policy.Kinds)
allowStatus := stringSet(policy.Statuses)
for i := range laneChildren {
child := laneChildren[i]
laneID := child.ID.String()
laneLabel := laneLabelFor(&child, policy)
meta.Lanes = append(meta.Lanes, LaneInfo{
ID: laneID,
Label: laneLabel,
ProjectID: child.ID.String(),
})
// Lane-aggregated levels skip projection — the lane loader runs
// the actuals pipeline only.
laneRows, _, err := s.loadProjectTrack(ctx, userID, &child, opts, "parent", nil, false)
if err != nil {
return nil, meta, fmt.Errorf("projection: lane child %s: %w", child.ID, err)
}
for j := range laneRows {
row := laneRows[j]
row.LaneID = laneID
if !rowSurvivesPolicy(row, allowKind, allowStatus) {
continue
}
out = append(out, row)
}
}
sortTimeline(out)
return out, meta, nil
}
// loadLaneChildren returns the direct children matching the policy's
// lane axis, sorted deterministically. Visibility is owned by the
// underlying ProjectService (each child lookup goes through visibility
// predicates), so a user only ever sees lanes they're entitled to.
func (s *ProjectionService) loadLaneChildren(
ctx context.Context,
userID uuid.UUID,
proj *models.Project,
policy LevelPolicy,
) ([]models.Project, error) {
want := childTypeForAxis(policy.LaneAxis)
if want == "" {
return nil, nil
}
all, err := s.projects.ListChildren(ctx, userID, proj.ID)
if err != nil {
return nil, fmt.Errorf("projection: list lane children: %w", err)
}
out := make([]models.Project, 0, len(all))
for _, c := range all {
if c.Type != want {
continue
}
// Skip CCR sub-projects from the lane list — they surface as
// their own column on the parent case's SmartTimeline (Slice 3
// behaviour), not as a separate lane at higher levels.
if c.CounterclaimOf != nil {
continue
}
out = append(out, c)
}
return out, nil
}
// childTypeForAxis maps a lane axis identifier to the project type the
// children must have. Returns "" when the axis is unknown / not lane-
// aggregated (Case level).
func childTypeForAxis(axis string) string {
switch axis {
case "child_case":
return "case"
case "child_patent":
return "patent"
case "child_litigation":
return "litigation"
}
return ""
}
// laneLabelFor picks the human-readable label for a lane sub-header.
// Patent level → "<case title> (<proceeding code>)"; Litigation level
// → patent reference / patent_number; Client level → litigation title.
// Falls back to the child's Title when no axis-specific identifier is
// available.
func laneLabelFor(child *models.Project, policy LevelPolicy) string {
switch policy.LaneAxis {
case "child_case":
// Append the proceeding type code when known so the lawyer can
// identify which case at a glance ("UPC-CFI München (upc.inf.cfi)").
if child.ProceedingTypeID != nil {
return child.Title
}
return child.Title
case "child_patent":
if child.PatentNumber != nil && strings.TrimSpace(*child.PatentNumber) != "" {
return strings.TrimSpace(*child.PatentNumber)
}
if child.Reference != nil && strings.TrimSpace(*child.Reference) != "" {
return strings.TrimSpace(*child.Reference)
}
return child.Title
case "child_litigation":
return child.Title
}
return child.Title
}
// rowSurvivesPolicy applies the (kinds, statuses) filter from levelPolicy.
// Bubble-up project_events override the filter unconditionally — that's
// the contract for structural milestones at higher levels.
func rowSurvivesPolicy(row TimelineEvent, allowKind, allowStatus map[string]bool) bool {
if row.BubbleUp {
return true
}
if len(allowKind) > 0 && !allowKind[row.Kind] {
return false
}
if len(allowStatus) > 0 && !allowStatus[row.Status] {
return false
}
return true
}
// stringSet builds a lookup map from a slice; nil/empty input returns
// nil so callers can skip the filter when the policy doesn't constrain
// the dimension.
func stringSet(vals []string) map[string]bool {
if len(vals) == 0 {
return nil
}
out := make(map[string]bool, len(vals))
for _, v := range vals {
out[v] = true
}
return out
}
// levelPolicy returns the (kinds, statuses, lane_axis) triple per
// project type per design §5.1. Unknown / empty types fall back to the
// Case-level policy — the safest default since it shows everything.
func levelPolicy(projectType string) LevelPolicy {
switch projectType {
case "patent":
return LevelPolicy{
Kinds: []string{"deadline", "milestone"},
Statuses: []string{"done", "open", "overdue"},
LaneAxis: "child_case",
}
case "litigation":
return LevelPolicy{
Kinds: []string{"milestone"},
Statuses: []string{"done"},
LaneAxis: "child_patent",
}
case "client":
return LevelPolicy{
Kinds: []string{"milestone"},
Statuses: []string{"done"},
LaneAxis: "child_litigation",
}
default:
// Case + everything else.
return LevelPolicy{LaneAxis: "self_plus_ccr"}
}
}
// loadProjectTrack runs the actuals + projection pipeline for ONE
// project and returns rows tagged with trackTag. When subProject is
// non-nil, every emitted row also carries SubProjectID + SubProjectTitle
// so the frontend can render the sub-project label in the column header.
//
// Each track applies its own lookahead cap independently — the meta
// returned represents only this track. The caller decides which track's
// meta surfaces in headers; today the main track's meta wins.
//
// includeProjection — when false, the calculator is skipped (lane-
// aggregated rendering at Patent / Litigation / Client levels per §5,
// where projected rows are deliberately hidden). The actuals pipeline
// runs unchanged either way.
func (s *ProjectionService) loadProjectTrack(
ctx context.Context,
userID uuid.UUID,
proj *models.Project,
opts ProjectionOpts,
trackTag string,
subProject *models.Project,
includeProjection bool,
) ([]TimelineEvent, ProjectionMeta, error) {
meta := ProjectionMeta{Lookahead: applyLookaheadDefault(opts.LookaheadCap)}
out := make([]TimelineEvent, 0, 16)
projectID := proj.ID
// --- Deadlines ----
deadlineRows, err := s.deadlines.ListVisibleForUser(ctx, userID, ListFilter{
ProjectID: &projectID,
DirectOnly: opts.DirectOnly,
})
if err != nil {
return nil, meta, fmt.Errorf("projection: deadlines: %w", err)
}
for _, d := range deadlineRows {
ev := TimelineEvent{
Kind: "deadline",
Status: deadlineStatus(d.Status, d.DueDate),
Track: trackTag,
Date: timePtr(time.Date(d.DueDate.Year(), d.DueDate.Month(), d.DueDate.Day(), 0, 0, 0, 0, time.UTC)),
Title: d.Title,
DeadlineID: &d.ID,
}
if d.Description != nil {
ev.Description = *d.Description
}
if d.RuleCode != nil {
ev.RuleCode = *d.RuleCode
}
if d.RuleID != nil {
id := *d.RuleID
ev.DeadlineRuleID = &id
}
applySubProject(&ev, subProject)
out = append(out, ev)
}
// --- Appointments ----
apptRows, err := s.appointments.ListVisibleForUser(ctx, userID, AppointmentListFilter{
ProjectID: &projectID,
DirectOnly: opts.DirectOnly,
})
if err != nil {
return nil, meta, fmt.Errorf("projection: appointments: %w", err)
}
now := time.Now().UTC()
for _, a := range apptRows {
startCopy := a.StartAt
ev := TimelineEvent{
Kind: "appointment",
Status: appointmentStatus(startCopy, now),
Track: trackTag,
Date: &startCopy,
Title: a.Title,
AppointmentID: &a.ID,
}
if a.Description != nil {
ev.Description = *a.Description
}
applySubProject(&ev, subProject)
out = append(out, ev)
}
if err := s.hydrateAppointmentRuleIDs(ctx, projectID, opts.DirectOnly, out); err != nil {
return nil, meta, err
}
// --- Milestones ----
skippedRules, milestoneRows, err := s.listProjectEvents(ctx, userID, projectID, opts)
if err != nil {
return nil, meta, fmt.Errorf("projection: milestones: %w", err)
}
for i := range milestoneRows {
milestoneRows[i].Track = trackTag
applySubProject(&milestoneRows[i], subProject)
}
out = append(out, milestoneRows...)
// --- Projection (Slice 2) ----
if includeProjection {
projectedRows, projMeta, err := s.computeProjections(ctx, proj, skippedRules, opts)
if err != nil {
return nil, meta, fmt.Errorf("projection: calculate: %w", err)
}
for i := range projectedRows {
projectedRows[i].Track = trackTag
applySubProject(&projectedRows[i], subProject)
}
out = append(out, projectedRows...)
meta.HasProjection = projMeta.HasProjection
meta.ProjectedTotal = projMeta.ProjectedTotal
meta.ProjectedShown = projMeta.ProjectedShown
meta.PredictedOverdue = projMeta.PredictedOverdue
}
// --- Dependency annotations ----
if proj.ProceedingTypeID != nil && s.rules != nil {
rules, err := s.rules.List(ctx, proj.ProceedingTypeID)
if err == nil {
s.annotateDependsOn(out, rules, lang(opts.Lang))
}
}
return out, meta, nil
}
// applySubProject fills SubProjectID + SubProjectTitle when the row
// belongs to a non-primary track. No-op when subProject is nil.
func applySubProject(ev *TimelineEvent, subProject *models.Project) {
if subProject == nil {
return
}
id := subProject.ID
ev.SubProjectID = &id
ev.SubProjectTitle = subProject.Title
}
// computeProjections runs FristenrechnerService.Calculate for the project
// and emits TimelineEvent rows for every rule that does NOT have a
// matching actual. Returns the projected rows + the meta summary.
//
// Lookahead cap (m/paliad#31 layer 1): future predicted rows beyond
// LookaheadCap are dropped from the output; predicted-overdue rows
// (past projected dates without an anchor) bypass the cap. Court-set
// rules in the future surface with Status="court_set"; all other future
// rules with Status="predicted".
func (s *ProjectionService) computeProjections(
ctx context.Context,
proj *models.Project,
skippedRuleCodes map[string]bool,
opts ProjectionOpts,
) ([]TimelineEvent, ProjectionMeta, error) {
cap := applyLookaheadDefault(opts.LookaheadCap)
meta := ProjectionMeta{Lookahead: cap}
if proj.ProceedingTypeID == nil || s.fristen == nil || s.rules == nil {
return nil, meta, nil
}
// Resolve proceeding code from id.
var proceedingCode string
err := s.db.GetContext(ctx, &proceedingCode,
`SELECT code FROM paliad.proceeding_types WHERE id = $1 AND is_active = true`,
*proj.ProceedingTypeID)
if errors.Is(err, sql.ErrNoRows) {
return nil, meta, nil
}
if err != nil {
return nil, meta, fmt.Errorf("resolve proceeding code: %w", err)
}
// Build the AnchorOverrides map from completed actuals + appointments
// tied to a rule. Track which rule_ids have actuals so we can skip
// emitting projected rows for them.
overrides := map[string]string{}
ruleIDsWithActual := map[uuid.UUID]bool{}
rules, err := s.rules.List(ctx, proj.ProceedingTypeID)
if err != nil {
return nil, meta, fmt.Errorf("list rules for proceeding: %w", err)
}
ruleByID := make(map[uuid.UUID]models.DeadlineRule, len(rules))
for _, r := range rules {
ruleByID[r.ID] = r
}
// Deadlines with rule_id → override the rule's anchor with the actual
// date. Prefer completed_at on done rows; fall back to due_date.
if err := s.collectActualsForOverrides(ctx, proj.ID, ruleByID,
opts.DirectOnly, overrides, ruleIDsWithActual); err != nil {
return nil, meta, err
}
// Determine triggerDate. Look for the proceeding's root rule
// (parent_id IS NULL) — when an actual exists for it, that's the
// trigger; otherwise today as a placeholder so the projection still
// computes relative dates. Once the user clicks "Datum setzen" on
// the root row (e.g. SoC), the next read uses the real anchor.
triggerDateStr := s.deriveTriggerDate(rules, overrides)
flags := flagsForProject(proj)
resp, err := s.fristen.Calculate(ctx, proceedingCode, triggerDateStr, CalcOptions{
AnchorOverrides: overrides,
Flags: flags,
})
if err != nil {
// Calculator hiccup is non-fatal — degrade to actuals-only so
// the page still renders. Log via the standard pattern.
return nil, meta, nil
}
meta.HasProjection = true
today := startOfUTCDay(time.Now().UTC())
projected := make([]TimelineEvent, 0, len(resp.Deadlines))
for _, ui := range resp.Deadlines {
if ui.RuleID == "" {
continue
}
ruleID, err := uuid.Parse(ui.RuleID)
if err != nil {
continue
}
if ruleIDsWithActual[ruleID] {
// Already represented as a Kind="deadline" or "appointment"
// row — skip duplicate.
continue
}
// Rule explicitly skipped via /timeline/skip (§6.4) — drop
// from cascade so the user's "ist nicht eingetreten" decision
// sticks across reloads.
if ui.Code != "" && skippedRuleCodes[ui.Code] {
continue
}
rule, ok := ruleByID[ruleID]
if !ok {
// Defensive: the calculator returned a rule_id that isn't in
// the per-proceeding map. After Phase 3 Slice 7
// (t-paliad-188) the unified FristenrechnerService.Calculate
// stays scoped to one proceeding (Option A in design §6.2),
// so spawned-into rules don't arrive here — they're appended
// below via expandCrossProceedingSpawns. A miss now means
// either a stale ruleByID (unlikely) or a future calculator
// extension we haven't accounted for; skip the dependency
// annotation but still surface the row.
rule = models.DeadlineRule{}
}
ev := TimelineEvent{
Kind: "projected",
Track: "parent",
Title: ruleDisplayName(rule, ui, lang(opts.Lang)),
RuleCode: ui.Code,
DeadlineRuleParty: ui.Party,
IsConditional: ui.IsConditional,
}
idCopy := ruleID
ev.DeadlineRuleID = &idCopy
// Date — UIDeadline.DueDate is YYYY-MM-DD when set, "" for
// court-set / conditional rules whose date isn't bound yet.
if ui.DueDate != "" {
if t, perr := time.Parse("2006-01-02", ui.DueDate); perr == nil {
dt := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC)
ev.Date = &dt
}
}
// Conditional rows from the fristenrechner (t-paliad-289):
// pre-stamp the dependency reference here so the row carries
// the "abhängig von <parent>" payload even when the parent has
// no computed date for annotateDependsOn to pick up later.
// annotateDependsOn won't overwrite a non-empty DependsOnRuleCode,
// and the parent's actual date (if anchored elsewhere) still
// flows into DependsOnDate via the actuals-first preference.
if ui.IsConditional && ui.ParentRuleCode != "" {
ev.DependsOnRuleCode = ui.ParentRuleCode
switch lang(opts.Lang) {
case "en":
if ui.ParentRuleNameEN != "" {
ev.DependsOnRuleName = ui.ParentRuleNameEN
} else {
ev.DependsOnRuleName = ui.ParentRuleName
}
default:
ev.DependsOnRuleName = ui.ParentRuleName
}
}
switch {
case ui.IsConditional:
// Anchor uncertain (court-set ancestor without override,
// backward-anchor without forward date, or optional event
// not recorded). Surface as conditional so the frontend
// renders "abhängig von <parent>" in place of a date.
// Conditional rows must not carry a date even if the
// calculator left one — clear it to match the wire contract.
// (t-paliad-289)
ev.Date = nil
ev.Status = "conditional"
case ui.IsCourtSet && ev.Date == nil:
// Pure court-set rule — date is bound by the court at
// hearing/decision time. Surface as undated court_set.
ev.Status = "court_set"
case ui.IsCourtSet:
// Court-set rule with a derived date (e.g. R.151 chain
// off a court-set parent). Render as court_set so the UI
// shows the dashed border + the "wird vom Gericht bestimmt"
// nuance.
ev.Status = "court_set"
case ev.Date != nil && ev.Date.Before(today):
// Past predicted but no anchor: surface as overdue. These
// bypass the lookahead cap (§6.4 + #31 layer 1).
ev.Status = "predicted_overdue"
default:
ev.Status = "predicted"
}
projected = append(projected, ev)
}
// Phase 3 Slice 7 (t-paliad-188): expand cross-proceeding spawn rules.
// is_spawn=true rules with a non-NULL spawn_proceeding_type_id appear
// in the current proceeding's rule set; we resolve each spawn target's
// root rule (lowest sequence_order) via a one-shot global SELECT and
// emit a spawned-into projected row anchored on the spawn source's
// computed date. Cycle guard: visited-set DFS keyed by
// proceeding_type_id; ErrCyclicSpawn degrades to "no spawned rows"
// rather than failing the whole SmartTimeline render.
if proj.ProceedingTypeID != nil {
visited := map[int]bool{*proj.ProceedingTypeID: true}
spawnRows, spawnErr := s.expandCrossProceedingSpawns(ctx, rules, resp.Deadlines, visited, 0)
if spawnErr != nil {
if !errors.Is(spawnErr, ErrCyclicSpawn) {
return nil, meta, fmt.Errorf("expand spawns: %w", spawnErr)
}
// Cyclic spawn: drop spawned rows from this projection,
// continue rendering the rest. SmartTimeline stays usable.
// Surfaced in meta so the caller can log / show a banner.
meta.SpawnCycleDropped = true
} else if len(spawnRows) > 0 {
projected = append(projected, spawnRows...)
}
}
// Apply lookahead cap. Predicted-overdue rows are exempt — surface
// all of them. Court-set undated rows are exempt too because their
// position on the timeline is "future, indefinite" and dropping the
// Hauptverhandlung row to make room for a Rejoinder would be wrong.
cappedProjected, projTotal, projShown, overdueCount := applyLookaheadCap(projected, cap)
meta.ProjectedTotal = projTotal
meta.ProjectedShown = projShown
meta.PredictedOverdue = overdueCount
return cappedProjected, meta, nil
}
// expandCrossProceedingSpawns walks the spawn graph rooted at the
// caller's source proceeding (the `visited` set seeds it). For each
// rule in `sourceRules` with is_spawn=true AND a non-NULL
// SpawnProceedingTypeID, it resolves the target proceeding's root rule
// and emits a spawned-into TimelineEvent linking back to the source.
//
// Cycle guard: when a spawn target's proceeding_type_id is already in
// `visited`, the function returns ErrCyclicSpawn wrapped with the
// rule + proceeding context. The caller (computeProjections) catches
// it and degrades to "no spawned rows" — better than blocking the
// whole render with an error.
//
// Recursion: after emitting a spawned-into row, the function recurses
// into the target proceeding's own spawn rules. depth is bounded by
// maxSpawnDepth as a safety belt; the visited set is the real loop
// guard.
//
// Spawn-source dates come from `sourceDeadlines` — the UIResponse the
// calculator just emitted. The spawned-into row inherits the source's
// computed due date as its anchor; computing the target proceeding's
// own deadlines off that anchor is deferred to a follow-up slice (the
// rule editor will let editors set per-rule offsets that the
// projection can compose). For Slice 7 v1, the spawned-into row
// surfaces undated with Status="predicted" and Track="spawn" so the
// frontend renders a clear boundary divider.
func (s *ProjectionService) expandCrossProceedingSpawns(
ctx context.Context,
sourceRules []models.DeadlineRule,
sourceDeadlines []UIDeadline,
visited map[int]bool,
depth int,
) ([]TimelineEvent, error) {
if depth >= maxSpawnDepth {
return nil, fmt.Errorf("%w: max depth %d exceeded", ErrCyclicSpawn, maxSpawnDepth)
}
// Index source rule computed dates by rule id for anchor lookup.
dateByRuleID := make(map[uuid.UUID]string, len(sourceDeadlines))
for _, ui := range sourceDeadlines {
if ui.RuleID == "" || ui.DueDate == "" {
continue
}
if id, err := uuid.Parse(ui.RuleID); err == nil {
dateByRuleID[id] = ui.DueDate
}
}
// Identify spawn rules + collect target proceeding ids. The cycle
// guard runs here on each unique target — if any target is already
// in `visited`, abort the whole expansion (one cyclic edge poisons
// the graph; we can't selectively render around it without
// fabricating an incomplete dependency tree).
type spawnSource struct {
rule models.DeadlineRule
anchorDate string
}
var sources []spawnSource
targetIDs := make(map[int]struct{})
for _, r := range sourceRules {
if !r.IsSpawn || r.SpawnProceedingTypeID == nil {
continue
}
if visited[*r.SpawnProceedingTypeID] {
return nil, fmt.Errorf("%w: rule %s (proceeding %d) spawns into proceeding %d which is already in the chain",
ErrCyclicSpawn, r.ID, derefIntPtr(r.ProceedingTypeID), *r.SpawnProceedingTypeID)
}
targetIDs[*r.SpawnProceedingTypeID] = struct{}{}
sources = append(sources, spawnSource{rule: r, anchorDate: dateByRuleID[r.ID]})
}
if len(sources) == 0 {
return nil, nil
}
// Bulk-load target proceedings' rules in one round-trip. The result
// is pre-sorted by (proceeding_type_id, sequence_order) so the
// first rule per proceeding is the root (lowest sequence_order).
ids := make([]int, 0, len(targetIDs))
for id := range targetIDs {
ids = append(ids, id)
}
targetRules, err := s.rules.ListByProceedingTypeIDs(ctx, ids)
if err != nil {
return nil, err
}
// Group target rules by proceeding_type_id; first slot wins (root).
firstByPT := make(map[int]models.DeadlineRule, len(ids))
rulesByPT := make(map[int][]models.DeadlineRule, len(ids))
for _, tr := range targetRules {
if tr.ProceedingTypeID == nil {
continue
}
rulesByPT[*tr.ProceedingTypeID] = append(rulesByPT[*tr.ProceedingTypeID], tr)
if _, seen := firstByPT[*tr.ProceedingTypeID]; !seen {
firstByPT[*tr.ProceedingTypeID] = tr
}
}
// Render one spawned-into TimelineEvent per source rule. Recurse
// into the target proceeding's spawn rules (depth + 1) with the
// target's proceeding_type_id added to `visited`.
var out []TimelineEvent
for _, src := range sources {
first, ok := firstByPT[*src.rule.SpawnProceedingTypeID]
if !ok {
// Target proceeding has no active rules (defensive — a
// future seed could land it). Skip silently.
continue
}
title := first.Name
if src.rule.SpawnLabel != nil && *src.rule.SpawnLabel != "" {
title = title + " (" + *src.rule.SpawnLabel + ")"
}
ev := TimelineEvent{
Kind: "projected",
Status: "predicted",
Track: "spawn",
Title: title,
DependsOnRuleName: src.rule.Name,
}
if first.SubmissionCode != nil {
ev.RuleCode = *first.SubmissionCode
}
if src.rule.SubmissionCode != nil {
ev.DependsOnRuleCode = *src.rule.SubmissionCode
}
idCopy := first.ID
ev.DeadlineRuleID = &idCopy
if first.PrimaryParty != nil {
ev.DeadlineRuleParty = *first.PrimaryParty
}
// Anchor date: the spawn source's projected due date if
// known. We don't compute the target's offset in Slice 7
// v1 — that's the deferred per-rule editor concern — so the
// row surfaces undated when the source has no anchor.
if src.anchorDate != "" {
if t, perr := time.Parse("2006-01-02", src.anchorDate); perr == nil {
dt := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC)
ev.DependsOnDate = &dt
}
}
out = append(out, ev)
// Recurse: walk the target's own spawn rules. Carry forward
// the visited set with the target proceeding added so a
// later hop back to it triggers ErrCyclicSpawn.
nextVisited := make(map[int]bool, len(visited)+1)
for k, v := range visited {
nextVisited[k] = v
}
nextVisited[*src.rule.SpawnProceedingTypeID] = true
sub, err := s.expandCrossProceedingSpawns(ctx, rulesByPT[*src.rule.SpawnProceedingTypeID], nil, nextVisited, depth+1)
if err != nil {
return out, err
}
out = append(out, sub...)
}
return out, nil
}
// derefIntPtr returns 0 when the pointer is nil — used only in error
// messages for human-readable proceeding-id context. Never load-bearing
// for the spawn-resolution logic itself (which checks for nil before
// dereferencing).
func derefIntPtr(p *int) int {
if p == nil {
return 0
}
return *p
}
// collectActualsForOverrides loads every paliad.deadlines + paliad.appointments
// row tied to a rule_id (or rule_code) for the project + descendants and
// fills the overrides + ruleIDsWithActual maps.
//
// We bypass the WithProject list helpers because the caller has already
// gated visibility via projects.GetByID + the deadline list call above
// for the displayed rows. This second pass is a narrow read of (rule_id,
// rule_code, completed_at, due_date) for the override map only — never
// surfaced to the user.
func (s *ProjectionService) collectActualsForOverrides(
ctx context.Context,
projectID uuid.UUID,
ruleByID map[uuid.UUID]models.DeadlineRule,
directOnly bool,
overrides map[string]string,
ruleIDsWithActual map[uuid.UUID]bool,
) error {
type drow struct {
RuleID *uuid.UUID `db:"rule_id"`
RuleCode *string `db:"rule_code"`
DueDate time.Time `db:"due_date"`
CompletedAt *time.Time `db:"completed_at"`
Status string `db:"status"`
}
var dRows []drow
scopeFilter := scopeProjectIDFilter("d", "project_id", projectID, directOnly)
q := `SELECT d.sequencing_rule_id AS rule_id, d.rule_code, d.due_date, d.completed_at, d.status
FROM paliad.deadlines d
WHERE ` + scopeFilter
if err := s.db.SelectContext(ctx, &dRows, q, projectID); err != nil {
return fmt.Errorf("collect deadline actuals: %w", err)
}
for _, d := range dRows {
var anchor time.Time
switch {
case d.CompletedAt != nil:
anchor = *d.CompletedAt
case d.Status == "completed":
anchor = d.DueDate
default:
// Pending deadline — not an anchor for downstream reflow.
// Still mark rule_id so we don't double-emit projected.
if d.RuleID != nil {
ruleIDsWithActual[*d.RuleID] = true
}
continue
}
if d.RuleID != nil {
ruleIDsWithActual[*d.RuleID] = true
if r, ok := ruleByID[*d.RuleID]; ok && r.SubmissionCode != nil {
overrides[*r.SubmissionCode] = anchor.Format("2006-01-02")
}
}
if d.RuleCode != nil && *d.RuleCode != "" {
overrides[*d.RuleCode] = anchor.Format("2006-01-02")
}
}
type arow struct {
RuleID *uuid.UUID `db:"deadline_rule_id"`
StartAt time.Time `db:"start_at"`
}
var aRows []arow
apptScopeFilter := scopeProjectIDFilter("a", "project_id", projectID, directOnly)
aq := `SELECT a.deadline_rule_id, a.start_at
FROM paliad.appointments a
WHERE a.deadline_rule_id IS NOT NULL AND ` + apptScopeFilter
if err := s.db.SelectContext(ctx, &aRows, aq, projectID); err != nil {
return fmt.Errorf("collect appointment actuals: %w", err)
}
for _, a := range aRows {
if a.RuleID == nil {
continue
}
ruleIDsWithActual[*a.RuleID] = true
if r, ok := ruleByID[*a.RuleID]; ok && r.SubmissionCode != nil {
overrides[*r.SubmissionCode] = a.StartAt.UTC().Format("2006-01-02")
}
}
return nil
}
// hydrateAppointmentRuleIDs back-fills DeadlineRuleID on the actual
// appointment rows in `rows` from the appointments.deadline_rule_id
// column. The list service doesn't carry that field today; doing one
// extra narrow query keeps the existing model + handler API stable.
func (s *ProjectionService) hydrateAppointmentRuleIDs(ctx context.Context, projectID uuid.UUID, directOnly bool, rows []TimelineEvent) error {
type ar struct {
ID uuid.UUID `db:"id"`
RuleID *uuid.UUID `db:"deadline_rule_id"`
}
var rs []ar
scope := scopeProjectIDFilter("a", "project_id", projectID, directOnly)
q := `SELECT a.id, a.deadline_rule_id
FROM paliad.appointments a
WHERE a.deadline_rule_id IS NOT NULL AND ` + scope
if err := s.db.SelectContext(ctx, &rs, q, projectID); err != nil {
return fmt.Errorf("hydrate appointment rule_ids: %w", err)
}
if len(rs) == 0 {
return nil
}
byID := make(map[uuid.UUID]uuid.UUID, len(rs))
for _, r := range rs {
if r.RuleID != nil {
byID[r.ID] = *r.RuleID
}
}
for i := range rows {
if rows[i].Kind != "appointment" || rows[i].AppointmentID == nil {
continue
}
if rid, ok := byID[*rows[i].AppointmentID]; ok {
ridCopy := rid
rows[i].DeadlineRuleID = &ridCopy
}
}
return nil
}
// deriveTriggerDate picks the calculator's triggerDate. When the
// proceeding's root rule (parent_id IS NULL) has been anchored, use that
// date so the projection starts from reality. Otherwise fall back to
// today() — the projection becomes "what would happen if filed today",
// which the user fixes by clicking "Datum setzen" on the SoC row.
func (s *ProjectionService) deriveTriggerDate(rules []models.DeadlineRule, overrides map[string]string) string {
for _, r := range rules {
if r.ParentID != nil || r.SubmissionCode == nil {
continue
}
if anchor, ok := overrides[*r.SubmissionCode]; ok {
return anchor
}
}
return time.Now().UTC().Format("2006-01-02")
}
// listProjectEvents reads paliad.project_events for the timeline. Returns
// the rule_skipped set (used to suppress projected rows for those rules)
// alongside the surfaced TimelineEvent rows; rule_skipped events stay
// hidden in the user-facing list since they record a non-event.
func (s *ProjectionService) listProjectEvents(ctx context.Context, userID, projectID uuid.UUID, opts ProjectionOpts) (map[string]bool, []TimelineEvent, error) {
skipped := map[string]bool{}
var projectFilter string
if opts.DirectOnly {
projectFilter = `pe.project_id = $1 AND ` + visibilityPredicatePositional("p", 2)
} else {
projectFilter = `$1 = ANY(string_to_array(p.path, '.')::uuid[]) AND ` + visibilityPredicatePositional("p", 2)
}
kindFilter := `AND (pe.timeline_kind IS NOT NULL OR pe.event_type = 'rule_skipped')`
if opts.IncludeAuditFull {
kindFilter = ``
}
type row struct {
ID uuid.UUID `db:"id"`
ProjectID uuid.UUID `db:"project_id"`
EventType *string `db:"event_type"`
Title string `db:"title"`
Description *string `db:"description"`
EventDate *time.Time `db:"event_date"`
CreatedAt time.Time `db:"created_at"`
Metadata json.RawMessage `db:"metadata"`
TimelineKind *string `db:"timeline_kind"`
}
query := `
SELECT pe.id, pe.project_id, pe.event_type, pe.title, pe.description,
pe.event_date, pe.created_at, pe.metadata, pe.timeline_kind
FROM paliad.project_events pe
JOIN paliad.projects p ON p.id = pe.project_id
WHERE ` + projectFilter + ` ` + kindFilter + `
ORDER BY COALESCE(pe.event_date, pe.created_at) DESC, pe.id DESC`
var rows []row
if err := s.db.SelectContext(ctx, &rows, query, projectID, userID); err != nil {
return nil, nil, fmt.Errorf("list project_events: %w", err)
}
out := make([]TimelineEvent, 0, len(rows))
for _, r := range rows {
// rule_skipped rows: extract the rule_code from metadata so
// computeProjections can drop the matching projected row, but
// don't surface them as user-facing timeline rows. The decision
// is captured in the audit log via /admin/audit-log.
if r.EventType != nil && *r.EventType == "rule_skipped" {
if code := extractMetadataString(r.Metadata, "rule_code"); code != "" {
skipped[code] = true
}
continue
}
var when time.Time
if r.EventDate != nil {
when = *r.EventDate
} else {
when = r.CreatedAt
}
whenCopy := when
ev := TimelineEvent{
Kind: "milestone",
Status: milestoneStatus(r.TimelineKind, r.EventType),
Track: "parent",
Date: &whenCopy,
Title: r.Title,
ProjectEventID: &r.ID,
BubbleUp: extractBubbleUp(r.Metadata, r.EventType, r.TimelineKind),
}
if r.EventType != nil {
ev.ProjectEventType = *r.EventType
}
if r.Description != nil {
ev.Description = *r.Description
}
out = append(out, ev)
}
return skipped, out, nil
}
// extractBubbleUp resolves the bubble_up flag for a project_events row
// per design Q5 (t-paliad-175). Explicit metadata.bubble_up wins; when
// absent, structural milestones (counterclaim_created, third_party_intervention,
// scope_change) default to true and everything else (including
// custom_milestone) defaults to false. Frontend exposes a checkbox on
// the custom-milestone form so the user can override per entry.
func extractBubbleUp(raw json.RawMessage, eventType, timelineKind *string) bool {
if len(raw) > 0 {
var m map[string]any
if err := json.Unmarshal(raw, &m); err == nil {
if v, ok := m["bubble_up"]; ok {
switch t := v.(type) {
case bool:
return t
case string:
return strings.EqualFold(t, "true") || t == "1"
}
}
}
}
if eventType != nil {
switch *eventType {
case "counterclaim_created", "third_party_intervention", "scope_change":
return true
}
}
// custom_milestone defaults to false (Q5 lock); user-set
// metadata.bubble_up=true on the row is the only path to surface
// these at higher levels.
_ = timelineKind
return false
}
// RecordCustomMilestone writes a "Eigener Meilenstein" project_event
// (event_type='custom_milestone', timeline_kind='custom_milestone')
// and returns the resulting TimelineEvent so the caller can append it
// directly to the rendered list without a re-fetch.
//
// bubbleUp persists into metadata.bubble_up — when true the milestone
// surfaces on the parent-node SmartTimeline at Patent / Litigation /
// Client levels. The frontend's custom-milestone form exposes the
// checkbox; absent the override, custom_milestone defaults to false
// per design Q5.
func (s *ProjectionService) RecordCustomMilestone(
ctx context.Context,
userID, projectID uuid.UUID,
title string,
description *string,
occurredAt *time.Time,
bubbleUp bool,
) (*TimelineEvent, error) {
if _, err := s.projects.GetByID(ctx, userID, projectID); err != nil {
return nil, err
}
if title == "" {
return nil, fmt.Errorf("%w: title is required", ErrInvalidInput)
}
id := uuid.New()
now := time.Now().UTC()
var eventDate *time.Time
if occurredAt != nil {
ts := occurredAt.UTC()
eventDate = &ts
}
metaJSON := json.RawMessage(`{}`)
if bubbleUp {
// Only persist bubble_up when true so existing rows-without-it
// keep extractBubbleUp's default-off behaviour for custom
// milestones.
metaJSON = json.RawMessage(`{"bubble_up":true}`)
}
_, err := s.db.ExecContext(ctx,
`INSERT INTO paliad.project_events
(id, project_id, event_type, title, description, event_date,
created_by, metadata, created_at, updated_at, timeline_kind)
VALUES ($1, $2, 'custom_milestone', $3, $4, $5, $6, $7::jsonb, $8, $8, 'custom_milestone')`,
id, projectID, title, description, eventDate, userID, string(metaJSON), now)
if err != nil {
return nil, fmt.Errorf("insert custom_milestone: %w", err)
}
when := now
if eventDate != nil {
when = *eventDate
}
whenCopy := when
ev := &TimelineEvent{
Kind: "milestone",
Status: "off_script",
Track: "parent",
Date: &whenCopy,
Title: title,
ProjectEventID: &id,
BubbleUp: bubbleUp,
}
if description != nil {
ev.Description = *description
}
return ev, nil
}
// AnchorInput is the body of POST /api/projects/{id}/timeline/anchor.
type AnchorInput struct {
RuleCode string
ActualDate time.Time
Kind string // "deadline" | "appointment" | "" → derive from rule.event_type
}
// AnchorResult signals what was written. Exactly one of DeadlineID /
// AppointmentID is non-nil. Updated=true means an existing row was
// PATCH'd (idempotent re-anchor); false = new row inserted.
type AnchorResult struct {
DeadlineID *uuid.UUID
AppointmentID *uuid.UUID
Updated bool
}
// PredecessorMissingError is returned when the anchor write fails the
// sequence guard (m/paliad#31 layer 3). The handler maps this to 409.
type PredecessorMissingError struct {
MissingRuleCode string
MissingRuleNameDE string
MissingRuleNameEN string
RequestedRuleCode string
RequestedRuleNameDE string
RequestedRuleNameEN string
}
func (e *PredecessorMissingError) Error() string {
return fmt.Sprintf("predecessor missing: %s requires %s to be anchored first",
e.RequestedRuleCode, e.MissingRuleCode)
}
// IsPredecessorMissing unwraps a PredecessorMissingError if present.
func IsPredecessorMissing(err error) (*PredecessorMissingError, bool) {
var pme *PredecessorMissingError
if errors.As(err, &pme) {
return pme, true
}
return nil, false
}
// CrossProceedingAnchorError is returned when a user tries to anchor a
// rule on a CCR sub-project, but the rule belongs to the parent
// infringement project's proceeding type (t-paliad-237). The
// SmartTimeline on a CCR project surfaces the parent's track in the
// parent_context lane — clicking "Datum setzen" on a parent-track rule
// would silently corrupt the inf project's actuals if written onto the
// CCR. Reject with a clear pointer to the parent project.
type CrossProceedingAnchorError struct {
RequestedRuleCode string
RequestedRuleNameDE string
RequestedRuleNameEN string
ParentProjectID uuid.UUID
ParentProjectTitle string
}
func (e *CrossProceedingAnchorError) Error() string {
return fmt.Sprintf("rule %q belongs to parent project %s, not this CCR",
e.RequestedRuleCode, e.ParentProjectID)
}
// IsCrossProceedingAnchor unwraps a CrossProceedingAnchorError if present.
func IsCrossProceedingAnchor(err error) (*CrossProceedingAnchorError, bool) {
var cpe *CrossProceedingAnchorError
if errors.As(err, &cpe) {
return cpe, true
}
return nil, false
}
// RecordAnchor writes (or PATCHes) the actual occurrence of a rule for
// the given project. Implements the §6 click-to-anchor + #31 layer 3
// sequence guard:
//
// 1. Resolve the rule by (proceeding_type_id, code).
// 2. If rule has parent_id, verify the parent has an anchored actual
// for this project — return PredecessorMissingError if not.
// 3. Court-set rules (event_type IN ('hearing','decision','order'))
// write a paliad.appointments row with deadline_rule_id set; all
// other rules write a paliad.deadlines row with source='anchor'.
// 4. Idempotent: if a row already exists for (project_id, rule_id),
// PATCH it instead of inserting (race-safe per design §13).
//
// Visibility: caller must have admin / lead / member responsibility on
// the project — checked via DeadlineService.assertCanAdminProject (the
// existing pattern).
func (s *ProjectionService) RecordAnchor(ctx context.Context, userID, projectID uuid.UUID, in AnchorInput) (*AnchorResult, error) {
proj, err := s.projects.GetByID(ctx, userID, projectID)
if err != nil {
return nil, err
}
if proj.ProceedingTypeID == nil {
return nil, fmt.Errorf("%w: project has no proceeding type set", ErrInvalidInput)
}
if in.RuleCode == "" {
return nil, fmt.Errorf("%w: rule_code is required", ErrInvalidInput)
}
if in.ActualDate.IsZero() {
return nil, fmt.Errorf("%w: actual_date is required", ErrInvalidInput)
}
if err := s.deadlines.assertCanAdminProject(ctx, userID, projectID); err != nil {
return nil, err
}
rule, err := s.lookupRuleBySubmissionCode(ctx, *proj.ProceedingTypeID, in.RuleCode)
if errors.Is(err, sql.ErrNoRows) {
// Cross-proceeding fallback (t-paliad-237). On a CCR project,
// the SmartTimeline renders the parent infringement project's
// rules in the parent_context lane. The user can click "Datum
// setzen" on those rows; writing the anchor onto the CCR
// would corrupt the inf project's actuals. Detect this and
// reject with a pointer to the parent project so the frontend
// can guide the user to anchor there instead.
if proj.CounterclaimOf != nil {
parent, perr := s.projects.GetByID(ctx, userID, *proj.CounterclaimOf)
if perr == nil && parent != nil && parent.ProceedingTypeID != nil {
parentRule, plookErr := s.lookupRuleBySubmissionCode(ctx, *parent.ProceedingTypeID, in.RuleCode)
if plookErr == nil && parentRule != nil {
return nil, &CrossProceedingAnchorError{
RequestedRuleCode: in.RuleCode,
RequestedRuleNameDE: parentRule.Name,
RequestedRuleNameEN: parentRule.NameEN,
ParentProjectID: parent.ID,
ParentProjectTitle: parent.Title,
}
}
}
}
return nil, fmt.Errorf("%w: unknown submission_code %q", ErrInvalidInput, in.RuleCode)
}
if err != nil {
return nil, err
}
// Sequence guard. The rule's parent_id is the dependency anchor for
// reflow; we reject the write when the parent has no anchored actual
// for this project. v1 rejects without a confirm-and-write override
// (per brief defaults locked above the impl).
if rule.ParentID != nil {
parentRule, err := s.lookupRuleByID(ctx, *rule.ParentID)
if err != nil {
return nil, fmt.Errorf("lookup parent rule: %w", err)
}
anchored, err := s.parentHasAnchoredActual(ctx, projectID, *rule.ParentID)
if err != nil {
return nil, fmt.Errorf("check parent anchor: %w", err)
}
if !anchored {
parentCode := ""
if parentRule.SubmissionCode != nil {
parentCode = *parentRule.SubmissionCode
}
return nil, &PredecessorMissingError{
MissingRuleCode: parentCode,
MissingRuleNameDE: parentRule.Name,
MissingRuleNameEN: parentRule.NameEN,
RequestedRuleCode: in.RuleCode,
RequestedRuleNameDE: rule.Name,
RequestedRuleNameEN: rule.NameEN,
}
}
}
kind := strings.ToLower(strings.TrimSpace(in.Kind))
if kind == "" {
kind = ruleAnchorKind(rule)
}
switch kind {
case "appointment":
return s.upsertAnchorAppointment(ctx, userID, projectID, rule, in.ActualDate)
case "deadline":
return s.upsertAnchorDeadline(ctx, userID, projectID, rule, in.ActualDate)
default:
return nil, fmt.Errorf("%w: unknown kind %q", ErrInvalidInput, kind)
}
}
// RecordRuleSkipped marks a projected rule as "ist nicht eingetreten /
// wurde verschoben" (§6.4). Writes a paliad.project_events row with
// event_type='rule_skipped', metadata={rule_code, reason}; computeProjections
// uses the rule_code to drop the matching projected row from future reads.
func (s *ProjectionService) RecordRuleSkipped(ctx context.Context, userID, projectID uuid.UUID, ruleCode, reason string) error {
if _, err := s.projects.GetByID(ctx, userID, projectID); err != nil {
return err
}
if err := s.deadlines.assertCanAdminProject(ctx, userID, projectID); err != nil {
return err
}
if ruleCode == "" {
return fmt.Errorf("%w: rule_code is required", ErrInvalidInput)
}
id := uuid.New()
now := time.Now().UTC()
meta := map[string]any{"rule_code": ruleCode}
if reason != "" {
meta["reason"] = reason
}
mb, _ := json.Marshal(meta)
_, err := s.db.ExecContext(ctx,
`INSERT INTO paliad.project_events
(id, project_id, event_type, title, description, event_date,
created_by, metadata, created_at, updated_at, timeline_kind)
VALUES ($1, $2, 'rule_skipped', $3, $4, $5, $6, $7::jsonb, $5, $5, NULL)`,
id, projectID, "Regel übersprungen: "+ruleCode, nilIfEmpty(reason), now, userID, string(mb))
if err != nil {
return fmt.Errorf("insert rule_skipped: %w", err)
}
return nil
}
// lookupRuleBySubmissionCode resolves (proceeding_type_id, submission_code)
// → DeadlineRule. Returns sql.ErrNoRows when the rule is not present so
// callers can implement cross-proceeding fallback logic (t-paliad-237);
// other DB errors are wrapped.
func (s *ProjectionService) lookupRuleBySubmissionCode(ctx context.Context, ptID int, code string) (*models.DeadlineRule, error) {
var rule models.DeadlineRule
err := s.db.GetContext(ctx, &rule,
`SELECT `+ruleColumns+`
FROM paliad.deadline_rules_unified
WHERE proceeding_type_id = $1 AND submission_code = $2 AND is_active = true`,
ptID, code)
if errors.Is(err, sql.ErrNoRows) {
return nil, err
}
if err != nil {
return nil, fmt.Errorf("lookup rule by submission_code: %w", err)
}
return &rule, nil
}
// lookupRuleByID resolves a rule by UUID.
func (s *ProjectionService) lookupRuleByID(ctx context.Context, id uuid.UUID) (*models.DeadlineRule, error) {
var rule models.DeadlineRule
err := s.db.GetContext(ctx, &rule,
`SELECT `+ruleColumns+`
FROM paliad.deadline_rules_unified
WHERE id = $1`, id)
if err != nil {
return nil, fmt.Errorf("lookup rule by id: %w", err)
}
return &rule, nil
}
// parentHasAnchoredActual returns true when paliad.deadlines OR
// paliad.appointments has a row tied to parentRuleID for this project
// AND that row represents a real anchor (deadline.completed_at /
// deadline.status='completed' / source='anchor', appointment present).
//
// Pending deadlines (status='pending') don't count as anchors — the user
// hasn't recorded the actual yet, and downstream reflow needs a concrete
// date.
func (s *ProjectionService) parentHasAnchoredActual(ctx context.Context, projectID, parentRuleID uuid.UUID) (bool, error) {
var count int
err := s.db.GetContext(ctx, &count, `
SELECT COUNT(*) FROM (
SELECT 1 FROM paliad.deadlines
WHERE project_id = $1 AND sequencing_rule_id = $2
AND (completed_at IS NOT NULL
OR status = 'completed'
OR source = 'anchor')
UNION ALL
SELECT 1 FROM paliad.appointments
WHERE project_id = $1 AND deadline_rule_id = $2
) t`, projectID, parentRuleID)
if err != nil {
return false, err
}
return count > 0, nil
}
// ruleAnchorKind picks the write kind for a rule based on its event_type.
// Court-set rules (hearing / decision / order) anchor as appointments;
// everything else anchors as a deadline. The brief locks this default.
func ruleAnchorKind(rule *models.DeadlineRule) string {
if rule == nil || rule.EventType == nil {
return "deadline"
}
switch *rule.EventType {
case "hearing", "decision", "order":
return "appointment"
}
return "deadline"
}
// upsertAnchorDeadline inserts or PATCHes a paliad.deadlines row for the
// (project_id, rule_id) pair. Idempotent: re-anchoring the same rule
// updates due_date + completed_at instead of double-inserting.
func (s *ProjectionService) upsertAnchorDeadline(ctx context.Context, userID, projectID uuid.UUID, rule *models.DeadlineRule, actual time.Time) (*AnchorResult, error) {
dateStr := actual.UTC().Format("2006-01-02")
now := time.Now().UTC()
var existingID uuid.UUID
err := s.db.GetContext(ctx, &existingID,
`SELECT id FROM paliad.deadlines
WHERE project_id = $1 AND sequencing_rule_id = $2
ORDER BY created_at ASC
LIMIT 1`, projectID, rule.ID)
switch {
case errors.Is(err, sql.ErrNoRows):
// fresh insert
case err != nil:
return nil, fmt.Errorf("check existing deadline: %w", err)
default:
// PATCH — flip to status=done, source='anchor', due_date=actual.
_, err := s.db.ExecContext(ctx, `
UPDATE paliad.deadlines
SET due_date = $1::date,
completed_at = $2,
status = 'completed',
source = 'anchor',
updated_at = $2
WHERE id = $3`, dateStr, now, existingID)
if err != nil {
return nil, fmt.Errorf("patch deadline anchor: %w", err)
}
return &AnchorResult{DeadlineID: &existingID, Updated: true}, nil
}
id := uuid.New()
title := rule.Name
ruleCode := ""
if rule.SubmissionCode != nil {
ruleCode = *rule.SubmissionCode
}
_, err = s.db.ExecContext(ctx, `
INSERT INTO paliad.deadlines
(id, project_id, title, due_date, original_due_date,
source, rule_id, rule_code, status, completed_at,
created_by, created_at, updated_at,
approval_status)
VALUES ($1, $2, $3, $4::date, $4::date,
'anchor', $5, NULLIF($6, ''), 'completed', $7,
$8, $7, $7,
'approved')`,
id, projectID, title, dateStr, rule.ID, ruleCode, now, userID)
if err != nil {
return nil, fmt.Errorf("insert deadline anchor: %w", err)
}
return &AnchorResult{DeadlineID: &id, Updated: false}, nil
}
// upsertAnchorAppointment inserts or PATCHes a paliad.appointments row
// for the (project_id, deadline_rule_id) pair. Mirror of upsertAnchorDeadline
// for court-set rules (hearing / decision / order).
func (s *ProjectionService) upsertAnchorAppointment(ctx context.Context, userID, projectID uuid.UUID, rule *models.DeadlineRule, actual time.Time) (*AnchorResult, error) {
now := time.Now().UTC()
apptType := "hearing"
if rule.EventType != nil {
switch *rule.EventType {
case "decision":
apptType = "deadline_hearing" // closest match in the existing CHECK; no 'decision' value yet
case "order":
apptType = "deadline_hearing"
case "hearing":
apptType = "hearing"
}
}
var existingID uuid.UUID
err := s.db.GetContext(ctx, &existingID,
`SELECT id FROM paliad.appointments
WHERE project_id = $1 AND deadline_rule_id = $2
ORDER BY created_at ASC
LIMIT 1`, projectID, rule.ID)
switch {
case errors.Is(err, sql.ErrNoRows):
// fresh insert
case err != nil:
return nil, fmt.Errorf("check existing appointment: %w", err)
default:
_, err := s.db.ExecContext(ctx, `
UPDATE paliad.appointments
SET start_at = $1,
updated_at = $2
WHERE id = $3`, actual.UTC(), now, existingID)
if err != nil {
return nil, fmt.Errorf("patch appointment anchor: %w", err)
}
return &AnchorResult{AppointmentID: &existingID, Updated: true}, nil
}
id := uuid.New()
_, err = s.db.ExecContext(ctx, `
INSERT INTO paliad.appointments
(id, project_id, title, start_at, appointment_type,
deadline_rule_id, created_by, created_at, updated_at,
approval_status)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8, 'approved')`,
id, projectID, rule.Name, actual.UTC(), apptType, rule.ID, userID, now)
if err != nil {
return nil, fmt.Errorf("insert appointment anchor: %w", err)
}
return &AnchorResult{AppointmentID: &id, Updated: false}, nil
}
// annotateDependsOn fills DependsOnRuleCode/Date/Name on every row that
// has a DeadlineRuleID by walking the rule's parent_id chain. The parent
// date prefers any actual date already on a sibling row (so the user
// sees real chains), falling back to the projected date for that rule.
func (s *ProjectionService) annotateDependsOn(rows []TimelineEvent, rules []models.DeadlineRule, lang string) {
if len(rules) == 0 {
return
}
ruleByID := make(map[uuid.UUID]models.DeadlineRule, len(rules))
for _, r := range rules {
ruleByID[r.ID] = r
}
dateByRuleID := make(map[uuid.UUID]time.Time, len(rows))
for _, r := range rows {
if r.DeadlineRuleID == nil || r.Date == nil {
continue
}
// Prefer actuals over projections — when both exist for the
// same rule (shouldn't, but defensive), the actual wins because
// it's recorded reality.
if r.Kind != "projected" || dateByRuleID[*r.DeadlineRuleID].IsZero() {
dateByRuleID[*r.DeadlineRuleID] = *r.Date
}
}
for i := range rows {
ev := &rows[i]
if ev.DeadlineRuleID == nil {
continue
}
rule, ok := ruleByID[*ev.DeadlineRuleID]
if !ok || rule.ParentID == nil {
continue
}
parent, ok := ruleByID[*rule.ParentID]
if !ok {
continue
}
if parent.SubmissionCode != nil {
ev.DependsOnRuleCode = *parent.SubmissionCode
}
ev.DependsOnRuleName = ruleNameInLang(parent, lang)
if dt, ok := dateByRuleID[parent.ID]; ok && !dt.IsZero() {
d := dt
ev.DependsOnDate = &d
}
}
}
// ----------------------------------------------------------------------
// Pure helpers — kept package-private and testable without DB.
// ----------------------------------------------------------------------
// applyLookaheadDefault clamps the user-supplied cap to [1, MaxLookaheadCap]
// or returns DefaultLookaheadCap when the input is zero.
func applyLookaheadDefault(n int) int {
if n <= 0 {
return DefaultLookaheadCap
}
if n > MaxLookaheadCap {
return MaxLookaheadCap
}
return n
}
// applyLookaheadCap drops future predicted rows beyond cap. Returns the
// trimmed slice + counts. Predicted-overdue rows + court-set rows are
// exempt from the cap.
func applyLookaheadCap(rows []TimelineEvent, cap int) (kept []TimelineEvent, projTotal, projShown, overdueCount int) {
if cap <= 0 {
cap = DefaultLookaheadCap
}
// Sort future predicted by date ASC so "next 7" is deterministic.
type indexed struct {
ev TimelineEvent
key string
}
var futurePred []indexed
other := make([]TimelineEvent, 0, len(rows))
for _, r := range rows {
if r.Status == "predicted_overdue" {
overdueCount++
other = append(other, r)
continue
}
if r.Status == "predicted" && r.Date != nil {
projTotal++
key := r.Date.Format("2006-01-02") + "|" + r.RuleCode + "|" + r.Title
futurePred = append(futurePred, indexed{ev: r, key: key})
continue
}
// court_set, undated, or non-projection — pass through.
other = append(other, r)
}
sort.SliceStable(futurePred, func(i, j int) bool {
return futurePred[i].key < futurePred[j].key
})
if len(futurePred) > cap {
futurePred = futurePred[:cap]
}
projShown = len(futurePred)
kept = other
for _, p := range futurePred {
kept = append(kept, p.ev)
}
return
}
// flagsForProject builds the CalcOptions.Flags set from the project's
// metadata. Slice 2 honours the existing condition_flag system (with_ccr
// etc.); Slice 3 will surface this via project columns once the CCR
// sub-project FK lands. For now, no flags are set unless the project
// metadata explicitly carries them.
func flagsForProject(p *models.Project) []string {
var flags []string
if len(p.Metadata) == 0 {
return flags
}
var meta map[string]any
if err := json.Unmarshal(p.Metadata, &meta); err != nil {
return flags
}
if raw, ok := meta["fristen_flags"]; ok {
if arr, ok := raw.([]any); ok {
for _, v := range arr {
if s, ok := v.(string); ok && s != "" {
flags = append(flags, s)
}
}
}
}
return flags
}
// ruleDisplayName picks a display name for a projected row. Prefers the
// rule's name in the requested language; falls back to the calculator's
// UI name (which already handles translation in some cases).
func ruleDisplayName(rule models.DeadlineRule, ui UIDeadline, lang string) string {
if name := ruleNameInLang(rule, lang); name != "" {
return name
}
if lang == "en" && ui.NameEN != "" {
return ui.NameEN
}
return ui.Name
}
func ruleNameInLang(rule models.DeadlineRule, lang string) string {
if lang == "en" && rule.NameEN != "" {
return rule.NameEN
}
return rule.Name
}
// lang normalises the option string. Empty / unknown defaults to "de"
// (Paliad's frontend default).
func lang(s string) string {
switch strings.ToLower(strings.TrimSpace(s)) {
case "en":
return "en"
default:
return "de"
}
}
// extractMetadataString reads a top-level string field from a JSON-encoded
// metadata blob. Used to pull rule_code out of paliad.project_events.metadata
// for rule_skipped rows.
func extractMetadataString(raw json.RawMessage, key string) string {
if len(raw) == 0 {
return ""
}
var m map[string]any
if err := json.Unmarshal(raw, &m); err != nil {
return ""
}
if v, ok := m[key]; ok {
if s, ok := v.(string); ok {
return s
}
}
return ""
}
// scopeProjectIDFilter renders a where-clause fragment for "project_id
// matches projectID exactly" (DirectOnly) or "project_id is in projectID's
// path" (subtree). Always uses positional $1 for projectID — the caller
// passes projectID as the first argument.
func scopeProjectIDFilter(alias, column string, _ uuid.UUID, directOnly bool) string {
if directOnly {
return fmt.Sprintf("%s.%s = $1", alias, column)
}
return fmt.Sprintf(
`%s.%s IN (
SELECT id FROM paliad.projects
WHERE $1 = ANY(string_to_array(path, '.')::uuid[])
)`, alias, column)
}
func nilIfEmpty(s string) any {
if s == "" {
return nil
}
return s
}
// deadlineStatus maps the deadline row's status + due date into the
// SmartTimeline status vocabulary.
func deadlineStatus(rawStatus string, due time.Time) string {
if rawStatus == "completed" {
return "done"
}
today := startOfUTCDay(time.Now().UTC())
dueDay := startOfUTCDay(due)
if dueDay.Before(today) {
return "overdue"
}
return "open"
}
// appointmentStatus is "done" once the start has passed, "open" otherwise.
func appointmentStatus(start, now time.Time) string {
if start.Before(now) {
return "done"
}
return "open"
}
// milestoneStatus picks a status for project_event timeline rows.
func milestoneStatus(timelineKind, eventType *string) string {
if timelineKind != nil && *timelineKind == "custom_milestone" {
return "off_script"
}
if eventType != nil && *eventType == "custom_milestone" {
return "off_script"
}
return "done"
}
// sortTimeline orders rows by Date ASC with undated rows pinned at the
// end; ties break on actuals-first, then kindOrder, then title, then
// provenance id so the order is fully deterministic across requests.
func sortTimeline(rows []TimelineEvent) {
sort.SliceStable(rows, func(i, j int) bool {
di, dj := rows[i].Date, rows[j].Date
if di == nil && dj == nil {
return timelineTiebreak(rows[i], rows[j])
}
if di == nil {
return false
}
if dj == nil {
return true
}
if di.Equal(*dj) {
return timelineTiebreak(rows[i], rows[j])
}
return di.Before(*dj)
})
}
// timelineTiebreak: actuals before projections of the same date (deadline
// > appointment > milestone > projected); same-kind ties fall back to
// title then to the stringified provenance UUID.
func timelineTiebreak(a, b TimelineEvent) bool {
if a.Kind != b.Kind {
return kindOrder(a.Kind) < kindOrder(b.Kind)
}
if a.Title != b.Title {
return a.Title < b.Title
}
return timelineRowID(a) < timelineRowID(b)
}
func kindOrder(kind string) int {
switch kind {
case "deadline":
return 0
case "appointment":
return 1
case "milestone":
return 2
case "projected":
return 3
}
return 4
}
func timelineRowID(ev TimelineEvent) string {
switch {
case ev.DeadlineID != nil:
return ev.DeadlineID.String()
case ev.AppointmentID != nil:
return ev.AppointmentID.String()
case ev.ProjectEventID != nil:
return ev.ProjectEventID.String()
case ev.DeadlineRuleID != nil:
return ev.DeadlineRuleID.String()
}
return ""
}
func startOfUTCDay(t time.Time) time.Time {
t = t.UTC()
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC)
}
func timePtr(t time.Time) *time.Time { return &t }