feat(t-paliad-174): SmartTimeline Slice 3 — counterclaim sub-project schema + service
Migration 077 adds paliad.projects.counterclaim_of (nullable FK ON DELETE SET NULL) plus a partial index. A trigger function rejects two-level CCR chains: a project with counterclaim_of NOT NULL cannot be the target of another CCR — UPC practice has no CCR-of-a-CCR shape, so reject it at the schema level rather than defending in the application layer. ProjectService gains LoadCounterclaimChildrenVisible (list visible CCR sub-projects against a parent) and CreateCounterclaim (atomic: project row + creator-as-lead team membership + audit rows on parent AND child). The CCR child is placed as a sibling under the same patent (§4.4), our side flips claimant↔defendant by default with a "Stimmt nicht?" override for the R.49.2.b CCI edge case, and the proceeding type defaults to UPC_REV. Title auto-suggests from the patent ancestor's patent_number when available. Tracker advances 76 → 77.
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
-- t-paliad-174 — revert SmartTimeline Slice 3 schema.
|
||||
|
||||
DROP TRIGGER IF EXISTS projects_no_two_level_ccr ON paliad.projects;
|
||||
DROP FUNCTION IF EXISTS paliad.projects_no_two_level_ccr();
|
||||
|
||||
DROP INDEX IF EXISTS paliad.projects_counterclaim_of_idx;
|
||||
|
||||
ALTER TABLE paliad.projects
|
||||
DROP COLUMN IF EXISTS counterclaim_of;
|
||||
89
internal/db/migrations/077_projects_counterclaim_of.up.sql
Normal file
89
internal/db/migrations/077_projects_counterclaim_of.up.sql
Normal file
@@ -0,0 +1,89 @@
|
||||
-- t-paliad-174 — SmartTimeline Slice 3.
|
||||
-- Two structural additions for the counterclaim sub-project shape
|
||||
-- (§4 of docs/design-smart-timeline-2026-05-08.md):
|
||||
--
|
||||
-- 1. paliad.projects.counterclaim_of — nullable FK referencing
|
||||
-- paliad.projects(id) ON DELETE SET NULL. When non-NULL the row
|
||||
-- represents the CCR (counterclaim) sub-project filed against the
|
||||
-- target row. Standard parent_id keeps governing the project tree;
|
||||
-- counterclaim_of is the *additional* relation describing the CCR
|
||||
-- link. parent_id of the CCR child is set to the target's parent
|
||||
-- (sibling-under-patent placement, §4.4) — that placement is owned
|
||||
-- by ProjectService.CreateCounterclaim, not the schema.
|
||||
--
|
||||
-- 2. Two-level-CCR rejection trigger — UPC practice does NOT have
|
||||
-- counterclaim-of-a-counterclaim chains. Reject the malformed shape
|
||||
-- at the schema level so the application can never write it. CHECK
|
||||
-- can't reference other rows; trigger function raises explicitly.
|
||||
--
|
||||
-- Idempotent: re-applying is a no-op. Tracker advances 76 → 77.
|
||||
|
||||
-- 1. paliad.projects.counterclaim_of ---------------------------------------
|
||||
|
||||
ALTER TABLE paliad.projects
|
||||
ADD COLUMN IF NOT EXISTS counterclaim_of uuid NULL
|
||||
REFERENCES paliad.projects(id) ON DELETE SET NULL;
|
||||
|
||||
COMMENT ON COLUMN paliad.projects.counterclaim_of IS
|
||||
'When non-NULL this project is the CCR (counterclaim) filed against '
|
||||
'the referenced parent project. parent_id continues to govern the '
|
||||
'project tree (CCR is placed as a sibling under the same patent — '
|
||||
'see ProjectService.CreateCounterclaim). ON DELETE SET NULL keeps '
|
||||
'the CCR row alive when the parent is hard-deleted (rare; default '
|
||||
'is archival) so the audit trail survives.';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS projects_counterclaim_of_idx
|
||||
ON paliad.projects (counterclaim_of)
|
||||
WHERE counterclaim_of IS NOT NULL;
|
||||
|
||||
-- 2. Two-level-CCR rejection trigger ---------------------------------------
|
||||
|
||||
CREATE OR REPLACE FUNCTION paliad.projects_no_two_level_ccr() RETURNS trigger
|
||||
LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
-- A project that is itself a CCR may NOT be the target of another CCR.
|
||||
-- Two cases to reject:
|
||||
--
|
||||
-- (a) NEW row points at a parent that is itself a CCR:
|
||||
-- NEW.counterclaim_of -> some row with counterclaim_of NOT NULL.
|
||||
--
|
||||
-- (b) NEW row claims to be a CCR (NEW.counterclaim_of IS NOT NULL)
|
||||
-- but already has another CCR pointing AT it (NEW.id is the
|
||||
-- target of some other row's counterclaim_of). The cleaner
|
||||
-- phrasing: "no row may simultaneously have a CCR child AND
|
||||
-- a CCR parent".
|
||||
IF NEW.counterclaim_of IS NOT NULL THEN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM paliad.projects p
|
||||
WHERE p.id = NEW.counterclaim_of
|
||||
AND p.counterclaim_of IS NOT NULL
|
||||
) THEN
|
||||
RAISE EXCEPTION
|
||||
'two-level counterclaim chains are not allowed: parent project % is itself a counterclaim',
|
||||
NEW.counterclaim_of;
|
||||
END IF;
|
||||
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM paliad.projects p
|
||||
WHERE p.counterclaim_of = NEW.id
|
||||
) THEN
|
||||
RAISE EXCEPTION
|
||||
'project % already has a counterclaim child and cannot itself be a counterclaim',
|
||||
NEW.id;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION paliad.projects_no_two_level_ccr() IS
|
||||
'Rejects two-level counterclaim chains. UPC practice does not have '
|
||||
'CCR-of-a-CCR; reject the malformed shape at write time so the app '
|
||||
'layer never has to defend against it. See migration 077.';
|
||||
|
||||
DROP TRIGGER IF EXISTS projects_no_two_level_ccr ON paliad.projects;
|
||||
CREATE TRIGGER projects_no_two_level_ccr
|
||||
BEFORE INSERT OR UPDATE OF counterclaim_of ON paliad.projects
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION paliad.projects_no_two_level_ccr();
|
||||
@@ -163,6 +163,14 @@ type Project struct {
|
||||
// claimant, defendant, court, both.
|
||||
OurSide *string `db:"our_side" json:"our_side,omitempty"`
|
||||
|
||||
// CounterclaimOf is the parent project this row is a counterclaim
|
||||
// (CCR) against (t-paliad-174 SmartTimeline Slice 3). NULL on
|
||||
// regular projects; non-NULL rows are CCR sub-projects rendered as
|
||||
// the parallel right-track on the parent's SmartTimeline. parent_id
|
||||
// keeps governing the project tree — the CCR child is placed as a
|
||||
// sibling under the same patent (§4.4 of the design doc).
|
||||
CounterclaimOf *uuid.UUID `db:"counterclaim_of" json:"counterclaim_of,omitempty"`
|
||||
|
||||
Metadata json.RawMessage `db:"metadata" json:"metadata"`
|
||||
AISummary *string `db:"ai_summary" json:"ai_summary,omitempty"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
|
||||
@@ -97,7 +97,7 @@ func (s *ProjectService) DB() *sqlx.DB { return s.db }
|
||||
const projectColumns = `id, type, parent_id, path, title, reference, description, status,
|
||||
created_by, industry, country, billing_reference, client_number, matter_number,
|
||||
netdocuments_url, patent_number, filing_date, grant_date, court, case_number,
|
||||
proceeding_type_id, our_side, metadata, ai_summary, created_at, updated_at`
|
||||
proceeding_type_id, our_side, counterclaim_of, metadata, ai_summary, created_at, updated_at`
|
||||
|
||||
// CreateProjectInput is the payload for Create.
|
||||
type CreateProjectInput struct {
|
||||
@@ -122,6 +122,13 @@ type CreateProjectInput struct {
|
||||
CaseNumber *string `json:"case_number,omitempty"`
|
||||
ProceedingTypeID *int `json:"proceeding_type_id,omitempty"`
|
||||
OurSide *string `json:"our_side,omitempty"`
|
||||
|
||||
// CounterclaimOf marks this project as a CCR sub-project filed
|
||||
// against the referenced parent project (t-paliad-174 Slice 3).
|
||||
// Set by ProjectService.CreateCounterclaim — direct callers of
|
||||
// Create rarely need it. The two-level-CCR rejection trigger
|
||||
// (migration 077) will reject malformed shapes regardless.
|
||||
CounterclaimOf *uuid.UUID `json:"counterclaim_of,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateProjectInput is the partial-update payload.
|
||||
@@ -831,9 +838,10 @@ func (s *ProjectService) Create(ctx context.Context, userID uuid.UUID, input Cre
|
||||
(id, type, parent_id, path, title, reference, description, status,
|
||||
created_by, industry, country, billing_reference, client_number,
|
||||
matter_number, netdocuments_url, patent_number, filing_date, grant_date,
|
||||
court, case_number, proceeding_type_id, our_side, metadata, created_at, updated_at)
|
||||
court, case_number, proceeding_type_id, our_side, counterclaim_of,
|
||||
metadata, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $1::text, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13,
|
||||
$14, $15, $16, $17, $18, $19, $20, $21, '{}'::jsonb, $22, $22)`,
|
||||
$14, $15, $16, $17, $18, $19, $20, $21, $22, '{}'::jsonb, $23, $23)`,
|
||||
id, input.Type, input.ParentID,
|
||||
input.Title, input.Reference, input.Description, status,
|
||||
userID,
|
||||
@@ -842,6 +850,7 @@ func (s *ProjectService) Create(ctx context.Context, userID uuid.UUID, input Cre
|
||||
input.PatentNumber, input.FilingDate, input.GrantDate,
|
||||
input.Court, input.CaseNumber, input.ProceedingTypeID,
|
||||
nullableOurSide(input.OurSide),
|
||||
input.CounterclaimOf,
|
||||
now,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("insert project: %w", err)
|
||||
@@ -1096,6 +1105,259 @@ func (s *ProjectService) Delete(ctx context.Context, userID, id uuid.UUID) error
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// CounterclaimOpts narrows CreateCounterclaim. Empty zero values fall back
|
||||
// to the design defaults: proceeding_type_id = UPC_REV, our_side = inverted
|
||||
// from the parent, title = "<patent reference> — Widerklage (CCR)" when a
|
||||
// patent reference is resolvable, else "<parent title> — Widerklage".
|
||||
//
|
||||
// FlipOurSide is a tri-state via *bool to distinguish "default-flip" (nil)
|
||||
// from the explicit "Stimmt nicht?" override (false = keep parent's side,
|
||||
// true = flip explicitly). The R.49.2.b CCI edge case is the reason this
|
||||
// override exists (see docs/design-smart-timeline-2026-05-08.md §11 Q2).
|
||||
type CounterclaimOpts struct {
|
||||
ProceedingTypeID *int
|
||||
FlipOurSide *bool
|
||||
Title *string
|
||||
CaseNumber *string
|
||||
}
|
||||
|
||||
// LoadCounterclaimChildrenVisible returns the CCR sub-projects filed
|
||||
// against parentID that the caller can see. Each row is a normal
|
||||
// paliad.projects row with counterclaim_of=parentID. Used by the
|
||||
// SmartTimeline to render parallel right-tracks (t-paliad-174 §4.5).
|
||||
func (s *ProjectService) LoadCounterclaimChildrenVisible(ctx context.Context, userID, parentID uuid.UUID) ([]models.Project, error) {
|
||||
user, err := s.users.GetByID(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if user == nil {
|
||||
return []models.Project{}, nil
|
||||
}
|
||||
rows := []models.Project{}
|
||||
query := `SELECT ` + projectColumns + ` FROM paliad.projects p
|
||||
WHERE p.counterclaim_of = $1
|
||||
AND ` + visibilityPredicatePositional("p", 2) + `
|
||||
ORDER BY p.created_at ASC, p.id ASC`
|
||||
if err := s.db.SelectContext(ctx, &rows, query, parentID, userID); err != nil {
|
||||
return nil, fmt.Errorf("load counterclaim children: %w", err)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// CreateCounterclaim creates a CCR sub-project against parentID. Atomic:
|
||||
// project + creator-as-lead team membership + audit rows on parent AND
|
||||
// child are all written in a single transaction.
|
||||
//
|
||||
// Placement (§4.4): the CCR child is a sibling under the same patent —
|
||||
// child.parent_id = parent.parent_id. When the parent has no parent_id
|
||||
// (root case at the top of its tree) we fall back to parent.id as the
|
||||
// CCR child's parent so the row remains in the same subtree.
|
||||
//
|
||||
// our_side flip (§11 Q2): default-inverts claimant↔defendant; "court"
|
||||
// and "both" pass through unchanged. The opts.FlipOurSide override
|
||||
// supports the rare R.49.2.b CCI shape where flipping is wrong.
|
||||
//
|
||||
// proceeding_type_id default (§4.4): UPC_REV for the standard CCR-on-
|
||||
// validity. UPC_CCI is the rarer R.49.2.b path; callers pass the id
|
||||
// explicitly when they want it.
|
||||
func (s *ProjectService) CreateCounterclaim(ctx context.Context, userID, parentID uuid.UUID, opts CounterclaimOpts) (*models.Project, error) {
|
||||
user, err := s.users.GetByID(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if user == nil {
|
||||
return nil, fmt.Errorf("%w: user has no paliad.users row — onboarding required", ErrForbidden)
|
||||
}
|
||||
parent, err := s.GetByID(ctx, userID, parentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if parent.CounterclaimOf != nil {
|
||||
return nil, fmt.Errorf("%w: parent project is itself a counterclaim — two-level CCR chains are not allowed", ErrInvalidInput)
|
||||
}
|
||||
|
||||
// Resolve proceeding_type_id default to UPC_REV when caller didn't
|
||||
// override. The DB row is required because the projection layer
|
||||
// dereferences it (paliad.proceeding_types.code).
|
||||
procTypeID := 0
|
||||
if opts.ProceedingTypeID != nil {
|
||||
procTypeID = *opts.ProceedingTypeID
|
||||
} else {
|
||||
err := s.db.GetContext(ctx, &procTypeID,
|
||||
`SELECT id FROM paliad.proceeding_types
|
||||
WHERE code = 'UPC_REV' AND is_active = true`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve default UPC_REV proceeding type: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
childOurSide := derivedCounterclaimOurSide(parent.OurSide, opts.FlipOurSide)
|
||||
childParentID := parent.ParentID
|
||||
if childParentID == nil {
|
||||
// Parent has no parent_id (root case at the top of its tree).
|
||||
// Fall back to parent.id so the CCR child stays in the same
|
||||
// subtree rather than becoming a new root. The visibility
|
||||
// predicate inherits cleanly either way.
|
||||
fallback := parent.ID
|
||||
childParentID = &fallback
|
||||
}
|
||||
|
||||
// Resolve the best patent reference for the suggested title — when
|
||||
// parent is a case, the patent_number lives on its patent ancestor.
|
||||
patentRef := s.resolvePatentReferenceForTitle(ctx, userID, parent)
|
||||
title := derivedCounterclaimTitle(parent, patentRef, opts.Title)
|
||||
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
id := uuid.New()
|
||||
now := time.Now().UTC()
|
||||
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO paliad.projects
|
||||
(id, type, parent_id, path, title, status, created_by,
|
||||
court, case_number, proceeding_type_id, our_side, counterclaim_of,
|
||||
metadata, created_at, updated_at)
|
||||
VALUES ($1, 'case', $2, $1::text, $3, 'active', $4,
|
||||
$5, $6, $7, $8, $9, '{}'::jsonb, $10, $10)`,
|
||||
id, childParentID, title, userID,
|
||||
parent.Court, opts.CaseNumber, procTypeID,
|
||||
nullableOurSide(&childOurSide), parentID, now,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("insert counterclaim project: %w", err)
|
||||
}
|
||||
|
||||
// Auto-add creator as team lead on the new CCR row so RLS lets the
|
||||
// caller see the project they just made. Mirrors Create.
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO paliad.project_teams (project_id, user_id, role, responsibility, inherited, added_by)
|
||||
VALUES ($1, $2, 'lead', 'lead', false, $2)`, id, userID); err != nil {
|
||||
return nil, fmt.Errorf("insert creator team row: %w", err)
|
||||
}
|
||||
|
||||
// Audit rows on both parent and child for symmetric trail. Both rows
|
||||
// opt into the SmartTimeline via timeline_kind='milestone'.
|
||||
if err := insertCounterclaimEvent(ctx, tx, id, userID,
|
||||
"Widerklage (CCR) angelegt",
|
||||
map[string]any{"counterclaim_of": parentID.String()},
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := insertCounterclaimEvent(ctx, tx, parentID, userID,
|
||||
"Widerklage (CCR) angelegt",
|
||||
map[string]any{"counterclaim_id": id.String()},
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("commit create counterclaim: %w", err)
|
||||
}
|
||||
return s.GetByID(ctx, userID, id)
|
||||
}
|
||||
|
||||
// insertCounterclaimEvent writes a paliad.project_events row with
|
||||
// event_type='counterclaim_created' AND timeline_kind='milestone' so
|
||||
// the audit row surfaces on the SmartTimeline by default. Matches the
|
||||
// pattern Slice 1 established for opt-in milestones (§2.2).
|
||||
func insertCounterclaimEvent(ctx context.Context, tx *sqlx.Tx, projectID, userID uuid.UUID, title string, meta map[string]any) error {
|
||||
now := time.Now().UTC()
|
||||
metaJSON := json.RawMessage(`{}`)
|
||||
if len(meta) > 0 {
|
||||
b, err := json.Marshal(meta)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal counterclaim_created metadata: %w", err)
|
||||
}
|
||||
metaJSON = b
|
||||
}
|
||||
_, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO paliad.project_events
|
||||
(id, project_id, event_type, title, description, event_date,
|
||||
created_by, metadata, created_at, updated_at, timeline_kind)
|
||||
VALUES ($1, $2, 'counterclaim_created', $3, NULL, $4, $5, $6, $4, $4, 'milestone')`,
|
||||
uuid.New(), projectID, title, now, userID, metaJSON)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert counterclaim_created event: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// derivedCounterclaimOurSide computes the child's our_side from the
|
||||
// parent's our_side and the opts.FlipOurSide override.
|
||||
//
|
||||
// Default (override nil OR override=true): claimant ↔ defendant, court
|
||||
// and both pass through unchanged. NULL parent yields NULL child — the
|
||||
// flip is meaningless without a known starting side.
|
||||
//
|
||||
// Override=false: keep parent's side as-is. R.49.2.b CCI is the named
|
||||
// edge case where the CCR sub-project shares the parent's perspective.
|
||||
func derivedCounterclaimOurSide(parentSide *string, override *bool) string {
|
||||
if parentSide == nil {
|
||||
return ""
|
||||
}
|
||||
side := strings.TrimSpace(*parentSide)
|
||||
flip := true
|
||||
if override != nil {
|
||||
flip = *override
|
||||
}
|
||||
if !flip {
|
||||
return side
|
||||
}
|
||||
switch side {
|
||||
case "claimant":
|
||||
return "defendant"
|
||||
case "defendant":
|
||||
return "claimant"
|
||||
default:
|
||||
return side
|
||||
}
|
||||
}
|
||||
|
||||
// resolvePatentReferenceForTitle returns the closest patent_number /
|
||||
// reference to use as the CCR title prefix. Parent is usually a case
|
||||
// row (no patent_number on it) — walks up ancestors to find the patent
|
||||
// hub. Best-effort: returns empty when no patent ancestor is visible.
|
||||
func (s *ProjectService) resolvePatentReferenceForTitle(ctx context.Context, userID uuid.UUID, parent *models.Project) string {
|
||||
if parent.PatentNumber != nil && strings.TrimSpace(*parent.PatentNumber) != "" {
|
||||
return strings.TrimSpace(*parent.PatentNumber)
|
||||
}
|
||||
ancestors, err := s.ListAncestors(ctx, userID, parent.ID)
|
||||
if err != nil || len(ancestors) == 0 {
|
||||
return ""
|
||||
}
|
||||
for i := len(ancestors) - 1; i >= 0; i-- {
|
||||
a := ancestors[i]
|
||||
if a.PatentNumber != nil && strings.TrimSpace(*a.PatentNumber) != "" {
|
||||
return strings.TrimSpace(*a.PatentNumber)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// derivedCounterclaimTitle picks the auto-suggested title for the CCR
|
||||
// child. Override wins when supplied; otherwise prefers the patent
|
||||
// reference, then parent.reference, then parent.title — each yields
|
||||
// "<ref> — Widerklage (CCR)".
|
||||
func derivedCounterclaimTitle(parent *models.Project, patentRef string, override *string) string {
|
||||
if override != nil {
|
||||
v := strings.TrimSpace(*override)
|
||||
if v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
suffix := " — Widerklage (CCR)"
|
||||
if patentRef != "" {
|
||||
return patentRef + suffix
|
||||
}
|
||||
if parent.Reference != nil && strings.TrimSpace(*parent.Reference) != "" {
|
||||
return strings.TrimSpace(*parent.Reference) + suffix
|
||||
}
|
||||
return strings.TrimSpace(parent.Title) + suffix
|
||||
}
|
||||
|
||||
// MaxEventsPageLimit caps ListEvents page size.
|
||||
const MaxEventsPageLimit = 200
|
||||
|
||||
|
||||
Reference in New Issue
Block a user