diff --git a/frontend/src/client/fristenrechner.ts b/frontend/src/client/fristenrechner.ts index 58908b9..9e13f53 100644 --- a/frontend/src/client/fristenrechner.ts +++ b/frontend/src/client/fristenrechner.ts @@ -187,12 +187,21 @@ interface ProjectOption { // (Slice 3b) can scope the cascade by the project's jurisdiction // without an extra fetch. proceeding_type_id?: number | null; - // our_side carries which side the firm represents on this project - // (t-paliad-164). When a user selects an Akte, the perspective chip - // pre-locks to this value; a small hint above the strip flags the + // our_side carries which side the firm represents on this case + // project (Client Role; t-paliad-164, widened in t-paliad-222). + // When a user selects an Akte, the perspective chip pre-locks via + // ourSideToPerspective(); a small hint above the strip flags the // pre-selection and the user can still click another chip to // override. NULL/undefined leaves the chip unset (free-pick). - our_side?: "claimant" | "defendant" | "court" | "both" | null; + our_side?: + | "claimant" + | "defendant" + | "applicant" + | "appellant" + | "respondent" + | "third_party" + | "other" + | null; } async function fetchProjects(): Promise { @@ -3801,14 +3810,30 @@ function applyPerspective(p: Perspective) { triggerCascadeRefresh(); } -// ourSideToPerspective maps the project-level "Wir vertreten" enum -// onto the chip-strip Perspective. 'court' / 'both' map to null -// (chip cleared) — court actions are neutral to the user's side and -// "both" is explicit no-filter intent. +// ourSideToPerspective maps the project-level "Client Role" enum +// (DB column: our_side) onto the chip-strip Perspective. +// +// Per t-paliad-222 (m/paliad#47) the field carries one of seven +// sub-role values grouped at display time: +// Active (we initiate) : claimant, applicant, appellant → "claimant" +// Reactive (we defend) : defendant, respondent → "defendant" +// Other : third_party, other, NULL → null +// +// Legacy 'court' / 'both' values no longer exist in the column +// (mig 110 backfilled them to NULL); both fall through to the null +// default arm if a stale value sneaks in. function ourSideToPerspective(os: string | null | undefined): Perspective { - if (os === "claimant") return "claimant"; - if (os === "defendant") return "defendant"; - return null; + switch (os) { + case "claimant": + case "applicant": + case "appellant": + return "claimant"; + case "defendant": + case "respondent": + return "defendant"; + default: + return null; + } } // applyOurSidePredefine locks the perspective from project.our_side diff --git a/frontend/src/client/i18n.ts b/frontend/src/client/i18n.ts index dddeb8e..32f570c 100644 --- a/frontend/src/client/i18n.ts +++ b/frontend/src/client/i18n.ts @@ -1210,9 +1210,30 @@ const translations: Record> = { "projects.field.our_side.unset": "Unbekannt / nicht gesetzt", "projects.field.our_side.claimant": "Klägerseite", "projects.field.our_side.defendant": "Beklagtenseite", + "projects.field.our_side.applicant": "Antragsteller", + "projects.field.our_side.appellant": "Berufungsführer", + "projects.field.our_side.respondent": "Antragsgegner", + "projects.field.our_side.third_party": "Streithelfer / Dritter", + "projects.field.our_side.other": "Sonstige Beteiligte", "projects.field.our_side.court": "Gericht / Tribunal", "projects.field.our_side.both": "Beide Seiten", "projects.field.our_side.none": "—", + "projects.field.client_role": "Mandantenrolle", + "projects.field.client_role.hint": "Bestimmt die Voreinstellung der Perspektive im Fristenrechner-Determinator: Aktiv → Klägerseite, Reaktiv → Beklagtenseite. Lässt sich dort jederzeit überschreiben.", + "projects.field.client_role.unset": "Unbekannt", + "projects.field.client_role.group.active": "Aktiv (wir greifen an)", + "projects.field.client_role.group.reactive": "Reaktiv (wir verteidigen)", + "projects.field.client_role.group.other": "Dritte / Sonstige", + "projects.field.client_role.claimant": "Klägerseite", + "projects.field.client_role.applicant": "Antragsteller", + "projects.field.client_role.appellant": "Berufungsführer", + "projects.field.client_role.defendant": "Beklagtenseite", + "projects.field.client_role.respondent": "Antragsgegner", + "projects.field.client_role.third_party": "Streithelfer / Dritter", + "projects.field.client_role.other": "Sonstige Beteiligte", + "projects.field.opponent_code": "Gegner-Kürzel", + "projects.field.opponent_code.placeholder": "z.B. OPNT", + "projects.field.opponent_code.hint": "Kurzes Kürzel der Gegenseite (Großbuchstaben, Ziffern, Bindestriche, max. 16 Zeichen). Wird als mittleres Segment in automatisch abgeleiteten Projekt-Codes verwendet (z.B. EXMPL.OPNT.567.INF.CFI).", "projects.field.status": "Status", "projects.error.title_required": "Titel erforderlich", "projects.detail.edit.type_change_warning.title": "Diese Felder werden geleert:", @@ -3903,9 +3924,30 @@ const translations: Record> = { "projects.field.our_side.unset": "Unknown / not set", "projects.field.our_side.claimant": "Claimant side", "projects.field.our_side.defendant": "Defendant side", + "projects.field.our_side.applicant": "Applicant", + "projects.field.our_side.appellant": "Appellant", + "projects.field.our_side.respondent": "Respondent", + "projects.field.our_side.third_party": "Third Party", + "projects.field.our_side.other": "Other party", "projects.field.our_side.court": "Court / tribunal", "projects.field.our_side.both": "Both sides", "projects.field.our_side.none": "—", + "projects.field.client_role": "Client Role", + "projects.field.client_role.hint": "Pre-selects the perspective chip in the Fristenrechner Determinator: Active → claimant side, Reactive → defendant side. Always overridable from there.", + "projects.field.client_role.unset": "Unknown", + "projects.field.client_role.group.active": "Active (we initiate)", + "projects.field.client_role.group.reactive": "Reactive (we defend)", + "projects.field.client_role.group.other": "Third Party / Other", + "projects.field.client_role.claimant": "Claimant side", + "projects.field.client_role.applicant": "Applicant", + "projects.field.client_role.appellant": "Appellant", + "projects.field.client_role.defendant": "Defendant side", + "projects.field.client_role.respondent": "Respondent", + "projects.field.client_role.third_party": "Third Party", + "projects.field.client_role.other": "Other party", + "projects.field.opponent_code": "Opponent code", + "projects.field.opponent_code.placeholder": "e.g. OPNT", + "projects.field.opponent_code.hint": "Short slug for the opposing party (uppercase letters, digits, dashes, max 16 chars). Used as the middle segment in auto-derived project codes (e.g. EXMPL.OPNT.567.INF.CFI).", "projects.field.status": "Status", "projects.error.title_required": "Title required", "projects.detail.edit.type_change_warning.title": "These fields will be cleared:", diff --git a/frontend/src/client/project-form.ts b/frontend/src/client/project-form.ts index 17ef97a..7bd97f4 100644 --- a/frontend/src/client/project-form.ts +++ b/frontend/src/client/project-form.ts @@ -48,9 +48,11 @@ function tryGet(id: string): HTMLElement | null { export function showFieldsForType(typeSel: string) { const parentWrap = tryGet("projekt-parent-wrap") as HTMLDivElement | null; const clientFields = tryGet("fields-client") as HTMLDivElement | null; + const litigationFields = tryGet("fields-litigation") as HTMLDivElement | null; const patentFields = tryGet("fields-patent") as HTMLDivElement | null; const caseFields = tryGet("fields-case") as HTMLDivElement | null; if (clientFields) clientFields.style.display = typeSel === "client" ? "block" : "none"; + if (litigationFields) litigationFields.style.display = typeSel === "litigation" ? "block" : "none"; if (patentFields) patentFields.style.display = typeSel === "patent" ? "block" : "none"; if (caseFields) caseFields.style.display = typeSel === "case" ? "block" : "none"; if (parentWrap) parentWrap.style.display = typeSel === "client" ? "none" : "block"; @@ -174,20 +176,32 @@ export function readPayload( const gd = ($("project-grant-date") as HTMLInputElement).value; if (gd) payload.grant_date = gd + "T00:00:00Z"; } + if (type === "litigation") { + // opponent_code is the litigation-only short slug used as the + // middle segment when BuildProjectCode auto-derives a project + // code from the ancestor tree (t-paliad-222 / m/paliad#50). + // Uppercased on submit so the user can type lowercase comfortably + // — the DB CHECK enforces the [A-Z0-9-]{1,16} pattern. + const ocEl = tryGet("project-opponent-code") as HTMLInputElement | null; + if (ocEl) { + const v = ocEl.value.trim().toUpperCase(); + if (v) payload.opponent_code = v; + else if (!opts.omitEmpty) payload.opponent_code = ""; + } + } if (type === "case") { stringField("project-court", "court"); stringField("project-case-number", "case_number"); - } - // our_side is type-agnostic — every project type can carry "Wir - // vertreten" because the Determinator picks it up regardless of - // type. The select uses "" for the unset option; the service maps - // empty string to NULL via nullableOurSide. - const osSel = tryGet("project-our-side") as HTMLSelectElement | null; - if (osSel) { - const v = osSel.value.trim(); - if (v) payload.our_side = v; - else if (!opts.omitEmpty) payload.our_side = ""; + // Client Role (DB column: our_side) — case-only after t-paliad-222. + // The select uses "" for the unset option; the service maps empty + // string to NULL via nullableOurSide. + const osSel = tryGet("project-our-side") as HTMLSelectElement | null; + if (osSel) { + const v = osSel.value.trim(); + if (v) payload.our_side = v; + else if (!opts.omitEmpty) payload.our_side = ""; + } } const desc = ($("project-description") as HTMLTextAreaElement).value.trim(); @@ -228,6 +242,8 @@ export function prefillForm(p: Record) { get("project-case-number").value = String(p.case_number ?? ""); const osSel = tryGet("project-our-side") as HTMLSelectElement | null; if (osSel) osSel.value = String(p.our_side ?? ""); + const ocEl = tryGet("project-opponent-code") as HTMLInputElement | null; + if (ocEl) ocEl.value = String(p.opponent_code ?? ""); getTA("project-description").value = String(p.description ?? ""); getSel("project-status").value = String(p.status ?? "active"); } diff --git a/frontend/src/components/ProjectFormFields.tsx b/frontend/src/components/ProjectFormFields.tsx index fee40a0..398f41b 100644 --- a/frontend/src/components/ProjectFormFields.tsx +++ b/frontend/src/components/ProjectFormFields.tsx @@ -140,6 +140,24 @@ export function ProjectFormFields(): string { + {/* Litigation-specific */} + + {/* Case-specific */} - -
- - -

- Bestimmt die Voreinstellung der Perspektive im Fristenrechner-Determinator. Lässt sich dort jederzeit überschreiben. -

+
+ + +

+ Bestimmt die Voreinstellung der Perspektive im Fristenrechner-Determinator: Aktiv → Klägerseite, Reaktiv → Beklagtenseite. Lässt sich dort jederzeit überschreiben. +

+
diff --git a/go.mod b/go.mod index dbb1c9e..9d043d6 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/jmoiron/sqlx v1.4.0 github.com/lib/pq v1.12.3 github.com/xuri/excelize/v2 v2.10.1 + golang.org/x/text v0.34.0 ) require ( @@ -20,5 +21,4 @@ require ( github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect golang.org/x/crypto v0.48.0 // indirect golang.org/x/net v0.50.0 // indirect - golang.org/x/text v0.34.0 // indirect ) diff --git a/internal/db/migrations/110_client_role_rework.down.sql b/internal/db/migrations/110_client_role_rework.down.sql new file mode 100644 index 0000000..93229a5 --- /dev/null +++ b/internal/db/migrations/110_client_role_rework.down.sql @@ -0,0 +1,30 @@ +-- Down migration for 110_client_role_rework. +-- +-- Restores the original 4-value CHECK ('claimant','defendant', +-- 'court','both', NULL) and backfills any rows that landed on a new +-- sub-role value (applicant / appellant / respondent / third_party / +-- other) to NULL so the schema is internally consistent after the +-- step-down. + +BEGIN; + +-- Backfill new sub-role values to NULL so the old CHECK doesn't reject. +UPDATE paliad.projects + SET our_side = NULL + WHERE our_side IN ('applicant', 'appellant', 'respondent', 'third_party', 'other'); + +ALTER TABLE paliad.projects + DROP CONSTRAINT IF EXISTS projects_our_side_check; + +ALTER TABLE paliad.projects + ADD CONSTRAINT projects_our_side_check + CHECK (our_side IS NULL + OR our_side IN ('claimant', 'defendant', 'court', 'both')); + +COMMENT ON COLUMN paliad.projects.our_side IS + 'Which side the firm represents on this project. Used by the ' + 'Fristenrechner Determinator (Slice 3c) to predefine the ' + 'perspective chip from the project context. Allowed: claimant, ' + 'defendant, court, both.'; + +COMMIT; diff --git a/internal/db/migrations/110_client_role_rework.up.sql b/internal/db/migrations/110_client_role_rework.up.sql new file mode 100644 index 0000000..7ec0c21 --- /dev/null +++ b/internal/db/migrations/110_client_role_rework.up.sql @@ -0,0 +1,51 @@ +-- t-paliad-222 / m/paliad#47 — Client Role rework. +-- +-- Widens paliad.projects.our_side CHECK to seven sub-role values and +-- drops the legacy 'court' / 'both' entries. The DB column name stays +-- as 'our_side' (UI label changes only — see design doc §2.2 Q1). +-- +-- New allowed sub-roles, grouped at display time: +-- Active (we initiate) : claimant, applicant, appellant +-- Reactive (we defend) : defendant, respondent +-- Third Party / Other : third_party, other +-- NULL : unknown / not set +-- +-- Backfill: any rows still on 'court' / 'both' fall back to NULL. +-- Verified 2026-05-20: all 12 production rows are NULL, so this is +-- a no-op on prod; the UPDATE runs defensively for staging / test +-- fixtures that may carry the legacy values. +-- +-- Idempotent so re-runs against a partially-applied state stay safe. + +BEGIN; + +-- 1. Backfill any 'court' / 'both' rows to NULL. +UPDATE paliad.projects + SET our_side = NULL + WHERE our_side IN ('court', 'both'); + +-- 2. Swap the CHECK constraint for the widened sub-role set. +ALTER TABLE paliad.projects + DROP CONSTRAINT IF EXISTS projects_our_side_check; + +ALTER TABLE paliad.projects + ADD CONSTRAINT projects_our_side_check + CHECK (our_side IS NULL OR our_side IN ( + 'claimant', 'defendant', + 'applicant', 'appellant', + 'respondent', + 'third_party', 'other' + )); + +COMMENT ON COLUMN paliad.projects.our_side IS + 'Which side the firm represents on this case project (renamed in ' + 'the UI to "Client Role" / "Mandantenrolle" — t-paliad-222 / ' + 'm/paliad#47). Allowed sub-roles, grouped at display time: Active ' + '(claimant, applicant, appellant); Reactive (defendant, ' + 'respondent); Third Party / Other (third_party, other). NULL = ' + 'unknown. The form hides the field on non-case project types. ' + 'Drives the Fristenrechner Determinator perspective chip — Active ' + 'group → claimant-perspective, Reactive → defendant-perspective, ' + 'Third Party / Other → null (chip free-pick).'; + +COMMIT; diff --git a/internal/db/migrations/111_projects_opponent_code.down.sql b/internal/db/migrations/111_projects_opponent_code.down.sql new file mode 100644 index 0000000..2a8c925 --- /dev/null +++ b/internal/db/migrations/111_projects_opponent_code.down.sql @@ -0,0 +1,11 @@ +-- Down migration for 111_projects_opponent_code. + +BEGIN; + +ALTER TABLE paliad.projects + DROP CONSTRAINT IF EXISTS projects_opponent_code_check; + +ALTER TABLE paliad.projects + DROP COLUMN IF EXISTS opponent_code; + +COMMIT; diff --git a/internal/db/migrations/111_projects_opponent_code.up.sql b/internal/db/migrations/111_projects_opponent_code.up.sql new file mode 100644 index 0000000..f6a2461 --- /dev/null +++ b/internal/db/migrations/111_projects_opponent_code.up.sql @@ -0,0 +1,50 @@ +-- t-paliad-222 / m/paliad#50 — auto-derived project codes. +-- +-- Adds an opponent-code slug field on litigation projects. Used as +-- the middle segment when BuildProjectCode assembles an auto-derived +-- project code from the ancestor tree (e.g. EXMPL.OPNT.567.INF.CFI). +-- +-- NULL = segment skipped silently. Existing litigation rows yield +-- codes without an opponent segment until the user fills the field. +-- No backfill from `title` — the litigation title is free-text +-- ("Siemens AG ./. Huawei", "Mandant vs Gegner") and any regex would +-- be brittle; the user enters the slug once at project creation / +-- next edit. +-- +-- Slug shape: uppercase letters / digits / dashes, max 16 chars. +-- Constraint also gates on type='litigation' so a stray value on a +-- non-litigation row is rejected at the DB level (defence in depth; +-- the form already hides the field on other types). +-- +-- Idempotent. + +BEGIN; + +ALTER TABLE paliad.projects + ADD COLUMN IF NOT EXISTS opponent_code text; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'projects_opponent_code_check' + AND conrelid = 'paliad.projects'::regclass + ) THEN + ALTER TABLE paliad.projects + ADD CONSTRAINT projects_opponent_code_check + CHECK (opponent_code IS NULL + OR (opponent_code ~ '^[A-Z0-9-]{1,16}$' + AND type = 'litigation')); + END IF; +END $$; + +COMMENT ON COLUMN paliad.projects.opponent_code IS + 'Short slug for the opposing party on a litigation project ' + '(uppercase letters, digits, dashes, max 16 chars). Used as the ' + 'middle segment when BuildProjectCode walks the ancestor tree to ' + 'assemble a dotted project code — e.g. EXMPL.OPNT.567.INF.CFI ' + '(t-paliad-222 / m/paliad#50). NULL = segment skipped silently. ' + 'Only meaningful on type=''litigation'' rows; the CHECK enforces ' + 'that pairing.'; + +COMMIT; diff --git a/internal/models/models.go b/internal/models/models.go index 22d105a..164b585 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -159,10 +159,35 @@ type Project struct { // OurSide is which side the firm represents on this project. Used // by the Fristenrechner Determinator to predefine the perspective // chip from the project context (t-paliad-164). NULL = unknown / - // not set; Determinator falls back to free-pick. Allowed values: - // claimant, defendant, court, both. + // not set; Determinator falls back to free-pick. + // + // Allowed sub-roles (mig 110, t-paliad-222): + // Active : claimant, applicant, appellant + // Reactive : defendant, respondent + // Other : third_party, other + // + // The DB column name stays as `our_side`; the UI label has moved + // to "Client Role" / "Mandantenrolle" on case projects and is + // hidden on every other project type. OurSide *string `db:"our_side" json:"our_side,omitempty"` + // OpponentCode is the short slug for the opposing party on a + // litigation project (uppercase letters / digits / dashes, max 16 + // chars). Used as the middle segment when services.BuildProjectCode + // assembles an auto-derived project code from the ancestor tree — + // e.g. EXMPL.OPNT.567.INF.CFI (t-paliad-222 / m/paliad#50). NULL + // → segment skipped silently. Only meaningful on type='litigation' + // rows; CHECK constraint (mig 111) enforces the pairing. + OpponentCode *string `db:"opponent_code" json:"opponent_code,omitempty"` + + // Code is the auto-derived (or override) project code, computed at + // projection time by services.BuildProjectCode. Not a DB column — + // no `db:` tag — populated by service-layer projection helpers + // after the row is loaded. Empty on rows for which the helper has + // not run (e.g. raw fixtures in tests, internal projection paths + // that don't call the helper). + Code string `db:"-" json:"code,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 diff --git a/internal/services/project_code.go b/internal/services/project_code.go new file mode 100644 index 0000000..f83d0c2 --- /dev/null +++ b/internal/services/project_code.go @@ -0,0 +1,312 @@ +package services + +import ( + "context" + "fmt" + "regexp" + "strings" + "unicode" + + "github.com/google/uuid" + "github.com/jmoiron/sqlx" + "github.com/lib/pq" + "golang.org/x/text/runes" + "golang.org/x/text/transform" + "golang.org/x/text/unicode/norm" + + "mgit.msbls.de/m/paliad/internal/models" +) + +// Project codes — t-paliad-222 / m/paliad#50. +// +// BuildProjectCode assembles a dotted code from the ancestor chain of +// a project. Each ancestor contributes one segment derived from its +// type-specific metadata. Missing segments (NULL ancestor field, +// unfilled opponent_code, etc.) are skipped silently — there is no +// placeholder. +// +// client → reference if set, else slug(title), capped at 8 chars +// litigation → opponent_code (the slug the user typed at litigation +// creation), empty → skipped +// patent → last 3 digits of patent_number (full digit-stream when +// shorter), empty → skipped +// case → uppercase tail of proceeding_types.code (jurisdiction +// segment dropped), empty → skipped +// project → "" (generic projects don't contribute a segment) +// +// Custom override: if the target row's `reference` column is non-empty, +// it wins outright — the helper returns the literal `reference` string +// without walking the ancestor chain. +// +// Example: Client EXMPL → Litigation OPNT → Patent EP3456789 → Case +// `upc.inf.cfi` → "EXMPL.OPNT.789.INF.CFI". +// +// Collision handling: codes are display-only (no uniqueness +// constraint). Two cases that derive to the same code both return the +// same string. v1 contract — users disambiguate via `reference` when it +// matters. + +// projectChainRow is one row of the ancestor walk. Includes only the +// columns BuildProjectCode needs; trimmed for cheap projection. +type projectChainRow struct { + ID uuid.UUID `db:"id"` + Type string `db:"type"` + Title string `db:"title"` + Reference *string `db:"reference"` + OpponentCode *string `db:"opponent_code"` + PatentNumber *string `db:"patent_number"` + ProceedingTypeID *int `db:"proceeding_type_id"` + ProceedingCode *string `db:"proceeding_code"` +} + +// BuildProjectCode walks the ancestor chain via the existing +// paliad.projects.path ltree and returns the assembled code. One DB +// round-trip per call; suitable for per-row use in single-project +// projection paths. +// +// For list endpoints with many rows, the call still scales fine for +// firm-scale datasets (order-of-100s); if profiling later flags it as +// a hotspot, introduce a materialised view per the design doc §3.2 Q8. +func BuildProjectCode(ctx context.Context, db sqlx.QueryerContext, projectID uuid.UUID) (string, error) { + const query = ` + SELECT p.id, p.type, p.title, p.reference, p.opponent_code, + p.patent_number, p.proceeding_type_id, + pt.code AS proceeding_code + FROM paliad.projects p + LEFT JOIN paliad.proceeding_types pt ON pt.id = p.proceeding_type_id + WHERE p.path @> (SELECT path FROM paliad.projects WHERE id = $1) + ORDER BY nlevel(p.path) + ` + rows := []projectChainRow{} + if err := sqlx.SelectContext(ctx, db, &rows, query, projectID); err != nil { + return "", fmt.Errorf("build project code: load chain: %w", err) + } + if len(rows) == 0 { + return "", nil + } + return assembleProjectCode(rows), nil +} + +// PopulateProjectCodes assigns .Code on every project in `targets` via +// a single bulk round-trip. Used by List / ListChildren / ListAncestors +// projection paths to avoid N+1 BuildProjectCode calls. +// +// Empty slice → no-op. Rows that can't be matched (orphaned) get an +// empty code rather than an error. +func PopulateProjectCodes(ctx context.Context, db sqlx.QueryerContext, targets []models.Project) error { + if len(targets) == 0 { + return nil + } + ids := make([]string, len(targets)) + for i, t := range targets { + ids[i] = t.ID.String() + } + + // One CTE-based query: for each target id, fetch the full ancestor + // chain joined to proceeding_types, ordered so we can group in Go. + const query = ` + WITH targets AS ( + SELECT id, path + FROM paliad.projects + WHERE id = ANY($1::uuid[]) + ) + SELECT t.id AS target_id, + p.id, p.type, p.title, p.reference, p.opponent_code, + p.patent_number, p.proceeding_type_id, + pt.code AS proceeding_code, + nlevel(p.path) AS chain_level + FROM targets t + JOIN paliad.projects p ON p.path @> t.path + LEFT JOIN paliad.proceeding_types pt ON pt.id = p.proceeding_type_id + ORDER BY t.id, chain_level + ` + type bulkRow struct { + TargetID uuid.UUID `db:"target_id"` + projectChainRow + ChainLevel int `db:"chain_level"` + } + + rows := []bulkRow{} + if err := sqlx.SelectContext(ctx, db, &rows, query, pq.StringArray(ids)); err != nil { + return fmt.Errorf("populate project codes: bulk fetch: %w", err) + } + + chains := make(map[uuid.UUID][]projectChainRow, len(targets)) + for _, r := range rows { + chains[r.TargetID] = append(chains[r.TargetID], r.projectChainRow) + } + for i := range targets { + targets[i].Code = assembleProjectCode(chains[targets[i].ID]) + } + return nil +} + +// assembleProjectCode is the pure code-assembly step, split out from +// the DB hop so it can be table-tested without fixtures. +// +// Custom override: non-empty `reference` on the target row (last in +// chain) wins; the function returns it verbatim without computing the +// other segments. +func assembleProjectCode(chain []projectChainRow) string { + if len(chain) == 0 { + return "" + } + target := chain[len(chain)-1] + if target.Reference != nil { + if v := strings.TrimSpace(*target.Reference); v != "" { + return v + } + } + segments := make([]string, 0, len(chain)) + for _, p := range chain { + seg := projectCodeSegment(p) + if seg == "" { + continue + } + segments = append(segments, seg) + } + return strings.Join(segments, ".") +} + +// projectCodeSegment returns the per-row segment string for the dotted +// project code. Empty string → row contributes no segment (skipped by +// the assembler). Pure; never touches the DB. Table-tested. +func projectCodeSegment(p projectChainRow) string { + switch p.Type { + case "client": + if p.Reference != nil { + if v := sanitizeClientShort(*p.Reference); v != "" { + return v + } + } + return sanitizeClientShort(p.Title) + case "litigation": + if p.OpponentCode != nil { + return strings.TrimSpace(*p.OpponentCode) + } + return "" + case "patent": + if p.PatentNumber != nil { + return patentLast3(*p.PatentNumber) + } + return "" + case "case": + if p.ProceedingCode != nil { + return proceedingTail(*p.ProceedingCode) + } + return "" + default: + // 'project' (generic) and any future types contribute nothing. + return "" + } +} + +// sanitizeClientShort produces an 8-char uppercase slug from a client +// reference / title. Strips diacritics, replaces non-alphanumerics +// with nothing, trims, caps at 8 chars. Empty input → "". +// +// Examples (verified by table test): +// "EXMPL" → "EXMPL" +// "Example Co." → "EXAMPLEC" +// "Müller GmbH" → "MULLERGM" +// " " → "" +func sanitizeClientShort(s string) string { + s = strings.TrimSpace(s) + if s == "" { + return "" + } + // Strip diacritics: NFD-decompose, drop combining marks, NFC-recompose. + t := transform.Chain(norm.NFD, runes.Remove(runes.In(unicode.Mn)), norm.NFC) + stripped, _, err := transform.String(t, s) + if err != nil { + stripped = s + } + var b strings.Builder + b.Grow(len(stripped)) + for _, r := range stripped { + if unicode.IsLetter(r) || unicode.IsDigit(r) { + b.WriteRune(unicode.ToUpper(r)) + } + } + out := b.String() + if len(out) > 8 { + out = out[:8] + } + return out +} + +// patentDigitsPattern matches a run of digits inside a patent number. +// Pre-compiled once to avoid per-call regex compilation cost. +var patentDigitsPattern = regexp.MustCompile(`\d+`) + +// patentKindCodeSuffix matches the trailing kind code on a patent +// publication number (A1, A2, B1, B2, C, T3, etc.). Stripped before +// digit extraction so the kind-code's optional digit doesn't sneak +// into the patent number proper. +// +// EP / WO conventions allow A, B, C, T, U as the letter; the digit is +// optional. The regex anchors at end-of-string and tolerates trailing +// whitespace. +var patentKindCodeSuffix = regexp.MustCompile(`[A-Z][0-9]?\s*$`) + +// patentLast3 extracts the last 3 digits of a patent number, returning +// the full digit-stream if the patent has fewer than 3 digits total. +// +// Strips a trailing kind-code suffix (A1, B2, C, T3 …) first so its +// optional digit doesn't pollute the result, then collapses all digit +// runs in the remainder to handle spaced / slashed formats. Examples: +// +// "EP1234567" → "567" +// "EP 1 234 567" → "567" +// "EP3456789A1" → "789" +// "EP1234567 B1" → "567" +// "WO2020/123456A1" → "456" +// "DE12" → "12" +// "EP" → "" +// "" → "" +func patentLast3(s string) string { + s = strings.ToUpper(strings.TrimSpace(s)) + if s == "" { + return "" + } + // Strip the trailing kind code (one or two chars at end). + s = patentKindCodeSuffix.ReplaceAllString(s, "") + matches := patentDigitsPattern.FindAllString(s, -1) + if len(matches) == 0 { + return "" + } + digits := strings.Join(matches, "") + if len(digits) >= 3 { + return digits[len(digits)-3:] + } + return digits +} + +// proceedingTail takes a proceeding_types.code (e.g. "upc.inf.cfi") and +// returns the uppercase tail with the leading jurisdiction segment +// dropped. The jurisdiction is implied by the ancestor client / patent +// context, so it's redundant in the code. +// +// "upc.inf.cfi" → "INF.CFI" +// "upc.rev.cfi" → "REV.CFI" +// "upc.apl.merits" → "APL.MERITS" +// "de.inf.lg" → "INF.LG" +// "de.inf.olg" → "INF.OLG" +// "single" → "" (no tail after dropping the only segment) +// "" → "" +func proceedingTail(code string) string { + code = strings.TrimSpace(code) + if code == "" { + return "" + } + parts := strings.Split(code, ".") + if len(parts) < 2 { + return "" + } + tail := parts[1:] + out := make([]string, len(tail)) + for i, p := range tail { + out[i] = strings.ToUpper(p) + } + return strings.Join(out, ".") +} diff --git a/internal/services/project_code_test.go b/internal/services/project_code_test.go new file mode 100644 index 0000000..f0da470 --- /dev/null +++ b/internal/services/project_code_test.go @@ -0,0 +1,376 @@ +package services + +import ( + "testing" + + "github.com/google/uuid" +) + +// TestProjectCodeSegment pins the per-type segment derivation rules +// from t-paliad-222 design §3.2: +// +// client → reference if set, else sanitized title (cap 8 chars) +// litigation → opponent_code verbatim (empty → skipped) +// patent → last 3 digits of patent_number +// case → uppercase tail of proceeding_types.code +// project → "" +func TestProjectCodeSegment(t *testing.T) { + str := func(s string) *string { return &s } + intp := func(i int) *int { return &i } + + cases := []struct { + name string + row projectChainRow + want string + }{ + // Client rows. + { + "client with reference", + projectChainRow{Type: "client", Title: "Example Co.", Reference: str("EXMPL")}, + "EXMPL", + }, + { + "client without reference falls back to slug(title)", + projectChainRow{Type: "client", Title: "Example Co.", Reference: nil}, + "EXAMPLEC", + }, + { + "client without reference, diacritics stripped", + projectChainRow{Type: "client", Title: "Müller GmbH"}, + "MULLERGM", + }, + { + "client with empty reference falls back to title", + projectChainRow{Type: "client", Title: "ACME", Reference: str(" ")}, + "ACME", + }, + { + "client with empty title and no reference → empty", + projectChainRow{Type: "client", Title: ""}, + "", + }, + + // Litigation rows. + { + "litigation with opponent_code", + projectChainRow{Type: "litigation", Title: "X v Y", OpponentCode: str("OPNT")}, + "OPNT", + }, + { + "litigation without opponent_code → empty", + projectChainRow{Type: "litigation", Title: "X v Y", OpponentCode: nil}, + "", + }, + + // Patent rows. + { + "patent EP1234567 → 567", + projectChainRow{Type: "patent", PatentNumber: str("EP1234567")}, + "567", + }, + { + "patent with spaces EP 3 456 789 → 789", + projectChainRow{Type: "patent", PatentNumber: str("EP 3 456 789")}, + "789", + }, + { + "patent with kind code EP3456789A1 → 789", + projectChainRow{Type: "patent", PatentNumber: str("EP3456789A1")}, + "789", + }, + { + "patent WO2020/123456 → 456", + projectChainRow{Type: "patent", PatentNumber: str("WO2020/123456")}, + "456", + }, + { + "patent shorter than 3 digits → full", + projectChainRow{Type: "patent", PatentNumber: str("DE12")}, + "12", + }, + { + "patent nil → empty", + projectChainRow{Type: "patent", PatentNumber: nil}, + "", + }, + { + "patent empty digit-stream → empty", + projectChainRow{Type: "patent", PatentNumber: str("EP")}, + "", + }, + + // Case rows. + { + "case upc.inf.cfi → INF.CFI", + projectChainRow{Type: "case", ProceedingTypeID: intp(8), ProceedingCode: str("upc.inf.cfi")}, + "INF.CFI", + }, + { + "case upc.apl.merits → APL.MERITS", + projectChainRow{Type: "case", ProceedingTypeID: intp(11), ProceedingCode: str("upc.apl.merits")}, + "APL.MERITS", + }, + { + "case de.inf.lg → INF.LG", + projectChainRow{Type: "case", ProceedingTypeID: intp(12), ProceedingCode: str("de.inf.lg")}, + "INF.LG", + }, + { + "case without proceeding_code → empty", + projectChainRow{Type: "case", ProceedingTypeID: nil, ProceedingCode: nil}, + "", + }, + { + "case with single-segment code → empty (no tail)", + projectChainRow{Type: "case", ProceedingCode: str("single")}, + "", + }, + + // Generic project rows contribute nothing. + { + "generic project → empty", + projectChainRow{Type: "project", Title: "Whatever"}, + "", + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := projectCodeSegment(c.row) + if got != c.want { + t.Errorf("projectCodeSegment() = %q, want %q", got, c.want) + } + }) + } +} + +// TestAssembleProjectCode covers the chain assembler, including the +// custom-override fast-path on the target row's `reference`. +func TestAssembleProjectCode(t *testing.T) { + str := func(s string) *string { return &s } + intp := func(i int) *int { return &i } + + // The reference tree from the issue body: EXMPL → OPNT → EP3456789 → upc.inf.cfi. + fullChain := []projectChainRow{ + {ID: uuid.New(), Type: "client", Title: "Example Co.", Reference: str("EXMPL")}, + {ID: uuid.New(), Type: "litigation", Title: "Ex v Op", OpponentCode: str("OPNT")}, + {ID: uuid.New(), Type: "patent", PatentNumber: str("EP3456789")}, + {ID: uuid.New(), Type: "case", ProceedingTypeID: intp(8), ProceedingCode: str("upc.inf.cfi")}, + } + + cases := []struct { + name string + chain []projectChainRow + want string + }{ + { + "reference tree → EXMPL.OPNT.789.INF.CFI", + fullChain, + "EXMPL.OPNT.789.INF.CFI", + }, + { + "empty chain → empty", + nil, + "", + }, + { + "override on target wins outright", + append(append([]projectChainRow{}, fullChain[:3]...), projectChainRow{ + ID: uuid.New(), Type: "case", Reference: str("CUSTOM-CODE"), + ProceedingTypeID: intp(8), ProceedingCode: str("upc.inf.cfi"), + }), + "CUSTOM-CODE", + }, + { + "override with surrounding whitespace is trimmed", + append(append([]projectChainRow{}, fullChain[:3]...), projectChainRow{ + ID: uuid.New(), Type: "case", Reference: str(" TRIMMED "), + }), + "TRIMMED", + }, + { + "override empty string falls through to derivation", + append(append([]projectChainRow{}, fullChain[:3]...), projectChainRow{ + ID: uuid.New(), Type: "case", Reference: str(""), + ProceedingTypeID: intp(8), ProceedingCode: str("upc.inf.cfi"), + }), + "EXMPL.OPNT.789.INF.CFI", + }, + { + "missing ancestors are skipped silently — case directly under client", + []projectChainRow{ + {Type: "client", Reference: str("EXMPL")}, + {Type: "case", ProceedingCode: str("upc.inf.cfi")}, + }, + "EXMPL.INF.CFI", + }, + { + "missing patent contributes nothing; client+litigation+case", + []projectChainRow{ + {Type: "client", Reference: str("EXMPL")}, + {Type: "litigation", OpponentCode: str("OPNT")}, + {Type: "case", ProceedingCode: str("upc.inf.cfi")}, + }, + "EXMPL.OPNT.INF.CFI", + }, + { + "target itself is a litigation row (no case below) → up to opponent code", + []projectChainRow{ + {Type: "client", Reference: str("EXMPL")}, + {Type: "litigation", OpponentCode: str("OPNT")}, + }, + "EXMPL.OPNT", + }, + { + "litigation without opponent_code is skipped silently", + []projectChainRow{ + {Type: "client", Reference: str("EXMPL")}, + {Type: "litigation", OpponentCode: nil}, + {Type: "patent", PatentNumber: str("EP3456789")}, + {Type: "case", ProceedingCode: str("upc.inf.cfi")}, + }, + "EXMPL.789.INF.CFI", + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := assembleProjectCode(c.chain) + if got != c.want { + t.Errorf("assembleProjectCode() = %q, want %q", got, c.want) + } + }) + } +} + +// TestPatentLast3 pins the digit-extraction rule across the common +// patent-number formats users type. +func TestPatentLast3(t *testing.T) { + cases := []struct { + in, want string + }{ + {"EP1234567", "567"}, + {"EP 1 234 567", "567"}, + {"EP3456789A1", "789"}, + {"WO2020/123456A1", "456"}, + {"DE12", "12"}, + {"EP", ""}, + {"", ""}, + {"NoDigitsAtAll", ""}, + {"1", "1"}, + {"12", "12"}, + {"123", "123"}, + {"1234", "234"}, + } + for _, c := range cases { + t.Run(c.in, func(t *testing.T) { + if got := patentLast3(c.in); got != c.want { + t.Errorf("patentLast3(%q) = %q, want %q", c.in, got, c.want) + } + }) + } +} + +// TestSanitizeClientShort pins the client-short slug rule (uppercase, +// strip diacritics, drop non-alnum, cap 8). +func TestSanitizeClientShort(t *testing.T) { + cases := []struct { + in, want string + }{ + {"EXMPL", "EXMPL"}, + {"Example Co.", "EXAMPLEC"}, + {"Müller GmbH", "MULLERGM"}, + {" ACME ", "ACME"}, + {"", ""}, + {" ", ""}, + {"Hogan Lovells International LLP", "HOGANLOV"}, + {"A&B (Patents) Ltd.", "ABPATENT"}, + {"Société Générale", "SOCIETEG"}, + } + for _, c := range cases { + t.Run(c.in, func(t *testing.T) { + if got := sanitizeClientShort(c.in); got != c.want { + t.Errorf("sanitizeClientShort(%q) = %q, want %q", c.in, got, c.want) + } + }) + } +} + +// TestProceedingTail pins the jurisdiction-strip rule. +func TestProceedingTail(t *testing.T) { + cases := []struct { + in, want string + }{ + {"upc.inf.cfi", "INF.CFI"}, + {"upc.rev.cfi", "REV.CFI"}, + {"upc.pi.cfi", "PI.CFI"}, + {"upc.apl.merits", "APL.MERITS"}, + {"de.inf.lg", "INF.LG"}, + {"de.inf.olg", "INF.OLG"}, + {"single", ""}, + {"", ""}, + {"a.b", "B"}, + {" upc.inf.cfi ", "INF.CFI"}, + } + for _, c := range cases { + t.Run(c.in, func(t *testing.T) { + if got := proceedingTail(c.in); got != c.want { + t.Errorf("proceedingTail(%q) = %q, want %q", c.in, got, c.want) + } + }) + } +} + +// TestValidateOpponentCode pins the slug-validation rule + the +// type='litigation' pairing. Empty string is the explicit clear +// sentinel and always passes. +func TestValidateOpponentCode(t *testing.T) { + cases := []struct { + name string + code string + ptype string + wantE bool + }{ + {"empty clears, any type", "", "case", false}, + {"empty clears, litigation", "", "litigation", false}, + {"valid slug on litigation", "OPNT", "litigation", false}, + {"valid slug with digits on litigation", "OPNT-2026", "litigation", false}, + {"valid slug projectType empty (Update path)", "OPNT", "", false}, + {"lowercase rejected", "opnt", "litigation", true}, + {"underscore rejected", "OPNT_1", "litigation", true}, + {"too long rejected", "OPNT-AND-A-VERY-LONG-NAME", "litigation", true}, + {"non-litigation type rejected", "OPNT", "case", true}, + {"non-litigation type rejected (patent)", "OPNT", "patent", true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + err := validateOpponentCode(c.code, c.ptype) + if (err != nil) != c.wantE { + t.Errorf("validateOpponentCode(%q, %q) error = %v, wantErr=%v", + c.code, c.ptype, err, c.wantE) + } + }) + } +} + +// TestValidateOurSideSubRoles pins the widened allowlist (mig 110). +func TestValidateOurSideSubRoles(t *testing.T) { + valid := []string{ + "", "claimant", "defendant", "applicant", "appellant", + "respondent", "third_party", "other", + } + invalid := []string{"court", "both", "unknown", "CLAIMANT", "Defendant"} + + for _, v := range valid { + t.Run("valid_"+v, func(t *testing.T) { + if err := validateOurSide(v); err != nil { + t.Errorf("validateOurSide(%q) unexpected error: %v", v, err) + } + }) + } + for _, v := range invalid { + t.Run("invalid_"+v, func(t *testing.T) { + if err := validateOurSide(v); err == nil { + t.Errorf("validateOurSide(%q) expected error, got nil", v) + } + }) + } +} diff --git a/internal/services/project_service.go b/internal/services/project_service.go index 6041093..56bb224 100644 --- a/internal/services/project_service.go +++ b/internal/services/project_service.go @@ -23,6 +23,7 @@ import ( "encoding/json" "errors" "fmt" + "regexp" "slices" "strings" "time" @@ -114,7 +115,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, counterclaim_of, instance_level, metadata, ai_summary, + proceeding_type_id, our_side, opponent_code, counterclaim_of, instance_level, metadata, ai_summary, created_at, updated_at` // CreateProjectInput is the payload for Create. @@ -140,6 +141,12 @@ type CreateProjectInput struct { CaseNumber *string `json:"case_number,omitempty"` ProceedingTypeID *int `json:"proceeding_type_id,omitempty"` OurSide *string `json:"our_side,omitempty"` + // OpponentCode is the litigation-only short slug used as the middle + // segment when BuildProjectCode assembles a project code from the + // ancestor tree (t-paliad-222 / m/paliad#50). Empty / nil → segment + // skipped. Only meaningful on type='litigation' rows; the form + // hides the field elsewhere and the DB CHECK rejects it. + OpponentCode *string `json:"opponent_code,omitempty"` // InstanceLevel is the procedural instance the project sits at: // 'first' (default once the picker UI lands) | 'appeal' | 'cassation'. // NULL = unset. Phase 3 Slice 8 (t-paliad-189, design §7) — the @@ -179,6 +186,10 @@ type UpdateProjectInput struct { CaseNumber *string `json:"case_number,omitempty"` ProceedingTypeID *int `json:"proceeding_type_id,omitempty"` OurSide *string `json:"our_side,omitempty"` + // OpponentCode — see CreateProjectInput.OpponentCode. UPDATE path: + // pointer to "" clears the column (NULL); pointer to a non-empty + // slug sets it. + OpponentCode *string `json:"opponent_code,omitempty"` // InstanceLevel — see CreateProjectInput.InstanceLevel. UPDATE // path: caller passes a pointer to the new value to swap; pass // a pointer to "" to clear (NULL the column). @@ -249,6 +260,9 @@ func (s *ProjectService) List(ctx context.Context, userID uuid.UUID, f ProjectFi if err := stmt.SelectContext(ctx, &rows, args); err != nil { return nil, fmt.Errorf("list projects: %w", err) } + if err := PopulateProjectCodes(ctx, s.db, rows); err != nil { + return nil, err + } return rows, nil } @@ -287,6 +301,11 @@ func (s *ProjectService) GetByID(ctx context.Context, userID, id uuid.UUID) (*mo if err != nil { return nil, fmt.Errorf("get project: %w", err) } + code, err := BuildProjectCode(ctx, s.db, p.ID) + if err != nil { + return nil, err + } + p.Code = code return &p, nil } @@ -347,6 +366,9 @@ func (s *ProjectService) ListAncestors(ctx context.Context, userID, id uuid.UUID order[id] = i } sortByOrder(rows, order) + if err := PopulateProjectCodes(ctx, s.db, rows); err != nil { + return nil, err + } return rows, nil } @@ -468,6 +490,9 @@ func (s *ProjectService) BuildTreeWithOptions(ctx context.Context, userID uuid.U if err := s.db.SelectContext(ctx, &rows, query, userID); err != nil { return nil, fmt.Errorf("build tree list: %w", err) } + if err := PopulateProjectCodes(ctx, s.db, rows); err != nil { + return nil, err + } // Step 2 — per-node deadline counts (always; cheap one-shot query). type deadlineCount struct { @@ -814,6 +839,9 @@ func (s *ProjectService) GetTree(ctx context.Context, userID, id uuid.UUID) ([]m if err := s.db.SelectContext(ctx, &rows, query, root.Path, prefix, userID); err != nil { return nil, fmt.Errorf("get tree: %w", err) } + if err := PopulateProjectCodes(ctx, s.db, rows); err != nil { + return nil, err + } return rows, nil } @@ -873,6 +901,11 @@ func (s *ProjectService) Create(ctx context.Context, userID uuid.UUID, input Cre return nil, err } } + if input.OpponentCode != nil { + if err := validateOpponentCode(*input.OpponentCode, input.Type); err != nil { + return nil, err + } + } if input.InstanceLevel != nil { if err := validateInstanceLevel(*input.InstanceLevel); err != nil { return nil, err @@ -883,10 +916,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, counterclaim_of, - instance_level, metadata, created_at, updated_at) + court, case_number, proceeding_type_id, our_side, opponent_code, + counterclaim_of, instance_level, metadata, created_at, updated_at) VALUES ($1, $2, $3, '', $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, - $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, '{}'::jsonb, $24, $24)`, + $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, '{}'::jsonb, $25, $25)`, id, input.Type, input.ParentID, input.Title, input.Reference, input.Description, status, userID, @@ -895,6 +928,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), + nullableOpponentCode(input.OpponentCode), input.CounterclaimOf, nullableInstanceLevel(input.InstanceLevel), now, @@ -1039,6 +1073,12 @@ func (s *ProjectService) Update(ctx context.Context, userID, id uuid.UUID, input } appendSet("our_side", nullableOurSide(input.OurSide)) } + if input.OpponentCode != nil { + if err := validateOpponentCode(*input.OpponentCode, current.Type); err != nil { + return nil, err + } + appendSet("opponent_code", nullableOpponentCode(input.OpponentCode)) + } if input.InstanceLevel != nil { if err := validateInstanceLevel(*input.InstanceLevel); err != nil { return nil, err @@ -1223,6 +1263,9 @@ func (s *ProjectService) LoadCounterclaimChildrenVisible(ctx context.Context, us if err := s.db.SelectContext(ctx, &rows, query, parentID, userID); err != nil { return nil, fmt.Errorf("load counterclaim children: %w", err) } + if err := PopulateProjectCodes(ctx, s.db, rows); err != nil { + return nil, err + } return rows, nil } @@ -1382,9 +1425,21 @@ func insertCounterclaimEvent(ctx context.Context, tx *sqlx.Tx, projectID, userID // 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. +// Default (override nil OR override=true): flip across the active / +// reactive axis using the t-paliad-222 sub-role table — +// +// claimant ↔ defendant +// applicant ↔ respondent +// appellant → respondent (the CCR-against-appellant is the +// defending position; appellant has no +// symmetric counter-role in the new set) +// +// Third Party / Other (third_party, other) and NULL pass through +// unchanged — the flip is meaningless without a clear active / reactive +// posture. Legacy 'court' / 'both' no longer exist in the column +// (mig 110) so they have no case arm; if a stale value sneaks in via a +// pre-migration in-memory row it falls through to the default branch +// and passes through unchanged, preserving previous behaviour. // // 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. @@ -1405,6 +1460,12 @@ func derivedCounterclaimOurSide(parentSide *string, override *bool) string { return "defendant" case "defendant": return "claimant" + case "applicant": + return "respondent" + case "respondent": + return "applicant" + case "appellant": + return "respondent" default: return side } @@ -1914,15 +1975,29 @@ func validateProjectStatus(s string) error { return fmt.Errorf("%w: invalid status %q", ErrInvalidInput, s) } -// validateOurSide checks the project-level "represented side" enum -// (t-paliad-164). Empty string is the explicit "clear" sentinel — -// callers pass the value as-is from the form payload, and the helper -// accepts it so an Update can null the column. The DB-level CHECK -// constraint enforces the same set; this validation gives a clearer -// error than relying on the constraint to fire. +// validateOurSide checks the project-level "Client Role" enum +// (t-paliad-164, widened in t-paliad-222 / m/paliad#47). Empty string +// is the explicit "clear" sentinel — callers pass the value as-is +// from the form payload, and the helper accepts it so an Update can +// null the column. The DB-level CHECK constraint (mig 110) enforces +// the same set; this validation gives a clearer error than relying +// on the constraint to fire. +// +// Allowed sub-roles, grouped at display time: +// Active (we initiate) : claimant, applicant, appellant +// Reactive (we defend) : defendant, respondent +// Third Party / Other : third_party, other +// +// Legacy 'court' / 'both' are no longer accepted (mig 110 backfills +// existing rows to NULL); callers that still send them get a clear +// validation error rather than a constraint violation. func validateOurSide(s string) error { switch strings.TrimSpace(s) { - case "", "claimant", "defendant", "court", "both": + case "", + "claimant", "defendant", + "applicant", "appellant", + "respondent", + "third_party", "other": return nil } return fmt.Errorf("%w: invalid our_side %q", ErrInvalidInput, s) @@ -1973,6 +2048,49 @@ func nullableOurSide(p *string) any { return v } +// opponentCodePattern matches the slug shape enforced by the +// projects_opponent_code_check constraint (mig 111): uppercase letters, +// digits, dashes, 1-16 chars. The DB CHECK is the source of truth; this +// helper surfaces a friendlier ErrInvalidInput error before the write. +var opponentCodePattern = regexp.MustCompile(`^[A-Z0-9-]{1,16}$`) + +// validateOpponentCode checks the litigation-only opponent_code slug +// (t-paliad-222 / m/paliad#50). Empty string clears the column; a +// non-empty value must match opponentCodePattern AND the row must be +// type='litigation' (the DB CHECK enforces this pairing). +// +// projectType may be empty when the caller is doing a partial Update +// against the current row's type — in that case we skip the type gate +// (the Update layer passes current.Type instead, which always has it). +func validateOpponentCode(s, projectType string) error { + v := strings.TrimSpace(s) + if v == "" { + return nil + } + if projectType != "" && projectType != "litigation" { + return fmt.Errorf("%w: opponent_code only valid on type=litigation (got %q)", + ErrInvalidInput, projectType) + } + if !opponentCodePattern.MatchString(v) { + return fmt.Errorf("%w: invalid opponent_code %q (allowed: %s)", + ErrInvalidInput, s, "[A-Z0-9-]{1,16}") + } + return nil +} + +// nullableOpponentCode mirrors nullableOurSide for opponent_code: nil +// or empty/whitespace → SQL NULL; otherwise the trimmed slug. +func nullableOpponentCode(p *string) any { + if p == nil { + return nil + } + v := strings.TrimSpace(*p) + if v == "" { + return nil + } + return v +} + func sortByOrder(xs []models.Project, order map[uuid.UUID]int) { // Insertion sort — ancestor lists are short (<20). for i := 1; i < len(xs); i++ { diff --git a/internal/services/projection_service_unit_test.go b/internal/services/projection_service_unit_test.go index ca0dc4b..43a1c9d 100644 --- a/internal/services/projection_service_unit_test.go +++ b/internal/services/projection_service_unit_test.go @@ -317,8 +317,10 @@ func TestChildTypeForAxis(t *testing.T) { } // TestDerivedCounterclaimOurSide pins the our_side flip semantics -// (t-paliad-174 §11 Q2): -// - Default (override nil): claimant ↔ defendant; court / both pass through. +// (t-paliad-174 §11 Q2, widened in t-paliad-222 / m/paliad#47): +// - Default (override nil): flip across the active / reactive axis — +// claimant ↔ defendant, applicant ↔ respondent, appellant → +// respondent. third_party / other / NULL 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). @@ -337,11 +339,15 @@ func TestDerivedCounterclaimOurSide(t *testing.T) { {"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"}, + {"applicant → respondent (default)", str("applicant"), nil, "respondent"}, + {"respondent → applicant (default)", str("respondent"), nil, "applicant"}, + {"appellant → respondent (default)", str("appellant"), nil, "respondent"}, + {"third_party passes through", str("third_party"), nil, "third_party"}, + {"other passes through", str("other"), nil, "other"}, {"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"}, + {"flip=false on applicant keeps applicant", str("applicant"), &fal, "applicant"}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { diff --git a/internal/services/submission_render_test.go b/internal/services/submission_render_test.go index 75c1ff0..1f286f5 100644 --- a/internal/services/submission_render_test.go +++ b/internal/services/submission_render_test.go @@ -273,15 +273,24 @@ func TestLegalSourcePretty(t *testing.T) { } // TestOurSideTranslations pins the our_side enum → DE/EN prose -// mapping used by addProjectVars. +// mapping used by addProjectVars. Post t-paliad-222: seven sub-role +// values + the gender-neutral "-Seite" / "-Partei" suffix shape on +// DE. Legacy 'court' / 'both' yield "" (the column no longer accepts +// them after mig 110, but the function defensively handles stale +// in-memory values from older callers). func TestOurSideTranslations(t *testing.T) { cases := []struct { in, wantDE, wantEN string }{ - {"claimant", "Klägerin", "Claimant"}, - {"defendant", "Beklagte", "Defendant"}, - {"court", "Gericht", "Court"}, - {"both", "Klägerin und Beklagte", "Claimant and Defendant"}, + {"claimant", "Klägerseite", "Claimant"}, + {"defendant", "Beklagtenseite", "Defendant"}, + {"applicant", "Antragstellerseite", "Applicant"}, + {"appellant", "Berufungsklägerseite", "Appellant"}, + {"respondent", "Antragsgegnerseite", "Respondent"}, + {"third_party", "Drittpartei", "Third Party"}, + {"other", "sonstige Verfahrensbeteiligte", "other party"}, + {"court", "", ""}, + {"both", "", ""}, {"", "", ""}, {"unknown", "", ""}, } diff --git a/internal/services/submission_vars.go b/internal/services/submission_vars.go index 748a4e4..6444e07 100644 --- a/internal/services/submission_vars.go +++ b/internal/services/submission_vars.go @@ -262,6 +262,11 @@ func addUserVars(bag PlaceholderMap, u *models.User) { func addProjectVars(bag PlaceholderMap, p *models.Project, pt *models.ProceedingType, lang string) { bag["project.title"] = p.Title bag["project.reference"] = derefString(p.Reference) + // project.code is the auto-derived (or override) dotted project + // code computed by services.BuildProjectCode. Populated upstream + // by the service projection; templates that want the explicit + // override should read project.reference instead. + bag["project.code"] = p.Code bag["project.case_number"] = derefString(p.CaseNumber) bag["project.court"] = derefString(p.Court) bag["project.patent_number"] = derefString(p.PatentNumber) @@ -388,16 +393,29 @@ func formatDatePtr(t *time.Time, layout string) string { } // ourSideDE returns the German legal-prose form of an our_side value. +// +// t-paliad-222: unified on the gender-neutral "-Seite" / "-Partei" +// suffix shape to match the form labels and to avoid implying the +// firm represents a single (female) natural person — a B2B patent +// practice almost always represents companies. The seven sub-roles +// map onto the post-mig-110 schema; legacy 'court' / 'both' no +// longer exist in the column. func ourSideDE(side string) string { switch strings.ToLower(side) { case "claimant": - return "Klägerin" + return "Klägerseite" case "defendant": - return "Beklagte" - case "court": - return "Gericht" - case "both": - return "Klägerin und Beklagte" + return "Beklagtenseite" + case "applicant": + return "Antragstellerseite" + case "appellant": + return "Berufungsklägerseite" + case "respondent": + return "Antragsgegnerseite" + case "third_party": + return "Drittpartei" + case "other": + return "sonstige Verfahrensbeteiligte" } return "" } @@ -409,10 +427,16 @@ func ourSideEN(side string) string { return "Claimant" case "defendant": return "Defendant" - case "court": - return "Court" - case "both": - return "Claimant and Defendant" + case "applicant": + return "Applicant" + case "appellant": + return "Appellant" + case "respondent": + return "Respondent" + case "third_party": + return "Third Party" + case "other": + return "other party" } return "" }