feat(t-paliad-174): SmartTimeline Slice 3 — projection parallel tracks + counterclaim handler

ProjectionService.For now composes multiple tracks instead of a single
"parent" stream. The viewed project always emits Track="parent"; visible
CCR children emit Track="counterclaim:<child_id>"; a project that is
itself a CCR (counterclaim_of != nil) pulls its target's events as
Track="parent_context:<parent_id>" so the lawyer working the CCR sees
the main proceeding without leaving the page (§4.5).

Each track runs the actuals + projection pipeline independently with
its own lookahead cap and dependency annotations against its own
proceeding's rule tree. SubProjectID + SubProjectTitle are populated on
non-parent rows so the frontend can render the sub-project title in the
column sub-header.

ProjectionMeta gains AvailableTracks; the handler surfaces it as the
new X-Projection-Tracks response header (CSV) so the wire shape stays
[]TimelineEvent (frozen since Slice 1).

POST /api/projects/{id}/counterclaim wraps ProjectService.CreateCounterclaim
— accepts proceeding_type_id / flip_our_side / title / case_number,
returns the new project's id + canonical /projects/<id> URL.

Tests: pure-function coverage for derivedCounterclaimOurSide (default
flip + R.49.2.b override + court/both pass-through). Live-DB integration
test covers the four invariants — CreateCounterclaim atomicity (parent
audit + child audit + our_side flip + sibling-under-patent placement),
parent's projection surfaces the counterclaim track, child's projection
surfaces parent_context, two-level CCR chains are rejected by both the
service guard and the schema-level trigger.
This commit is contained in:
m
2026-05-09 16:07:37 +02:00
parent 306bb11618
commit 82888dea78
5 changed files with 535 additions and 25 deletions

View File

@@ -225,6 +225,9 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
protected.HandleFunc("POST /api/projects/{id}/timeline/milestone", handleCreateProjectTimelineMilestone) protected.HandleFunc("POST /api/projects/{id}/timeline/milestone", handleCreateProjectTimelineMilestone)
protected.HandleFunc("POST /api/projects/{id}/timeline/anchor", handleProjectTimelineAnchor) protected.HandleFunc("POST /api/projects/{id}/timeline/anchor", handleProjectTimelineAnchor)
protected.HandleFunc("POST /api/projects/{id}/timeline/skip", handleProjectTimelineSkip) protected.HandleFunc("POST /api/projects/{id}/timeline/skip", handleProjectTimelineSkip)
// /counterclaim creates a CCR sub-project linked via the new
// paliad.projects.counterclaim_of FK (t-paliad-174 Slice 3).
protected.HandleFunc("POST /api/projects/{id}/counterclaim", handleCreateProjectCounterclaim)
protected.HandleFunc("GET /api/projects/{id}/children", handleListProjectChildren) protected.HandleFunc("GET /api/projects/{id}/children", handleListProjectChildren)
protected.HandleFunc("GET /api/projects/{id}/tree", handleGetProjectTree) protected.HandleFunc("GET /api/projects/{id}/tree", handleGetProjectTree)
protected.HandleFunc("POST /api/projects/{id}/pin", handlePinProject) protected.HandleFunc("POST /api/projects/{id}/pin", handlePinProject)

View File

@@ -13,6 +13,7 @@ package handlers
import ( import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"strings"
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
@@ -75,6 +76,11 @@ func handleGetProjectTimeline(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Projection-Shown", itoa(meta.ProjectedShown)) w.Header().Set("X-Projection-Shown", itoa(meta.ProjectedShown))
w.Header().Set("X-Projection-Overdue", itoa(meta.PredictedOverdue)) w.Header().Set("X-Projection-Overdue", itoa(meta.PredictedOverdue))
w.Header().Set("X-Projection-Lookahead", itoa(meta.Lookahead)) w.Header().Set("X-Projection-Lookahead", itoa(meta.Lookahead))
if len(meta.AvailableTracks) > 0 {
// Comma-separated list of track tags ("parent", "counterclaim:<id>",
// "parent_context:<id>"). Track ids are UUIDs — safe in headers.
w.Header().Set("X-Projection-Tracks", strings.Join(meta.AvailableTracks, ","))
}
writeJSON(w, http.StatusOK, rows) writeJSON(w, http.StatusOK, rows)
} }
@@ -253,6 +259,68 @@ func itoa(n int) string {
return string(buf[i:]) return string(buf[i:])
} }
// POST /api/projects/{id}/counterclaim
//
// Body: {
// "proceeding_type_id": 9, // optional, defaults to UPC_REV
// "flip_our_side": false, // optional, default-flip otherwise
// "title": "EP3456789 — Widerklage (CCR)", // optional, auto-suggested
// "case_number": "ACT_xxx_2026" // optional CCR case number
// }
//
// Creates the CCR sub-project, writes audit rows on parent + child,
// returns the new project's id + canonical URL.
func handleCreateProjectCounterclaim(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
parentID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
return
}
var body struct {
ProceedingTypeID *int `json:"proceeding_type_id,omitempty"`
FlipOurSide *bool `json:"flip_our_side,omitempty"`
Title *string `json:"title,omitempty"`
CaseNumber *string `json:"case_number,omitempty"`
}
// Empty body is fine — full default behaviour.
if r.ContentLength > 0 {
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
}
opts := services.CounterclaimOpts{
ProceedingTypeID: body.ProceedingTypeID,
FlipOurSide: body.FlipOurSide,
Title: body.Title,
CaseNumber: body.CaseNumber,
}
child, err := dbSvc.projects.CreateCounterclaim(r.Context(), uid, parentID, opts)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusCreated, map[string]any{
"id": child.ID,
"url": "/projects/" + child.ID.String(),
"counterclaim_of": child.CounterclaimOf,
"parent_id": child.ParentID,
"title": child.Title,
"our_side": child.OurSide,
"proceeding_type": child.ProceedingTypeID,
"case_number": child.CaseNumber,
})
}
// POST /api/projects/{id}/timeline/milestone // POST /api/projects/{id}/timeline/milestone
// //
// Body shape: {"title": "...", "description": "...", "occurred_at": "YYYY-MM-DD"} // Body shape: {"title": "...", "description": "...", "occurred_at": "YYYY-MM-DD"}

View File

@@ -0,0 +1,302 @@
package services
// Live-DB integration test for the counterclaim sub-project shape
// (t-paliad-174 SmartTimeline Slice 3). Skipped without TEST_DATABASE_URL,
// matching the convention of the other live tests in this package.
//
// The test exercises the end-to-end shape:
// 1. CreateCounterclaim atomically creates child + flips our_side +
// writes audit rows on parent AND child + sets counterclaim_of.
// 2. parent_id of the child equals parent's parent_id (sibling-under-
// patent placement).
// 3. ProjectionService.For on the parent surfaces a parallel-track
// counterclaim event; AvailableTracks lists the new track.
// 4. ProjectionService.For on the child surfaces the parent's events
// with track="parent_context:<parent_id>".
// 5. Two-level CCR chains are rejected at the schema level.
import (
"context"
"errors"
"os"
"strings"
"testing"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
"mgit.msbls.de/m/paliad/internal/db"
)
func TestCreateCounterclaim_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() // sibling parent: the patent hub
caseID := uuid.New() // the parent case (UPC_INF)
// Resolve UPC_INF + UPC_REV ids once. We need real ids from the
// proceeding_types seed because they're NOT NULL on the test row.
var upcInf, upcRev int
if err := pool.GetContext(ctx, &upcInf,
`SELECT id FROM paliad.proceeding_types WHERE code = 'UPC_INF'`); err != nil {
t.Fatalf("resolve UPC_INF: %v", err)
}
if err := pool.GetContext(ctx, &upcRev,
`SELECT id FROM paliad.proceeding_types WHERE code = 'UPC_REV'`); err != nil {
t.Fatalf("resolve UPC_REV: %v", err)
}
cleanup := func() {
// Delete CCR children first (FK to caseID via counterclaim_of is
// ON DELETE SET NULL but the child rows still hold parent_id =
// patentID — clear them via a parent_id sweep).
pool.ExecContext(ctx, `DELETE FROM paliad.project_events WHERE project_id IN (
SELECT id FROM paliad.projects WHERE counterclaim_of = $1)`, caseID)
pool.ExecContext(ctx, `DELETE FROM paliad.project_teams WHERE project_id IN (
SELECT id FROM paliad.projects WHERE counterclaim_of = $1)`, caseID)
pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE counterclaim_of = $1`, caseID)
pool.ExecContext(ctx, `DELETE FROM paliad.project_events WHERE project_id IN ($1, $2)`, caseID, patentID)
pool.ExecContext(ctx, `DELETE FROM paliad.project_teams WHERE project_id IN ($1, $2)`, caseID, patentID)
pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE id IN ($1, $2)`, caseID, patentID)
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, 'ccr-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, 'ccr-test@hlc.com', 'CCR Test', 'munich', 'global_admin', 'de')`,
userID); err != nil {
t.Fatalf("seed paliad.users: %v", err)
}
// Parent 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, 'EP3456789 — Test Patent', 'EP3456789', '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)
}
// Child case (UPC_INF) under the patent.
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.projects
(id, type, parent_id, path, title, status, created_by,
proceeding_type_id, our_side)
VALUES ($1, 'case', $2, $2::text || '.' || $1::text,
'UPC-CFI München — Klage', 'active', $3, $4, 'claimant')`,
caseID, patentID, userID, upcInf); err != nil {
t.Fatalf("seed case: %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)`,
caseID, userID); err != nil {
t.Fatalf("seed case team: %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("CreateCounterclaim flips our_side, places sibling, audits both", func(t *testing.T) {
child, err := projects.CreateCounterclaim(ctx, userID, caseID, CounterclaimOpts{})
if err != nil {
t.Fatalf("CreateCounterclaim: %v", err)
}
defer pool.ExecContext(ctx, `DELETE FROM paliad.project_events WHERE project_id IN ($1, $2)`, child.ID, caseID)
defer pool.ExecContext(ctx, `DELETE FROM paliad.project_teams WHERE project_id = $1`, child.ID)
defer pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE id = $1`, child.ID)
// 1. counterclaim_of points at the parent.
if child.CounterclaimOf == nil || *child.CounterclaimOf != caseID {
t.Errorf("child.CounterclaimOf = %v, want %v", child.CounterclaimOf, caseID)
}
// 2. parent_id = parent's parent_id = patent hub (sibling-under-patent).
if child.ParentID == nil || *child.ParentID != patentID {
t.Errorf("child.ParentID = %v, want %v (sibling under patent)", child.ParentID, patentID)
}
// 3. our_side flipped: parent claimant → child defendant.
if child.OurSide == nil || *child.OurSide != "defendant" {
t.Errorf("child.OurSide = %v, want defendant", child.OurSide)
}
// 4. Default proceeding_type_id resolved to UPC_REV.
if child.ProceedingTypeID == nil || *child.ProceedingTypeID != upcRev {
t.Errorf("child.ProceedingTypeID = %v, want UPC_REV (%d)", child.ProceedingTypeID, upcRev)
}
// 5. Auto-suggested title carries the patent reference + suffix.
if !strings.Contains(child.Title, "EP3456789") || !strings.Contains(child.Title, "Widerklage") {
t.Errorf("child.Title = %q, want it to contain EP3456789 and Widerklage", child.Title)
}
// 6. Audit rows on BOTH parent and child with timeline_kind='milestone'.
var parentAudit, childAudit int
if err := pool.GetContext(ctx, &parentAudit,
`SELECT count(*) FROM paliad.project_events
WHERE project_id = $1 AND event_type = 'counterclaim_created'
AND timeline_kind = 'milestone'`, caseID); err != nil {
t.Fatalf("count parent audit: %v", err)
}
if parentAudit != 1 {
t.Errorf("parent counterclaim_created rows = %d, want 1", parentAudit)
}
if err := pool.GetContext(ctx, &childAudit,
`SELECT count(*) FROM paliad.project_events
WHERE project_id = $1 AND event_type = 'counterclaim_created'
AND timeline_kind = 'milestone'`, child.ID); err != nil {
t.Fatalf("count child audit: %v", err)
}
if childAudit != 1 {
t.Errorf("child counterclaim_created rows = %d, want 1", childAudit)
}
})
t.Run("ProjectionService.For on parent surfaces counterclaim track", func(t *testing.T) {
child, err := projects.CreateCounterclaim(ctx, userID, caseID, CounterclaimOpts{})
if err != nil {
t.Fatalf("CreateCounterclaim: %v", err)
}
defer pool.ExecContext(ctx, `DELETE FROM paliad.project_events WHERE project_id IN ($1, $2)`, child.ID, caseID)
defer pool.ExecContext(ctx, `DELETE FROM paliad.project_teams WHERE project_id = $1`, child.ID)
defer pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE id = $1`, child.ID)
rows, meta, err := projection.For(ctx, userID, caseID, ProjectionOpts{})
if err != nil {
t.Fatalf("projection.For parent: %v", err)
}
// AvailableTracks contains parent + the new counterclaim track.
expectTrack := "counterclaim:" + child.ID.String()
var sawCounterclaimTrack bool
for _, t := range meta.AvailableTracks {
if t == expectTrack {
sawCounterclaimTrack = true
break
}
}
if !sawCounterclaimTrack {
t.Errorf("AvailableTracks = %v, want to contain %q", meta.AvailableTracks, expectTrack)
}
// At least one row carries the counterclaim track + the
// SubProjectID = child.ID.
var countCCR int
for _, r := range rows {
if r.Track == expectTrack {
countCCR++
if r.SubProjectID == nil || *r.SubProjectID != child.ID {
t.Errorf("ccr-track row missing SubProjectID = child.ID")
}
}
}
if countCCR == 0 {
t.Errorf("expected at least one row on counterclaim track, saw 0 (rows=%d)", len(rows))
}
})
t.Run("ProjectionService.For on child surfaces parent_context track", func(t *testing.T) {
child, err := projects.CreateCounterclaim(ctx, userID, caseID, CounterclaimOpts{})
if err != nil {
t.Fatalf("CreateCounterclaim: %v", err)
}
defer pool.ExecContext(ctx, `DELETE FROM paliad.project_events WHERE project_id IN ($1, $2)`, child.ID, caseID)
defer pool.ExecContext(ctx, `DELETE FROM paliad.project_teams WHERE project_id = $1`, child.ID)
defer pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE id = $1`, child.ID)
rows, meta, err := projection.For(ctx, userID, child.ID, ProjectionOpts{})
if err != nil {
t.Fatalf("projection.For child: %v", err)
}
expectTrack := "parent_context:" + caseID.String()
var sawParentContext bool
for _, t := range meta.AvailableTracks {
if t == expectTrack {
sawParentContext = true
break
}
}
if !sawParentContext {
t.Errorf("AvailableTracks = %v, want to contain %q", meta.AvailableTracks, expectTrack)
}
var countParentCtx int
for _, r := range rows {
if r.Track == expectTrack {
countParentCtx++
if r.SubProjectID == nil || *r.SubProjectID != caseID {
t.Errorf("parent_context row missing SubProjectID = parent.ID")
}
}
}
if countParentCtx == 0 {
t.Errorf("expected at least one parent_context row, saw 0 (rows=%d)", len(rows))
}
})
t.Run("Two-level CCR chains are rejected at the schema level", func(t *testing.T) {
child, err := projects.CreateCounterclaim(ctx, userID, caseID, CounterclaimOpts{})
if err != nil {
t.Fatalf("CreateCounterclaim: %v", err)
}
defer pool.ExecContext(ctx, `DELETE FROM paliad.project_events WHERE project_id IN ($1, $2)`, child.ID, caseID)
defer pool.ExecContext(ctx, `DELETE FROM paliad.project_teams WHERE project_id = $1`, child.ID)
defer pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE id = $1`, child.ID)
// Trying to create a CCR against the CCR child = two-level chain.
// CreateCounterclaim guards with an early ErrInvalidInput before
// hitting the trigger; verify the early guard fires.
_, err = projects.CreateCounterclaim(ctx, userID, child.ID, CounterclaimOpts{})
if err == nil {
t.Fatal("expected error for two-level CCR chain, got nil")
}
if !errors.Is(err, ErrInvalidInput) {
t.Errorf("expected ErrInvalidInput, got %v", err)
}
// Also pin the schema-level trigger guard: a direct INSERT
// pointing at a row that already has counterclaim_of NOT NULL
// must be rejected by paliad.projects_no_two_level_ccr.
grandchild := uuid.New()
_, err = pool.ExecContext(ctx,
`INSERT INTO paliad.projects
(id, type, parent_id, path, title, status, created_by, counterclaim_of)
VALUES ($1, 'case', $2, $1::text, 'Grandchild CCR', 'active', $3, $4)`,
grandchild, patentID, userID, child.ID)
if err == nil {
pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE id = $1`, grandchild)
t.Fatal("expected schema trigger to reject grandchild CCR insert, got success")
}
if !strings.Contains(err.Error(), "two-level counterclaim") {
t.Errorf("trigger error message: %v (want two-level counterclaim)", err)
}
})
}

View File

@@ -125,11 +125,18 @@ type ProjectionOpts struct {
// wire shape stays []TimelineEvent (frozen from Slice 1) while the // wire shape stays []TimelineEvent (frozen from Slice 1) while the
// frontend still gets enough info to render "Mehr anzeigen". // frontend still gets enough info to render "Mehr anzeigen".
type ProjectionMeta struct { type ProjectionMeta struct {
HasProjection bool `json:"has_projection"` // true when calculator was invoked HasProjection bool `json:"has_projection"` // true when calculator was invoked
ProjectedTotal int `json:"projected_total"` // future predicted rows pre-cap ProjectedTotal int `json:"projected_total"` // future predicted rows pre-cap (main track)
ProjectedShown int `json:"projected_shown"` // future predicted rows after cap ProjectedShown int `json:"projected_shown"` // future predicted rows after cap (main track)
PredictedOverdue int `json:"predicted_overdue"` // overdue projection rows (uncapped) PredictedOverdue int `json:"predicted_overdue"` // overdue projection rows (main track, uncapped)
Lookahead int `json:"lookahead"` // applied cap value 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"`
} }
// ProjectionService composes the SmartTimeline. // ProjectionService composes the SmartTimeline.
@@ -173,17 +180,98 @@ func NewProjectionService(
// Sort: actuals before projections of the same date; projections sorted // Sort: actuals before projections of the same date; projections sorted
// by date ASC (predicted_overdue first since they're in the past), // by date ASC (predicted_overdue first since they're in the past),
// undated rows last. See sortTimeline for the deterministic tiebreak. // undated rows last. See sortTimeline for the deterministic tiebreak.
//
// Track composition (t-paliad-174 §4.5):
// - 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.
func (s *ProjectionService) For(ctx context.Context, userID, projectID uuid.UUID, opts ProjectionOpts) ([]TimelineEvent, ProjectionMeta, error) { func (s *ProjectionService) For(ctx context.Context, userID, projectID uuid.UUID, opts ProjectionOpts) ([]TimelineEvent, ProjectionMeta, error) {
meta := ProjectionMeta{Lookahead: applyLookaheadDefault(opts.LookaheadCap)} meta := ProjectionMeta{
Lookahead: applyLookaheadDefault(opts.LookaheadCap),
AvailableTracks: []string{"parent"},
}
proj, err := s.projects.GetByID(ctx, userID, projectID) proj, err := s.projects.GetByID(ctx, userID, projectID)
if err != nil { if err != nil {
return nil, meta, err return nil, meta, err
} }
out := make([]TimelineEvent, 0, 32) // --- Main project track (always present) ---------------------------
mainRows, mainMeta, err := s.loadProjectTrack(ctx, userID, proj, opts, "parent", nil)
if err != nil {
return nil, meta, err
}
meta.HasProjection = mainMeta.HasProjection
meta.ProjectedTotal = mainMeta.ProjectedTotal
meta.ProjectedShown = mainMeta.ProjectedShown
meta.PredictedOverdue = mainMeta.PredictedOverdue
// --- Actuals (deadlines + appointments + milestones) ----------------- 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)
if err != nil {
return nil, meta, fmt.Errorf("projection: ccr child %s: %w", child.ID, err)
}
out = append(out, childRows...)
meta.AvailableTracks = append(meta.AvailableTracks, tag)
}
}
// --- 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)
if err != nil {
return nil, meta, fmt.Errorf("projection: parent context: %w", err)
}
out = append(out, parentRows...)
meta.AvailableTracks = append(meta.AvailableTracks, tag)
}
// 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
}
// 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.
func (s *ProjectionService) loadProjectTrack(
ctx context.Context,
userID uuid.UUID,
proj *models.Project,
opts ProjectionOpts,
trackTag string,
subProject *models.Project,
) ([]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{ deadlineRows, err := s.deadlines.ListVisibleForUser(ctx, userID, ListFilter{
ProjectID: &projectID, ProjectID: &projectID,
DirectOnly: opts.DirectOnly, DirectOnly: opts.DirectOnly,
@@ -195,7 +283,7 @@ func (s *ProjectionService) For(ctx context.Context, userID, projectID uuid.UUID
ev := TimelineEvent{ ev := TimelineEvent{
Kind: "deadline", Kind: "deadline",
Status: deadlineStatus(d.Status, d.DueDate), Status: deadlineStatus(d.Status, d.DueDate),
Track: "parent", Track: trackTag,
Date: timePtr(time.Date(d.DueDate.Year(), d.DueDate.Month(), d.DueDate.Day(), 0, 0, 0, 0, time.UTC)), Date: timePtr(time.Date(d.DueDate.Year(), d.DueDate.Month(), d.DueDate.Day(), 0, 0, 0, 0, time.UTC)),
Title: d.Title, Title: d.Title,
DeadlineID: &d.ID, DeadlineID: &d.ID,
@@ -210,9 +298,11 @@ func (s *ProjectionService) For(ctx context.Context, userID, projectID uuid.UUID
id := *d.RuleID id := *d.RuleID
ev.DeadlineRuleID = &id ev.DeadlineRuleID = &id
} }
applySubProject(&ev, subProject)
out = append(out, ev) out = append(out, ev)
} }
// --- Appointments ----
apptRows, err := s.appointments.ListVisibleForUser(ctx, userID, AppointmentListFilter{ apptRows, err := s.appointments.ListVisibleForUser(ctx, userID, AppointmentListFilter{
ProjectID: &projectID, ProjectID: &projectID,
DirectOnly: opts.DirectOnly, DirectOnly: opts.DirectOnly,
@@ -226,7 +316,7 @@ func (s *ProjectionService) For(ctx context.Context, userID, projectID uuid.UUID
ev := TimelineEvent{ ev := TimelineEvent{
Kind: "appointment", Kind: "appointment",
Status: appointmentStatus(startCopy, now), Status: appointmentStatus(startCopy, now),
Track: "parent", Track: trackTag,
Date: &startCopy, Date: &startCopy,
Title: a.Title, Title: a.Title,
AppointmentID: &a.ID, AppointmentID: &a.ID,
@@ -234,42 +324,41 @@ func (s *ProjectionService) For(ctx context.Context, userID, projectID uuid.UUID
if a.Description != nil { if a.Description != nil {
ev.Description = *a.Description ev.Description = *a.Description
} }
applySubProject(&ev, subProject)
out = append(out, ev) out = append(out, ev)
} }
// Appointments don't carry rule_id in the WithProject view; pull the
// rule_ids in one extra round-trip so the ruleByActual lookup sees
// court-set anchors written via /timeline/anchor.
if err := s.hydrateAppointmentRuleIDs(ctx, projectID, opts.DirectOnly, out); err != nil { if err := s.hydrateAppointmentRuleIDs(ctx, projectID, opts.DirectOnly, out); err != nil {
return nil, meta, err return nil, meta, err
} }
// --- Milestones ----
skippedRules, milestoneRows, err := s.listProjectEvents(ctx, userID, projectID, opts) skippedRules, milestoneRows, err := s.listProjectEvents(ctx, userID, projectID, opts)
if err != nil { if err != nil {
return nil, meta, fmt.Errorf("projection: milestones: %w", err) 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...) out = append(out, milestoneRows...)
// --- Projection (Slice 2) -------------------------------------------- // --- Projection (Slice 2) ----
// Only run the calculator when the project has a proceeding type; no
// trigger column on paliad.projects yet (deferred to a later slice),
// so today's heuristic is "use the root rule's anchored actual when
// present, else use today() as placeholder". Either way, downstream
// rows reflow off the override map keyed by rule_code.
projectedRows, projMeta, err := s.computeProjections(ctx, proj, skippedRules, opts) projectedRows, projMeta, err := s.computeProjections(ctx, proj, skippedRules, opts)
if err != nil { if err != nil {
return nil, meta, fmt.Errorf("projection: calculate: %w", err) 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.HasProjection = projMeta.HasProjection
meta.ProjectedTotal = projMeta.ProjectedTotal meta.ProjectedTotal = projMeta.ProjectedTotal
meta.ProjectedShown = projMeta.ProjectedShown meta.ProjectedShown = projMeta.ProjectedShown
meta.PredictedOverdue = projMeta.PredictedOverdue meta.PredictedOverdue = projMeta.PredictedOverdue
out = append(out, projectedRows...)
// --- Dependency annotations ------------------------------------------ // --- Dependency annotations ----
// Walk parent_id chains for every row that carries a DeadlineRuleID.
// The annotation map is built from the proceeding's full rule tree;
// rows with no rule (off-script milestones) are left empty.
if proj.ProceedingTypeID != nil && s.rules != nil { if proj.ProceedingTypeID != nil && s.rules != nil {
rules, err := s.rules.List(ctx, proj.ProceedingTypeID) rules, err := s.rules.List(ctx, proj.ProceedingTypeID)
if err == nil { if err == nil {
@@ -277,10 +366,20 @@ func (s *ProjectionService) For(ctx context.Context, userID, projectID uuid.UUID
} }
} }
sortTimeline(out)
return out, meta, nil 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 // computeProjections runs FristenrechnerService.Calculate for the project
// and emits TimelineEvent rows for every rule that does NOT have a // and emits TimelineEvent rows for every rule that does NOT have a
// matching actual. Returns the projected rows + the meta summary. // matching actual. Returns the projected rows + the meta summary.

View File

@@ -151,3 +151,41 @@ func TestKindOrder(t *testing.T) {
t.Error("milestone should sort before projected") t.Error("milestone should sort before projected")
} }
} }
// TestDerivedCounterclaimOurSide pins the our_side flip semantics
// (t-paliad-174 §11 Q2):
// - Default (override nil): claimant ↔ defendant; court / both pass through.
// - Override true: same default-flip semantics.
// - Override false (R.49.2.b CCI edge case): keep parent's side.
// - NULL parent_side yields empty string (no flip without a starting side).
func TestDerivedCounterclaimOurSide(t *testing.T) {
tru := true
fal := false
str := func(s string) *string { return &s }
cases := []struct {
name string
parent *string
override *bool
want string
}{
{"nil parent → empty", nil, nil, ""},
{"nil parent + override → empty", nil, &tru, ""},
{"claimant → defendant (default)", str("claimant"), nil, "defendant"},
{"defendant → claimant (default)", str("defendant"), nil, "claimant"},
{"court passes through", str("court"), nil, "court"},
{"both passes through", str("both"), nil, "both"},
{"explicit flip=true", str("claimant"), &tru, "defendant"},
{"explicit flip=false keeps parent's side", str("claimant"), &fal, "claimant"},
{"flip=false on defendant keeps defendant", str("defendant"), &fal, "defendant"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := derivedCounterclaimOurSide(c.parent, c.override)
if got != c.want {
t.Errorf("derivedCounterclaimOurSide(%v, %v) = %q, want %q",
c.parent, c.override, got, c.want)
}
})
}
}