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.
31 KiB
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_sideCHECK to the new sub-role values; drop'court'and'both'; backfill existing rows to NULL. - Add
paliad.projects.opponent_code textontype='litigation'rows (segment source for project codes). - New Go helper
services.BuildProjectCode(ctx, projectID) (string, error)that walks the ancestor chain via the existing ltreepathand assembles the dotted code. Custompaliad.projects.referenceon the project itself wins. - Wire the helper into project header, breadcrumb, picker labels, the
submission-template variable bag (
{{project.code}}), and the Excel export__metasheet.
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
referencestrings — manual entries stay intact; auto-derive only fills empty slots. - Renaming the
our_sideDB 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 constraintprojects_our_side_checkallows('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/OurSideininternal/andfrontend/src/):frontend/src/client/fristenrechner.ts:2187,2734,3754-3776— Determinator Slice 3c,ourSideToPerspective()mapsclaimant → 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}}/_enlegal-prose forms.ourSideDE/ourSideENswitch on the 4 enum values.internal/services/project_service.go:1083-1104—our_side_changedproject-event row on writes.internal/services/project_service.go:1228,1372,1955-— CCR counterclaim child default-invertsour_side;nullableOurSide()andisValidOurSide()(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 → perspectivenull
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
-- 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:
- Move the
<div className="form-field">containing#project-our-sidefrom the always-visible block (line 156) into theprojekt-fields-caseblock (after the court / case-number row). - Rename label
data-i18n="projects.field.our_side"→projects.field.client_role. - Replace the five flat
<option>s with three<optgroup>s + the seven new options + an "Unbekannt" empty option. - 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:
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:
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—TestOurSideTranslationswidens the table to cover the 7 new values in both DE and EN.internal/services/projection_service_unit_test.go:319—TestDerivedCounterclaimOurSidewidens to cover the new flip map.- New:
TestProjectFormHidesOurSideForNonCase— unit test on the project-form payload reader confirmsour_sideis silently dropped when the form renders for a non-case project type.
§2.7 Acceptance (issue #47)
- Creating a project of
type='client','litigation','patent','project'does not show the field. - Creating a project of
type='case'shows the field labelled "Mandantenrolle" (DE) / "Client Role" (EN) with three optgroups and seven options. - Existing
'court'/'both'rows (none in prod, but defensive) are migrated to NULL. - Submission templates referencing
{{project.our_side_de}}/_enrender coherent prose for the five new values. - Determinator perspective chip pre-fills correctly from each sub-role (Active→claimant, Reactive→defendant, Other→null).
- CCR counterclaim flip yields a sensible child role for the new sub-roles.
go build && go test ./internal/... && cd frontend && bun run buildclean.
§3 Issue #50 — Auto-derived project codes
§3.1 Current state (verified 2026-05-20)
paliad.projects.reference textexists and is informally used (live values:EXMPLon a client,L-2026-001on a litigation,C-UPC-0001on a case,P-EP1111222on a patent). No format enforcement.paliad.projects.path ltreeis maintained by a Postgres trigger (projects.pathjoined UUIDs root-to-self). Walking ancestors in Go is straightforward:SELECT * FROM paliad.projects WHERE path @> $1::ltree ORDER BY nlevel(path).- No
opponentfield exists anywhere. Opponent text lives only inside the litigationtitle(e.g. "Siemens AG ./. Huawei Technologies"). paliad.proceeding_types.codeis dot-separated:upc.inf.cfi,upc.rev.cfi,de.inf.lg,upc.apl.merits, etc. Splitting on.and upper-casing yieldsINF,REV,LG,APL.MERITS. Suitable as the case segment.paliad.projects.court textis 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.CFIupc.rev.cfi→REV.CFIupc.pi.cfi→PI.CFIupc.apl.merits→APL.MERITSde.inf.lg→INF.LGde.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
-- 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:
// 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 viagolang.org/x/text/unicode/norm+ filter, replace non-alnum with-, trim, cap at 8 chars. Already similar to whatinternal/util/slugdoes 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:
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:
- If the last row (the target) has non-empty
reference→ return it verbatim. Done. - Otherwise walk the chain top-to-bottom, call
projectCodeSegmenton each row, skip empty segments, join with., return.
§3.5 Wiring into surfaces
internal/services/project_service.goprojection — add aCodestring 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— addbag["project.code"] = derefString(p.Code)so submission templates can reference{{project.code}}.frontend/src/components/ProjectHeader.tsx(current header component on/projects/{id}) — rendercodenext to the title (small monospace badge) if non-empty.frontend/src/components/Breadcrumb*.tsx— when rendering the trail, useproject.codeas the trailing badge per segment if the caller asks for it (opt-in to avoid breaking other consumers).frontend/src/client/project-form.tsand any project-picker typeahead — showcode · titlein the dropdown labels whencodeis non-empty.- Excel
__metasheet — add aProject Coderow (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 yieldsEXMPL.OPNT.567.INF.CFI.TestBuildProjectCodeRespectsOverride— non-emptyreferencewins outright.TestBuildProjectCodeMissingAncestors— case directly under client (no litigation, no patent) yieldsEXMPL.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.goif present) — up → down → up.
§3.7 Acceptance (issue #50)
BuildProjectCodereturnsEXMPL.OPNT.567.INF.CFIfor the reference tree (Client EXMPL → Litigation OPNT → Patent EP1234567 → Caseupc.inf.cfi).- Setting
projects.reference = 'CUSTOM-CODE'on the case returnsCUSTOM-CODEverbatim. - Missing ancestor segments are skipped silently
(no
..collapses, no "?" placeholder). {{project.code}}resolves in submission templates.- Project header, breadcrumb, picker, Excel
__metaall show the code when set/derived. - Litigation form has a new "Opponent Code" field (DE: "Gegner-Kürzel") with the slug pattern validation. Hidden on non-litigation types.
go build && go test ./internal/... && cd frontend && bun run buildclean.
§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.)
- §2.2 Q1 — Keep column name
our_side? (Recommend YES; rename touches 11+ Go files + bundled-template wire format for zero gain.) - §2.2 Q2 — Store 7 sub-roles? (Recommend YES; group-only is lossy.)
- §2.2 Q3 — Hide the field on
litigationandpatenttoo, not just onclient? (Recommend YES per m's "only on case projects".) - §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.) - §3.2 Q1 — Add a dedicated
opponent_codecolumn on litigations? (Recommend YES; regex-on-title is brittle.) - §3.2 Q2 — Patent segment = last 3 digits (variable for <4-digit numbers)? (Recommend YES, matches m's example.)
- §3.2 Q3 — Case segment drops the jurisdiction prefix from
proceeding_types.code(soupc.inf.cfi→INF.CFI, notUPC.INF.CFI)? (Recommend YES — jurisdiction is implied by the ancestor client/patent context.) - §3.2 Q7 —
BuildProjectCodepopulates acodefield on every projected Project JSON (not lazy per-render)? (Recommend YES; simpler consumers, one DB round-trip per list page.) - §3.2 Q8 — No cache / materialised view in v1? (Recommend YES; profile later if list views get slow.)
§5 Implementation order (coder phase)
- 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/ | tailfirst to verify slot availability (boltzmann's gap-tolerant runner means 110 is fine even if 109 was the last applied). - Backend —
isValidOurSide,ourSideDE/EN,derivedCounterclaimOurSide, newproject_code.gopackage- ProjectService wiring + projection
Codefield.
- ProjectService wiring + projection
- Frontend —
ProjectFormFields.tsx(conditional render + new options + opponent_code field on litigation block),i18n.tskeys,fristenrechner.tsourSideToPerspectivewiden, header / breadcrumb / picker code-badge wiring. - Tests — pinning tests above;
go test ./internal/...clean. - Build verification —
go build && cd frontend && bun run buildclean. - 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}}forour_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_codewould derive identical case codes. Per Q6 we accept this; users see it in the picker and customisereferenceif 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.