feat(t-paliad-175): SmartTimeline Slice 4 — backend levelPolicy + lane aggregation + bubble-up
ProjectionService now dispatches on project type per design §5.1:
- Case (and unknown) — full detail flow: parent track + CCR sub-projects
+ parent_context for CCR children. Lanes mirror tracks ("self" +
"counterclaim:<id>" + "parent_context:<id>").
- Patent / Litigation / Client — lane-aggregated: load direct children
matching the axis (cases / patents / litigations), gather subtree
events per lane, apply (kinds, statuses) filter, tag rows with
LaneID = direct-child id. Calculator skipped at higher levels —
predicted future is a Case-level concern.
levelPolicy(projectType) returns the (kinds, statuses, lane_axis)
triple. Patent = deadlines+milestones with done/open/overdue;
Litigation + Client = milestones with done.
metadata.bubble_up on paliad.project_events (no schema change — uses
existing jsonb column) overrides the kind/status filter at higher
levels. Defaults per Q5: counterclaim_created / third_party_intervention
/ scope_change → true; custom_milestone → false (user opts in via
form checkbox). insertCounterclaimEvent now sets bubble_up=true on
both parent + child audit rows so the counterclaim_created milestone
surfaces at Patent / Litigation / Client.
Wire shape changed from []TimelineEvent to envelope {events, lanes} —
lane metadata can ride alongside the rows without exceeding header-
size limits when a Client-level projection has many lanes. Frontend
reads .events for the per-row contract and .lanes for parallel-column
rendering. X-Projection-* headers preserved for Slice 1-3 affordances
(lookahead toggle, track chip).
RecordCustomMilestone gains a bubbleUp bool param; persisted to
metadata.bubble_up only when true (so existing rows-without-it keep
the default-off behaviour).
Tests: TestLevelPolicy locks the triple table; TestRowSurvivesPolicy_
BubbleUpOverridesFilter pins the override contract; TestExtractBubbleUp
covers all per-event-type defaults + explicit override paths;
TestChildTypeForAxis pins the axis → type map. Live integration test
TestProjectionService_LevelAggregation_Live walks the patent-level
fixture: bubbled-up milestone surfaces, regular custom_milestone is
filtered, deadlines surface at Patent level.
Refs: docs/design-smart-timeline-2026-05-08.md §5 + §10 Slice 4
Refs: m/paliad#31, t-paliad-175
This commit is contained in:
@@ -1239,16 +1239,25 @@ func (s *ProjectService) CreateCounterclaim(ctx context.Context, userID, parentI
|
||||
}
|
||||
|
||||
// Audit rows on both parent and child for symmetric trail. Both rows
|
||||
// opt into the SmartTimeline via timeline_kind='milestone'.
|
||||
// opt into the SmartTimeline via timeline_kind='milestone'. The
|
||||
// bubble_up=true flag (t-paliad-175 §5.3 Q5) lets these structural
|
||||
// milestones surface on Patent / Litigation / Client SmartTimelines
|
||||
// even though the level policy filters out other milestones.
|
||||
if err := insertCounterclaimEvent(ctx, tx, id, userID,
|
||||
"Widerklage (CCR) angelegt",
|
||||
map[string]any{"counterclaim_of": parentID.String()},
|
||||
map[string]any{
|
||||
"counterclaim_of": parentID.String(),
|
||||
"bubble_up": true,
|
||||
},
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := insertCounterclaimEvent(ctx, tx, parentID, userID,
|
||||
"Widerklage (CCR) angelegt",
|
||||
map[string]any{"counterclaim_id": id.String()},
|
||||
map[string]any{
|
||||
"counterclaim_id": id.String(),
|
||||
"bubble_up": true,
|
||||
},
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
271
internal/services/projection_levels_test.go
Normal file
271
internal/services/projection_levels_test.go
Normal file
@@ -0,0 +1,271 @@
|
||||
package services
|
||||
|
||||
// Live-DB integration test for parent-node lane aggregation
|
||||
// (t-paliad-175 SmartTimeline Slice 4 §5). Skipped without TEST_DATABASE_URL.
|
||||
//
|
||||
// Builds a 3-level fixture (Patent → Case-A + Case-B → CCR-A) and walks
|
||||
// the level policy at each viewpoint:
|
||||
//
|
||||
// - Case-A view: full detail + CCR sub-project track (single project,
|
||||
// own actuals + projection, "self" lane + "counterclaim:<id>" lane).
|
||||
// - Patent view: lanes per child case; events from each case subtree;
|
||||
// deadlines + milestones surface, statuses done/open/overdue.
|
||||
// - Bubble-up: a counterclaim_created milestone (default-on bubble_up)
|
||||
// surfaces at Patent level under Case-A's lane.
|
||||
// - Custom milestone with bubble_up=true surfaces too; without, it's
|
||||
// filtered out.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/lib/pq"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/db"
|
||||
)
|
||||
|
||||
func TestProjectionService_LevelAggregation_Live(t *testing.T) {
|
||||
url := os.Getenv("TEST_DATABASE_URL")
|
||||
if url == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
|
||||
}
|
||||
if err := db.ApplyMigrations(url); err != nil {
|
||||
t.Fatalf("apply migrations: %v", err)
|
||||
}
|
||||
pool, err := sqlx.Connect("postgres", url)
|
||||
if err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
userID := uuid.New()
|
||||
patentID := uuid.New()
|
||||
caseAID := uuid.New()
|
||||
caseBID := uuid.New()
|
||||
|
||||
cleanup := func() {
|
||||
// CCR children (counterclaim_of points at one of the cases)
|
||||
// must go first so the FK doesn't block the case delete.
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.project_events WHERE project_id IN (
|
||||
SELECT id FROM paliad.projects WHERE counterclaim_of IN ($1, $2))`, caseAID, caseBID)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.project_teams WHERE project_id IN (
|
||||
SELECT id FROM paliad.projects WHERE counterclaim_of IN ($1, $2))`, caseAID, caseBID)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE counterclaim_of IN ($1, $2)`, caseAID, caseBID)
|
||||
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.project_events WHERE project_id IN ($1, $2, $3)`, patentID, caseAID, caseBID)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.deadlines WHERE project_id IN ($1, $2, $3)`, patentID, caseAID, caseBID)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.appointments WHERE project_id IN ($1, $2, $3)`, patentID, caseAID, caseBID)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.project_teams WHERE project_id IN ($1, $2, $3)`, patentID, caseAID, caseBID)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE id IN ($1, $2, $3)`, patentID, caseAID, caseBID)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.users WHERE id = $1`, userID)
|
||||
pool.ExecContext(ctx, `DELETE FROM auth.users WHERE id = $1`, userID)
|
||||
}
|
||||
cleanup()
|
||||
defer cleanup()
|
||||
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO auth.users (id, email) VALUES ($1, 'level-agg-test@hlc.com')`,
|
||||
userID); err != nil {
|
||||
t.Fatalf("seed auth.users: %v", err)
|
||||
}
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO paliad.users (id, email, display_name, office, global_role, lang)
|
||||
VALUES ($1, 'level-agg-test@hlc.com', 'Level Agg Test', 'munich', 'global_admin', 'de')`,
|
||||
userID); err != nil {
|
||||
t.Fatalf("seed paliad.users: %v", err)
|
||||
}
|
||||
// Patent hub.
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO paliad.projects (id, type, path, title, patent_number, status, created_by)
|
||||
VALUES ($1, 'patent', $1::text, 'EP9999999 — Test Patent', 'EP9999999', 'active', $2)`,
|
||||
patentID, userID); err != nil {
|
||||
t.Fatalf("seed patent: %v", err)
|
||||
}
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO paliad.project_teams (project_id, user_id, role, responsibility, inherited, added_by)
|
||||
VALUES ($1, $2, 'lead', 'lead', false, $2)`,
|
||||
patentID, userID); err != nil {
|
||||
t.Fatalf("seed patent team: %v", err)
|
||||
}
|
||||
// Case-A under the patent.
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO paliad.projects (id, type, parent_id, path, title, status, created_by)
|
||||
VALUES ($1, 'case', $2, $2::text || '.' || $1::text, 'Case A', 'active', $3)`,
|
||||
caseAID, patentID, userID); err != nil {
|
||||
t.Fatalf("seed case A: %v", err)
|
||||
}
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO paliad.project_teams (project_id, user_id, role, responsibility, inherited, added_by)
|
||||
VALUES ($1, $2, 'lead', 'lead', false, $2)`,
|
||||
caseAID, userID); err != nil {
|
||||
t.Fatalf("seed case A team: %v", err)
|
||||
}
|
||||
// Case-B under the patent.
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO paliad.projects (id, type, parent_id, path, title, status, created_by)
|
||||
VALUES ($1, 'case', $2, $2::text || '.' || $1::text, 'Case B', 'active', $3)`,
|
||||
caseBID, patentID, userID); err != nil {
|
||||
t.Fatalf("seed case B: %v", err)
|
||||
}
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO paliad.project_teams (project_id, user_id, role, responsibility, inherited, added_by)
|
||||
VALUES ($1, $2, 'lead', 'lead', false, $2)`,
|
||||
caseBID, userID); err != nil {
|
||||
t.Fatalf("seed case B team: %v", err)
|
||||
}
|
||||
|
||||
// Case-A: one open deadline + one done milestone (bubble_up=true via
|
||||
// counterclaim_created event_type) + one custom_milestone (bubble_up=false).
|
||||
now := time.Now().UTC()
|
||||
deadlineA := uuid.New()
|
||||
bubbledMilestoneA := uuid.New()
|
||||
regularMilestoneA := uuid.New()
|
||||
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO paliad.deadlines
|
||||
(id, project_id, title, due_date, source, status, created_by)
|
||||
VALUES ($1, $2, 'Case-A open deadline', $3::date, 'manual', 'pending', $4)`,
|
||||
deadlineA, caseAID, now.AddDate(0, 0, 14).Format("2006-01-02"), userID); err != nil {
|
||||
t.Fatalf("seed deadline A: %v", err)
|
||||
}
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO paliad.project_events
|
||||
(id, project_id, event_type, title, event_date, created_by, metadata,
|
||||
created_at, updated_at, timeline_kind)
|
||||
VALUES ($1, $2, 'counterclaim_created', 'Widerklage angelegt', $3, $4,
|
||||
'{"bubble_up":true}'::jsonb, $5, $5, 'milestone')`,
|
||||
bubbledMilestoneA, caseAID, now.AddDate(0, 0, -7), userID, now); err != nil {
|
||||
t.Fatalf("seed bubbled milestone A: %v", err)
|
||||
}
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO paliad.project_events
|
||||
(id, project_id, event_type, title, event_date, created_by, metadata,
|
||||
created_at, updated_at, timeline_kind)
|
||||
VALUES ($1, $2, 'custom_milestone', 'Random Note (no bubble)', $3, $4,
|
||||
'{}'::jsonb, $5, $5, 'custom_milestone')`,
|
||||
regularMilestoneA, caseAID, now.AddDate(0, 0, -3), userID, now); err != nil {
|
||||
t.Fatalf("seed regular milestone A: %v", err)
|
||||
}
|
||||
|
||||
users := NewUserService(pool)
|
||||
projects := NewProjectService(pool, users)
|
||||
eventTypes := NewEventTypeService(pool, users)
|
||||
deadlines := NewDeadlineService(pool, projects, eventTypes)
|
||||
appointments := NewAppointmentService(pool, projects)
|
||||
rules := NewDeadlineRuleService(pool)
|
||||
holidays := NewHolidayService(pool)
|
||||
courts := NewCourtService(pool)
|
||||
fristen := NewFristenrechnerService(rules, holidays, courts)
|
||||
projection := NewProjectionService(pool, projects, deadlines, appointments, fristen, rules)
|
||||
|
||||
t.Run("Case-level: lanes mirror tracks (self + CCR)", func(t *testing.T) {
|
||||
_, meta, err := projection.For(ctx, userID, caseAID, ProjectionOpts{})
|
||||
if err != nil {
|
||||
t.Fatalf("For caseA: %v", err)
|
||||
}
|
||||
// At least the "self" lane is present.
|
||||
var sawSelf bool
|
||||
for _, l := range meta.Lanes {
|
||||
if l.ID == "self" {
|
||||
sawSelf = true
|
||||
if l.Label != "Case A" {
|
||||
t.Errorf("self lane label = %q, want Case A", l.Label)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !sawSelf {
|
||||
t.Errorf("Lanes = %v, want a 'self' entry", meta.Lanes)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Patent-level: lanes per child case + milestones bubble", func(t *testing.T) {
|
||||
rows, meta, err := projection.For(ctx, userID, patentID, ProjectionOpts{})
|
||||
if err != nil {
|
||||
t.Fatalf("For patent: %v", err)
|
||||
}
|
||||
|
||||
// Lanes: one per child case.
|
||||
laneIDs := map[string]LaneInfo{}
|
||||
for _, l := range meta.Lanes {
|
||||
laneIDs[l.ID] = l
|
||||
}
|
||||
if _, ok := laneIDs[caseAID.String()]; !ok {
|
||||
t.Errorf("Lanes missing Case-A entry: %v", meta.Lanes)
|
||||
}
|
||||
if _, ok := laneIDs[caseBID.String()]; !ok {
|
||||
t.Errorf("Lanes missing Case-B entry: %v", meta.Lanes)
|
||||
}
|
||||
|
||||
// Bubbled-up milestone (counterclaim_created) surfaces under
|
||||
// Case-A's lane.
|
||||
var sawBubbled, sawRegular, sawDeadline bool
|
||||
for _, r := range rows {
|
||||
if r.ProjectEventID != nil && *r.ProjectEventID == bubbledMilestoneA {
|
||||
sawBubbled = true
|
||||
if r.LaneID != caseAID.String() {
|
||||
t.Errorf("bubbled milestone LaneID = %q, want %s", r.LaneID, caseAID.String())
|
||||
}
|
||||
if !r.BubbleUp {
|
||||
t.Errorf("bubbled milestone BubbleUp should be true")
|
||||
}
|
||||
}
|
||||
if r.ProjectEventID != nil && *r.ProjectEventID == regularMilestoneA {
|
||||
sawRegular = true
|
||||
}
|
||||
if r.DeadlineID != nil && *r.DeadlineID == deadlineA {
|
||||
sawDeadline = true
|
||||
if r.LaneID != caseAID.String() {
|
||||
t.Errorf("deadline LaneID = %q, want %s", r.LaneID, caseAID.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
if !sawBubbled {
|
||||
t.Errorf("bubbled milestone (counterclaim_created) should surface at Patent level")
|
||||
}
|
||||
// Patent policy = milestones + deadlines, statuses done/open/overdue.
|
||||
// The pending deadline (status=open) survives; the regular custom
|
||||
// milestone (off_script status, no bubble_up) is filtered out.
|
||||
if !sawDeadline {
|
||||
t.Errorf("Case-A's open deadline should surface at Patent level (kinds=deadline allowed)")
|
||||
}
|
||||
if sawRegular {
|
||||
t.Errorf("regular custom_milestone (no bubble_up, off_script status) should be filtered at Patent level")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Patent-level: bubble_up false → row dropped", func(t *testing.T) {
|
||||
// Re-write the regular milestone with bubble_up=true and confirm
|
||||
// it surfaces. Then revert.
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`UPDATE paliad.project_events
|
||||
SET metadata = '{"bubble_up":true}'::jsonb
|
||||
WHERE id = $1`, regularMilestoneA); err != nil {
|
||||
t.Fatalf("flip bubble_up: %v", err)
|
||||
}
|
||||
defer pool.ExecContext(ctx,
|
||||
`UPDATE paliad.project_events SET metadata = '{}'::jsonb WHERE id = $1`,
|
||||
regularMilestoneA)
|
||||
|
||||
rows, _, err := projection.For(ctx, userID, patentID, ProjectionOpts{})
|
||||
if err != nil {
|
||||
t.Fatalf("For patent (after flip): %v", err)
|
||||
}
|
||||
var saw bool
|
||||
for _, r := range rows {
|
||||
if r.ProjectEventID != nil && *r.ProjectEventID == regularMilestoneA {
|
||||
saw = true
|
||||
if !r.BubbleUp {
|
||||
t.Errorf("flipped milestone BubbleUp should be true")
|
||||
}
|
||||
}
|
||||
}
|
||||
if !saw {
|
||||
t.Errorf("custom_milestone with bubble_up=true should surface at Patent level")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -15,7 +15,21 @@ package services
|
||||
// derived from a deadline_rule with a parent_id.
|
||||
// - Anchor + skip write paths (RecordAnchor, RecordRuleSkipped).
|
||||
//
|
||||
// See docs/design-smart-timeline-2026-05-08.md §6 + §9 + §10
|
||||
// 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 (
|
||||
@@ -92,6 +106,73 @@ type TimelineEvent struct {
|
||||
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"`
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -137,6 +218,15 @@ type ProjectionMeta struct {
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// ProjectionService composes the SmartTimeline.
|
||||
@@ -181,17 +271,24 @@ func NewProjectionService(
|
||||
// by date ASC (predicted_overdue first since they're in the past),
|
||||
// undated rows last. See sortTimeline for the deterministic tiebreak.
|
||||
//
|
||||
// Track composition (t-paliad-174 §4.5):
|
||||
// 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 (paliad.projects.counterclaim_of = self)
|
||||
// emit Track="counterclaim:<child_id>" rows alongside.
|
||||
// - When the viewed project is itself a CCR (counterclaim_of != nil),
|
||||
// the parent emits Track="parent_context:<parent_id>" rows so the
|
||||
// lawyer working the CCR sees the main proceeding without leaving.
|
||||
// - 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)
|
||||
@@ -199,8 +296,34 @@ func (s *ProjectionService) For(ctx context.Context, userID, projectID uuid.UUID
|
||||
return nil, meta, err
|
||||
}
|
||||
|
||||
policy := levelPolicy(proj.Type)
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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)
|
||||
mainRows, mainMeta, err := s.loadProjectTrack(ctx, userID, proj, opts, "parent", nil, true)
|
||||
if err != nil {
|
||||
return nil, meta, err
|
||||
}
|
||||
@@ -209,6 +332,15 @@ func (s *ProjectionService) For(ctx context.Context, userID, projectID uuid.UUID
|
||||
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...)
|
||||
|
||||
@@ -221,12 +353,20 @@ func (s *ProjectionService) For(ctx context.Context, userID, projectID uuid.UUID
|
||||
for i := range ccrChildren {
|
||||
child := ccrChildren[i]
|
||||
tag := "counterclaim:" + child.ID.String()
|
||||
childRows, _, err := s.loadProjectTrack(ctx, userID, &child, opts, tag, &child)
|
||||
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(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,12 +375,20 @@ func (s *ProjectionService) For(ctx context.Context, userID, projectID uuid.UUID
|
||||
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)
|
||||
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
|
||||
@@ -251,6 +399,199 @@ func (s *ProjectionService) For(ctx context.Context, userID, projectID uuid.UUID
|
||||
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)").
|
||||
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
|
||||
@@ -259,6 +600,11 @@ func (s *ProjectionService) For(ctx context.Context, userID, projectID uuid.UUID
|
||||
// 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,
|
||||
@@ -266,6 +612,7 @@ func (s *ProjectionService) loadProjectTrack(
|
||||
opts ProjectionOpts,
|
||||
trackTag string,
|
||||
subProject *models.Project,
|
||||
includeProjection bool,
|
||||
) ([]TimelineEvent, ProjectionMeta, error) {
|
||||
meta := ProjectionMeta{Lookahead: applyLookaheadDefault(opts.LookaheadCap)}
|
||||
out := make([]TimelineEvent, 0, 16)
|
||||
@@ -344,19 +691,21 @@ func (s *ProjectionService) loadProjectTrack(
|
||||
out = append(out, milestoneRows...)
|
||||
|
||||
// --- Projection (Slice 2) ----
|
||||
projectedRows, projMeta, err := s.computeProjections(ctx, proj, skippedRules, opts)
|
||||
if err != nil {
|
||||
return nil, meta, fmt.Errorf("projection: calculate: %w", err)
|
||||
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
|
||||
}
|
||||
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 {
|
||||
@@ -749,6 +1098,7 @@ func (s *ProjectionService) listProjectEvents(ctx context.Context, userID, proje
|
||||
Date: &whenCopy,
|
||||
Title: r.Title,
|
||||
ProjectEventID: &r.ID,
|
||||
BubbleUp: extractBubbleUp(r.Metadata, r.EventType, r.TimelineKind),
|
||||
}
|
||||
if r.Description != nil {
|
||||
ev.Description = *r.Description
|
||||
@@ -758,16 +1108,56 @@ func (s *ProjectionService) listProjectEvents(ctx context.Context, userID, proje
|
||||
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
|
||||
@@ -784,12 +1174,20 @@ func (s *ProjectionService) RecordCustomMilestone(
|
||||
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, '{}'::jsonb, $7, $7, 'custom_milestone')`,
|
||||
id, projectID, title, description, eventDate, userID, now)
|
||||
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)
|
||||
}
|
||||
@@ -806,6 +1204,7 @@ func (s *ProjectionService) RecordCustomMilestone(
|
||||
Date: &whenCopy,
|
||||
Title: title,
|
||||
ProjectEventID: &id,
|
||||
BubbleUp: bubbleUp,
|
||||
}
|
||||
if description != nil {
|
||||
ev.Description = *description
|
||||
|
||||
@@ -211,7 +211,7 @@ func TestProjectionService_For_MergesActuals_Live(t *testing.T) {
|
||||
desc := "from RecordCustomMilestone test"
|
||||
when := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
ev, err := projection.RecordCustomMilestone(ctx, userID, projectID, title, &desc, &when)
|
||||
ev, err := projection.RecordCustomMilestone(ctx, userID, projectID, title, &desc, &when, false)
|
||||
if err != nil {
|
||||
t.Fatalf("RecordCustomMilestone: %v", err)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ package services
|
||||
// covers the SQL paths.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -152,6 +153,169 @@ func TestKindOrder(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestLevelPolicy pins the (kinds, statuses, lane_axis) triple per
|
||||
// project type per design §5.1 (t-paliad-175 SmartTimeline Slice 4).
|
||||
// These are user-visible policy decisions — locked here to catch
|
||||
// accidental shifts during refactors.
|
||||
func TestLevelPolicy(t *testing.T) {
|
||||
cases := []struct {
|
||||
projectType string
|
||||
kinds []string
|
||||
statuses []string
|
||||
laneAxis string
|
||||
}{
|
||||
{"case", nil, nil, "self_plus_ccr"},
|
||||
{"", nil, nil, "self_plus_ccr"}, // unknown falls back to case behaviour
|
||||
{"unknown", nil, nil, "self_plus_ccr"},
|
||||
{
|
||||
"patent",
|
||||
[]string{"deadline", "milestone"},
|
||||
[]string{"done", "open", "overdue"},
|
||||
"child_case",
|
||||
},
|
||||
{
|
||||
"litigation",
|
||||
[]string{"milestone"},
|
||||
[]string{"done"},
|
||||
"child_patent",
|
||||
},
|
||||
{
|
||||
"client",
|
||||
[]string{"milestone"},
|
||||
[]string{"done"},
|
||||
"child_litigation",
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.projectType, func(t *testing.T) {
|
||||
got := levelPolicy(c.projectType)
|
||||
if got.LaneAxis != c.laneAxis {
|
||||
t.Errorf("LaneAxis = %q, want %q", got.LaneAxis, c.laneAxis)
|
||||
}
|
||||
if !sliceEqual(got.Kinds, c.kinds) {
|
||||
t.Errorf("Kinds = %v, want %v", got.Kinds, c.kinds)
|
||||
}
|
||||
if !sliceEqual(got.Statuses, c.statuses) {
|
||||
t.Errorf("Statuses = %v, want %v", got.Statuses, c.statuses)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func sliceEqual(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// TestRowSurvivesPolicy_BubbleUpOverridesFilter pins the contract that
|
||||
// a project_event milestone with bubble_up=true survives the level
|
||||
// policy's kind/status filter at higher levels (design §5.3 + Q5).
|
||||
func TestRowSurvivesPolicy_BubbleUpOverridesFilter(t *testing.T) {
|
||||
allowKind := stringSet([]string{"deadline"}) // milestones excluded
|
||||
allowStatus := stringSet([]string{"done"}) // off_script excluded
|
||||
bubbledMilestone := TimelineEvent{
|
||||
Kind: "milestone",
|
||||
Status: "off_script",
|
||||
BubbleUp: true,
|
||||
}
|
||||
if !rowSurvivesPolicy(bubbledMilestone, allowKind, allowStatus) {
|
||||
t.Error("bubble_up=true row should survive both kind and status filters")
|
||||
}
|
||||
|
||||
regularMilestone := TimelineEvent{
|
||||
Kind: "milestone",
|
||||
Status: "off_script",
|
||||
}
|
||||
if rowSurvivesPolicy(regularMilestone, allowKind, allowStatus) {
|
||||
t.Error("regular milestone should be filtered when kind/status both excluded")
|
||||
}
|
||||
|
||||
// kind allowed, status excluded → drop.
|
||||
allowedKindBadStatus := TimelineEvent{
|
||||
Kind: "deadline",
|
||||
Status: "open",
|
||||
}
|
||||
if rowSurvivesPolicy(allowedKindBadStatus, allowKind, allowStatus) {
|
||||
t.Error("excluded status should drop a row even when kind allowed")
|
||||
}
|
||||
|
||||
// kind excluded, status allowed → drop.
|
||||
badKindGoodStatus := TimelineEvent{
|
||||
Kind: "appointment",
|
||||
Status: "done",
|
||||
}
|
||||
if rowSurvivesPolicy(badKindGoodStatus, allowKind, allowStatus) {
|
||||
t.Error("excluded kind should drop a row even when status allowed")
|
||||
}
|
||||
|
||||
// Empty filters = pass-through.
|
||||
if !rowSurvivesPolicy(badKindGoodStatus, nil, nil) {
|
||||
t.Error("empty filters should pass everything")
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractBubbleUp pins the per-event-type defaults (Q5):
|
||||
// - counterclaim_created / third_party_intervention / scope_change
|
||||
// default to true.
|
||||
// - custom_milestone defaults to false.
|
||||
// - Explicit metadata.bubble_up always wins.
|
||||
func TestExtractBubbleUp(t *testing.T) {
|
||||
str := func(s string) *string { return &s }
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
raw string
|
||||
eventType *string
|
||||
timelineKind *string
|
||||
want bool
|
||||
}{
|
||||
{"counterclaim_created defaults true", "{}", str("counterclaim_created"), str("milestone"), true},
|
||||
{"third_party_intervention defaults true", "", str("third_party_intervention"), nil, true},
|
||||
{"scope_change defaults true", "", str("scope_change"), nil, true},
|
||||
{"custom_milestone defaults false", "{}", str("custom_milestone"), str("custom_milestone"), false},
|
||||
{"unknown defaults false", "{}", str("note_created"), nil, false},
|
||||
{"explicit true overrides", `{"bubble_up":true}`, str("custom_milestone"), nil, true},
|
||||
{"explicit false overrides", `{"bubble_up":false}`, str("counterclaim_created"), nil, false},
|
||||
{"string \"true\" parses", `{"bubble_up":"true"}`, str("custom_milestone"), nil, true},
|
||||
{"string \"1\" parses", `{"bubble_up":"1"}`, str("custom_milestone"), nil, true},
|
||||
{"non-bool ignored", `{"bubble_up":42}`, str("custom_milestone"), nil, false},
|
||||
{"malformed metadata falls back to default", `{`, str("counterclaim_created"), nil, true},
|
||||
{"empty metadata + nil event_type = false", "", nil, nil, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := extractBubbleUp(json.RawMessage(c.raw), c.eventType, c.timelineKind)
|
||||
if got != c.want {
|
||||
t.Errorf("extractBubbleUp = %v, want %v", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestChildTypeForAxis pins the axis → project type map.
|
||||
func TestChildTypeForAxis(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"child_case": "case",
|
||||
"child_patent": "patent",
|
||||
"child_litigation": "litigation",
|
||||
"self_plus_ccr": "",
|
||||
"": "",
|
||||
"bogus": "",
|
||||
}
|
||||
for axis, want := range cases {
|
||||
if got := childTypeForAxis(axis); got != want {
|
||||
t.Errorf("childTypeForAxis(%q) = %q, want %q", axis, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDerivedCounterclaimOurSide pins the our_side flip semantics
|
||||
// (t-paliad-174 §11 Q2):
|
||||
// - Default (override nil): claimant ↔ defendant; court / both pass through.
|
||||
|
||||
Reference in New Issue
Block a user