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:
@@ -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)
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|||||||
302
internal/services/projection_counterclaim_test.go
Normal file
302
internal/services/projection_counterclaim_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user