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.
2258 lines
79 KiB
Go
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 }
|