Compare commits
8 Commits
mai/hermes
...
mai/kepler
| Author | SHA1 | Date | |
|---|---|---|---|
| d723df6fd4 | |||
| 9de14f0665 | |||
| d326acb31a | |||
| 0a1a1d45ba | |||
| 37cdf23c32 | |||
| e6353d907c | |||
| 2cfd54f0cd | |||
| f99a32490d |
686
docs/design-project-metadata-rework-2026-05-20.md
Normal file
686
docs/design-project-metadata-rework-2026-05-20.md
Normal file
@@ -0,0 +1,686 @@
|
||||
# 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.
|
||||
@@ -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
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -8,6 +8,11 @@ export interface ProjectMini {
|
||||
title: string;
|
||||
type: string;
|
||||
reference?: string | null;
|
||||
// t-paliad-222 / m/paliad#50: auto-derived dotted project code from
|
||||
// the ancestor tree. Populated by the service projection on every
|
||||
// /api/projects response, so the picker can show the code without an
|
||||
// extra fetch.
|
||||
code?: string;
|
||||
}
|
||||
|
||||
export interface ProjectFormState {
|
||||
@@ -48,9 +53,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";
|
||||
@@ -88,18 +95,28 @@ export function initParentPicker() {
|
||||
}
|
||||
const matches = parentCandidates
|
||||
.filter((p) => {
|
||||
const hay = (p.title + " " + (p.reference || "")).toLowerCase();
|
||||
// Search across title + manual reference + auto-derived code
|
||||
// so the user can type "EXMPL" or "INF.CFI" and find the row.
|
||||
const hay = (p.title + " " + (p.reference || "") + " " + (p.code || "")).toLowerCase();
|
||||
return hay.includes(q);
|
||||
})
|
||||
.slice(0, 8);
|
||||
sugs.innerHTML = matches
|
||||
.map(
|
||||
(p) =>
|
||||
`<div class="collab-suggestion" data-id="${esc(p.id)}" data-title="${esc(p.title)}">
|
||||
.map((p) => {
|
||||
// Render the auto-derived code (if any, and distinct from
|
||||
// reference) as a small mono badge on the right so the user
|
||||
// can disambiguate two same-titled projects by their tree
|
||||
// position. Single template literal kept readable inline.
|
||||
const code = p.code && p.code !== (p.reference || "") ? p.code : "";
|
||||
const codeBadge = code
|
||||
? `<span class="entity-ref entity-ref-code">${esc(code)}</span>`
|
||||
: "";
|
||||
return `<div class="collab-suggestion" data-id="${esc(p.id)}" data-title="${esc(p.title)}">
|
||||
<strong>${esc(p.title)}</strong>
|
||||
<span class="entity-type-chip entity-type-${esc(p.type)}">${esc(tDyn("projects.type." + p.type) || p.type)}</span>
|
||||
</div>`,
|
||||
)
|
||||
${codeBadge}
|
||||
</div>`;
|
||||
})
|
||||
.join("");
|
||||
sugs.querySelectorAll<HTMLDivElement>(".collab-suggestion").forEach((el) => {
|
||||
el.addEventListener("click", () => {
|
||||
@@ -174,20 +191,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 +257,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");
|
||||
}
|
||||
|
||||
@@ -21,6 +21,12 @@ interface Project {
|
||||
path: string;
|
||||
title: string;
|
||||
reference?: string | null;
|
||||
// t-paliad-222 / m/paliad#50: auto-derived dotted project code from
|
||||
// the ancestor tree (e.g. EXMPL.OPNT.789.INF.CFI). Populated by the
|
||||
// service layer on every projection; equal to `reference` when the
|
||||
// user typed an override.
|
||||
code?: string;
|
||||
opponent_code?: string | null;
|
||||
description?: string | null;
|
||||
status: string;
|
||||
client_number?: string | null;
|
||||
@@ -1095,6 +1101,24 @@ function renderHeader() {
|
||||
(document.getElementById("project-title-display") as HTMLElement).textContent = project.title;
|
||||
(document.getElementById("project-ref-display") as HTMLElement).textContent = project.reference || "";
|
||||
|
||||
// t-paliad-222 / m/paliad#50 — show the auto-derived project code
|
||||
// as a second badge whenever it's non-empty AND distinct from the
|
||||
// manual reference. Hides when the derived value equals reference
|
||||
// (avoids visual duplication when the user typed the same string)
|
||||
// or when no derivation produced a value.
|
||||
const codeEl = document.getElementById("project-code-display") as HTMLElement | null;
|
||||
if (codeEl) {
|
||||
const code = project.code ?? "";
|
||||
const ref = project.reference ?? "";
|
||||
if (code && code !== ref) {
|
||||
codeEl.textContent = code;
|
||||
codeEl.style.display = "";
|
||||
} else {
|
||||
codeEl.textContent = "";
|
||||
codeEl.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
// t-paliad-177 — link from Verlauf header to standalone chart page.
|
||||
// Wired here (not in the TSX shell) because we need the resolved
|
||||
// project id, which only exists after the detail fetch settles.
|
||||
|
||||
@@ -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ü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ü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ä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ässt sich dort jederzeit ü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ä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ü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 → Klägerseite, Reaktiv → Beklagtenseite. Lässt sich dort jederzeit überschreiben.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
|
||||
@@ -2163,6 +2163,19 @@ export type I18nKey =
|
||||
| "projects.field.billing_reference"
|
||||
| "projects.field.case_number"
|
||||
| "projects.field.client_number"
|
||||
| "projects.field.client_role"
|
||||
| "projects.field.client_role.appellant"
|
||||
| "projects.field.client_role.applicant"
|
||||
| "projects.field.client_role.claimant"
|
||||
| "projects.field.client_role.defendant"
|
||||
| "projects.field.client_role.group.active"
|
||||
| "projects.field.client_role.group.other"
|
||||
| "projects.field.client_role.group.reactive"
|
||||
| "projects.field.client_role.hint"
|
||||
| "projects.field.client_role.other"
|
||||
| "projects.field.client_role.respondent"
|
||||
| "projects.field.client_role.third_party"
|
||||
| "projects.field.client_role.unset"
|
||||
| "projects.field.clientmatter.hint"
|
||||
| "projects.field.collaborators"
|
||||
| "projects.field.collaborators.hint"
|
||||
@@ -2180,13 +2193,21 @@ export type I18nKey =
|
||||
| "projects.field.matter_number"
|
||||
| "projects.field.netdocuments_url"
|
||||
| "projects.field.office"
|
||||
| "projects.field.opponent_code"
|
||||
| "projects.field.opponent_code.hint"
|
||||
| "projects.field.opponent_code.placeholder"
|
||||
| "projects.field.our_side"
|
||||
| "projects.field.our_side.appellant"
|
||||
| "projects.field.our_side.applicant"
|
||||
| "projects.field.our_side.both"
|
||||
| "projects.field.our_side.claimant"
|
||||
| "projects.field.our_side.court"
|
||||
| "projects.field.our_side.defendant"
|
||||
| "projects.field.our_side.hint"
|
||||
| "projects.field.our_side.none"
|
||||
| "projects.field.our_side.other"
|
||||
| "projects.field.our_side.respondent"
|
||||
| "projects.field.our_side.third_party"
|
||||
| "projects.field.our_side.unset"
|
||||
| "projects.field.parent"
|
||||
| "projects.field.parent.hint"
|
||||
|
||||
@@ -50,6 +50,14 @@ export function renderProjectsDetail(): string {
|
||||
<div className="entity-detail-meta">
|
||||
<span id="project-type-chip" className="entity-type-chip" />
|
||||
<span className="entity-ref" id="project-ref-display" />
|
||||
{/* Auto-derived project code (t-paliad-222 / m/paliad#50).
|
||||
Rendered as a separate badge so the user can still
|
||||
distinguish a custom reference (left badge) from a
|
||||
tree-derived code (right badge); when reference is
|
||||
blank, the derived code IS reference and only this
|
||||
badge shows. Hidden via inline style until the
|
||||
client populates it. */}
|
||||
<span className="entity-ref entity-ref-code" id="project-code-display" style="display:none" title="Auto-derived project code" />
|
||||
<span id="project-clientmatter" className="entity-ref" />
|
||||
<span id="project-status-chip" className="entity-status-chip" />
|
||||
<a id="project-netdocs" className="netdocs-link" target="_blank" rel="noopener" style="display:none">netDocuments ↗</a>
|
||||
|
||||
@@ -6793,6 +6793,17 @@ dialog.modal::backdrop {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* Auto-derived project code badge (t-paliad-222 / m/paliad#50).
|
||||
Distinct from the user's manual reference badge — same mono shape,
|
||||
subtly bracketed so the reader knows it's a derived/computed value
|
||||
rather than something typed by hand. Renders only when distinct
|
||||
from the manual reference (see renderHeader in projects-detail.ts). */
|
||||
.entity-ref-code {
|
||||
opacity: 0.75;
|
||||
}
|
||||
.entity-ref-code::before { content: "[ "; }
|
||||
.entity-ref-code::after { content: " ]"; }
|
||||
|
||||
.entity-detail-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
|
||||
2
go.mod
2
go.mod
@@ -8,6 +8,7 @@ require (
|
||||
github.com/jmoiron/sqlx v1.4.0
|
||||
github.com/lib/pq v1.12.3
|
||||
github.com/xuri/excelize/v2 v2.10.1
|
||||
golang.org/x/text v0.34.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -20,5 +21,4 @@ require (
|
||||
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/net v0.50.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
)
|
||||
|
||||
30
internal/db/migrations/112_client_role_rework.down.sql
Normal file
30
internal/db/migrations/112_client_role_rework.down.sql
Normal file
@@ -0,0 +1,30 @@
|
||||
-- Down migration for 112_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;
|
||||
51
internal/db/migrations/112_client_role_rework.up.sql
Normal file
51
internal/db/migrations/112_client_role_rework.up.sql
Normal file
@@ -0,0 +1,51 @@
|
||||
-- mig 112 — 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;
|
||||
11
internal/db/migrations/113_projects_opponent_code.down.sql
Normal file
11
internal/db/migrations/113_projects_opponent_code.down.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
-- Down migration for 113_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;
|
||||
50
internal/db/migrations/113_projects_opponent_code.up.sql
Normal file
50
internal/db/migrations/113_projects_opponent_code.up.sql
Normal file
@@ -0,0 +1,50 @@
|
||||
-- mig 113 — 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;
|
||||
@@ -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 112, 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 113) 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
|
||||
|
||||
312
internal/services/project_code.go
Normal file
312
internal/services/project_code.go
Normal 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, ".")
|
||||
}
|
||||
376
internal/services/project_code_test.go
Normal file
376
internal/services/project_code_test.go
Normal 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 112).
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 112) 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 112) 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 112 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 113): 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++ {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 112, 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", "", ""},
|
||||
}
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user