Compare commits

..

1 Commits

Author SHA1 Message Date
mAi
aa435e5435 fix(verfahrensablauf): m/paliad#59 — restore click-to-edit on timeline dates
Per-rule due dates on /tools/verfahrensablauf were rendered as plain
spans with no `frist-date-edit` attrs and no delegated click handler,
so clicking a date did nothing (m's "the timeline dates seem to be fix,
nothing happens when I click on a date"). The wiring existed on
/tools/fristenrechner but had never been mirrored onto the abstract-
browse surface introduced in t-paliad-179.

Fix: lift the inline date editor + delegated click wiring out of
fristenrechner.ts into views/verfahrensablauf-core.ts so both pages
share one implementation:

  - openInlineDateEditor(span, onCommit) — swaps the date span for
    a `<input type=date>`, commits on blur/Enter, cancels on Escape,
    fires `onCommit(ruleCode, newValue)` ("" = revert).
  - wireDateEditClicks(container, onCommit) — idempotent delegated
    click + keyboard handler that resolves `.frist-date-edit
    [data-rule-code]` and opens the editor. Survives innerHTML
    rewrites because the listener lives on the container.

verfahrensablauf.ts now:
  - Owns its own anchorOverrides Map (cleared when proceeding-type
    changes — overrides for one proceeding don't apply to another).
  - Forwards overrides in calculateDeadlines() so downstream rules
    re-anchor on the user's date.
  - Passes `editable: true` to renderColumnsBody + renderTimelineBody.
  - Calls wireDateEditClicks() once on #timeline-container in
    DOMContentLoaded.

fristenrechner.ts shrinks: openInlineDateEditor + the inline click /
keydown blocks are replaced by an `onDateEditCommit` callback handed
to the shared wireDateEditClicks(). No behaviour change there.

Regression test: views/verfahrensablauf-core.test.ts pins the
editable→`data-rule-code` contract on `deadlineCardHtml` so a future
refactor that drops the attrs fails loudly instead of silently
breaking click-to-edit on both pages.
2026-05-20 14:29:58 +02:00
43 changed files with 197 additions and 3358 deletions

View File

@@ -1,3 +1,6 @@
# Project-specific mai configuration
# Auto-generated by 'mai init' — run 'mai setup' to customize
provider: claude
providers:
claude:
@@ -44,13 +47,21 @@ worker:
name_scheme: role
default_level: standard
auto_discard: false
max_workers: 7
max_workers: 5
persistent: true
head:
name: paliadin
name: "paliadin"
max_loops: 50
infinity_mode: false
max_idle_duration: 2h0m0s
backoff_intervals:
- 5
- 10
- 15
- 30
capacity:
global:
max_workers: 7
max_workers: 5
max_heads: 3
per_worker:
max_tasks_lifetime: 0

View File

@@ -1,686 +0,0 @@
# 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.

View File

@@ -76,15 +76,12 @@ interface FieldSpec {
required?: boolean;
}
// Deadline-only fields rendered in the editable section. `rule_code` and
// `event_type_ids` are intentionally NOT here — they're bundled into the
// dedicated "Verfahrenshandlung" section below the base fields so the
// event-type (parent concept) reads before the rule (m/paliad#56).
const DEADLINE_FIELDS: ReadonlyArray<FieldSpec> = [
{ key: "title", labelKey: "deadlines.field.title", inputType: "text", required: true },
{ key: "due_date", labelKey: "deadlines.field.due", inputType: "date" },
{ key: "original_due_date", labelKey: "approvals.suggest.field.original_due_date", inputType: "date" },
{ key: "warning_date", labelKey: "approvals.suggest.field.warning_date", inputType: "date" },
{ key: "rule_code", labelKey: "approvals.suggest.field.rule_code", inputType: "text" },
{ key: "description", labelKey: "approvals.suggest.field.description", inputType: "textarea" },
{ key: "notes", labelKey: "deadlines.field.notes", inputType: "textarea" },
];
@@ -124,7 +121,7 @@ export async function openApprovalEditModal(
let eventTypePicker: PickerHandle | null = null;
let eventTypePickerLoaded = false;
if (args.entityType === "deadline") {
const pickerSection = renderEventTypePickerSection(original, preImage);
const pickerSection = renderEventTypePickerSection();
body.appendChild(pickerSection.section);
void (async () => {
try {
@@ -194,94 +191,67 @@ function renderFieldsSection(
section.appendChild(h);
for (const f of fields) {
section.appendChild(renderSingleField(f, original, preImage));
const wrap = document.createElement("div");
wrap.className = "form-field approval-suggest-field";
const label = document.createElement("label");
label.textContent = t(f.labelKey as never);
wrap.appendChild(label);
const value = formatFieldForInput(original[f.key], f.inputType);
let input: HTMLInputElement | HTMLTextAreaElement;
if (f.inputType === "textarea") {
input = document.createElement("textarea");
input.rows = 3;
(input as HTMLTextAreaElement).value = value;
} else {
input = document.createElement("input");
(input as HTMLInputElement).type = f.inputType;
(input as HTMLInputElement).value = value;
}
input.dataset.suggestField = f.key;
input.dataset.suggestOriginal = value;
input.dataset.suggestInputType = f.inputType;
if (f.required) input.required = true;
// Wire the <label> to focus the <input> on click.
const inputID = `suggest-field-${f.key}`;
input.id = inputID;
label.setAttribute("for", inputID);
wrap.appendChild(input);
// "Vorher" hint when pre_image carries a distinct value for this field.
const preVal = formatFieldForInput(preImage[f.key], f.inputType);
if (preVal && preVal !== value) {
const hint = document.createElement("span");
hint.className = "approval-suggest-prehint";
hint.textContent = `${t("approvals.diff.before")}: ${preVal}`;
wrap.appendChild(hint);
}
section.appendChild(wrap);
}
return section;
}
// Verfahrenshandlung section — bundles the event-type picker and the
// rule_code input so the editor reads "what procedural step? which rule
// cites it?" instead of two disconnected fields with rule above type
// (m/paliad#56). The hint underneath spells out the parent/child
// relationship so first-time editors don't read them as peers.
function renderEventTypePickerSection(
original: Record<string, unknown>,
preImage: Record<string, unknown>,
): { section: HTMLElement; host: HTMLElement } {
function renderEventTypePickerSection(): { section: HTMLElement; host: HTMLElement } {
const section = document.createElement("section");
section.className = "approval-suggest-section approval-suggest-section--editable";
const h = document.createElement("h3");
h.className = "approval-suggest-section-title";
h.textContent = t("approvals.suggest.section.event_type_rule");
h.textContent = t("deadlines.field.event_type");
section.appendChild(h);
const host = document.createElement("div");
host.className = "approval-suggest-event-type-picker";
section.appendChild(host);
// Rule citation — rendered as a sub-field directly beneath the picker so
// the visual hierarchy matches the conceptual one (rule is meta on the
// event type, not a peer).
const ruleField: FieldSpec = {
key: "rule_code",
labelKey: "approvals.suggest.field.rule_code",
inputType: "text",
};
section.appendChild(renderSingleField(ruleField, original, preImage));
return { section, host };
}
// renderSingleField builds one labelled input in the same shape as the
// fields-section loop. Extracted so the Verfahrenshandlung section can
// host the rule_code input next to the picker without duplicating the
// wiring (dirty-tracking, pre_image hint, label/for binding).
function renderSingleField(
f: FieldSpec,
original: Record<string, unknown>,
preImage: Record<string, unknown>,
): HTMLElement {
const wrap = document.createElement("div");
wrap.className = "form-field approval-suggest-field";
const label = document.createElement("label");
label.textContent = t(f.labelKey as never);
wrap.appendChild(label);
const value = formatFieldForInput(original[f.key], f.inputType);
let input: HTMLInputElement | HTMLTextAreaElement;
if (f.inputType === "textarea") {
input = document.createElement("textarea");
input.rows = 3;
(input as HTMLTextAreaElement).value = value;
} else {
input = document.createElement("input");
(input as HTMLInputElement).type = f.inputType;
(input as HTMLInputElement).value = value;
}
input.dataset.suggestField = f.key;
input.dataset.suggestOriginal = value;
input.dataset.suggestInputType = f.inputType;
if (f.required) input.required = true;
const inputID = `suggest-field-${f.key}`;
input.id = inputID;
label.setAttribute("for", inputID);
wrap.appendChild(input);
const preVal = formatFieldForInput(preImage[f.key], f.inputType);
if (preVal && preVal !== value) {
const hint = document.createElement("span");
hint.className = "approval-suggest-prehint";
hint.textContent = `${t("approvals.diff.before")}: ${preVal}`;
wrap.appendChild(hint);
}
return wrap;
}
function renderContextSection(
args: ApprovalEditModalArgs,
original: Record<string, unknown>,

View File

@@ -125,11 +125,8 @@ const STATUS_OPTIONS_DEADLINE: StatusOption[] = [
{ value: "completed", key: "deadlines.filter.completed" },
];
// Appointment status options — m/paliad#54: the legacy 'upcoming' /
// "Ab heute" option was a UI lie (backend never narrowed past events for
// appointments) and is removed. 'today' is the sane default — matches the
// dashboard tile. 'all' stays as the explicit opt-in for past events.
const STATUS_OPTIONS_APPOINTMENT: StatusOption[] = [
{ value: "upcoming", key: "events.filter.status.upcoming" },
{ value: "today", key: "deadlines.filter.today" },
{ value: "this_week", key: "deadlines.filter.thisweek" },
{ value: "next_week", key: "deadlines.filter.nextweek" },
@@ -143,7 +140,7 @@ function statusOptionsFor(type: EventTypeChoice): StatusOption[] {
}
function defaultStatusFor(type: EventTypeChoice): string {
return type === "appointment" ? "today" : "pending";
return type === "appointment" ? "upcoming" : "pending";
}
let currentType: EventTypeChoice = "deadline";

View File

@@ -187,21 +187,12 @@ 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 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
// 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
// pre-selection and the user can still click another chip to
// override. NULL/undefined leaves the chip unset (free-pick).
our_side?:
| "claimant"
| "defendant"
| "applicant"
| "appellant"
| "respondent"
| "third_party"
| "other"
| null;
our_side?: "claimant" | "defendant" | "court" | "both" | null;
}
async function fetchProjects(): Promise<ProjectOption[]> {
@@ -260,19 +251,6 @@ function closeSaveModal() {
if (modal) modal.style.display = "none";
}
// preselectedProjectId returns the project the user picked in Step 1
// (if any) so the various save/add flows can default their project
// pickers to it. Carries through anywhere a "save to Akte" pop-out
// renders \u2014 preselection is *only* a default; the picker still
// renders every available project and the user can override.
// m/paliad#57 part 1: 2026-05-20 user complaint \u2014 "the pre-selected
// project should be pre-selected" on Add.
function preselectedProjectId(): string {
return currentStep1Context.kind === "project" && currentStep1Context.projectId
? currentStep1Context.projectId
: "";
}
async function openSaveModal() {
if (!lastResponse) return;
ensureSaveModal();
@@ -289,7 +267,6 @@ async function openSaveModal() {
sel.style.display = "";
noProjects.style.display = "none";
submit.disabled = false;
const preselected = preselectedProjectId();
sel.innerHTML = projects
.map((p) => {
const ref = (p.reference || "").trim();
@@ -297,11 +274,9 @@ async function openSaveModal() {
const label = ref
? `${indent}${escHtml(ref)} \u2014 ${escHtml(p.title)}`
: `${indent}${escHtml(p.title)}`;
const selected = p.id === preselected ? " selected" : "";
return `<option value="${escAttr(p.id)}"${selected}>${label}</option>`;
return `<option value="${escAttr(p.id)}">${label}</option>`;
})
.join("");
if (preselected) sel.value = preselected;
}
const list = document.getElementById("frist-save-list")!;
@@ -1285,27 +1260,19 @@ function expandCardCalc(card: HTMLElement, autoSelectPill: HTMLElement | null) {
card.classList.add("is-expanded");
card.setAttribute("aria-expanded", "true");
// m/paliad#57 part 4: when the user clicked a specific rule pill, the
// context is already known — the calc panel renders with that pill
// locked in and no "Which context?" picker. The card's pill list is
// hidden via CSS while is-expanded so the rules aren't listed twice.
// When the user clicked the card body (no autoSelectPill), the picker
// is the primary surface — still no duplicate pill list above it.
const lockedPill = (autoSelectPill && autoSelectPill.dataset.kind === "rule")
? rulePills.find((p) =>
p.proceeding?.code === autoSelectPill.dataset.proc
&& (autoSelectPill.dataset.focus
? p.rule_local_code === autoSelectPill.dataset.focus
: true))
: undefined;
const panel = buildCalcPanel(cardData, rulePills, lockedPill || null);
const panel = buildCalcPanel(cardData, rulePills);
card.appendChild(panel);
// Auto-select the clicked pill if it's a rule pill; otherwise the
// first pill is preselected by buildCalcPanel.
if (autoSelectPill && autoSelectPill.dataset.kind === "rule") {
selectCalcPill(card, autoSelectPill.dataset.proc, autoSelectPill.dataset.focus);
}
scheduleCardCalc(card);
}
function buildCalcPanel(_cardData: SearchCard, rulePills: SearchPill[], lockedPill: SearchPill | null = null): HTMLElement {
function buildCalcPanel(_cardData: SearchCard, rulePills: SearchPill[]): HTMLElement {
const panel = document.createElement("div");
panel.className = "fristen-card-calc";
// stopPropagation so clicks inside the panel don't bubble to the
@@ -1316,38 +1283,10 @@ function buildCalcPanel(_cardData: SearchCard, rulePills: SearchPill[], lockedPi
const lang = getLang();
const today = new Date().toISOString().split("T")[0];
// Picker semantics (m/paliad#57 part 4):
// - lockedPill set → context known (user clicked a specific
// rule pill on the card). Render as a
// hidden input only; the calc panel shows
// no "Which context?" question. A small
// "ändern" link reopens the picker fieldset.
// - rulePills.length <= 1 → only one possible context, never a
// picker (hidden input carries the data).
// - otherwise → show the picker as primary surface; the
// card's pill list is hidden via CSS while
// the panel is open, so the user isn't
// asked the same thing twice.
let pickerHtml: string;
if (lockedPill) {
const procName = lockedPill.proceeding
? (lang === "en" && lockedPill.proceeding.name_en ? lockedPill.proceeding.name_en : lockedPill.proceeding.name_de)
: "";
const ruleName = lang === "en" && lockedPill.rule_name_en ? lockedPill.rule_name_en : lockedPill.rule_name_de;
const src = lockedPill.legal_source_display || lockedPill.legal_source || "";
const reopenLabel = t("deadlines.card.calc.pill_picker.change");
pickerHtml = `<div class="fristen-card-calc-pill-locked">
<span class="fristen-card-calc-pill-locked-label">${escHtml(t("deadlines.card.calc.pill_picker.locked_label"))}</span>
<span class="fristen-card-calc-pill-locked-proc">${escHtml(procName)}</span>
<span class="fristen-card-calc-pill-locked-rule">${escHtml(ruleName)}</span>
${src ? `<span class="fristen-card-calc-pill-locked-source">${escHtml(src)}</span>` : ""}
${rulePills.length > 1 ? `<button type="button" class="fristen-card-calc-pill-change">${escHtml(reopenLabel)}</button>` : ""}
<input type="hidden" class="fristen-card-calc-pill-picker" data-proc="${escAttr(lockedPill.proceeding?.code || "")}" data-focus="${escAttr(lockedPill.rule_local_code || "")}" />
</div>`;
} else if (rulePills.length <= 1) {
pickerHtml = `<input type="hidden" class="fristen-card-calc-pill-picker" data-proc="${escAttr(rulePills[0].proceeding?.code || "")}" data-focus="${escAttr(rulePills[0].rule_local_code || "")}" />`;
} else {
pickerHtml = `<fieldset class="fristen-card-calc-pill-picker" role="radiogroup">
// Pill picker (only when >1 rule pill).
const pickerHtml = rulePills.length <= 1
? `<input type="hidden" class="fristen-card-calc-pill-picker" data-proc="${escAttr(rulePills[0].proceeding?.code || "")}" data-focus="${escAttr(rulePills[0].rule_local_code || "")}" />`
: `<fieldset class="fristen-card-calc-pill-picker" role="radiogroup">
<legend class="fristen-card-calc-label">${escHtml(t("deadlines.card.calc.pill_picker.label"))}</legend>
${rulePills.map((p, i) => {
const procName = p.proceeding ? (lang === "en" && p.proceeding.name_en ? p.proceeding.name_en : p.proceeding.name_de) : "";
@@ -1361,7 +1300,6 @@ function buildCalcPanel(_cardData: SearchCard, rulePills: SearchPill[], lockedPi
</label>`;
}).join("")}
</fieldset>`;
}
panel.innerHTML = `
<button type="button" class="fristen-card-calc-close" aria-label="${escAttr(t("deadlines.card.calc.close"))}">×</button>
@@ -1414,38 +1352,6 @@ function buildCalcPanel(_cardData: SearchCard, rulePills: SearchPill[], lockedPi
void addCalcToProject(card, last);
});
// "ändern" — swap the locked-context caption for the full radio
// picker so the user can change context without collapsing the panel.
panel.querySelector<HTMLButtonElement>(".fristen-card-calc-pill-change")?.addEventListener("click", () => {
const card = panel.closest<HTMLElement>(".fristen-card");
const locked = panel.querySelector<HTMLElement>(".fristen-card-calc-pill-locked");
if (!card || !locked) return;
const fieldset = document.createElement("fieldset");
fieldset.className = "fristen-card-calc-pill-picker";
fieldset.setAttribute("role", "radiogroup");
const lockedProc = locked.querySelector<HTMLInputElement>("input.fristen-card-calc-pill-picker")?.dataset.proc || "";
const lockedFocus = locked.querySelector<HTMLInputElement>("input.fristen-card-calc-pill-picker")?.dataset.focus || "";
fieldset.innerHTML = `
<legend class="fristen-card-calc-label">${escHtml(t("deadlines.card.calc.pill_picker.label"))}</legend>
${rulePills.map((p, i) => {
const procName = p.proceeding ? (lang === "en" && p.proceeding.name_en ? p.proceeding.name_en : p.proceeding.name_de) : "";
const ruleName = lang === "en" && p.rule_name_en ? p.rule_name_en : p.rule_name_de;
const src = p.legal_source_display || p.legal_source || "";
const isChecked = (p.proceeding?.code || "") === lockedProc
&& (p.rule_local_code || "") === lockedFocus;
return `<label class="fristen-card-calc-pill-option">
<input type="radio" name="fristen-card-calc-pill" value="${i}" ${isChecked ? "checked" : ""} data-proc="${escAttr(p.proceeding?.code || "")}" data-focus="${escAttr(p.rule_local_code || "")}" />
<span class="fristen-card-calc-pill-option-proc">${escHtml(procName)}</span>
<span class="fristen-card-calc-pill-option-rule">${escHtml(ruleName)}</span>
${src ? `<span class="fristen-card-calc-pill-option-source">${escHtml(src)}</span>` : ""}
</label>`;
}).join("")}`;
locked.replaceWith(fieldset);
fieldset.querySelectorAll<HTMLInputElement>('input[name="fristen-card-calc-pill"]').forEach((r) => {
r.addEventListener("change", () => scheduleCardCalc(card, 0));
});
});
return panel;
}
@@ -1649,7 +1555,6 @@ async function addCalcToProject(card: HTMLElement, calc: RuleCalcResponse) {
const lang = getLang();
const ruleName = lang === "en" ? calc.rule.nameEN : calc.rule.nameDE;
const dueLabel = formatDate(calc.dueDate);
const preselected = preselectedProjectId();
msgEl.innerHTML = `
<div class="fristen-card-calc-add-picker">
<label class="fristen-card-calc-label">${escHtml(t("deadlines.save.modal.akte"))}
@@ -1658,8 +1563,7 @@ async function addCalcToProject(card: HTMLElement, calc: RuleCalcResponse) {
const ref = (p.reference || "").trim();
const indent = projectIndent(p.path);
const label = ref ? `${indent}${ref}${p.title}` : `${indent}${p.title}`;
const selected = p.id === preselected ? " selected" : "";
return `<option value="${escAttr(p.id)}"${selected}>${escHtml(label)}</option>`;
return `<option value="${escAttr(p.id)}">${escHtml(label)}</option>`;
}).join("")}
</select>
</label>
@@ -1669,7 +1573,6 @@ async function addCalcToProject(card: HTMLElement, calc: RuleCalcResponse) {
`;
const sel = msgEl.querySelector<HTMLSelectElement>(".fristen-card-calc-add-select")!;
if (preselected) sel.value = preselected;
msgEl.querySelector<HTMLButtonElement>(".fristen-card-calc-add-cancel")!.addEventListener("click", () => {
msgEl.innerHTML = "";
addBtn.disabled = false;
@@ -1739,12 +1642,12 @@ function renderConceptCard(card: SearchCard, lang: "de" | "en"): string {
const triggerPills = card.pills.filter((p) => p.kind === "trigger");
const ruleSection = rulePills.length === 0 ? "" : `
<div class="fristen-card-pills-section fristen-card-pills-section--rules">
<div class="fristen-card-pills-section">
<h4 class="fristen-card-pills-heading">${escHtml(t("deadlines.search.pills.heading"))}</h4>
<div class="fristen-card-pills">${rulePills.map((p) => renderPill(p, lang)).join("")}</div>
</div>`;
const triggerSection = triggerPills.length === 0 ? "" : `
<div class="fristen-card-pills-section fristen-card-pills-section--cross">
<div class="fristen-card-pills-section">
<h4 class="fristen-card-pills-heading">${escHtml(t("deadlines.search.pills.cross_cutting"))}</h4>
<div class="fristen-card-pills">${triggerPills.map((p) => renderPill(p, lang)).join("")}</div>
</div>`;
@@ -2520,17 +2423,6 @@ interface EventCategoryNode {
let eventCategoryTree: EventCategoryNode[] | null = null;
let eventCategoryFetchInflight: Promise<EventCategoryNode[]> | null = null;
// Top-level cascade roots that represent forward-looking workflows ("I
// want to file X, what deadlines does my action trigger?") rather than
// the backward-looking calc the Fristenrechner is built for ("event Y
// happened, what deadlines spawn?"). m's 2026-05-20 ask (m/paliad#57):
// remove these from the "Was ist passiert?" picker — they belong in a
// future forward-workflow tool, not here. The DB rows stay so that
// future tool can pick them back up; we just hide them at the UI layer.
const HIDDEN_CASCADE_ROOTS: ReadonlySet<string> = new Set([
"ich-moechte-einreichen",
]);
async function loadEventCategoryTree(): Promise<EventCategoryNode[]> {
if (eventCategoryTree) return eventCategoryTree;
if (eventCategoryFetchInflight) return eventCategoryFetchInflight;
@@ -2539,8 +2431,7 @@ async function loadEventCategoryTree(): Promise<EventCategoryNode[]> {
const r = await fetch("/api/tools/fristenrechner/event-categories");
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const data = await r.json();
const raw = (data.tree || []) as EventCategoryNode[];
eventCategoryTree = raw.filter((n) => !HIDDEN_CASCADE_ROOTS.has(n.slug));
eventCategoryTree = (data.tree || []) as EventCategoryNode[];
return eventCategoryTree;
} finally {
eventCategoryFetchInflight = null;
@@ -3810,30 +3701,14 @@ function applyPerspective(p: Perspective) {
triggerCascadeRefresh();
}
// 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.
// 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.
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;
}
if (os === "claimant") return "claimant";
if (os === "defendant") return "defendant";
return null;
}
// applyOurSidePredefine locks the perspective from project.our_side

View File

@@ -272,10 +272,10 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.step1.divider.new": "oder eine neue Akte",
"deadlines.step1.divider.adhoc": "oder ad-hoc, ohne Akte",
"deadlines.step1.new.cta": "+ Neue Akte anlegen",
"deadlines.step1.adhoc.upc": "UPC-Verfahren",
"deadlines.step1.adhoc.de": "DE-Verfahren",
"deadlines.step1.adhoc.epa": "EPA-Verfahren",
"deadlines.step1.adhoc.dpma": "DPMA-Verfahren",
"deadlines.step1.adhoc.upc": "Custom UPC-Verfahren",
"deadlines.step1.adhoc.de": "Custom DE-Verfahren",
"deadlines.step1.adhoc.epa": "Custom EPA-Verfahren",
"deadlines.step1.adhoc.dpma": "Custom DPMA-Verfahren",
"deadlines.step1.selected": "Akte:",
"deadlines.step1.reselect": "Andere Akte",
"deadlines.step1.summary.adhoc.suffix": "ohne Akte (Erkundung)",
@@ -345,8 +345,6 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.card.calc.expand_hint": "Frist berechnen oder zu Akte hinzufügen",
"deadlines.card.calc.close": "schließen",
"deadlines.card.calc.pill_picker.label": "Welcher Kontext?",
"deadlines.card.calc.pill_picker.locked_label": "Kontext:",
"deadlines.card.calc.pill_picker.change": "ändern",
"deadlines.card.calc.trigger.label": "Datum des auslösenden Ereignisses",
"deadlines.card.calc.flags.label": "Bedingungen:",
"deadlines.card.calc.flag.with_ccr": "Mit Nichtigkeitswiderklage",
@@ -1210,30 +1208,9 @@ 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:",
@@ -1428,7 +1405,6 @@ const translations: Record<Lang, Record<string, string>> = {
"projects.type.patent": "Patent",
"projects.type.case": "Verfahren",
"projects.type.project": "Projekt",
"projects.type.other": "Sonstiges",
"projects.team.role.lead": "Leitung",
"projects.team.role.associate": "Associate",
"projects.team.role.pa": "PA",
@@ -1436,15 +1412,10 @@ const translations: Record<Lang, Record<string, string>> = {
"projects.team.role.local_counsel": "Local Counsel",
"projects.team.role.expert": "Experte",
"projects.team.role.observer": "Beobachter",
"projects.team.responsibility.admin": "Admin",
"projects.team.responsibility.admin.hint": "Kann Team und Rollen auf diesem Projekt und Unterprojekten verwalten",
"projects.team.responsibility.lead": "Leitung",
"projects.team.responsibility.member": "Mitglied",
"projects.team.responsibility.observer": "Beobachter",
"projects.team.responsibility.external": "Extern",
"projects.team.error.last_admin": "Mindestens ein Admin muss auf diesem Projekt oder einem übergeordneten verbleiben.",
"projects.team.error.forbidden": "Diese Aktion ist nicht erlaubt.",
"projects.team.error.generic": "Aktion fehlgeschlagen.",
"projects.team.profession.partner": "Partner",
"projects.team.profession.of_counsel": "Of Counsel",
"projects.team.profession.associate": "Associate",
@@ -1494,7 +1465,6 @@ const translations: Record<Lang, Record<string, string>> = {
"projects.chip.type.patent": "Patent",
"projects.chip.type.case": "Verfahren",
"projects.chip.type.project": "Projekt",
"projects.chip.type.other": "Sonstiges",
"projects.chip.multi.none": "Keine Auswahl",
"projects.chip.multi.count": "{n} ausgew\u00e4hlt",
"projects.empty.filtered.action": "Filter zur\u00fccksetzen",
@@ -1837,14 +1807,6 @@ const translations: Record<Lang, Record<string, string>> = {
"team.filter.project.all": "Alle Projekte",
"team.filter.project.selected": "ausgewählt",
"team.filter.project.clear": "Alle abwählen",
// Click-to-select (t-paliad-223 #53). Layered ON TOP of the existing
// filter pills — selection is an explicit subset of the visible set,
// pruned on filter change, wiped on page navigation.
"team.selection.count": "{n} ausgewählt",
"team.selection.clear": "Auswahl aufheben",
"team.selection.send": "E-Mail an Auswahl",
"team.selection.select_all": "Alle sichtbaren auswählen",
"team.selection.toggle_card": "Kontakt auswählen",
// Broadcast modal (t-paliad-147)
"team.broadcast.button": "E-Mail an Auswahl",
"team.broadcast.title": "E-Mail an Auswahl",
@@ -2331,7 +2293,6 @@ const translations: Record<Lang, Record<string, string>> = {
"approvals.suggest.next_request_link": "→ Neuer Vorschlag von {name}",
"approvals.suggest.unsupported_lifecycle": "Änderungen vorschlagen ist nur für Update-Anfragen möglich.",
"approvals.suggest.section.editable": "Felder",
"approvals.suggest.section.event_type_rule": "Verfahrenshandlung (Typ + Regel)",
"approvals.suggest.section.context": "Kontext",
"approvals.suggest.context.project": "Projekt",
"approvals.suggest.context.requester": "Eingereicht von",
@@ -2999,10 +2960,10 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.step1.divider.new": "or a new matter",
"deadlines.step1.divider.adhoc": "or ad-hoc, without a matter",
"deadlines.step1.new.cta": "+ Create new matter",
"deadlines.step1.adhoc.upc": "UPC proceeding",
"deadlines.step1.adhoc.de": "DE proceeding",
"deadlines.step1.adhoc.epa": "EPA proceeding",
"deadlines.step1.adhoc.dpma": "DPMA proceeding",
"deadlines.step1.adhoc.upc": "Custom UPC proceeding",
"deadlines.step1.adhoc.de": "Custom DE proceeding",
"deadlines.step1.adhoc.epa": "Custom EPA proceeding",
"deadlines.step1.adhoc.dpma": "Custom DPMA proceeding",
"deadlines.step1.selected": "Matter:",
"deadlines.step1.reselect": "Other matter",
"deadlines.step1.summary.adhoc.suffix": "no matter (exploration)",
@@ -3079,8 +3040,6 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.card.calc.expand_hint": "Calculate deadline or add to project",
"deadlines.card.calc.close": "close",
"deadlines.card.calc.pill_picker.label": "Which context?",
"deadlines.card.calc.pill_picker.locked_label": "Context:",
"deadlines.card.calc.pill_picker.change": "change",
"deadlines.card.calc.trigger.label": "Date of triggering event",
"deadlines.card.calc.flags.label": "Conditions:",
"deadlines.card.calc.flag.with_ccr": "With counterclaim for revocation",
@@ -3924,30 +3883,9 @@ 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:",
@@ -4141,7 +4079,6 @@ const translations: Record<Lang, Record<string, string>> = {
"projects.type.patent": "Patent",
"projects.type.case": "Case",
"projects.type.project": "Project",
"projects.type.other": "Other",
"projects.team.role.lead": "Lead",
"projects.team.role.associate": "Associate",
"projects.team.role.pa": "PA",
@@ -4149,15 +4086,10 @@ const translations: Record<Lang, Record<string, string>> = {
"projects.team.role.local_counsel": "Local Counsel",
"projects.team.role.expert": "Expert",
"projects.team.role.observer": "Observer",
"projects.team.responsibility.admin": "Admin",
"projects.team.responsibility.admin.hint": "Can manage team and roles on this project and its sub-projects",
"projects.team.responsibility.lead": "Lead",
"projects.team.responsibility.member": "Member",
"projects.team.responsibility.observer": "Observer",
"projects.team.responsibility.external": "External",
"projects.team.error.last_admin": "At least one admin must remain on this project or an ancestor.",
"projects.team.error.forbidden": "This action is not permitted.",
"projects.team.error.generic": "Action failed.",
"projects.team.profession.partner": "Partner",
"projects.team.profession.of_counsel": "Of Counsel",
"projects.team.profession.associate": "Associate",
@@ -4207,7 +4139,6 @@ const translations: Record<Lang, Record<string, string>> = {
"projects.chip.type.patent": "Patent",
"projects.chip.type.case": "Case",
"projects.chip.type.project": "Project",
"projects.chip.type.other": "Other",
"projects.chip.multi.none": "Nothing selected",
"projects.chip.multi.count": "{n} selected",
"projects.empty.filtered.action": "Reset filters",
@@ -4547,12 +4478,6 @@ const translations: Record<Lang, Record<string, string>> = {
"team.filter.project.all": "All projects",
"team.filter.project.selected": "selected",
"team.filter.project.clear": "Deselect all",
// Click-to-select (t-paliad-223 #53).
"team.selection.count": "{n} selected",
"team.selection.clear": "Clear selection",
"team.selection.send": "Email selection",
"team.selection.select_all": "Select all visible",
"team.selection.toggle_card": "Select contact",
// Broadcast modal (t-paliad-147)
"team.broadcast.button": "Email selection",
"team.broadcast.title": "Email selection",
@@ -5039,7 +4964,6 @@ const translations: Record<Lang, Record<string, string>> = {
"approvals.suggest.next_request_link": "→ New suggestion by {name}",
"approvals.suggest.unsupported_lifecycle": "Suggest changes is only available for update requests.",
"approvals.suggest.section.editable": "Fields",
"approvals.suggest.section.event_type_rule": "Event type + rule",
"approvals.suggest.section.context": "Context",
"approvals.suggest.context.project": "Project",
"approvals.suggest.context.requester": "Submitted by",

View File

@@ -8,11 +8,6 @@ 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 {
@@ -53,11 +48,9 @@ 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";
@@ -95,28 +88,18 @@ export function initParentPicker() {
}
const matches = parentCandidates
.filter((p) => {
// 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();
const hay = (p.title + " " + (p.reference || "")).toLowerCase();
return hay.includes(q);
})
.slice(0, 8);
sugs.innerHTML = matches
.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)}">
.map(
(p) =>
`<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>
${codeBadge}
</div>`;
})
</div>`,
)
.join("");
sugs.querySelectorAll<HTMLDivElement>(".collab-suggestion").forEach((el) => {
el.addEventListener("click", () => {
@@ -191,32 +174,20 @@ 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");
}
// 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 = "";
}
// 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 = "";
}
const desc = ($("project-description") as HTMLTextAreaElement).value.trim();
@@ -257,8 +228,6 @@ export function prefillForm(p: Record<string, unknown>) {
get("project-case-number").value = String(p.case_number ?? "");
const osSel = tryGet("project-our-side") as HTMLSelectElement | null;
if (osSel) osSel.value = String(p.our_side ?? "");
const ocEl = tryGet("project-opponent-code") as HTMLInputElement | null;
if (ocEl) ocEl.value = String(p.opponent_code ?? "");
getTA("project-description").value = String(p.description ?? "");
getSel("project-status").value = String(p.status ?? "active");
}

View File

@@ -21,12 +21,6 @@ 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;
@@ -40,12 +34,6 @@ interface Project {
grant_date?: string | null;
court?: string | null;
case_number?: string | null;
// t-paliad-223: piggybacked onto the GET /api/projects/{id} payload so
// the team panel can render an inline <select> for callers who can
// change responsibilities (global_admin or effective_project_admin on
// this project / ancestor). Optional for back-compat with cached
// payloads.
effective_admin?: boolean;
updated_at: string;
created_at: string;
}
@@ -1101,24 +1089,6 @@ 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.
@@ -2524,11 +2494,6 @@ function renderTeam() {
}
empty.style.display = "none";
// t-paliad-223: callers with effective_project_admin authority see an
// inline <select> on the Rolle cell. Everyone else sees the read-only
// <span>. The bool comes from the GET /api/projects/{id} payload.
const canEditResponsibility = !!project?.effective_admin;
body.innerHTML = teamMembers
.map((m) => {
// t-paliad-148: profession is firm-wide (read-only badge) and
@@ -2554,20 +2519,11 @@ function renderTeam() {
: "";
const officeLabel = m.user_office ? tDyn("office." + m.user_office) || m.user_office : "";
const profCls = m.user_profession ? "projekt-team-profession" : "projekt-team-profession projekt-team-profession--none";
// Inline-select only on direct rows where the caller can edit.
// Inherited rows stay read-only — the edit must happen at the
// ancestor where the row is direct.
const responsibilityCell =
canEditResponsibility && !m.inherited
? renderResponsibilitySelect(m.user_id, responsibility)
: `<span class="projekt-team-responsibility">${esc(responsibilityLabel)}</span>`;
return `<tr>
<td><strong>${esc(m.user_display_name || m.user_email)}</strong>
<span class="form-hint">&middot; ${esc(m.user_email)}${officeLabel ? " &middot; " + esc(officeLabel) : ""}</span></td>
<td><span class="${profCls}" title="${escAttr(professionTitle)}">${esc(professionLabel)}</span></td>
<td>${responsibilityCell}</td>
<td><span class="projekt-team-responsibility">${esc(responsibilityLabel)}</span></td>
<td>${source}</td>
<td>${removeBtn}</td>
</tr>`;
@@ -2586,47 +2542,6 @@ function renderTeam() {
if (resp.ok) {
await loadTeam(project.id);
renderTeam();
} else {
await showTeamErrorToast(resp);
}
});
});
body.querySelectorAll<HTMLSelectElement>(".team-responsibility-select").forEach((sel) => {
// Capture the pre-change value on focus so we can roll back the
// <select> if the PATCH fails (e.g. last-admin guard).
sel.dataset.previous = sel.value;
sel.addEventListener("focus", () => {
sel.dataset.previous = sel.value;
});
sel.addEventListener("change", async () => {
if (!project) return;
const userID = sel.dataset.userId!;
const previous = sel.dataset.previous || "member";
const next = sel.value;
if (next === previous) return;
sel.disabled = true;
try {
const resp = await fetch(
`/api/projects/${project.id}/team/${encodeURIComponent(userID)}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ responsibility: next }),
},
);
if (!resp.ok) {
sel.value = previous;
await showTeamErrorToast(resp);
return;
}
sel.dataset.previous = next;
// Refresh the team list so derived/descendant sections re-render
// with the new authority shape.
await loadTeam(project.id);
renderTeam();
} finally {
sel.disabled = false;
}
});
});
@@ -2810,54 +2725,7 @@ function wireExportButton(projectID: string): void {
function canRemoveTeamMember(m: ProjectTeamMember): boolean {
if (!me) return false;
if (m.user_id === me.id) return true;
if (me.global_role === "global_admin") return true;
// t-paliad-223: effective_project_admin (from the project payload)
// also covers remove. RLS makes the request fail anyway if the bit is
// stale; this just hides the affordance.
return !!project?.effective_admin;
}
// t-paliad-223: build the inline <select> for the responsibility cell.
// Options mirror the IsValidResponsibility set in approval_levels.go.
function renderResponsibilitySelect(userID: string, current: string): string {
const options = ["admin", "lead", "member", "observer", "external"]
.map((v) => {
const label = tDyn(`projects.team.responsibility.${v}`) || v;
const sel = v === current ? " selected" : "";
return `<option value="${esc(v)}"${sel}>${esc(label)}</option>`;
})
.join("");
return `<select class="team-responsibility-select projekt-team-responsibility" data-user-id="${esc(userID)}">${options}</select>`;
}
// t-paliad-223: surface backend error responses (last-admin guard / 403
// from RLS / etc.) as a transient toast. We have no global toast service
// yet on this page, so write into #team-msg.
async function showTeamErrorToast(resp: Response): Promise<void> {
const msg = document.getElementById("team-msg") as HTMLParagraphElement | null;
if (!msg) return;
let text = "";
try {
const data = (await resp.json()) as { error?: string };
text = data?.error || "";
} catch {
text = "";
}
if (!text) {
if (resp.status === 409) text = t("projects.team.error.last_admin") || "Mindestens ein Admin muss auf diesem Projekt oder einem übergeordneten verbleiben.";
else if (resp.status === 403 || resp.status === 404) text = t("projects.team.error.forbidden") || "Diese Aktion ist nicht erlaubt.";
else text = t("projects.team.error.generic") || "Aktion fehlgeschlagen.";
}
msg.textContent = text;
msg.classList.add("form-msg--error");
// Auto-clear after 5s so a stale error doesn't linger past the next
// successful action.
window.setTimeout(() => {
if (msg.textContent === text) {
msg.textContent = "";
msg.classList.remove("form-msg--error");
}
}, 5000);
return me.global_role === "global_admin";
}
function initTeamForm(id: string) {

View File

@@ -77,25 +77,6 @@ let activeRole = "all";
let activeProjectIDs: Set<string> = new Set();
let searchQuery = "";
// t-paliad-223 (#53) — explicit click-to-select layer ON TOP of the existing
// filter pills. When selection.size > 0 the sticky footer takes over the
// broadcast action and targets only the explicit subset; with empty
// selection the existing top-bar broadcast button still targets the whole
// filter result (purely additive).
//
// Invariant: selection only ever holds user_ids that match the current
// filter set — render() prunes drop-outs every cycle. This keeps the
// counter honest and avoids "hidden-but-selected" debug nightmares.
const selectedUserIDs: Set<string> = new Set();
// For Shift-click range select — the user_id of the most recent toggle
// in the currently-rendered list order. Reset to null on any filter
// change so the range never spans an invisible row.
let lastToggledUserID: string | null = null;
// Snapshot of the rendered user-IDs in DOM order, refreshed on each render.
// Drives Shift-click range expansion and the master-checkbox "select all
// visible" action.
let renderedUserIDs: string[] = [];
const ICON_MAIL = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>';
const ICON_PIN = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>';
@@ -422,17 +403,8 @@ function memberAsUser(m: DepartmentMember): User | undefined {
function renderUserCard(u: User): string {
const additional = (u.additional_offices ?? []).filter((o) => o !== u.office);
const jobTitle = (u.job_title ?? "").trim();
// t-paliad-223 (#53): per-row select-checkbox. Wrapped in a label so a
// click on the checkbox cell triggers the toggle; the rest of the card
// (links, email, etc.) keeps its native behaviour. Selection state
// mirrored to data-selected so the CSS can highlight the card.
const selected = selectedUserIDs.has(u.id);
const selectAria = t("team.selection.toggle_card") || "Kontakt auswählen";
return `
<article class="team-card" data-user-id="${esc(u.id)}" data-selected="${selected ? "true" : "false"}">
<label class="team-card-select" title="${escAttr(selectAria)}">
<input type="checkbox" class="team-card-select-input" data-user-id="${esc(u.id)}"${selected ? " checked" : ""} aria-label="${escAttr(selectAria)}" />
</label>
<article class="team-card">
<div class="team-avatar" aria-hidden="true">${esc(initials(u.display_name))}</div>
<div class="team-card-body">
<div class="team-card-name">${esc(u.display_name)}</div>
@@ -446,13 +418,6 @@ function renderUserCard(u: User): string {
</article>`;
}
// escAttr is the attribute-context counterpart of esc. Used in title=""
// + aria-label="" where esc()'s div-textContent trick is fine but
// double-quote-escaping is the bit we actually need.
function escAttr(s: string): string {
return esc(s).replace(/"/g, "&quot;");
}
function renderGroupByOffice(filtered: User[]): string {
const present = presentOffices();
const sections = present
@@ -540,22 +505,12 @@ function render() {
const filtered = users.filter(
(u) => userMatchesOffice(u) && userMatchesRole(u) && userMatchesProject(u) && userMatchesSearch(u),
);
// t-paliad-223 (#53): prune drop-outs from the explicit selection. The
// invariant is "selection ⊆ visible"; carrying invisible IDs forward
// would create stale "12 selected" counters that don't match what the
// user sees on screen.
pruneSelectionToVisible(new Set(filtered.map((u) => u.id)));
count.textContent = `${filtered.length} / ${users.length}`;
updateBroadcastButton();
if (filtered.length === 0) {
list.innerHTML = "";
empty.style.display = "block";
renderedUserIDs = [];
syncMasterCheckbox();
renderSelectionFooter();
return;
}
empty.style.display = "none";
@@ -563,223 +518,6 @@ function render() {
list.innerHTML = groupBy === "office"
? renderGroupByOffice(filtered)
: renderGroupByDepartment(filtered);
// Refresh the DOM-order snapshot Shift-click + master-checkbox rely on.
renderedUserIDs = Array.from(
list.querySelectorAll<HTMLElement>(".team-card"),
).map((el) => el.dataset.userId || "");
wireSelectionCheckboxes(list);
syncMasterCheckbox();
renderSelectionFooter();
}
// pruneSelectionToVisible drops user_ids from selection that no longer
// match the visible set. Always called from render() before painting so
// the per-row "checked" state and the footer counter stay in sync.
function pruneSelectionToVisible(visible: Set<string>): void {
const removed: string[] = [];
for (const id of selectedUserIDs) {
if (!visible.has(id)) removed.push(id);
}
for (const id of removed) selectedUserIDs.delete(id);
if (removed.length > 0 && lastToggledUserID && !visible.has(lastToggledUserID)) {
lastToggledUserID = null;
}
}
// wireSelectionCheckboxes attaches click handlers to every per-row
// checkbox in the freshly-rendered list. Each click toggles the
// underlying selection Set + the data-selected attribute on the card.
// Shift-click extends a contiguous range from the previous toggle to
// the current row using renderedUserIDs as the order reference.
function wireSelectionCheckboxes(list: HTMLElement): void {
list.querySelectorAll<HTMLInputElement>(".team-card-select-input").forEach((cb) => {
cb.addEventListener("click", (ev) => {
const id = cb.dataset.userId || "";
if (!id) return;
const checked = cb.checked;
if ((ev as MouseEvent).shiftKey && lastToggledUserID && lastToggledUserID !== id) {
applyRangeSelection(lastToggledUserID, id, checked);
} else {
if (checked) selectedUserIDs.add(id);
else selectedUserIDs.delete(id);
}
lastToggledUserID = id;
// Visual + footer refresh without a full re-render (selection
// changes don't affect the filter set; render() is reserved for
// filter/data changes to keep typing in the search box fast).
refreshCardSelectedAttribute();
syncMasterCheckbox();
renderSelectionFooter();
});
});
}
// applyRangeSelection sets selection state for every user between
// (inclusive) startID and endID in renderedUserIDs order. Mode = the
// final state — checked => add to selection, unchecked => remove.
function applyRangeSelection(startID: string, endID: string, mode: boolean): void {
const a = renderedUserIDs.indexOf(startID);
const b = renderedUserIDs.indexOf(endID);
if (a === -1 || b === -1) {
// One of the anchors dropped out of the current visible set; fall
// back to a single-row toggle of the end-id.
if (mode) selectedUserIDs.add(endID);
else selectedUserIDs.delete(endID);
return;
}
const [lo, hi] = a <= b ? [a, b] : [b, a];
for (let i = lo; i <= hi; i++) {
const id = renderedUserIDs[i];
if (mode) selectedUserIDs.add(id);
else selectedUserIDs.delete(id);
}
}
// refreshCardSelectedAttribute syncs every visible card's data-selected
// + checkbox.checked to the canonical Set, without a full re-render.
function refreshCardSelectedAttribute(): void {
const list = document.getElementById("team-list");
if (!list) return;
list.querySelectorAll<HTMLElement>(".team-card").forEach((card) => {
const id = card.dataset.userId || "";
const selected = selectedUserIDs.has(id);
card.dataset.selected = selected ? "true" : "false";
const cb = card.querySelector<HTMLInputElement>(".team-card-select-input");
if (cb) cb.checked = selected;
});
}
// renderSelectionFooter mounts (or hides) the sticky footer that takes
// over the broadcast action when ≥ 1 row is checked. The footer lives
// outside the main content tree so it can be position: fixed without
// fighting any of the existing layout rules.
function renderSelectionFooter(): void {
let footer = document.getElementById("team-selection-footer") as HTMLDivElement | null;
const n = selectedUserIDs.size;
if (n === 0) {
if (footer) footer.style.display = "none";
document.body.classList.remove("team-has-selection");
return;
}
if (!footer) {
footer = document.createElement("div");
footer.id = "team-selection-footer";
footer.className = "team-selection-footer";
document.body.appendChild(footer);
}
const countLabel = (t("team.selection.count") || "{n} ausgewählt").replace(
"{n}",
String(n),
);
footer.innerHTML = `
<span class="team-selection-count">${esc(countLabel)}</span>
<button type="button" class="btn-secondary btn-small" id="team-selection-clear">
${esc(t("team.selection.clear") || "Auswahl aufheben")}
</button>
<button type="button" class="btn-primary" id="team-selection-send">
${esc(t("team.selection.send") || "E-Mail an Auswahl")}
</button>
`;
footer.style.display = "";
document.body.classList.add("team-has-selection");
document.getElementById("team-selection-clear")?.addEventListener("click", () => {
selectedUserIDs.clear();
lastToggledUserID = null;
refreshCardSelectedAttribute();
syncMasterCheckbox();
renderSelectionFooter();
});
document.getElementById("team-selection-send")?.addEventListener("click", () => {
onBroadcastFromSelection();
});
}
// selectedRecipients maps the explicit selection Set into the
// BroadcastRecipient shape openBroadcastModal expects. Mirrors the
// role-resolution rules of displayedRecipients() (active project
// filter wins; falls back to first available role).
function selectedRecipients(): BroadcastRecipient[] {
const out: BroadcastRecipient[] = [];
for (const id of selectedUserIDs) {
const u = users.find((u) => u.id === id);
if (!u) continue;
const m = memberships.find((m) => m.user_id === u.id);
let role = "";
if (m) {
if (activeProjectIDs.size > 0) {
const idx = m.project_ids.findIndex((pid) => activeProjectIDs.has(pid));
if (idx >= 0) role = m.roles[idx];
} else if (m.roles.length > 0) {
role = m.roles[0];
}
}
out.push({
user_id: u.id,
email: u.email,
display_name: u.display_name,
first_name: firstName(u.display_name),
role_on_project: role,
});
}
return out;
}
function onBroadcastFromSelection(): void {
const recipients = selectedRecipients();
if (recipients.length === 0) return;
const selectedProjectIDs = Array.from(activeProjectIDs);
// Same scope-resolution as displayedRecipients/onBroadcastClick: pass
// project_id only when exactly one is selected so the server can
// verify lead-ship; multi-project relies on global_admin.
const projectID = selectedProjectIDs.length === 1 ? selectedProjectIDs[0] : null;
const offices = activeOffice === "all" ? [] : [activeOffice];
const roles = activeRole === "all" ? [] : [activeRole];
openBroadcastModal({
recipients,
projectID,
projectIDs: selectedProjectIDs,
offices,
roles,
});
}
// syncMasterCheckbox refreshes the master "select all visible" checkbox
// to one of three states: empty / partial / full. The HTML element lives
// in team.tsx (#team-select-master); when missing (older shells) the
// helper no-ops so the page still works.
function syncMasterCheckbox(): void {
const master = document.getElementById("team-select-master") as HTMLInputElement | null;
if (!master) return;
const visible = renderedUserIDs.length;
if (visible === 0) {
master.checked = false;
master.indeterminate = false;
master.disabled = true;
return;
}
master.disabled = false;
let selectedHere = 0;
for (const id of renderedUserIDs) {
if (selectedUserIDs.has(id)) selectedHere++;
}
master.checked = selectedHere === visible;
master.indeterminate = selectedHere > 0 && selectedHere < visible;
}
function onMasterToggle(): void {
const master = document.getElementById("team-select-master") as HTMLInputElement | null;
if (!master) return;
const checked = master.checked;
for (const id of renderedUserIDs) {
if (checked) selectedUserIDs.add(id);
else selectedUserIDs.delete(id);
}
lastToggledUserID = checked && renderedUserIDs.length > 0 ? renderedUserIDs[renderedUserIDs.length - 1] : null;
refreshCardSelectedAttribute();
syncMasterCheckbox();
renderSelectionFooter();
}
function initToggle() {
@@ -809,8 +547,6 @@ document.addEventListener("DOMContentLoaded", () => {
initSidebar();
initSearch();
initToggle();
// t-paliad-223 (#53): master checkbox toggles every visible row.
document.getElementById("team-select-master")?.addEventListener("change", onMasterToggle);
onLangChange(() => {
buildOfficeFilters();
buildRoleFilters();

View File

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

View File

@@ -82,21 +82,15 @@ export function renderDeadlinesDetail(): string {
<input type="date" id="deadline-due-edit" style="display:none" />
</dd>
{/* m/paliad#56 — Verfahrenshandlung block.
Event type (parent concept) renders first; rule
sits beneath as the citation under that event
type. Editor splits them back into separate
pickers but the read-only stack reads as one
compound "Typ — Regel" surface. */}
<dt data-i18n="deadlines.detail.rule">Regel</dt>
<dd id="deadline-rule-display">&mdash;</dd>
<dt data-i18n="deadlines.field.event_type">Typ (optional)</dt>
<dd>
<span id="deadline-event-types-display">&mdash;</span>
<div id="deadline-event-types-edit" className="event-type-picker-host" style="display:none" />
</dd>
<dt data-i18n="deadlines.detail.rule">Regel</dt>
<dd id="deadline-rule-display">&mdash;</dd>
<dt data-i18n="deadlines.detail.source">Quelle</dt>
<dd id="deadline-source-display" />

View File

@@ -101,19 +101,18 @@ export function renderDeadlinesNew(): string {
</p>
</div>
{/* m/paliad#56 — Regel sits directly beneath the Typ
picker so the parent/child relationship reads at a
glance. Due date is its own row below. */}
<div className="form-field">
<label htmlFor="deadline-rule" data-i18n="deadlines.field.rule">Regel (optional)</label>
<select id="deadline-rule">
<option value="" data-i18n="deadlines.field.rule.none">Keine Regel</option>
</select>
</div>
<div className="form-field-row">
<div className="form-field">
<label htmlFor="deadline-due" data-i18n="deadlines.field.due">F&auml;lligkeitsdatum</label>
<input type="date" id="deadline-due" required />
</div>
<div className="form-field">
<label htmlFor="deadline-due" data-i18n="deadlines.field.due">F&auml;lligkeitsdatum</label>
<input type="date" id="deadline-due" required />
<div className="form-field">
<label htmlFor="deadline-rule" data-i18n="deadlines.field.rule">Regel (optional)</label>
<select id="deadline-rule">
<option value="" data-i18n="deadlines.field.rule.none">Keine Regel</option>
</select>
</div>
</div>
<div className="form-field">

View File

@@ -161,19 +161,19 @@ export function renderFristenrechner(): string {
<div className="fristen-adhoc-chips" role="group" aria-label="Ad-hoc proceeding">
<button type="button" className="fristen-adhoc-chip" data-ad-hoc="upc"
data-i18n="deadlines.step1.adhoc.upc">
UPC proceeding
Custom UPC proceeding
</button>
<button type="button" className="fristen-adhoc-chip" data-ad-hoc="de"
data-i18n="deadlines.step1.adhoc.de">
DE proceeding
Custom DE proceeding
</button>
<button type="button" className="fristen-adhoc-chip" data-ad-hoc="epa"
data-i18n="deadlines.step1.adhoc.epa">
EPA proceeding
Custom EPA proceeding
</button>
<button type="button" className="fristen-adhoc-chip" data-ad-hoc="dpma"
data-i18n="deadlines.step1.adhoc.dpma">
DPMA proceeding
Custom DPMA proceeding
</button>
</div>
</div>
@@ -485,10 +485,7 @@ export function renderFristenrechner(): string {
<div className="date-input-group">
<div className="date-field-row">
{/* Read-only caption labelling the value <span>. Not a
<label htmlFor> — m/paliad#60: <label for=…> must
point at a labelable form control, never a span. */}
<span className="date-label" data-i18n="deadlines.trigger.event">Ausl&ouml;sendes Ereignis:</span>
<label htmlFor="trigger-event" className="date-label" data-i18n="deadlines.trigger.event">Ausl&ouml;sendes Ereignis:</label>
<span id="trigger-event" className="trigger-event-name">&mdash;</span>
</div>
<div className="date-field-row">

View File

@@ -658,7 +658,6 @@ export type I18nKey =
| "approvals.suggest.note_placeholder"
| "approvals.suggest.section.context"
| "approvals.suggest.section.editable"
| "approvals.suggest.section.event_type_rule"
| "approvals.suggest.submit"
| "approvals.suggest.submit_disabled_hint"
| "approvals.suggest.unsupported_lifecycle"
@@ -972,9 +971,7 @@ export type I18nKey =
| "deadlines.card.calc.flag.with_cci"
| "deadlines.card.calc.flag.with_ccr"
| "deadlines.card.calc.flags.label"
| "deadlines.card.calc.pill_picker.change"
| "deadlines.card.calc.pill_picker.label"
| "deadlines.card.calc.pill_picker.locked_label"
| "deadlines.card.calc.result.calculating"
| "deadlines.card.calc.result.court_set"
| "deadlines.card.calc.result.due"
@@ -1975,7 +1972,6 @@ export type I18nKey =
| "projects.chip.type.case"
| "projects.chip.type.client"
| "projects.chip.type.litigation"
| "projects.chip.type.other"
| "projects.chip.type.patent"
| "projects.chip.type.project"
| "projects.col.clientmatter"
@@ -2163,19 +2159,6 @@ 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"
@@ -2193,21 +2176,13 @@ 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"
@@ -2254,9 +2229,6 @@ export type I18nKey =
| "projects.team.derived.from"
| "projects.team.derived.visibility"
| "projects.team.direct"
| "projects.team.error.forbidden"
| "projects.team.error.generic"
| "projects.team.error.last_admin"
| "projects.team.inherited.hint"
| "projects.team.profession.associate"
| "projects.team.profession.hint"
@@ -2267,8 +2239,6 @@ export type I18nKey =
| "projects.team.profession.paralegal"
| "projects.team.profession.partner"
| "projects.team.profession.senior_pa"
| "projects.team.responsibility.admin"
| "projects.team.responsibility.admin.hint"
| "projects.team.responsibility.external"
| "projects.team.responsibility.lead"
| "projects.team.responsibility.member"
@@ -2317,7 +2287,6 @@ export type I18nKey =
| "projects.type.case"
| "projects.type.client"
| "projects.type.litigation"
| "projects.type.other"
| "projects.type.patent"
| "projects.type.project"
| "projects.unavailable"
@@ -2384,11 +2353,6 @@ export type I18nKey =
| "team.role.senior_associate"
| "team.role.trainee"
| "team.search.placeholder"
| "team.selection.clear"
| "team.selection.count"
| "team.selection.select_all"
| "team.selection.send"
| "team.selection.toggle_card"
| "team.subtitle"
| "team.title"
| "theme.toggle.auto"

View File

@@ -50,14 +50,6 @@ 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 &nearr;</a>
@@ -270,7 +262,6 @@ export function renderProjectsDetail(): string {
<div className="form-field">
<label htmlFor="team-responsibility" data-i18n="projects.detail.team.form.responsibility">Rolle im Projekt</label>
<select id="team-responsibility">
<option value="admin" data-i18n="projects.team.responsibility.admin">Admin</option>
<option value="lead" data-i18n="projects.team.responsibility.lead">Lead</option>
<option value="member" selected data-i18n="projects.team.responsibility.member">Mitglied</option>
<option value="observer" data-i18n="projects.team.responsibility.observer">Beobachter</option>

View File

@@ -127,8 +127,7 @@ export function renderProjects(): string {
<label><input type="checkbox" value="litigation" /><span data-i18n="projects.chip.type.litigation">Streitsache</span></label>
<label><input type="checkbox" value="patent" /><span data-i18n="projects.chip.type.patent">Patent</span></label>
<label><input type="checkbox" value="case" /><span data-i18n="projects.chip.type.case">Verfahren</span></label>
<label><input type="checkbox" value="project" /><span data-i18n="projects.chip.type.project">Projekt</span></label>
<label><input type="checkbox" value="other" /><span data-i18n="projects.chip.type.other">Sonstiges</span></label>
<label><input type="checkbox" value="project" data-i18n-text="projects.chip.type.project"><span data-i18n="projects.chip.type.project">Projekt</span></input></label>
</div>
</details>
<button type="button" className="projects-chip" data-chip="has_open_deadlines" data-i18n="projects.chip.has_open_deadlines">Mit aktiven Fristen</button>

View File

@@ -59,14 +59,6 @@
--color-overlay-strong: rgba(0, 0, 0, 0.10);
--color-overlay-modal: rgba(0, 0, 0, 0.4); /* modal/drawer scrim */
/* Segmented-control active pill — brand-lime accent so every density /
view-mode toggle reads as the same primary action (m/paliad#52).
Surfaces consuming these tokens: .filter-bar-segment (FilterBar
density + future view-mode segments). Override on dark mode below. */
--color-segment-active-bg: var(--color-accent);
--color-segment-active-fg: var(--color-accent-dark);
--color-segment-active-border: var(--color-accent);
/* Status palette — five buckets (red/amber/green/blue/neutral) shared
across dashboard cards, frist-due-chips, agenda urgency, termin
badges, login forms. Light values match the existing pastel-on-dark
@@ -181,13 +173,6 @@
--color-overlay-strong: rgba(255, 255, 255, 0.12);
--color-overlay-modal: rgba(0, 0, 0, 0.65);
/* Segmented active pill — lime stays the brand on dark mode too; the
--color-accent-dark token already resolves to midnight in both
themes, keeping the foreground WCAG-AA on lime. */
--color-segment-active-bg: var(--color-accent);
--color-segment-active-fg: var(--color-accent-dark);
--color-segment-active-border: var(--color-accent);
--shadow: 0 1px 3px rgba(0, 0, 0, 0.4), 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.45);
--shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.55);
@@ -2685,61 +2670,6 @@ input[type="range"]::-moz-range-thumb {
font-family: ui-monospace, monospace;
}
/* m/paliad#57 part 4 — once a card is expanded into a calc panel,
the rule-pill list is redundant with the calc panel's context
picker (locked caption or fieldset). Hide it so the user isn't
asked the same thing twice. The cross-cutting section stays —
those pills are alternative concepts to explore, not the same
proceeding context. */
.fristen-card.is-expanded .fristen-card-pills-section--rules {
display: none;
}
/* Locked-context caption when the user clicked a specific rule pill
to expand. Shows the picked (proceeding, rule) tuple compactly
with a small "ändern" button to swap back to the radio picker. */
.fristen-card-calc-pill-locked {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.4rem;
padding: 0.35rem 0.55rem;
border: 1px solid var(--color-border-subtle, #ececec);
border-radius: 5px;
background: rgba(198, 244, 28, 0.06);
font-size: 0.88rem;
}
.fristen-card-calc-pill-locked-label {
font-weight: 600;
color: var(--color-muted, #777);
text-transform: uppercase;
font-size: 0.74rem;
letter-spacing: 0.04em;
}
.fristen-card-calc-pill-locked-proc {
font-weight: 600;
color: var(--color-text, #222);
}
.fristen-card-calc-pill-locked-rule {
color: var(--color-text, #222);
}
.fristen-card-calc-pill-locked-source {
font-size: 0.8rem;
color: var(--color-muted, #888);
font-family: ui-monospace, monospace;
}
.fristen-card-calc-pill-change {
margin-left: auto;
background: transparent;
border: 0;
padding: 0;
color: var(--color-link, #1267a8);
cursor: pointer;
font-size: 0.82rem;
text-decoration: underline;
}
.fristen-card-calc-pill-change:hover { text-decoration: none; }
.fristen-card-calc-inputs {
display: flex;
flex-wrap: wrap;
@@ -6793,17 +6723,6 @@ 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;
@@ -9608,7 +9527,7 @@ label.caldav-toggle-label {
background: var(--color-surface);
border: 1px solid var(--color-border, #e5e5ed);
border-radius: 12px;
transition: border-color 0.15s, box-shadow 0.15s, background 0.15s;
transition: border-color 0.15s, box-shadow 0.15s;
}
.team-card:hover {
@@ -9616,95 +9535,6 @@ label.caldav-toggle-label {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
/* t-paliad-223 (#53) — selected card highlight. */
.team-card[data-selected="true"] {
border-color: var(--color-accent, var(--hlc-lime));
background: var(--color-bg-lime-tint, rgba(198, 244, 28, 0.08));
box-shadow: 0 0 0 1px var(--color-accent, var(--hlc-lime)) inset;
}
.team-card-select {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
padding-top: 0.15rem;
}
.team-card-select-input {
width: 18px;
height: 18px;
cursor: pointer;
accent-color: var(--color-accent, var(--hlc-lime));
}
/* Master "select all visible" row, sits above the team list. */
.team-select-master-row {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0.5rem 0 0.75rem;
padding: 0.35rem 0.75rem;
font-size: 0.82rem;
color: var(--color-text-muted, #64647a);
}
.team-select-master-label {
display: inline-flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
.team-select-master-label input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: var(--color-accent, var(--hlc-lime));
}
/* Sticky footer that takes over the broadcast action when ≥ 1 row is
selected. z-index 150 sits above the mobile bottom-nav (100) and well
below modal overlays (1000+), per t-paliad-223 design §4.5. */
.team-selection-footer {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 150;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.75rem;
padding: 0.8rem 1.25rem;
background: var(--color-surface, #ffffff);
border-top: 2px solid var(--color-accent, var(--hlc-lime));
box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.08);
}
.team-selection-count {
flex: 1;
font-weight: 600;
color: var(--color-text, var(--hlc-midnight));
}
/* Reserve a small bottom margin on the main content while the footer is
visible so the last row of cards doesn't tuck under the bar. */
body.team-has-selection main {
padding-bottom: 4.5rem;
}
@media (max-width: 600px) {
.team-selection-footer {
flex-wrap: wrap;
padding-bottom: calc(0.8rem + env(safe-area-inset-bottom, 0));
}
.team-selection-count {
width: 100%;
margin-bottom: 0.25rem;
}
}
.team-avatar {
flex-shrink: 0;
width: 40px;
@@ -14273,9 +14103,8 @@ dialog.quick-add-sheet::backdrop {
border: 1px solid transparent;
}
.filter-bar-segment .filter-bar-chip.agenda-chip-active {
background: var(--color-segment-active-bg);
color: var(--color-segment-active-fg);
border-color: var(--color-segment-active-border);
background: var(--color-surface, #ffffff);
border-color: var(--color-border, #e5e7eb);
}
.filter-bar-chip-pending {

View File

@@ -75,14 +75,6 @@ export function renderTeam(): string {
<div className="team-broadcast-wrap" id="team-broadcast-wrap" style="display:none">
</div>
{/* t-paliad-223 (#53) — master "select all visible" checkbox. */}
<div className="team-select-master-row">
<label className="team-select-master-label">
<input type="checkbox" id="team-select-master" />
<span data-i18n="team.selection.select_all">Alle sichtbaren ausw&auml;hlen</span>
</label>
</div>
<div className="team-list" id="team-list" />
<div className="glossar-empty" id="team-empty" style="display:none">

View File

@@ -163,10 +163,7 @@ export function renderVerfahrensablauf(): string {
<div className="date-input-group">
<div className="date-field-row">
{/* Read-only caption labelling the value <span>. Not a
<label htmlFor> — m/paliad#60: <label for=…> must
point at a labelable form control, never a span. */}
<span className="date-label" data-i18n="deadlines.trigger.event">Ausl&ouml;sendes Ereignis:</span>
<label htmlFor="trigger-event" className="date-label" data-i18n="deadlines.trigger.event">Ausl&ouml;sendes Ereignis:</label>
<span id="trigger-event" className="trigger-event-name">&mdash;</span>
</div>
<div className="date-field-row">

2
go.mod
View File

@@ -8,7 +8,6 @@ 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 (
@@ -21,4 +20,5 @@ require (
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/net v0.50.0 // indirect
golang.org/x/text v0.34.0 // indirect
)

View File

@@ -1,22 +0,0 @@
-- mig 110 (down) — revert 'other' addition to paliad.projects.type
--
-- Coerces any 'other' rows back to 'project' (the historical catch-all)
-- so the narrower CHECK constraint can re-attach. This is a lossy
-- rollback: rows that were genuinely 'other' lose that distinction.
SELECT set_config(
'paliad.audit_reason',
'mig 110 (down): revert ''other'' from projects.type CHECK; coerce rows to ''project''',
true);
UPDATE paliad.projects
SET type = 'project'
WHERE type = 'other';
ALTER TABLE paliad.projects
DROP CONSTRAINT IF EXISTS projects_type_check;
ALTER TABLE paliad.projects
ADD CONSTRAINT projects_type_check
CHECK (type IN (
'client', 'litigation', 'patent', 'case', 'project'
));

View File

@@ -1,33 +0,0 @@
-- mig 110 — add 'other' as a sixth paliad.projects.type value
--
-- m/paliad#51 (t-paliad-221): the type chip filter on /projects used to
-- treat unclassified projects as a synthetic "Empty" bucket. We replace
-- that with a real 'other' type so every row carries a meaningful label
-- and the filter UI stops needing a NULL/Empty shim.
--
-- Defensive backfill: NOT NULL + the original IN-list CHECK already
-- forbid NULL rows, but we coerce any stray rows just in case a future
-- migration ever relaxed the constraint. As of 2026-05-20 production
-- carries zero rows that would change here (live query confirmed).
--
-- The Go-side source of truth lives in
-- internal/services/project_service.go (ProjectType constants +
-- isValidProjectType); this migration keeps the DB in sync.
SELECT set_config(
'paliad.audit_reason',
'mig 110: add ''other'' to projects.type CHECK + backfill NULLs (m/paliad#51)',
true);
-- Backfill first so the new CHECK never rejects a pre-existing row.
UPDATE paliad.projects
SET type = 'other'
WHERE type IS NULL;
ALTER TABLE paliad.projects
DROP CONSTRAINT IF EXISTS projects_type_check;
ALTER TABLE paliad.projects
ADD CONSTRAINT projects_type_check
CHECK (type IN (
'client', 'litigation', 'patent', 'case', 'project', 'other'
));

View File

@@ -1,65 +0,0 @@
-- Reverse of 111_project_admin_and_select.up.sql.
--
-- Drops effective_project_admin, restores the original RLS policies,
-- and shrinks the responsibility CHECK back to four values. Any rows
-- still carrying responsibility='admin' would violate the restored
-- CHECK; the down-migration backfills them to 'lead' (the closest
-- existing role) before re-adding the constraint.
-- ============================================================================
-- 1. Backfill any responsibility='admin' rows to 'lead'.
-- ============================================================================
UPDATE paliad.project_teams
SET responsibility = 'lead'
WHERE responsibility = 'admin';
-- ============================================================================
-- 2. Restore the original CHECK (lead/member/observer/external).
-- ============================================================================
ALTER TABLE paliad.project_teams
DROP CONSTRAINT IF EXISTS project_teams_responsibility_check;
ALTER TABLE paliad.project_teams
ADD CONSTRAINT project_teams_responsibility_check
CHECK (responsibility IN ('lead', 'member', 'observer', 'external'));
-- ============================================================================
-- 3. Restore the pre-110 RLS policies.
-- ============================================================================
DROP POLICY IF EXISTS project_teams_update ON paliad.project_teams;
CREATE POLICY project_teams_update
ON paliad.project_teams FOR UPDATE
USING (paliad.can_see_project(project_id))
WITH CHECK (paliad.can_see_project(project_id));
DROP POLICY IF EXISTS project_teams_insert ON paliad.project_teams;
CREATE POLICY project_teams_insert
ON paliad.project_teams FOR INSERT
WITH CHECK (
user_id = auth.uid()
OR paliad.can_see_project(project_id)
);
DROP POLICY IF EXISTS project_teams_delete ON paliad.project_teams;
CREATE POLICY project_teams_delete
ON paliad.project_teams FOR DELETE
USING (
paliad.can_see_project(project_id)
AND (
user_id = auth.uid()
OR EXISTS (
SELECT 1 FROM paliad.users u
WHERE u.id = auth.uid()
AND u.global_role = 'global_admin'
)
)
);
-- ============================================================================
-- 4. Drop the predicate function.
-- ============================================================================
DROP FUNCTION IF EXISTS paliad.effective_project_admin(uuid, uuid);

View File

@@ -1,152 +0,0 @@
-- t-paliad-223 Slice A: Project Admin role on project_teams.responsibility +
-- inheritable role-edit gate.
--
-- Design: docs/design-team-admin-rework-2026-05-20.md (gauss, m-locked
-- 2026-05-20 via head's "all R approved").
--
-- Adds a fifth 'admin' value to the project_teams.responsibility enum
-- (orthogonal to the profession-driven approval ladder — admin does NOT
-- open the 4-Augen gate by itself). Introduces paliad.effective_project_admin
-- which mirrors paliad.can_see_project's shape and walks the ltree path
-- to compute inheritance. Replaces the three write-side RLS policies on
-- paliad.project_teams so role edits are gated on the new predicate
-- instead of "anyone with visibility".
--
-- Day-1 deploy = no behaviour change for callers who never use the admin
-- value: existing lead/member/observer/external rows keep their meaning,
-- and the global_admin shortcut + self-join INSERT / self-DELETE remain
-- intact.
--
-- Sections:
-- 1. ALTER project_teams.responsibility CHECK to include 'admin'.
-- 2. CREATE paliad.effective_project_admin(uuid, uuid).
-- 3. Replace project_teams_update policy: gated on effective_project_admin.
-- 4. Replace project_teams_insert policy: self-join OR effective_project_admin.
-- 5. Replace project_teams_delete policy: self / global_admin / effective_project_admin.
-- ============================================================================
-- 1. Extend responsibility CHECK to include 'admin'.
--
-- 'admin' inherits down the project tree (see effective_project_admin in §2).
-- A user marked admin on a Mandant-level project is implicitly admin on
-- every Litigation / Patent / Case descendant — same shape as how 'lead'
-- already inherits.
-- ============================================================================
ALTER TABLE paliad.project_teams
DROP CONSTRAINT IF EXISTS project_teams_responsibility_check;
ALTER TABLE paliad.project_teams
ADD CONSTRAINT project_teams_responsibility_check
CHECK (responsibility IN ('admin', 'lead', 'member', 'observer', 'external'));
COMMENT ON COLUMN paliad.project_teams.responsibility IS
'Per-project responsibility. admin = can manage team + roles on this '
'project and descendants (inherited via paliad.effective_project_admin). '
'lead/member open the 4-Augen approval gate; observer/external close it. '
'admin is orthogonal to the approval gate — it does NOT open it by itself.';
-- ============================================================================
-- 2. paliad.effective_project_admin(_user_id, _project_id)
--
-- Mirrors paliad.can_see_project: STABLE SECURITY DEFINER, ltree path-walk
-- against projects.path. Two branches:
-- (a) global_admin short-circuit — firm-wide admins are always admin.
-- (b) ancestor-or-self project_teams row with responsibility='admin'.
--
-- Used by the project_teams_update / _insert / _delete policies below
-- and by ProjectService for the effective_admin payload field.
--
-- The ltree-array cast is the same pattern can_see_project uses; the
-- existing GiST index on projects.path is the load-bearing index. No new
-- index needed.
-- ============================================================================
CREATE OR REPLACE FUNCTION paliad.effective_project_admin(_user_id uuid, _project_id uuid)
RETURNS boolean
LANGUAGE sql STABLE SECURITY DEFINER
SET search_path TO 'paliad', 'public'
AS $$
SELECT EXISTS (
SELECT 1 FROM paliad.users u
WHERE u.id = _user_id
AND u.global_role = 'global_admin'
)
OR EXISTS (
SELECT 1
FROM paliad.projects target
JOIN paliad.project_teams pt
ON pt.user_id = _user_id
AND pt.responsibility = 'admin'
AND pt.project_id = ANY(string_to_array(target.path, '.')::uuid[])
WHERE target.id = _project_id
);
$$;
COMMENT ON FUNCTION paliad.effective_project_admin(uuid, uuid) IS
'True iff the user is global_admin OR has responsibility=admin on the '
'project itself or any ancestor in the materialised ltree path. '
'Drives the role-edit gate on project_teams (UPDATE/INSERT/DELETE RLS).';
-- ============================================================================
-- 3. project_teams_update policy: gated on effective_project_admin.
--
-- Before: USING + CHECK = can_see_project (anyone with visibility could
-- edit anyone's responsibility — the load-bearing gap that t-paliad-223
-- closes).
-- After: USING + CHECK = effective_project_admin (only project-admins
-- and global_admins can change roles).
-- ============================================================================
DROP POLICY IF EXISTS project_teams_update ON paliad.project_teams;
CREATE POLICY project_teams_update
ON paliad.project_teams FOR UPDATE
USING (paliad.effective_project_admin(auth.uid(), project_id))
WITH CHECK (paliad.effective_project_admin(auth.uid(), project_id));
-- ============================================================================
-- 4. project_teams_insert policy: self-join OR effective_project_admin.
--
-- The self-join branch (user_id = auth.uid()) preserves the legacy
-- creator-as-lead INSERT in ProjectService.Create: the project creator
-- auto-joins their own project with responsibility='lead' before any
-- admin exists. Without this branch, the first-ever team row on a new
-- project would fail because no admin has been granted yet.
--
-- For all other inserts (adding other users), the caller must be an
-- effective_project_admin on the target project.
-- ============================================================================
DROP POLICY IF EXISTS project_teams_insert ON paliad.project_teams;
CREATE POLICY project_teams_insert
ON paliad.project_teams FOR INSERT
WITH CHECK (
user_id = auth.uid()
OR paliad.effective_project_admin(auth.uid(), project_id)
);
-- ============================================================================
-- 5. project_teams_delete policy: self / global_admin / effective_project_admin.
--
-- Additive: self-remove + global_admin still work; project-admin can now
-- also remove members.
-- ============================================================================
DROP POLICY IF EXISTS project_teams_delete ON paliad.project_teams;
CREATE POLICY project_teams_delete
ON paliad.project_teams FOR DELETE
USING (
paliad.can_see_project(project_id)
AND (
user_id = auth.uid()
OR EXISTS (
SELECT 1 FROM paliad.users u
WHERE u.id = auth.uid()
AND u.global_role = 'global_admin'
)
OR paliad.effective_project_admin(auth.uid(), project_id)
)
);

View File

@@ -1,30 +0,0 @@
-- 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;

View File

@@ -1,51 +0,0 @@
-- 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;

View File

@@ -1,11 +0,0 @@
-- 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;

View File

@@ -1,50 +0,0 @@
-- 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;

View File

@@ -325,7 +325,6 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
// Team membership endpoints for Project detail "Team" tab.
protected.HandleFunc("GET /api/projects/{id}/team", handleListProjectTeam)
protected.HandleFunc("POST /api/projects/{id}/team", handleAddProjectTeamMember)
protected.HandleFunc("PATCH /api/projects/{id}/team/{user_id}", handleChangeProjectTeamMemberResponsibility)
protected.HandleFunc("DELETE /api/projects/{id}/team/{user_id}", handleRemoveProjectTeamMember)
// t-paliad-139 — sub-team aggregation surfaces for the Team tab.
protected.HandleFunc("GET /api/projects/{id}/team/derived", handleListDerivedTeam)

View File

@@ -11,7 +11,6 @@ import (
"github.com/google/uuid"
"mgit.msbls.de/m/paliad/internal/auth"
"mgit.msbls.de/m/paliad/internal/models"
"mgit.msbls.de/m/paliad/internal/services"
)
@@ -105,8 +104,6 @@ func writeServiceError(w http.ResponseWriter, err error) {
})
case errors.Is(err, services.ErrEventTypeSlugTaken):
writeJSON(w, http.StatusConflict, map[string]string{"error": err.Error()})
case errors.Is(err, services.ErrLastProjectAdmin):
writeJSON(w, http.StatusConflict, map[string]string{"error": err.Error()})
default:
log.Printf("ERROR service: %v", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
@@ -322,24 +319,7 @@ func handleGetProject(w http.ResponseWriter, r *http.Request) {
writeServiceError(w, err)
return
}
// t-paliad-223: piggyback effective_project_admin onto the project
// payload so the frontend can drive the inline role-edit affordance
// without a second round-trip. JSON-merge via a small wrapper that
// embeds the existing Project shape — every existing caller keeps
// reading the same fields and gains effective_admin as additive.
effAdmin, err := dbSvc.team.IsEffectiveProjectAdmin(r.Context(), uid, id)
if err != nil {
writeServiceError(w, err)
return
}
type projectWithPermissions struct {
*models.Project
EffectiveAdmin bool `json:"effective_admin"`
}
writeJSON(w, http.StatusOK, projectWithPermissions{
Project: p,
EffectiveAdmin: effAdmin,
})
writeJSON(w, http.StatusOK, p)
}
// GET /api/projects/{id}/children — direct children.
@@ -371,7 +351,7 @@ func handleListProjectChildren(w http.ResponseWriter, r *http.Request) {
// Query parameters (all optional, additive):
// ?scope=all|mine|pinned — chip-driven scope (default "all")
// ?status=active,archived,closed — status whitelist (CSV; default = no narrowing)
// ?type=client,litigation,patent,case,project,other — type whitelist
// ?type=client,litigation,patent,case,project — type whitelist
// ?has_open_deadlines=true|false — narrow by deadline activity
// ?q=<term> — search title / reference / clientmatter
// ?subtree_counts=true|false — populate *_subtree fields (default true)

View File

@@ -473,8 +473,6 @@ func humanProjectType(t string) string {
return "Verfahren"
case services.ProjectTypeProject:
return "Projekt"
case services.ProjectTypeOther:
return "Sonstiges"
}
return t
}

View File

@@ -93,53 +93,6 @@ func handleListMembershipsIndex(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, rows)
}
// PATCH /api/projects/{id}/team/{user_id} — change a direct member's
// responsibility. Body: {"responsibility": "<admin|lead|member|observer|external>"}.
//
// Authorisation is RLS-enforced (project_teams_update gated on
// effective_project_admin in mig 111). Non-admins get a pq permission
// error from the UPDATE; we surface that as 404 to avoid leaking that
// the row exists. The last-admin guard runs inside the service tx and
// returns ErrLastProjectAdmin (mapped to 409 by writeServiceError).
func handleChangeProjectTeamMemberResponsibility(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
projectID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid project id"})
return
}
userID, err := uuid.Parse(r.PathValue("user_id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid user id"})
return
}
var body struct {
Responsibility string `json:"responsibility"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
m, err := dbSvc.team.ChangeResponsibility(r.Context(), uid, projectID, userID, body.Responsibility)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
writeJSON(w, http.StatusNotFound, map[string]string{
"error": "no direct membership found",
})
return
}
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, m)
}
// DELETE /api/projects/{id}/team/{user_id} — remove a direct member.
// Inherited memberships can't be removed at the child level.
func handleRemoveProjectTeamMember(w http.ResponseWriter, r *http.Request) {

View File

@@ -159,35 +159,10 @@ 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 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.
// not set; Determinator falls back to free-pick. Allowed values:
// claimant, defendant, court, both.
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

View File

@@ -25,14 +25,7 @@ const (
// Project-level responsibility values on paliad.project_teams.responsibility.
// Open the ladder gate (lead/member) or close it (observer/external).
//
// ResponsibilityAdmin (t-paliad-223) is orthogonal to the approval gate —
// it grants role-edit authority on the project + descendants via the
// paliad.effective_project_admin predicate, but does NOT by itself open
// the 4-Augen approval gate. An Admin who has no profession set is still
// not an approver. Use responsibilityOpensGate to test the approval axis.
const (
ResponsibilityAdmin = "admin"
ResponsibilityLead = "lead"
ResponsibilityMember = "member"
ResponsibilityObserver = "observer"
@@ -150,7 +143,7 @@ func IsValidProfession(p string) bool {
// recognised project-responsibility enum values. Used by TeamService.
func IsValidResponsibility(r string) bool {
switch r {
case ResponsibilityAdmin, ResponsibilityLead, ResponsibilityMember,
case ResponsibilityLead, ResponsibilityMember,
ResponsibilityObserver, ResponsibilityExternal:
return true
}

View File

@@ -190,8 +190,7 @@ func TestIsValidProfession(t *testing.T) {
}
func TestIsValidResponsibility(t *testing.T) {
// t-paliad-223 added 'admin'; the four legacy values stay valid.
for _, r := range []string{"admin", "lead", "member", "observer", "external"} {
for _, r := range []string{"lead", "member", "observer", "external"} {
t.Run(r, func(t *testing.T) {
if !IsValidResponsibility(r) {
t.Errorf("IsValidResponsibility(%q) must be true", r)
@@ -207,30 +206,6 @@ func TestIsValidResponsibility(t *testing.T) {
}
}
// t-paliad-223: admin maps to legacy 'lead' for the deprecated shadow
// column. The other mappings are unchanged from t-paliad-148. Pin them
// so a future refactor doesn't silently flip them.
func TestLegacyRoleFromResponsibility(t *testing.T) {
cases := []struct {
in, want string
}{
{ResponsibilityAdmin, "lead"},
{ResponsibilityLead, "lead"},
{ResponsibilityObserver, "observer"},
{ResponsibilityExternal, "local_counsel"},
{ResponsibilityMember, "associate"},
{"", "associate"}, // unknown / empty falls through to associate
}
for _, c := range cases {
t.Run(c.in, func(t *testing.T) {
got := legacyRoleFromResponsibility(c.in)
if got != c.want {
t.Errorf("legacyRoleFromResponsibility(%q) = %q, want %q", c.in, got, c.want)
}
})
}
}
func TestApprovalEventType(t *testing.T) {
cases := []struct {
entity, step, want string

View File

@@ -279,12 +279,7 @@ func shouldExcludeAppointmentsForStatus(status DeadlineStatusFilter) bool {
// matches a bucket-style deadline status — used to filter the
// appointment side when the user clicks a card on the unified events
// page. Returns (nil, nil) for non-bucket statuses (pending / all /
// "" / overdue / completed — those are handled separately).
//
// DeadlineFilterUpcoming maps to "start_at >= today" so legacy
// `?status=upcoming` URLs hide past appointments instead of falling
// through to the unfiltered query (m/paliad#54 — the UI option that
// surfaced this status has been removed, but bookmarks may persist).
// upcoming / "" / overdue / completed — those are handled separately).
func bucketAppointmentWindow(status DeadlineStatusFilter, b deadlineBucketBounds) (*time.Time, *time.Time) {
switch status {
case DeadlineFilterToday:
@@ -298,8 +293,6 @@ func bucketAppointmentWindow(status DeadlineStatusFilter, b deadlineBucketBounds
return &b.nextMonday, &t
case DeadlineFilterLater:
return &b.weekAfter, nil
case DeadlineFilterUpcoming:
return &b.today, nil
}
return nil, nil
}

View File

@@ -1,312 +0,0 @@
package services
import (
"context"
"fmt"
"regexp"
"strings"
"unicode"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"github.com/lib/pq"
"golang.org/x/text/runes"
"golang.org/x/text/transform"
"golang.org/x/text/unicode/norm"
"mgit.msbls.de/m/paliad/internal/models"
)
// Project codes — t-paliad-222 / m/paliad#50.
//
// BuildProjectCode assembles a dotted code from the ancestor chain of
// a project. Each ancestor contributes one segment derived from its
// type-specific metadata. Missing segments (NULL ancestor field,
// unfilled opponent_code, etc.) are skipped silently — there is no
// placeholder.
//
// client → reference if set, else slug(title), capped at 8 chars
// litigation → opponent_code (the slug the user typed at litigation
// creation), empty → skipped
// patent → last 3 digits of patent_number (full digit-stream when
// shorter), empty → skipped
// case → uppercase tail of proceeding_types.code (jurisdiction
// segment dropped), empty → skipped
// project → "" (generic projects don't contribute a segment)
//
// Custom override: if the target row's `reference` column is non-empty,
// it wins outright — the helper returns the literal `reference` string
// without walking the ancestor chain.
//
// Example: Client EXMPL → Litigation OPNT → Patent EP3456789 → Case
// `upc.inf.cfi` → "EXMPL.OPNT.789.INF.CFI".
//
// Collision handling: codes are display-only (no uniqueness
// constraint). Two cases that derive to the same code both return the
// same string. v1 contract — users disambiguate via `reference` when it
// matters.
// projectChainRow is one row of the ancestor walk. Includes only the
// columns BuildProjectCode needs; trimmed for cheap projection.
type projectChainRow struct {
ID uuid.UUID `db:"id"`
Type string `db:"type"`
Title string `db:"title"`
Reference *string `db:"reference"`
OpponentCode *string `db:"opponent_code"`
PatentNumber *string `db:"patent_number"`
ProceedingTypeID *int `db:"proceeding_type_id"`
ProceedingCode *string `db:"proceeding_code"`
}
// BuildProjectCode walks the ancestor chain via the existing
// paliad.projects.path ltree and returns the assembled code. One DB
// round-trip per call; suitable for per-row use in single-project
// projection paths.
//
// For list endpoints with many rows, the call still scales fine for
// firm-scale datasets (order-of-100s); if profiling later flags it as
// a hotspot, introduce a materialised view per the design doc §3.2 Q8.
func BuildProjectCode(ctx context.Context, db sqlx.QueryerContext, projectID uuid.UUID) (string, error) {
const query = `
SELECT p.id, p.type, p.title, p.reference, p.opponent_code,
p.patent_number, p.proceeding_type_id,
pt.code AS proceeding_code
FROM paliad.projects p
LEFT JOIN paliad.proceeding_types pt ON pt.id = p.proceeding_type_id
WHERE p.path @> (SELECT path FROM paliad.projects WHERE id = $1)
ORDER BY nlevel(p.path)
`
rows := []projectChainRow{}
if err := sqlx.SelectContext(ctx, db, &rows, query, projectID); err != nil {
return "", fmt.Errorf("build project code: load chain: %w", err)
}
if len(rows) == 0 {
return "", nil
}
return assembleProjectCode(rows), nil
}
// PopulateProjectCodes assigns .Code on every project in `targets` via
// a single bulk round-trip. Used by List / ListChildren / ListAncestors
// projection paths to avoid N+1 BuildProjectCode calls.
//
// Empty slice → no-op. Rows that can't be matched (orphaned) get an
// empty code rather than an error.
func PopulateProjectCodes(ctx context.Context, db sqlx.QueryerContext, targets []models.Project) error {
if len(targets) == 0 {
return nil
}
ids := make([]string, len(targets))
for i, t := range targets {
ids[i] = t.ID.String()
}
// One CTE-based query: for each target id, fetch the full ancestor
// chain joined to proceeding_types, ordered so we can group in Go.
const query = `
WITH targets AS (
SELECT id, path
FROM paliad.projects
WHERE id = ANY($1::uuid[])
)
SELECT t.id AS target_id,
p.id, p.type, p.title, p.reference, p.opponent_code,
p.patent_number, p.proceeding_type_id,
pt.code AS proceeding_code,
nlevel(p.path) AS chain_level
FROM targets t
JOIN paliad.projects p ON p.path @> t.path
LEFT JOIN paliad.proceeding_types pt ON pt.id = p.proceeding_type_id
ORDER BY t.id, chain_level
`
type bulkRow struct {
TargetID uuid.UUID `db:"target_id"`
projectChainRow
ChainLevel int `db:"chain_level"`
}
rows := []bulkRow{}
if err := sqlx.SelectContext(ctx, db, &rows, query, pq.StringArray(ids)); err != nil {
return fmt.Errorf("populate project codes: bulk fetch: %w", err)
}
chains := make(map[uuid.UUID][]projectChainRow, len(targets))
for _, r := range rows {
chains[r.TargetID] = append(chains[r.TargetID], r.projectChainRow)
}
for i := range targets {
targets[i].Code = assembleProjectCode(chains[targets[i].ID])
}
return nil
}
// assembleProjectCode is the pure code-assembly step, split out from
// the DB hop so it can be table-tested without fixtures.
//
// Custom override: non-empty `reference` on the target row (last in
// chain) wins; the function returns it verbatim without computing the
// other segments.
func assembleProjectCode(chain []projectChainRow) string {
if len(chain) == 0 {
return ""
}
target := chain[len(chain)-1]
if target.Reference != nil {
if v := strings.TrimSpace(*target.Reference); v != "" {
return v
}
}
segments := make([]string, 0, len(chain))
for _, p := range chain {
seg := projectCodeSegment(p)
if seg == "" {
continue
}
segments = append(segments, seg)
}
return strings.Join(segments, ".")
}
// projectCodeSegment returns the per-row segment string for the dotted
// project code. Empty string → row contributes no segment (skipped by
// the assembler). Pure; never touches the DB. Table-tested.
func projectCodeSegment(p projectChainRow) string {
switch p.Type {
case "client":
if p.Reference != nil {
if v := sanitizeClientShort(*p.Reference); v != "" {
return v
}
}
return sanitizeClientShort(p.Title)
case "litigation":
if p.OpponentCode != nil {
return strings.TrimSpace(*p.OpponentCode)
}
return ""
case "patent":
if p.PatentNumber != nil {
return patentLast3(*p.PatentNumber)
}
return ""
case "case":
if p.ProceedingCode != nil {
return proceedingTail(*p.ProceedingCode)
}
return ""
default:
// 'project' (generic) and any future types contribute nothing.
return ""
}
}
// sanitizeClientShort produces an 8-char uppercase slug from a client
// reference / title. Strips diacritics, replaces non-alphanumerics
// with nothing, trims, caps at 8 chars. Empty input → "".
//
// Examples (verified by table test):
// "EXMPL" → "EXMPL"
// "Example Co." → "EXAMPLEC"
// "Müller GmbH" → "MULLERGM"
// " " → ""
func sanitizeClientShort(s string) string {
s = strings.TrimSpace(s)
if s == "" {
return ""
}
// Strip diacritics: NFD-decompose, drop combining marks, NFC-recompose.
t := transform.Chain(norm.NFD, runes.Remove(runes.In(unicode.Mn)), norm.NFC)
stripped, _, err := transform.String(t, s)
if err != nil {
stripped = s
}
var b strings.Builder
b.Grow(len(stripped))
for _, r := range stripped {
if unicode.IsLetter(r) || unicode.IsDigit(r) {
b.WriteRune(unicode.ToUpper(r))
}
}
out := b.String()
if len(out) > 8 {
out = out[:8]
}
return out
}
// patentDigitsPattern matches a run of digits inside a patent number.
// Pre-compiled once to avoid per-call regex compilation cost.
var patentDigitsPattern = regexp.MustCompile(`\d+`)
// patentKindCodeSuffix matches the trailing kind code on a patent
// publication number (A1, A2, B1, B2, C, T3, etc.). Stripped before
// digit extraction so the kind-code's optional digit doesn't sneak
// into the patent number proper.
//
// EP / WO conventions allow A, B, C, T, U as the letter; the digit is
// optional. The regex anchors at end-of-string and tolerates trailing
// whitespace.
var patentKindCodeSuffix = regexp.MustCompile(`[A-Z][0-9]?\s*$`)
// patentLast3 extracts the last 3 digits of a patent number, returning
// the full digit-stream if the patent has fewer than 3 digits total.
//
// Strips a trailing kind-code suffix (A1, B2, C, T3 …) first so its
// optional digit doesn't pollute the result, then collapses all digit
// runs in the remainder to handle spaced / slashed formats. Examples:
//
// "EP1234567" → "567"
// "EP 1 234 567" → "567"
// "EP3456789A1" → "789"
// "EP1234567 B1" → "567"
// "WO2020/123456A1" → "456"
// "DE12" → "12"
// "EP" → ""
// "" → ""
func patentLast3(s string) string {
s = strings.ToUpper(strings.TrimSpace(s))
if s == "" {
return ""
}
// Strip the trailing kind code (one or two chars at end).
s = patentKindCodeSuffix.ReplaceAllString(s, "")
matches := patentDigitsPattern.FindAllString(s, -1)
if len(matches) == 0 {
return ""
}
digits := strings.Join(matches, "")
if len(digits) >= 3 {
return digits[len(digits)-3:]
}
return digits
}
// proceedingTail takes a proceeding_types.code (e.g. "upc.inf.cfi") and
// returns the uppercase tail with the leading jurisdiction segment
// dropped. The jurisdiction is implied by the ancestor client / patent
// context, so it's redundant in the code.
//
// "upc.inf.cfi" → "INF.CFI"
// "upc.rev.cfi" → "REV.CFI"
// "upc.apl.merits" → "APL.MERITS"
// "de.inf.lg" → "INF.LG"
// "de.inf.olg" → "INF.OLG"
// "single" → "" (no tail after dropping the only segment)
// "" → ""
func proceedingTail(code string) string {
code = strings.TrimSpace(code)
if code == "" {
return ""
}
parts := strings.Split(code, ".")
if len(parts) < 2 {
return ""
}
tail := parts[1:]
out := make([]string, len(tail))
for i, p := range tail {
out[i] = strings.ToUpper(p)
}
return strings.Join(out, ".")
}

View File

@@ -1,376 +0,0 @@
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)
}
})
}
}

View File

@@ -23,7 +23,6 @@ import (
"encoding/json"
"errors"
"fmt"
"regexp"
"slices"
"strings"
"time"
@@ -45,12 +44,6 @@ var (
ErrForbidden = errors.New("forbidden")
// ErrInvalidInput signals a bad request (empty required field etc.).
ErrInvalidInput = errors.New("invalid input")
// ErrLastProjectAdmin guards demoting / removing the last remaining
// effective_project_admin from a project + its ancestor chain. t-paliad-223
// invariant: every project should keep at least one admin somewhere in
// its ancestor chain so a non-global-admin can still manage the team.
// Handlers map to 409 Conflict.
ErrLastProjectAdmin = errors.New("cannot remove last project admin from project + ancestors")
// ErrInvalidProceedingTypeCategory signals that the caller supplied
// a proceeding_type_id pointing at a non-fristenrechner-category row.
// Phase 3 Slice 5 soft-merge (t-paliad-186, design §3.F): only
@@ -61,16 +54,12 @@ var (
)
// ProjectType values enumerated on the projects.type CHECK constraint.
// 'other' (mig 110, m/paliad#51) is the explicit "unclassified" bucket —
// previously this appeared as a synthetic "Empty" option in the type
// filter; the chip now offers it as a real selectable type.
const (
ProjectTypeClient = "client"
ProjectTypeLitigation = "litigation"
ProjectTypePatent = "patent"
ProjectTypeCase = "case"
ProjectTypeProject = "project"
ProjectTypeOther = "other"
)
// Legacy ProjectRole values that used to live on paliad.project_teams.role.
@@ -115,7 +104,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, opponent_code, counterclaim_of, instance_level, metadata, ai_summary,
proceeding_type_id, our_side, counterclaim_of, instance_level, metadata, ai_summary,
created_at, updated_at`
// CreateProjectInput is the payload for Create.
@@ -141,12 +130,6 @@ 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
@@ -186,10 +169,6 @@ 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).
@@ -260,9 +239,6 @@ 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
}
@@ -301,11 +277,6 @@ 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
}
@@ -366,9 +337,6 @@ 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
}
@@ -490,9 +458,6 @@ 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 {
@@ -839,9 +804,6 @@ 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
}
@@ -901,11 +863,6 @@ 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
@@ -916,10 +873,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, opponent_code,
counterclaim_of, instance_level, metadata, created_at, updated_at)
court, case_number, proceeding_type_id, our_side, 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, $24, '{}'::jsonb, $25, $25)`,
$14, $15, $16, $17, $18, $19, $20, $21, $22, $23, '{}'::jsonb, $24, $24)`,
id, input.Type, input.ParentID,
input.Title, input.Reference, input.Description, status,
userID,
@@ -928,7 +885,6 @@ 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,
@@ -1073,12 +1029,6 @@ 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
@@ -1263,9 +1213,6 @@ 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
}
@@ -1425,21 +1372,9 @@ 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): 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.
// 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.
//
// 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.
@@ -1460,12 +1395,6 @@ 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
}
@@ -1961,7 +1890,7 @@ func typeSpecificColumns(t string) []string {
func isValidProjectType(t string) bool {
switch t {
case ProjectTypeClient, ProjectTypeLitigation, ProjectTypePatent,
ProjectTypeCase, ProjectTypeProject, ProjectTypeOther:
ProjectTypeCase, ProjectTypeProject:
return true
}
return false
@@ -1975,29 +1904,15 @@ func validateProjectStatus(s string) error {
return fmt.Errorf("%w: invalid status %q", ErrInvalidInput, s)
}
// 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.
// 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.
func validateOurSide(s string) error {
switch strings.TrimSpace(s) {
case "",
"claimant", "defendant",
"applicant", "appellant",
"respondent",
"third_party", "other":
case "", "claimant", "defendant", "court", "both":
return nil
}
return fmt.Errorf("%w: invalid our_side %q", ErrInvalidInput, s)
@@ -2048,49 +1963,6 @@ 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++ {

View File

@@ -317,10 +317,8 @@ func TestChildTypeForAxis(t *testing.T) {
}
// TestDerivedCounterclaimOurSide pins the our_side flip semantics
// (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.
// (t-paliad-174 §11 Q2):
// - Default (override nil): claimant ↔ defendant; court / both 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).
@@ -339,15 +337,11 @@ 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"},
{"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"},
{"court passes through", str("court"), nil, "court"},
{"both passes through", str("both"), nil, "both"},
{"explicit flip=true", str("claimant"), &tru, "defendant"},
{"explicit flip=false keeps parent's side", str("claimant"), &fal, "claimant"},
{"flip=false on defendant keeps defendant", str("defendant"), &fal, "defendant"},
{"flip=false on applicant keeps applicant", str("applicant"), &fal, "applicant"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {

View File

@@ -273,24 +273,15 @@ func TestLegalSourcePretty(t *testing.T) {
}
// TestOurSideTranslations pins the our_side enum → DE/EN prose
// 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).
// mapping used by addProjectVars.
func TestOurSideTranslations(t *testing.T) {
cases := []struct {
in, wantDE, wantEN string
}{
{"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", "", ""},
{"claimant", "Klägerin", "Claimant"},
{"defendant", "Beklagte", "Defendant"},
{"court", "Gericht", "Court"},
{"both", "Klägerin und Beklagte", "Claimant and Defendant"},
{"", "", ""},
{"unknown", "", ""},
}

View File

@@ -262,11 +262,6 @@ 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)
@@ -393,29 +388,16 @@ 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ägerseite"
return "Klägerin"
case "defendant":
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 "Beklagte"
case "court":
return "Gericht"
case "both":
return "Klägerin und Beklagte"
}
return ""
}
@@ -427,16 +409,10 @@ func ourSideEN(side string) string {
return "Claimant"
case "defendant":
return "Defendant"
case "applicant":
return "Applicant"
case "appellant":
return "Appellant"
case "respondent":
return "Respondent"
case "third_party":
return "Third Party"
case "other":
return "other party"
case "court":
return "Court"
case "both":
return "Claimant and Defendant"
}
return ""
}

View File

@@ -13,7 +13,6 @@ package services
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
@@ -81,13 +80,9 @@ func (s *TeamService) AddMember(ctx context.Context, callerID, projectID, userID
// column. external → 'local_counsel' is intentionally narrower than the
// new enum (loses the expert distinction); we accept that for the short
// transition window.
//
// ResponsibilityAdmin (t-paliad-223) maps to legacy 'lead' — the closest
// legacy match. The legacy column is dead either way; the mapping is
// purely cosmetic until the column is dropped.
func legacyRoleFromResponsibility(r string) string {
switch r {
case ResponsibilityAdmin, ResponsibilityLead:
case ResponsibilityLead:
return "lead"
case ResponsibilityObserver:
return "observer"
@@ -104,43 +99,11 @@ func legacyRoleFromResponsibility(r string) string {
// RemoveMember deletes a direct team membership. Inherited memberships (from
// ancestors) can't be removed at the child level — the caller must remove
// the ancestor row to break the inheritance.
//
// t-paliad-223 last-admin guard: if the row being removed carries
// responsibility='admin', refuse when it would leave the project + its
// ancestor chain with zero admins. Wrapped in a tx so the count + delete
// are atomic; ErrLastProjectAdmin bubbles up unchanged for the handler
// to map to 409.
func (s *TeamService) RemoveMember(ctx context.Context, callerID, projectID, userID uuid.UUID) error {
if _, err := s.projects.GetByID(ctx, callerID, projectID); err != nil {
return err
}
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
// Look up the row first so we know whether to run the guard.
var existing models.ProjectTeamMember
if err := tx.GetContext(ctx, &existing,
`SELECT id, project_id, user_id, role, responsibility, inherited, added_by, created_at
FROM paliad.project_teams
WHERE project_id = $1 AND user_id = $2 AND inherited = false`,
projectID, userID); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return sql.ErrNoRows
}
return fmt.Errorf("lookup team member: %w", err)
}
if existing.Responsibility == ResponsibilityAdmin {
if err := assertProjectKeepsAdmin(ctx, tx, projectID, userID); err != nil {
return err
}
}
res, err := tx.ExecContext(ctx,
res, err := s.db.ExecContext(ctx,
`DELETE FROM paliad.project_teams
WHERE project_id = $1 AND user_id = $2 AND inherited = false`,
projectID, userID)
@@ -150,104 +113,6 @@ func (s *TeamService) RemoveMember(ctx context.Context, callerID, projectID, use
if rows, _ := res.RowsAffected(); rows == 0 {
return sql.ErrNoRows
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit remove team member: %w", err)
}
return nil
}
// ChangeResponsibility updates a direct team member's responsibility.
// RLS enforces the authorisation (only effective_project_admin can pass
// the project_teams_update WITH CHECK); this method handles validation
// + the last-admin guard when the change is AWAY from admin.
//
// Inherited rows can't be edited here — the caller must change the
// ancestor row. Trying to update an inherited row returns sql.ErrNoRows.
func (s *TeamService) ChangeResponsibility(ctx context.Context, callerID, projectID, userID uuid.UUID, newResponsibility string) (*models.ProjectTeamMember, error) {
if !IsValidResponsibility(newResponsibility) {
return nil, fmt.Errorf("%w: invalid responsibility %q", ErrInvalidInput, newResponsibility)
}
if _, err := s.projects.GetByID(ctx, callerID, projectID); err != nil {
return nil, err
}
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
// Read current row so we know whether the guard needs to fire and so
// we can short-circuit no-op writes.
var current models.ProjectTeamMember
if err := tx.GetContext(ctx, &current,
`SELECT id, project_id, user_id, role, responsibility, inherited, added_by, created_at
FROM paliad.project_teams
WHERE project_id = $1 AND user_id = $2 AND inherited = false`,
projectID, userID); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, sql.ErrNoRows
}
return nil, fmt.Errorf("lookup team member: %w", err)
}
if current.Responsibility == newResponsibility {
// No-op; commit the empty tx so caller still gets a typed result.
_ = tx.Commit()
return &current, nil
}
if current.Responsibility == ResponsibilityAdmin && newResponsibility != ResponsibilityAdmin {
if err := assertProjectKeepsAdmin(ctx, tx, projectID, userID); err != nil {
return nil, err
}
}
legacyRole := legacyRoleFromResponsibility(newResponsibility)
var updated models.ProjectTeamMember
if err := tx.GetContext(ctx, &updated,
`UPDATE paliad.project_teams
SET responsibility = $3, role = $4
WHERE project_id = $1 AND user_id = $2 AND inherited = false
RETURNING id, project_id, user_id, role, responsibility, inherited, added_by, created_at`,
projectID, userID, newResponsibility, legacyRole); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, sql.ErrNoRows
}
return nil, fmt.Errorf("change responsibility: %w", err)
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit change responsibility: %w", err)
}
return &updated, nil
}
// assertProjectKeepsAdmin returns ErrLastProjectAdmin iff removing the
// (projectID, excludeUserID) admin row would leave the project's ancestor
// chain (project + every ancestor up to the root) with zero admins.
//
// Counts admin rows on every row in the ancestor chain, excluding the row
// being changed. Uses the same ltree path-walk as paliad.can_see_project.
//
// This is a service-layer guard; we don't put it in an RLS WITH CHECK
// because the count happens post-mutation in a typical WITH CHECK, and
// the natural place to express it is here where we already hold the tx.
func assertProjectKeepsAdmin(ctx context.Context, tx *sqlx.Tx, projectID, excludeUserID uuid.UUID) error {
var remaining int
if err := tx.GetContext(ctx, &remaining, `
SELECT count(*)
FROM paliad.projects p
JOIN paliad.project_teams pt
ON pt.project_id = ANY(string_to_array(p.path, '.')::uuid[])
AND pt.responsibility = 'admin'
WHERE p.id = $1
AND NOT (pt.project_id = $1 AND pt.user_id = $2)
`, projectID, excludeUserID); err != nil {
return fmt.Errorf("count remaining admins: %w", err)
}
if remaining == 0 {
return ErrLastProjectAdmin
}
return nil
}
@@ -394,27 +259,6 @@ func (s *TeamService) ListMembershipsIndex(ctx context.Context, callerID uuid.UU
return out, nil
}
// IsEffectiveProjectAdmin reports whether the user is global_admin OR has
// responsibility='admin' on the project itself or any ancestor in the
// materialised ltree path.
//
// Delegates to paliad.effective_project_admin SQL (t-paliad-223 mig 111).
// The function is STABLE SECURITY DEFINER so it sees rows regardless of
// the caller's RLS context — the boolean answer doesn't leak data.
//
// Used by the project-detail handler to drive the inline-select affordance
// in the team panel: only effective_project_admins see the editable
// <select>; everyone else sees a read-only <span>.
func (s *TeamService) IsEffectiveProjectAdmin(ctx context.Context, userID, projectID uuid.UUID) (bool, error) {
var b bool
if err := s.db.GetContext(ctx, &b,
`SELECT paliad.effective_project_admin($1, $2)`,
userID, projectID); err != nil {
return false, fmt.Errorf("effective_project_admin: %w", err)
}
return b, nil
}
// ---------------------------------------------------------------------------
// pathToIDStrings splits a materialised path into its UUID labels as strings,