From 306bb11618b4187311868342f792a4cfbf59f6bd Mon Sep 17 00:00:00 2001 From: m Date: Sat, 9 May 2026 16:07:17 +0200 Subject: [PATCH] =?UTF-8?q?feat(t-paliad-174):=20SmartTimeline=20Slice=203?= =?UTF-8?q?=20=E2=80=94=20counterclaim=20sub-project=20schema=20+=20servic?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../077_projects_counterclaim_of.down.sql | 9 + .../077_projects_counterclaim_of.up.sql | 89 ++++++ internal/models/models.go | 8 + internal/services/project_service.go | 268 +++++++++++++++++- 4 files changed, 371 insertions(+), 3 deletions(-) create mode 100644 internal/db/migrations/077_projects_counterclaim_of.down.sql create mode 100644 internal/db/migrations/077_projects_counterclaim_of.up.sql diff --git a/internal/db/migrations/077_projects_counterclaim_of.down.sql b/internal/db/migrations/077_projects_counterclaim_of.down.sql new file mode 100644 index 0000000..b48287f --- /dev/null +++ b/internal/db/migrations/077_projects_counterclaim_of.down.sql @@ -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; diff --git a/internal/db/migrations/077_projects_counterclaim_of.up.sql b/internal/db/migrations/077_projects_counterclaim_of.up.sql new file mode 100644 index 0000000..bb5e02e --- /dev/null +++ b/internal/db/migrations/077_projects_counterclaim_of.up.sql @@ -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(); diff --git a/internal/models/models.go b/internal/models/models.go index 1834754..5fe5707 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -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"` diff --git a/internal/services/project_service.go b/internal/services/project_service.go index a44589e..c27af67 100644 --- a/internal/services/project_service.go +++ b/internal/services/project_service.go @@ -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 = " — Widerklage (CCR)" when a +// patent reference is resolvable, else " — 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 +// " — 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