wip(projects): t-paliad-222 — backend + frontend changes (pre-merge checkpoint)

Backend: mig 110/111 (will be renumbered after merging main),
validators + helpers widened, BuildProjectCode helper + projection
populator wired into List/GetByID/ListAncestors/GetTree/CCR. All
internal Go tests pass.

Frontend: ProjectFormFields conditional render — opponent_code on
litigation, our_side renamed to Client Role on case with grouped
optgroups. i18n keys for both DE and EN. fristenrechner perspective
mapping widened. project-form.ts payload reader/writer + showFieldsForType
toggle for new litigation block.

Migration slots about to be bumped (mig 110 was claimed by euler's
project_type_other on main).
This commit is contained in:
mAi
2026-05-20 14:45:33 +02:00
parent cc23e9e537
commit 5dea0a703b
16 changed files with 1192 additions and 70 deletions

View File

@@ -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<ProjectOption[]> {
@@ -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

View File

@@ -1210,9 +1210,30 @@ const translations: Record<Lang, Record<string, string>> = {
"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<Lang, Record<string, string>> = {
"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:",

View File

@@ -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<string, unknown>) {
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");
}

View File

@@ -140,6 +140,24 @@ export function ProjectFormFields(): string {
</div>
</div>
{/* Litigation-specific */}
<div className="projekt-fields projekt-fields-litigation" id="fields-litigation" style="display:none">
<div className="form-field">
<label htmlFor="project-opponent-code" data-i18n="projects.field.opponent_code">Gegner-K&uuml;rzel</label>
<input
type="text"
id="project-opponent-code"
maxLength={16}
pattern="[A-Z0-9-]{1,16}"
placeholder="OPNT"
data-i18n-placeholder="projects.field.opponent_code.placeholder"
/>
<p className="form-hint" data-i18n="projects.field.opponent_code.hint">
Kurzes K&uuml;rzel der Gegenseite (Grossbuchstaben, Ziffern, Bindestriche, max. 16 Zeichen). Wird als mittleres Segment in automatisch abgeleiteten Projekt-Codes verwendet (z.B. EXMPL.OPNT.567.INF.CFI).
</p>
</div>
</div>
{/* Case-specific */}
<div className="projekt-fields projekt-fields-case" id="fields-case" style="display:none">
<div className="form-field-row">
@@ -152,20 +170,29 @@ export function ProjectFormFields(): string {
<input type="text" id="project-case-number" placeholder="UPC_CFI_123/2026" />
</div>
</div>
</div>
<div className="form-field">
<label htmlFor="project-our-side" data-i18n="projects.field.our_side">Wir vertreten</label>
<select id="project-our-side">
<option value="" data-i18n="projects.field.our_side.unset">Unbekannt / nicht gesetzt</option>
<option value="claimant" data-i18n="projects.field.our_side.claimant">Kl&auml;gerseite</option>
<option value="defendant" data-i18n="projects.field.our_side.defendant">Beklagtenseite</option>
<option value="court" data-i18n="projects.field.our_side.court">Gericht / Tribunal</option>
<option value="both" data-i18n="projects.field.our_side.both">Beide Seiten</option>
</select>
<p className="form-hint" data-i18n="projects.field.our_side.hint">
Bestimmt die Voreinstellung der Perspektive im Fristenrechner-Determinator. L&auml;sst sich dort jederzeit &uuml;berschreiben.
</p>
<div className="form-field">
<label htmlFor="project-our-side" data-i18n="projects.field.client_role">Mandantenrolle</label>
<select id="project-our-side">
<option value="" data-i18n="projects.field.client_role.unset">Unbekannt</option>
<optgroup data-i18n-label="projects.field.client_role.group.active" label="Aktiv (wir greifen an)">
<option value="claimant" data-i18n="projects.field.client_role.claimant">Kl&auml;gerseite</option>
<option value="applicant" data-i18n="projects.field.client_role.applicant">Antragsteller</option>
<option value="appellant" data-i18n="projects.field.client_role.appellant">Berufungsf&uuml;hrer</option>
</optgroup>
<optgroup data-i18n-label="projects.field.client_role.group.reactive" label="Reaktiv (wir verteidigen)">
<option value="defendant" data-i18n="projects.field.client_role.defendant">Beklagtenseite</option>
<option value="respondent" data-i18n="projects.field.client_role.respondent">Antragsgegner</option>
</optgroup>
<optgroup data-i18n-label="projects.field.client_role.group.other" label="Dritte / Sonstige">
<option value="third_party" data-i18n="projects.field.client_role.third_party">Streithelfer / Dritter</option>
<option value="other" data-i18n="projects.field.client_role.other">Sonstige Beteiligte</option>
</optgroup>
</select>
<p className="form-hint" data-i18n="projects.field.client_role.hint">
Bestimmt die Voreinstellung der Perspektive im Fristenrechner-Determinator: Aktiv &rarr; Kl&auml;gerseite, Reaktiv &rarr; Beklagtenseite. L&auml;sst sich dort jederzeit &uuml;berschreiben.
</p>
</div>
</div>
<div className="form-field">

2
go.mod
View File

@@ -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
)

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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

View File

@@ -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, ".")
}

View File

@@ -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)
}
})
}
}

View File

@@ -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++ {

View File

@@ -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) {

View File

@@ -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", "", ""},
}

View File

@@ -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 ""
}