From 82888dea789a32049d826fed4e635bc75a40000a Mon Sep 17 00:00:00 2001 From: m Date: Sat, 9 May 2026 16:07:37 +0200 Subject: [PATCH] =?UTF-8?q?feat(t-paliad-174):=20SmartTimeline=20Slice=203?= =?UTF-8?q?=20=E2=80=94=20projection=20parallel=20tracks=20+=20counterclai?= =?UTF-8?q?m=20handler?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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:"; a project that is itself a CCR (counterclaim_of != nil) pulls its target's events as Track="parent_context:" 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/ 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. --- internal/handlers/handlers.go | 3 + internal/handlers/projection.go | 68 ++++ .../services/projection_counterclaim_test.go | 302 ++++++++++++++++++ internal/services/projection_service.go | 149 +++++++-- .../services/projection_service_unit_test.go | 38 +++ 5 files changed, 535 insertions(+), 25 deletions(-) create mode 100644 internal/services/projection_counterclaim_test.go diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 4a0ae5a..c6c2ed3 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -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/anchor", handleProjectTimelineAnchor) 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}/tree", handleGetProjectTree) protected.HandleFunc("POST /api/projects/{id}/pin", handlePinProject) diff --git a/internal/handlers/projection.go b/internal/handlers/projection.go index 82577e6..9d23acb 100644 --- a/internal/handlers/projection.go +++ b/internal/handlers/projection.go @@ -13,6 +13,7 @@ package handlers import ( "encoding/json" "net/http" + "strings" "time" "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-Overdue", itoa(meta.PredictedOverdue)) w.Header().Set("X-Projection-Lookahead", itoa(meta.Lookahead)) + if len(meta.AvailableTracks) > 0 { + // Comma-separated list of track tags ("parent", "counterclaim:", + // "parent_context:"). Track ids are UUIDs — safe in headers. + w.Header().Set("X-Projection-Tracks", strings.Join(meta.AvailableTracks, ",")) + } writeJSON(w, http.StatusOK, rows) } @@ -253,6 +259,68 @@ func itoa(n int) string { 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 // // Body shape: {"title": "...", "description": "...", "occurred_at": "YYYY-MM-DD"} diff --git a/internal/services/projection_counterclaim_test.go b/internal/services/projection_counterclaim_test.go new file mode 100644 index 0000000..4414ec2 --- /dev/null +++ b/internal/services/projection_counterclaim_test.go @@ -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:". +// 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) + } + }) +} diff --git a/internal/services/projection_service.go b/internal/services/projection_service.go index 1b5eb41..a1a0b48 100644 --- a/internal/services/projection_service.go +++ b/internal/services/projection_service.go @@ -125,11 +125,18 @@ type ProjectionOpts struct { // 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 - ProjectedShown int `json:"projected_shown"` // future predicted rows after cap - PredictedOverdue int `json:"predicted_overdue"` // overdue projection rows (uncapped) - Lookahead int `json:"lookahead"` // applied cap value + 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:" is added when CCR + // children exist; "parent_context:" 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. @@ -173,17 +180,98 @@ func NewProjectionService( // 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. +// +// 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:" rows alongside. +// - When the viewed project is itself a CCR (counterclaim_of != nil), +// the parent emits Track="parent_context:" 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) { - meta := ProjectionMeta{Lookahead: applyLookaheadDefault(opts.LookaheadCap)} + meta := ProjectionMeta{ + Lookahead: applyLookaheadDefault(opts.LookaheadCap), + AvailableTracks: []string{"parent"}, + } proj, err := s.projects.GetByID(ctx, userID, projectID) if err != nil { 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{ ProjectID: &projectID, DirectOnly: opts.DirectOnly, @@ -195,7 +283,7 @@ func (s *ProjectionService) For(ctx context.Context, userID, projectID uuid.UUID ev := TimelineEvent{ Kind: "deadline", 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)), Title: d.Title, DeadlineID: &d.ID, @@ -210,9 +298,11 @@ func (s *ProjectionService) For(ctx context.Context, userID, projectID uuid.UUID 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, @@ -226,7 +316,7 @@ func (s *ProjectionService) For(ctx context.Context, userID, projectID uuid.UUID ev := TimelineEvent{ Kind: "appointment", Status: appointmentStatus(startCopy, now), - Track: "parent", + Track: trackTag, Date: &startCopy, Title: a.Title, AppointmentID: &a.ID, @@ -234,42 +324,41 @@ func (s *ProjectionService) For(ctx context.Context, userID, projectID uuid.UUID if a.Description != nil { ev.Description = *a.Description } + applySubProject(&ev, subProject) 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 { 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) -------------------------------------------- - // 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. + // --- Projection (Slice 2) ---- 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 - out = append(out, projectedRows...) - // --- 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. + // --- Dependency annotations ---- if proj.ProceedingTypeID != nil && s.rules != nil { rules, err := s.rules.List(ctx, proj.ProceedingTypeID) if err == nil { @@ -277,10 +366,20 @@ func (s *ProjectionService) For(ctx context.Context, userID, projectID uuid.UUID } } - sortTimeline(out) 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. diff --git a/internal/services/projection_service_unit_test.go b/internal/services/projection_service_unit_test.go index a658df7..a5b1d3d 100644 --- a/internal/services/projection_service_unit_test.go +++ b/internal/services/projection_service_unit_test.go @@ -151,3 +151,41 @@ func TestKindOrder(t *testing.T) { 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) + } + }) + } +}