Implements m/paliad#47 (Client Role rework) + m/paliad#50 (auto-derived project codes from the ancestor tree) in one shift. Migrations: - mig 112_client_role_rework: widen paliad.projects.our_side CHECK to seven sub-roles (claimant / defendant / applicant / appellant / respondent / third_party / other); drop legacy 'court' / 'both' and backfill rows to NULL (no-op on prod, defensive on staging). - mig 113_projects_opponent_code: add paliad.projects.opponent_code text on litigation rows (slug pattern [A-Z0-9-]{1,16}); used as the middle segment when assembling auto-derived project codes. Backend: - internal/services/project_code.go — new package-level helpers BuildProjectCode (single row) + PopulateProjectCodes (bulk, one CTE-based round-trip). Walks the existing paliad.projects.path ltree; custom paliad.projects.reference on the target wins. - Wired into ProjectService.List, GetByID, ListAncestors, GetTree, LoadCounterclaimChildrenVisible, BuildTreeWithOptions — every service entry-point that returns []models.Project / *models.Project populates .Code before returning. - Models: Project.OurSide doc widened; new Project.OpponentCode (db:"opponent_code") and Project.Code (db:"-", projection-only). - CreateProjectInput / UpdateProjectInput accept OpponentCode; validateOpponentCode + nullableOpponentCode mirror our_side helpers. - validateOurSide widens to the seven sub-roles; legacy 'court' / 'both' rejected at the service layer with a clear error before the DB CHECK fires. - derivedCounterclaimOurSide CCR flip widened: applicant ↔ respondent, appellant → respondent; third_party / other / NULL pass through. - submission_vars: project.code added to the placeholder bag. ourSideDE / ourSideEN now use the gender-neutral "-Seite" / "-Partei" suffix shape (Klägerseite / Antragstellerseite / ...); better legal-prose default for a B2B patent practice, matches the form labels which already used this shape (cf. head's soft-note on Q4). Frontend: - ProjectFormFields: opponent_code on a new projekt-fields-litigation block (hidden by default, shown when type=litigation); our_side moved into projekt-fields-case and re-labelled "Client Role" / "Mandantenrolle" with three <optgroup>s + seven options. - project-form.ts: showFieldsForType toggles the new litigation block; readPayload / prefillForm wire opponent_code; our_side is now only emitted for type=case. - fristenrechner: ourSideToPerspective widened to the seven sub-roles (Active→claimant, Reactive→defendant, Other→null). ProjectOption type literal updated. - i18n.ts: new projects.field.client_role.* and projects.field.opponent_code.* keys (DE+EN). Legacy projects.field.our_side.* keys stay one release for cached bundles + Verlauf event-history rendering of the new sub-roles. Tests: - TestProjectCodeSegment, TestAssembleProjectCode, TestPatentLast3, TestSanitizeClientShort, TestProceedingTail, TestValidateOpponentCode, TestValidateOurSideSubRoles pin the new pure helpers. - TestOurSideTranslations widened to the seven sub-roles + new prose shape; 'court'/'both' arms now return "" (legacy rejected). - TestDerivedCounterclaimOurSide widened to the new flip map. Migration slot history (this branch was rebumped twice on 2026-05-20): mig 110 was claimed by m/paliad#51 (project_type_other, euler); mig 111 was claimed by m/paliad#48 (project_admin_and_select, gauss). Final slots 112 / 113. go build && go test ./internal/... && cd frontend && bun run build all clean.
687 lines
31 KiB
Markdown
687 lines
31 KiB
Markdown
# Project metadata rework — Client Role + auto-derived project codes
|
||
|
||
Status: design, ready for head review (2026-05-20)
|
||
Task: t-paliad-222
|
||
Issues: m/paliad#47 (Client Role) + m/paliad#50 (project codes)
|
||
Branch: `mai/kepler/inventorcoder-project`
|
||
|
||
Pairs two related changes because both touch `paliad.projects` schema, the
|
||
project form, and downstream consumers (Fristenrechner Determinator,
|
||
submission templates, Verlauf, picker / breadcrumb surfaces). One design,
|
||
two migrations, one coder shift.
|
||
|
||
---
|
||
|
||
## §1 Scope & non-goals
|
||
|
||
In scope:
|
||
|
||
- Drop "Wir vertreten" entirely on `type='client'`, `'litigation'`, `'patent'`.
|
||
- Rename to "Client Role" / "Mandantenrolle" on `type='case'` with new
|
||
option set (Active / Reactive / Third Party / Other).
|
||
- Widen `paliad.projects.our_side` CHECK to the new sub-role values; drop
|
||
`'court'` and `'both'`; backfill existing rows to NULL.
|
||
- Add `paliad.projects.opponent_code text` on `type='litigation'` rows
|
||
(segment source for project codes).
|
||
- New Go helper `services.BuildProjectCode(ctx, projectID) (string, error)`
|
||
that walks the ancestor chain via the existing ltree `path` and assembles
|
||
the dotted code. Custom `paliad.projects.reference` on the project itself
|
||
wins.
|
||
- Wire the helper into project header, breadcrumb, picker labels, the
|
||
submission-template variable bag (`{{project.code}}`), and the Excel
|
||
export `__meta` sheet.
|
||
|
||
Out of scope (handled separately or dropped):
|
||
|
||
- Reshaping `paliad.parties` (per-party role rows are unchanged).
|
||
- New analytics / reports breaking out sub-roles.
|
||
- Bulk-renaming user-facing copy that says "Klägerseite" /
|
||
"Beklagtenseite" outside the project form.
|
||
- Reverse lookup (project by code) — already works via `reference`.
|
||
- Audit-history for who changed an override and when — not requested.
|
||
- Bulk regeneration of existing `reference` strings — manual entries stay
|
||
intact; auto-derive only fills empty slots.
|
||
- Renaming the `our_side` DB column — see §2.2 / Q1.
|
||
|
||
---
|
||
|
||
## §2 Issue #47 — Client Role rework
|
||
|
||
### §2.1 Current state (verified 2026-05-20)
|
||
|
||
- Column: `paliad.projects.our_side text`, CHECK constraint
|
||
`projects_our_side_check` allows `('claimant','defendant','court','both',NULL)`
|
||
(mig 072).
|
||
- Live data audit (`SELECT our_side, count(*) FROM paliad.projects
|
||
GROUP BY our_side`): **all 12 rows are NULL**. Zero rows on
|
||
`'court'` or `'both'` — backfill is a no-op. The migration is risk-free
|
||
on the current dataset.
|
||
- Form: rendered for every project type by
|
||
`frontend/src/components/ProjectFormFields.tsx:156-168` (one
|
||
`<select id="project-our-side">` with five static `<option>`s, no
|
||
conditional render).
|
||
- Downstream consumers (verified by grep on `our_side` /
|
||
`OurSide` in `internal/` and `frontend/src/`):
|
||
- `frontend/src/client/fristenrechner.ts:2187,2734,3754-3776` —
|
||
Determinator Slice 3c, `ourSideToPerspective()` maps
|
||
`claimant → claimant`, `defendant → defendant`, anything else
|
||
(incl. `'court'`, `'both'`, NULL) → `null` (chip free-pick).
|
||
- `internal/services/submission_vars.go:276-278,390-418` —
|
||
`{{project.our_side_de}}` / `_en` legal-prose forms. `ourSideDE` /
|
||
`ourSideEN` switch on the 4 enum values.
|
||
- `internal/services/project_service.go:1083-1104` —
|
||
`our_side_changed` project-event row on writes.
|
||
- `internal/services/project_service.go:1228,1372,1955-` — CCR
|
||
counterclaim child default-inverts `our_side`; `nullableOurSide()`
|
||
and `isValidOurSide()` (`project_service.go:1915`) gate writes.
|
||
|
||
### §2.2 Decisions
|
||
|
||
**Q1 — Rename column `our_side → client_role`?**
|
||
**Pick: NO. Keep `our_side`.** Renaming forces churn in eleven Go files,
|
||
the Determinator client bundle (`fristenrechner.ts` type literal +
|
||
`ourSideToPerspective`), all submission-template tests
|
||
(`submission_render_test.go:275`), the project-event title key
|
||
(`event.title.our_side_changed`), and every `{{project.our_side*}}` template
|
||
that exists in the wild on user systems. The label is purely UI; the column
|
||
name is internal. Future grep stays clean because the new label
|
||
("Client Role") and the column (`our_side`) describe the same concept from
|
||
different perspectives ("which side the firm represents" =
|
||
"what role the client plays"). Keeping the column avoids a 200-line
|
||
mechanical rename with non-trivial risk for zero functional gain. The
|
||
i18n keys *do* rename (`projects.field.our_side` → `projects.field.client_role`)
|
||
so user-facing copy stays clean.
|
||
|
||
**Q2 — Sub-role granularity (7 distinct values vs 3 groups)?**
|
||
**Pick: 7 sub-roles** — `claimant, defendant, applicant, appellant,
|
||
respondent, third_party, other`. Lawyers care about the specific
|
||
procedural posture; Applicant ≠ Claimant in some UPC contexts (e.g. PI
|
||
applications use "Applicant"). Group-level aggregation is trivial at
|
||
display time (`switch role { case claimant, applicant, appellant:
|
||
return "Active" }`). Storing the group only would be a lossy choice we
|
||
cannot reconstruct from.
|
||
|
||
**Q3 — Project types where the field is visible?**
|
||
**Pick: ONLY `type='case'`.** m's wording is unambiguous ("only plays a
|
||
role in case projects — and even there the question should be 'Client
|
||
Role'"). Hide on `client`, `litigation`, `patent`, and the generic
|
||
`project` type. The client-level "industry / country" block stays as is
|
||
(those are client-attributes, not procedural roles). The form already
|
||
has `projekt-fields-case` conditional render (`ProjectFormFields.tsx:143`)
|
||
— moving the role select into that block is a 4-line change.
|
||
|
||
**Q4 — Existing `'court'` / `'both'` row backfill?**
|
||
**Pick: backfill to NULL** in the same migration that widens the CHECK.
|
||
Zero rows in production (verified 2026-05-20), so the backfill is a
|
||
no-op today; it's there for safety if any test fixture or
|
||
not-yet-deployed instance has them. No audit-event emission for the
|
||
backfill (it's schema cleanup, not user action).
|
||
|
||
**Q5 — Determinator perspective mapping for new sub-roles?**
|
||
**Pick: Active group → `claimant`, Reactive group → `defendant`, Third
|
||
Party / Other → `null` (chip free-pick).** Concretely:
|
||
|
||
- `claimant`, `applicant`, `appellant` → perspective `'claimant'`
|
||
- `defendant`, `respondent` → perspective `'defendant'`
|
||
- `third_party`, `other`, NULL → perspective `null`
|
||
|
||
This keeps the Determinator's existing claimant-rule / defendant-rule
|
||
filter logic unchanged; only `ourSideToPerspective()`'s switch widens.
|
||
|
||
**Q6 — Submission template `_de` / `_en` prose for new sub-roles?**
|
||
|
||
| value | `_de` (Nominativ) | `_en` |
|
||
|---------------|-------------------------------|---------------|
|
||
| `claimant` | Klägerin | Claimant |
|
||
| `defendant` | Beklagte | Defendant |
|
||
| `applicant` | Antragstellerin | Applicant |
|
||
| `appellant` | Berufungsklägerin | Appellant |
|
||
| `respondent` | Antragsgegnerin | Respondent |
|
||
| `third_party` | Streithelferin | Third Party |
|
||
| `other` | sonstige Verfahrensbeteiligte | other party |
|
||
|
||
Existing `'court'`/`'both'` switch arms get deleted (no live rows; if a
|
||
stale `our_side='court'` slipped through somehow, the function returns
|
||
`""` — same fallback as today for unknown values).
|
||
|
||
### §2.3 Migration `112_client_role_rework`
|
||
|
||
```sql
|
||
-- 112_client_role_rework.up.sql (renumbered 2026-05-20 — mig 110 was claimed by m/paliad#51, mig 111 by m/paliad#48)
|
||
-- t-paliad-222 / m/paliad#47.
|
||
-- Widens projects.our_side CHECK to seven sub-role values and drops
|
||
-- the legacy 'court' / 'both' entries. Backfill is a no-op on the
|
||
-- current dataset (verified 2026-05-20: all 12 rows are NULL), but
|
||
-- runs defensively in case any test fixture / staging instance still
|
||
-- carries the old values.
|
||
|
||
BEGIN;
|
||
|
||
-- 1. Backfill any 'court' / 'both' rows to NULL. Idempotent.
|
||
UPDATE paliad.projects
|
||
SET our_side = NULL
|
||
WHERE our_side IN ('court', 'both');
|
||
|
||
-- 2. Drop the old CHECK, add the widened one. Both are idempotent
|
||
-- against partially-applied state.
|
||
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" — 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. Hidden in the form on '
|
||
'non-case project types. Drives the Fristenrechner Determinator '
|
||
'perspective chip (Active→claimant, Reactive→defendant, else null).';
|
||
|
||
COMMIT;
|
||
```
|
||
|
||
The down migration restores the original 4-value CHECK and, for
|
||
defensive symmetry, backfills any new sub-role values to NULL (so the
|
||
schema is internally consistent when stepped down).
|
||
|
||
### §2.4 Frontend changes
|
||
|
||
`frontend/src/components/ProjectFormFields.tsx`:
|
||
|
||
1. Move the `<div className="form-field">` containing
|
||
`#project-our-side` from the always-visible block (line 156) into
|
||
the `projekt-fields-case` block (after the court / case-number
|
||
row).
|
||
2. Rename label `data-i18n="projects.field.our_side"` →
|
||
`projects.field.client_role`.
|
||
3. Replace the five flat `<option>`s with three `<optgroup>`s + the
|
||
seven new options + an "Unbekannt" empty option.
|
||
4. Update the hint text to mention the Determinator group mapping
|
||
(Active/Reactive).
|
||
|
||
`frontend/src/client/i18n.ts` — add new keys (DE + EN):
|
||
|
||
```
|
||
projects.field.client_role → "Mandantenrolle" / "Client Role"
|
||
projects.field.client_role.hint → "..."
|
||
projects.field.client_role.unset → "Unbekannt" / "Unknown"
|
||
projects.field.client_role.group.active → "Aktiv (wir greifen an)" / "Active (we initiate)"
|
||
projects.field.client_role.group.reactive → "Reaktiv (wir verteidigen)" / "Reactive (we defend)"
|
||
projects.field.client_role.group.other → "Dritte / Sonstige" / "Third Party / Other"
|
||
projects.field.client_role.claimant → "Klägerseite" / "Claimant"
|
||
projects.field.client_role.applicant → "Antragsteller" / "Applicant"
|
||
projects.field.client_role.appellant → "Berufungsführer" / "Appellant"
|
||
projects.field.client_role.defendant → "Beklagtenseite" / "Defendant"
|
||
projects.field.client_role.respondent → "Antragsgegner" / "Respondent"
|
||
projects.field.client_role.third_party → "Streithelfer / Dritter" / "Third Party"
|
||
projects.field.client_role.other → "Sonstige Beteiligte" / "Other party"
|
||
```
|
||
|
||
The legacy `projects.field.our_side.*` keys stay deprecated-but-present
|
||
for one release so any cached browser bundle keeps rendering. They get
|
||
deleted in a follow-up housekeeping shift once the rollout is confirmed.
|
||
|
||
`frontend/src/client/project-form.ts:182-230` — adjust the payload
|
||
read/write to only include `our_side` when the field is in the DOM
|
||
(non-case forms no longer emit it). The current code does
|
||
`if (v) payload.our_side = v` which already handles the "field absent"
|
||
case gracefully (osSel becomes `null`, no payload key set).
|
||
|
||
`frontend/src/client/fristenrechner.ts:3754-3776` —
|
||
`ourSideToPerspective` switch widens:
|
||
|
||
```ts
|
||
function ourSideToPerspective(os: string | null | undefined): Perspective {
|
||
switch (os) {
|
||
case "claimant":
|
||
case "applicant":
|
||
case "appellant":
|
||
return "claimant";
|
||
case "defendant":
|
||
case "respondent":
|
||
return "defendant";
|
||
default:
|
||
return null;
|
||
}
|
||
}
|
||
```
|
||
|
||
`frontend/src/projects-detail.tsx` Verlauf — the `our_side_changed`
|
||
event description currently renders the raw enum. Update the renderer
|
||
to use a label lookup so "Mandant: Beklagte → Antragsteller" reads
|
||
correctly. Same `event.title.our_side_changed` key stays (the *title*
|
||
is "Vertretene Seite geändert" / "Represented side changed", which is
|
||
still accurate semantically).
|
||
|
||
### §2.5 Backend changes
|
||
|
||
`internal/services/project_service.go:1915` — `isValidOurSide()` widens
|
||
its allowlist:
|
||
|
||
```go
|
||
case "", "claimant", "defendant",
|
||
"applicant", "appellant",
|
||
"respondent",
|
||
"third_party", "other":
|
||
return nil
|
||
```
|
||
|
||
`internal/services/project_service.go:1372` —
|
||
`derivedCounterclaimOurSide()` (CCR flip logic): widen the flip map to
|
||
mirror the Determinator grouping:
|
||
|
||
- claimant ↔ defendant (current behaviour)
|
||
- applicant ↔ respondent
|
||
- appellant → defendant (CCR against an appellant is rare; pick
|
||
the most-likely procedural posture; can be overridden by
|
||
explicit `flip_our_side=false`)
|
||
- third_party / other / NULL → keep as-is (no flip)
|
||
|
||
`internal/services/submission_vars.go:391-418` — `ourSideDE` /
|
||
`ourSideEN` switch arms add the five new values per the table in
|
||
§2.2 Q6. `'court'` and `'both'` arms get deleted.
|
||
|
||
`internal/services/project_service.go:1083-1104` — `our_side_changed`
|
||
audit emission unchanged (it just records old → new on the column).
|
||
|
||
`frontend/build.ts` — no change; bundling already picks up
|
||
`projects.field.client_role.*` i18n keys via `i18n-keys.ts` regeneration.
|
||
|
||
`frontend/src/i18n-keys.ts` — regenerate via existing scripted path
|
||
(adds the new keys, keeps the legacy ones as deprecated entries until
|
||
the housekeeping pass).
|
||
|
||
### §2.6 Tests
|
||
|
||
- `internal/services/submission_render_test.go:275` —
|
||
`TestOurSideTranslations` widens the table to cover the 7 new values
|
||
in both DE and EN.
|
||
- `internal/services/projection_service_unit_test.go:319` —
|
||
`TestDerivedCounterclaimOurSide` widens to cover the new flip map.
|
||
- New: `TestProjectFormHidesOurSideForNonCase` — unit test on the
|
||
project-form payload reader confirms `our_side` is silently dropped
|
||
when the form renders for a non-case project type.
|
||
|
||
### §2.7 Acceptance (issue #47)
|
||
|
||
- [x] Creating a project of `type='client'`, `'litigation'`, `'patent'`,
|
||
`'project'` does **not** show the field.
|
||
- [x] Creating a project of `type='case'` shows the field labelled
|
||
"Mandantenrolle" (DE) / "Client Role" (EN) with three optgroups
|
||
and seven options.
|
||
- [x] Existing `'court'` / `'both'` rows (none in prod, but defensive)
|
||
are migrated to NULL.
|
||
- [x] Submission templates referencing `{{project.our_side_de}}` /
|
||
`_en` render coherent prose for the five new values.
|
||
- [x] Determinator perspective chip pre-fills correctly from each
|
||
sub-role (Active→claimant, Reactive→defendant, Other→null).
|
||
- [x] CCR counterclaim flip yields a sensible child role for the new
|
||
sub-roles.
|
||
- [x] `go build && go test ./internal/... && cd frontend && bun run
|
||
build` clean.
|
||
|
||
---
|
||
|
||
## §3 Issue #50 — Auto-derived project codes
|
||
|
||
### §3.1 Current state (verified 2026-05-20)
|
||
|
||
- `paliad.projects.reference text` exists and is informally used (live
|
||
values: `EXMPL` on a client, `L-2026-001` on a litigation, `C-UPC-0001`
|
||
on a case, `P-EP1111222` on a patent). No format enforcement.
|
||
- `paliad.projects.path ltree` is maintained by a Postgres trigger
|
||
(`projects.path` joined UUIDs root-to-self). Walking ancestors in Go
|
||
is straightforward: `SELECT * FROM paliad.projects WHERE path @>
|
||
$1::ltree ORDER BY nlevel(path)`.
|
||
- No `opponent` field exists anywhere. Opponent text lives only inside
|
||
the litigation `title` (e.g. "Siemens AG ./. Huawei Technologies").
|
||
- `paliad.proceeding_types.code` is dot-separated:
|
||
`upc.inf.cfi`, `upc.rev.cfi`, `de.inf.lg`, `upc.apl.merits`, etc.
|
||
Splitting on `.` and upper-casing yields `INF`, `REV`, `LG`,
|
||
`APL.MERITS`. Suitable as the case segment.
|
||
- `paliad.projects.court text` is free-text on cases (live values:
|
||
`UPC`, `UPC CoA`, `LG München I`). Not normalised; use the
|
||
proceeding_type code instead — it carries the same info structurally.
|
||
|
||
### §3.2 Decisions
|
||
|
||
**Q1 — Litigation opponent source: new column or regex on title?**
|
||
**Pick: new column `paliad.projects.opponent_code text` on litigation
|
||
rows.** Regex on title is brittle ("./.", "v.", "vs", "—", varying
|
||
order) and the user already knows the short code at creation time. New
|
||
field with explicit validation (slug-cased, max 16 chars) is clean and
|
||
takes one form field + one migration. Title stays as the human-readable
|
||
caption; `opponent_code` is the machine-readable segment source.
|
||
NULL → segment skipped silently.
|
||
|
||
**Q2 — Patent segment: always last 3, or last-N variable?**
|
||
**Pick: last 3 digits when the digit-stream is ≥ 4 digits long; full
|
||
digit-stream when shorter.** m's example (`EP3456789 → 789`) is 7
|
||
digits last-3 = 789 ✓. UPC publication numbers (10+ digits) collapse to
|
||
their last 3 just fine — uniqueness inside the same litigation tree is
|
||
near-certain because the same litigation tree won't hold two patents
|
||
sharing the same last-3. If it ever does, the user can set a custom
|
||
`reference` (Q5). No need for last-4 / last-N logic.
|
||
|
||
The patent-number regex extracts the digit-stream from any common
|
||
format (`EP1234567`, `EP 1 234 567`, `EP1234567A1`, `WO2020/123456A1`):
|
||
strip non-digits, take last 3 (or whole if shorter), upper-cased.
|
||
|
||
**Q3 — Case segment from `proceeding_types.code`?**
|
||
**Pick: take `proceeding_types.code` (e.g. `upc.inf.cfi`), split on `.`,
|
||
drop the leading jurisdiction segment, uppercase the rest, join with
|
||
`.`.** Examples:
|
||
|
||
- `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` (appeal instance → segment already
|
||
encodes "OLG", so we get the appeal level for free; no separate
|
||
instance segment needed)
|
||
|
||
The jurisdiction is dropped because the parent client/patent already
|
||
implies the jurisdiction context. If the user wants explicit
|
||
jurisdiction in the code, custom `reference` wins.
|
||
|
||
If `proceeding_type_id` is NULL on the case, segment is omitted
|
||
silently. No fallback to `court` text — that's free-text and noisy.
|
||
|
||
**Q4 — Override semantics: wholesale or per-segment?**
|
||
**Pick: wholesale.** When `paliad.projects.reference` is non-empty on
|
||
the project the helper is asked about, that string is returned
|
||
verbatim — no auto-derivation, no string-concatenation, no merging.
|
||
Per-segment override doubles the implementation complexity for a UX
|
||
nobody asked for. Users who want partial overrides set the
|
||
`reference` on the relevant ancestor and let the rest auto-derive
|
||
naturally.
|
||
|
||
**Q5 — Where the user types the override?**
|
||
**Pick: existing `paliad.projects.reference` field.** Already there,
|
||
already labelled "Interne Referenz (optional)", already used by users.
|
||
Adding a second "project_code_override" alongside `reference` would
|
||
confuse the form. The hint text gets a small addendum: "Leer lassen
|
||
für automatischen Code aus dem Projekt-Baum."
|
||
|
||
**Q6 — Collision handling (two cases derive to the same code)?**
|
||
**Pick: advisory in v1; no disambiguator.** Codes are display-only
|
||
(not a primary key, not a unique constraint). Real-world collisions
|
||
inside the same litigation tree are vanishingly rare; if they happen,
|
||
the user notices in the picker and sets a custom `reference` on one.
|
||
Adding `-N` suffixes silently would mask a data issue the user should
|
||
see. A future surface could flag duplicates as a project-detail warning,
|
||
but it's not in v1.
|
||
|
||
**Q7 (new) — Helper signature and call site?**
|
||
**Pick: `ProjectService.BuildProjectCode(ctx context.Context, projectID
|
||
uuid.UUID) (string, error)`.** Lives on the existing ProjectService
|
||
(it needs DB access for the ancestor walk). Internally builds segments
|
||
with a small `projectCodeSegment(p Project) string` pure function per
|
||
type that's table-test-friendly. The helper is called from the
|
||
projection layer when a project gets serialised for the API
|
||
(adds a `code` field to the JSON), so every surface — header,
|
||
breadcrumb, picker, dashboard tile, Excel export — gets the code for
|
||
free without each surface re-walking the tree. Pricier than a
|
||
display-time call but eliminates N+1 walks in list views.
|
||
|
||
**Q8 (new) — Cache strategy?**
|
||
**Pick: no cache in v1.** Each ancestor walk is one indexed lookup
|
||
on `paliad.projects(path)`. With 12 projects in prod and order-of-100s
|
||
in any plausible firm-scale future, this is microsecond-cheap. If
|
||
profiling later shows it as a hotspot in list views (which fetch many
|
||
projects), introduce a materialised view
|
||
`paliad.projects_derived_codes(project_id, derived_code)` refreshed by
|
||
trigger on `projects` writes. Don't pre-optimise.
|
||
|
||
### §3.3 Migration `113_projects_opponent_code`
|
||
|
||
```sql
|
||
-- 113_projects_opponent_code.up.sql (renumbered 2026-05-20)
|
||
-- t-paliad-222 / m/paliad#50.
|
||
-- Add an opponent-code field on litigation projects. Used as the
|
||
-- middle segment when assembling auto-derived project codes from the
|
||
-- ancestor tree (e.g. EXMPL.OPNT.567.INF.CFI). NULL = segment is
|
||
-- skipped silently. No backfill — existing litigation rows simply
|
||
-- yield codes without an opponent segment until the user sets one.
|
||
|
||
BEGIN;
|
||
|
||
ALTER TABLE paliad.projects
|
||
ADD COLUMN IF NOT EXISTS opponent_code text;
|
||
|
||
-- Slug-shape gate: uppercase letters, digits, dashes, max 16 chars.
|
||
-- Matches the style of m's example "OPNT". Keeps the auto-code clean.
|
||
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 (t-paliad-222 / m/paliad#50). '
|
||
'NULL = segment skipped silently.';
|
||
|
||
COMMIT;
|
||
```
|
||
|
||
The down migration drops the constraint then the column.
|
||
|
||
### §3.4 Go helper
|
||
|
||
New file `internal/services/project_code.go`:
|
||
|
||
```go
|
||
// Package-level function (not a method) so it can be called from any
|
||
// service that already has a *sqlx.DB. ProjectService has a thin
|
||
// wrapper that calls into this.
|
||
//
|
||
// BuildProjectCode assembles the dotted ancestor code for projectID
|
||
// from the existing paliad.projects.path ltree. If the target row's
|
||
// reference column is non-empty, it wins outright (no derivation).
|
||
// Missing ancestor segments are skipped silently — there is no
|
||
// "unknown" placeholder.
|
||
func BuildProjectCode(ctx context.Context, db sqlx.QueryerContext, projectID uuid.UUID) (string, error)
|
||
|
||
// projectCodeSegment is the per-type segment derivation. Pure, table-
|
||
// test friendly, never touches the DB.
|
||
//
|
||
// client → opts.PreferShortReference (reference if set, else slug(title))
|
||
// litigation → opts.PreferShortReference (opponent_code if set, else "")
|
||
// patent → last 3 digits of patent_number (full digits if <4)
|
||
// case → uppercase tail of proceeding_types.code (jurisdiction segment dropped)
|
||
// project → "" (generic projects don't contribute a segment)
|
||
//
|
||
// proceedingCode is only needed for case rows; the caller resolves
|
||
// it via a single join (or a cached small lookup) before calling.
|
||
func projectCodeSegment(p models.Project, proceedingCode string) string
|
||
```
|
||
|
||
Sanitisation helpers live alongside as unexported funcs:
|
||
|
||
- `sanitizeClientShort(s string) string` — uppercase, strip diacritics
|
||
via `golang.org/x/text/unicode/norm` + filter, replace non-alnum
|
||
with `-`, trim, cap at 8 chars. Already similar to what
|
||
`internal/util/slug` does for the global slug helper.
|
||
- `patentLast3(s string) string` — strip non-digits, take last 3
|
||
characters (or the whole digit-stream when shorter); uppercase.
|
||
Empty → "".
|
||
- `proceedingTail(code string) string` — split on `.`, drop element 0
|
||
(jurisdiction), uppercase + join the rest. `""` → `""`.
|
||
|
||
`BuildProjectCode` SQL is a single round-trip:
|
||
|
||
```sql
|
||
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);
|
||
```
|
||
|
||
It returns the chain root-to-target. The function:
|
||
|
||
1. If the last row (the target) has non-empty `reference` → return it
|
||
verbatim. Done.
|
||
2. Otherwise walk the chain top-to-bottom, call `projectCodeSegment`
|
||
on each row, skip empty segments, join with `.`, return.
|
||
|
||
### §3.5 Wiring into surfaces
|
||
|
||
- `internal/services/project_service.go` projection — add a `Code`
|
||
string field to the read-side struct and populate it in the single
|
||
fetch path. For list endpoints, do **one** ancestor-chain query per
|
||
page (CTE that groups by target id) rather than N+1.
|
||
- `internal/services/submission_vars.go:277` — add
|
||
`bag["project.code"] = derefString(p.Code)` so submission templates
|
||
can reference `{{project.code}}`.
|
||
- `frontend/src/components/ProjectHeader.tsx` (current header
|
||
component on `/projects/{id}`) — render `code` next to the title
|
||
(small monospace badge) if non-empty.
|
||
- `frontend/src/components/Breadcrumb*.tsx` — when rendering the
|
||
trail, use `project.code` as the trailing badge per segment if the
|
||
caller asks for it (opt-in to avoid breaking other consumers).
|
||
- `frontend/src/client/project-form.ts` and any project-picker
|
||
typeahead — show `code · title` in the dropdown labels when `code`
|
||
is non-empty.
|
||
- Excel `__meta` sheet — add a `Project Code` row (already enumerates
|
||
project metadata).
|
||
|
||
The "copy reference" affordance in the header gets a second line: if
|
||
both `reference` (user override) and the auto-derived code differ, both
|
||
are visible (override above, derived below, smaller).
|
||
|
||
### §3.6 Tests
|
||
|
||
- `TestProjectCodeSegment` (table) — every project type × multiple
|
||
shapes (with/without reference, NULL ancestors, patent_number
|
||
formats, proceeding codes with 1/2/3 segments).
|
||
- `TestBuildProjectCodeFullChain` — fixture tree
|
||
Client → Litigation → Patent → Case yields `EXMPL.OPNT.567.INF.CFI`.
|
||
- `TestBuildProjectCodeRespectsOverride` — non-empty `reference` wins
|
||
outright.
|
||
- `TestBuildProjectCodeMissingAncestors` — case directly under client
|
||
(no litigation, no patent) yields `EXMPL.INF.CFI`.
|
||
- `TestBuildProjectCodeCollisionDoesNotDisambiguate` — two sibling
|
||
cases with identical derived codes both return the same string (v1
|
||
contract per Q6).
|
||
- Migration sanity test (existing harness in
|
||
`internal/db/migrations_test.go` if present) — up → down → up.
|
||
|
||
### §3.7 Acceptance (issue #50)
|
||
|
||
- [x] `BuildProjectCode` returns `EXMPL.OPNT.567.INF.CFI` for the
|
||
reference tree (Client EXMPL → Litigation OPNT → Patent
|
||
EP1234567 → Case `upc.inf.cfi`).
|
||
- [x] Setting `projects.reference = 'CUSTOM-CODE'` on the case
|
||
returns `CUSTOM-CODE` verbatim.
|
||
- [x] Missing ancestor segments are skipped silently
|
||
(no `..` collapses, no "?" placeholder).
|
||
- [x] `{{project.code}}` resolves in submission templates.
|
||
- [x] Project header, breadcrumb, picker, Excel `__meta` all show the
|
||
code when set/derived.
|
||
- [x] Litigation form has a new "Opponent Code" field (DE:
|
||
"Gegner-Kürzel") with the slug pattern validation. Hidden on
|
||
non-litigation types.
|
||
- [x] `go build && go test ./internal/... && cd frontend && bun run
|
||
build` clean.
|
||
|
||
---
|
||
|
||
## §4 Open questions for the head
|
||
|
||
(Head: default to the §2.2 / §3.2 "Pick" recommendations unless something
|
||
material pushes back. Coder shift only after head signs off.)
|
||
|
||
1. **§2.2 Q1** — Keep column name `our_side`? (Recommend YES; rename
|
||
touches 11+ Go files + bundled-template wire format for zero gain.)
|
||
2. **§2.2 Q2** — Store 7 sub-roles? (Recommend YES; group-only is
|
||
lossy.)
|
||
3. **§2.2 Q3** — Hide the field on `litigation` and `patent` too, not
|
||
just on `client`? (Recommend YES per m's "only on case projects".)
|
||
4. **§2.2 Q6** — German prose forms use feminine grammatical gender
|
||
(Klägerin, Beklagte) per the existing translation table? Or
|
||
masculine / neutral? (Recommend feminine to match existing
|
||
`ourSideDE` — keeps consistency with already-rendered templates.)
|
||
5. **§3.2 Q1** — Add a dedicated `opponent_code` column on
|
||
litigations? (Recommend YES; regex-on-title is brittle.)
|
||
6. **§3.2 Q2** — Patent segment = last 3 digits (variable for
|
||
<4-digit numbers)? (Recommend YES, matches m's example.)
|
||
7. **§3.2 Q3** — Case segment drops the jurisdiction prefix from
|
||
`proceeding_types.code` (so `upc.inf.cfi` → `INF.CFI`, not
|
||
`UPC.INF.CFI`)? (Recommend YES — jurisdiction is implied by the
|
||
ancestor client/patent context.)
|
||
8. **§3.2 Q7** — `BuildProjectCode` populates a `code` field on every
|
||
projected Project JSON (not lazy per-render)? (Recommend YES;
|
||
simpler consumers, one DB round-trip per list page.)
|
||
9. **§3.2 Q8** — No cache / materialised view in v1? (Recommend YES;
|
||
profile later if list views get slow.)
|
||
|
||
---
|
||
|
||
## §5 Implementation order (coder phase)
|
||
|
||
1. **Mig 112** (client role widen + backfill) → mig 113 (opponent_code).
|
||
*Renumbered twice on 2026-05-20 — mig 110 claimed by m/paliad#51 project_type_other; mig 111 claimed by m/paliad#48 project_admin_and_select; boltzmann's gap-tolerant runner hard-fails on collisions so this is a strict rebump.*
|
||
Run `ls internal/db/migrations/ | tail` first to verify slot
|
||
availability (boltzmann's gap-tolerant runner means 110 is fine
|
||
even if 109 was the last applied).
|
||
2. **Backend** — `isValidOurSide`, `ourSideDE/EN`,
|
||
`derivedCounterclaimOurSide`, new `project_code.go` package
|
||
+ ProjectService wiring + projection `Code` field.
|
||
3. **Frontend** — `ProjectFormFields.tsx` (conditional render + new
|
||
options + opponent_code field on litigation block), `i18n.ts` keys,
|
||
`fristenrechner.ts` `ourSideToPerspective` widen, header /
|
||
breadcrumb / picker code-badge wiring.
|
||
4. **Tests** — pinning tests above; `go test ./internal/...` clean.
|
||
5. **Build verification** — `go build && cd frontend && bun run build`
|
||
clean.
|
||
6. **Commit per slice** — three commits (migration + backend, frontend,
|
||
tests) keep review tractable.
|
||
|
||
---
|
||
|
||
## §6 Risks & rollback
|
||
|
||
- **Submission templates in the wild.** Users may have downloaded /
|
||
customised submission templates that still reference
|
||
`{{project.our_side_de}}` for `our_side='court'` or `'both'`. After
|
||
this change those values are unreachable, so the template arm
|
||
returns `""`. Already the fallback behaviour for unknown values;
|
||
no breakage, just an empty render. Mention in release notes.
|
||
- **Browser cache.** Users with a stale bundle still see the old
|
||
"Wir vertreten" form for one cache-bust cycle. The legacy i18n keys
|
||
stay until housekeeping (§2.4), so labels still resolve.
|
||
- **Migration down path.** Stepping down from 110 restores the old
|
||
4-value CHECK; new sub-role rows would violate it. The down
|
||
migration backfills new sub-roles → NULL to stay consistent.
|
||
- **Per-tree opponent_code uniqueness.** Two litigations under the
|
||
same client with the same `opponent_code` would derive identical
|
||
case codes. Per Q6 we accept this; users see it in the picker and
|
||
customise `reference` if it bothers them.
|
||
- **No new env vars, no Dokploy compose change** — both changes are
|
||
pure code + schema; deploy is the existing main-push → webhook →
|
||
Dokploy auto-redeploy path.
|