Compare commits

..

17 Commits

Author SHA1 Message Date
mAi
d723df6fd4 feat(project-picker): show auto-derived project code in parent typeahead
t-paliad-222 follow-up — wire .code into the parent-project picker so
two same-titled projects in different trees can be disambiguated by
their auto-derived dotted code. Search includes the code; the badge
renders only when distinct from the manual reference.

Excel __meta sheet still pending — the JSON code field is populated
by PopulateProjectCodes for every list payload, so the export
generator only needs to add one row in a follow-up shift.
2026-05-20 14:54:20 +02:00
mAi
9de14f0665 feat(projects-detail): render auto-derived project code as a second header badge
t-paliad-222 follow-up — wire the .code field populated by
PopulateProjectCodes into the project-detail header. Shows next to
the manual reference when distinct, hidden when they match (avoid
duplication) or when no segments resolved. CSS `.entity-ref-code`
adds bracket-styling so the user knows the value is derived rather
than typed.

Also extends the frontend Project interface with code + opponent_code
to make TypeScript surface the new fields cleanly across consumers.
2026-05-20 14:53:26 +02:00
mAi
d326acb31a feat(projects): t-paliad-222 — Client Role + auto-derived project codes
Implements m/paliad#47 (Client Role rework) + m/paliad#50 (auto-derived
project codes from the ancestor tree) in one shift.

Migrations:
- mig 112_client_role_rework: widen paliad.projects.our_side CHECK to
  seven sub-roles (claimant / defendant / applicant / appellant /
  respondent / third_party / other); drop legacy 'court' / 'both'
  and backfill rows to NULL (no-op on prod, defensive on staging).
- mig 113_projects_opponent_code: add paliad.projects.opponent_code
  text on litigation rows (slug pattern [A-Z0-9-]{1,16}); used as
  the middle segment when assembling auto-derived project codes.

Backend:
- internal/services/project_code.go — new package-level helpers
  BuildProjectCode (single row) + PopulateProjectCodes (bulk, one
  CTE-based round-trip). Walks the existing paliad.projects.path
  ltree; custom paliad.projects.reference on the target wins.
- Wired into ProjectService.List, GetByID, ListAncestors, GetTree,
  LoadCounterclaimChildrenVisible, BuildTreeWithOptions — every
  service entry-point that returns []models.Project / *models.Project
  populates .Code before returning.
- Models: Project.OurSide doc widened; new Project.OpponentCode
  (db:"opponent_code") and Project.Code (db:"-", projection-only).
- CreateProjectInput / UpdateProjectInput accept OpponentCode;
  validateOpponentCode + nullableOpponentCode mirror our_side helpers.
- validateOurSide widens to the seven sub-roles; legacy 'court' /
  'both' rejected at the service layer with a clear error before
  the DB CHECK fires.
- derivedCounterclaimOurSide CCR flip widened: applicant ↔ respondent,
  appellant → respondent; third_party / other / NULL pass through.
- submission_vars: project.code added to the placeholder bag.
  ourSideDE / ourSideEN now use the gender-neutral "-Seite" /
  "-Partei" suffix shape (Klägerseite / Antragstellerseite / ...);
  better legal-prose default for a B2B patent practice, matches the
  form labels which already used this shape (cf. head's soft-note on
  Q4).

Frontend:
- ProjectFormFields: opponent_code on a new projekt-fields-litigation
  block (hidden by default, shown when type=litigation); our_side
  moved into projekt-fields-case and re-labelled "Client Role" /
  "Mandantenrolle" with three <optgroup>s + seven options.
- project-form.ts: showFieldsForType toggles the new litigation
  block; readPayload / prefillForm wire opponent_code; our_side
  is now only emitted for type=case.
- fristenrechner: ourSideToPerspective widened to the seven sub-roles
  (Active→claimant, Reactive→defendant, Other→null). ProjectOption
  type literal updated.
- i18n.ts: new projects.field.client_role.* and
  projects.field.opponent_code.* keys (DE+EN). Legacy
  projects.field.our_side.* keys stay one release for cached
  bundles + Verlauf event-history rendering of the new sub-roles.

Tests:
- TestProjectCodeSegment, TestAssembleProjectCode, TestPatentLast3,
  TestSanitizeClientShort, TestProceedingTail, TestValidateOpponentCode,
  TestValidateOurSideSubRoles pin the new pure helpers.
- TestOurSideTranslations widened to the seven sub-roles + new
  prose shape; 'court'/'both' arms now return "" (legacy rejected).
- TestDerivedCounterclaimOurSide widened to the new flip map.

Migration slot history (this branch was rebumped twice on 2026-05-20):
mig 110 was claimed by m/paliad#51 (project_type_other, euler);
mig 111 was claimed by m/paliad#48 (project_admin_and_select, gauss).
Final slots 112 / 113.

go build && go test ./internal/... && cd frontend && bun run build
all clean.
2026-05-20 14:50:19 +02:00
mAi
0a1a1d45ba Merge remote-tracking branch 'origin/main' into mai/kepler/inventorcoder-project 2026-05-20 14:47:53 +02:00
mAi
37cdf23c32 wip(projects): bump migrations 110→111, 111→112 (euler claimed 110) 2026-05-20 14:47:52 +02:00
mAi
111c7c39e8 Merge: t-paliad-223 — team & admin Slices A + C (m/paliad#48 Project Admin role + #53 click-to-select)
Two slices on the team/admin surface. Slice B (Add User, m/paliad#49) is
parked pending m's go-ahead on the SUPABASE_SERVICE_ROLE_KEY credential.

## Slice A — Project Admin role (#48)
- mig 111 (renumbered from author's 110 to avoid collision with euler's
  project_type_other mig 110 merged immediately prior).
- 'admin' added to project_teams.responsibility CHECK.
- New paliad.effective_project_admin(user_id, project_id) SQL function
  walks the ltree path; sees admin on the row, on any ancestor, or
  global_admin status.
- ChangeResponsibility service method + last-admin-on-tree safeguard.
- Frontend inline-select on the project team panel, gated on
  effective_project_admin for the calling user.

## Slice C — Click-to-select (#53)
- /team gains a checkbox column + selection Set + sticky-footer
  broadcast action.
- Selection survives filter changes; drop-out rows de-select; navigation
  wipes selection.
- Empty-selection action falls back to the filtered set (no regression
  vs. existing broadcast).
- No backend changes; pure frontend.

All builds + tests green.
2026-05-20 14:47:13 +02:00
mAi
25cee32d01 feat(team): t-paliad-223 Slice C — click-to-select on /team with sticky-footer broadcast
#53 — adds an explicit selection layer ON TOP of the existing filter
pills on /team. Frontend-only; no backend changes, no migration.

- frontend/src/team.tsx: master "Alle sichtbaren auswählen" checkbox row above the team-list.
- frontend/src/client/team.ts:
  - Module-scoped selectedUserIDs Set + renderedUserIDs DOM-order snapshot + lastToggledUserID for Shift-click range expansion.
  - renderUserCard gains a per-row checkbox + data-selected attribute mirroring the Set.
  - pruneSelectionToVisible(): every render() drops user_ids that no longer match the filter — invariant "selection ⊆ visible".
  - wireSelectionCheckboxes() + applyRangeSelection() + refreshCardSelectedAttribute(): plain-click toggles one row, Shift-click extends a contiguous range using renderedUserIDs as the order reference.
  - renderSelectionFooter(): fixed-position bar that mounts when selection > 0, hides when empty. Hosts the live "{n} ausgewählt" counter, a "Auswahl aufheben" reset, and an "E-Mail an Auswahl" button that calls openBroadcastModal with selectedRecipients() — reuses the existing modal verbatim.
  - syncMasterCheckbox() + onMasterToggle(): tri-state master checkbox (empty / partial / full) for "select all visible".
- frontend/src/styles/global.css: .team-card[data-selected="true"] highlight, .team-card-select checkbox cell, .team-select-master-row, .team-selection-footer (z-index 150 — above mobile bottom-nav at 100, well below modal overlays at 1000+).
- i18n: +10 keys (team.selection.{count,clear,send,select_all,toggle_card}) × DE + EN.

Design picks honoured: surface=/team not /admin/team (Q1), checkbox column not modifier-key (Q2), sticky footer not always-visible (Q3), drop-out de-selects on filter change (Q4), fallback to filtered set when selection empty preserved by leaving the existing top-bar broadcast button intact (Q5), wipe on navigation since the Set is module-scoped in-memory only (Q6).

bun run build clean (2543 i18n keys, data-i18n scan clean). go build + go test -short ./internal/... unchanged (no backend touched).
2026-05-20 14:46:52 +02:00
mAi
2ed0ef3177 feat(team-admin): t-paliad-223 Slice A — Project Admin role + inheritable role-edit gate
#48 — adds 'admin' as fifth project_teams.responsibility value, plumbs an
inheritable role-edit gate via the materialised ltree path.

- migration 110: ALTER responsibility CHECK, CREATE paliad.effective_project_admin(uuid,uuid) STABLE SECURITY DEFINER (mirrors can_see_project shape), REPLACE project_teams_update / _insert / _delete RLS policies. Idempotent + down-mig provided. Dry-run BEGIN..ROLLBACK clean on live supabase.
- services/approval_levels.go: ResponsibilityAdmin const + IsValidResponsibility extension. responsibilityOpensGate UNCHANGED — admin is orthogonal to the 4-Augen approval gate.
- services/team_service.go: ChangeResponsibility() with last-admin guard inside tx (counts admins on project + ancestor chain, excludes the row being changed). RemoveMember() also runs the guard when removing an admin row. New IsEffectiveProjectAdmin() driving the frontend affordance. legacyRoleFromResponsibility: admin → 'lead' (deprecated shadow column).
- services/project_service.go: ErrLastProjectAdmin sentinel mapped to 409 in writeServiceError.
- handlers/teams.go: new PATCH /api/projects/{id}/team/{user_id}. RLS-enforced; non-admins get 404 to avoid existence leakage.
- handlers/projects.go: GET /api/projects/{id} now wraps the payload with effective_admin bool so the frontend drives the inline-select affordance without a second round-trip.
- frontend/src/projects-detail.tsx + client/projects-detail.ts: admin appears as 5th option in 'Mitglied hinzufügen' dropdown. Team-list Rolle cell switches to an inline <select> for callers with effective_admin (read-only span otherwise). Optimistic PATCH with rollback on error (last-admin guard / 403 from RLS / etc.) surfaced as transient toast in #team-msg.
- i18n: +6 keys (admin label + admin.hint + 3 error toasts × 2 langs).
- tests: TestIsValidResponsibility now covers admin; new TestLegacyRoleFromResponsibility pins the mapping table.

go build && go test -short ./internal/... && bun run build all clean.
2026-05-20 14:46:36 +02:00
mAi
e6353d907c Merge remote-tracking branch 'origin/main' into mai/kepler/inventorcoder-project 2026-05-20 14:45:38 +02:00
mAi
2cfd54f0cd wip(projects): t-paliad-222 — backend + frontend changes (pre-merge checkpoint)
Backend: mig 110/111 (will be renumbered after merging main),
validators + helpers widened, BuildProjectCode helper + projection
populator wired into List/GetByID/ListAncestors/GetTree/CCR. All
internal Go tests pass.

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

Migration slots about to be bumped (mig 110 was claimed by euler's
project_type_other on main).
2026-05-20 14:45:33 +02:00
mAi
a5ae2148fa Merge: small UX polish batch — m/paliad#51, #52, #54, #56, #60
5 small cleanups bundled in one batch per m's 'group sensibly' guidance.

- #51 (projects): 'other' added as real project type via CHECK extension;
  synthetic 'Empty' option dropped from the type filter. No NULL-typed rows
  in prod today; backfill is a no-op.
- #52 (approvals): density-picker (Compact/Comfortable) active state uses
  brand accent #c6f41c. Sourced from a CSS variable so future surfaces
  inherit.
- #54 (events): broken 'From Today' appointment filter dropped (frontend
  was sending status=upcoming with no matching backend case). Default for
  appointments is now today's bucket; 'Alle (auch vergangene)' stays as
  the explicit opt-in.
- #56 (deadlines): event type renders before rule; the two are bundled as
  a single 'Verfahrenshandlung' visual block on display (Event Type — Rule).
  Form-level keeps separate inputs but visually grouped.
- #60 (a11y): label htmlFor=trigger-event dropped — the target was a
  <span>, which isn't labelable; the warning surfaced in Chrome Issues tab.
2026-05-20 14:44:01 +02:00
mAi
5a0674a2cf fix(a11y): drop label htmlFor=trigger-event — span isn't labelable
m/paliad#60 (t-paliad-221) — Chrome's Issues tab flagged a label/for
violation on the timeline wizard: <label for="trigger-event"> pointed
at a <span> showing the selected trigger event name. <label for=…>
must target a labelable form control (input/select/textarea/…), never
a span; the browser strips the association and a11y tooling sees a
dangling reference.

Audit found two occurrences — verfahrensablauf.tsx and fristenrechner.tsx
both use the same wizard markup. Switch both captions to plain
<span class="date-label">; the .date-label rule already targets by
class only, so visual styling is unchanged. No other label-for
mismatches surfaced (194 label-fors scanned across frontend/src).
2026-05-20 14:43:42 +02:00
mAi
13bb01ec96 fix(deadlines): event type renders before rule; bundle as Verfahrenshandlung
m/paliad#56 (t-paliad-221) — the deadlines editor read Title → Rule →
Event Type, which inverted the conceptual hierarchy (rule is the
citation under an event type, not its peer). Reorder all three
surfaces so the event-type parent comes first and the rule sits
directly beneath it.

- deadlines-new.tsx: pull the Regel select out of the Due-date row and
  drop it directly under the Typ picker; Due becomes its own row below.
- deadlines-detail.tsx: swap the Typ and Regel <dt>/<dd> rows in the
  detail list.
- approval-edit-modal.ts: remove rule_code from the generic
  DEADLINE_FIELDS list and render it inside a new
  "Verfahrenshandlung (Typ + Regel)" section beneath the event-type
  picker. The shared per-field renderer is extracted so the bundled
  section reuses the same dirty-tracking / pre_image-hint wiring.
- New i18n key approvals.suggest.section.event_type_rule (DE/EN).

Form-level inputs stay independent (some rules attach to multiple
event types and vice versa) — the change is purely about visual
grouping and reading order.
2026-05-20 14:43:42 +02:00
mAi
072b3d0c3d fix(events): drop broken 'From Today' appointment filter; default to today
m/paliad#54 (t-paliad-221) — fix 92780cf added a status=upcoming option
for appointments and made it the default, but DeadlineFilterUpcoming
only narrowed deadlines. The appointment query had no matching case, so
the bucket fell through to the unfiltered path and past events leaked
into "Ab heute" / "From today".

- Drop the 'upcoming' option from STATUS_OPTIONS_APPOINTMENT — confusing
  label that never delivered.
- Default appointments to the 'today' bucket (matches the dashboard
  tile; sane lawyer-relevant view).
- Keep 'Alle (auch vergangene)' as the explicit opt-in at the bottom
  of the list.
- Defensive backend fix: map DeadlineFilterUpcoming to start_at >= today
  in bucketAppointmentWindow so any persisted ?status=upcoming bookmarks
  stop leaking past events.
2026-05-20 14:43:42 +02:00
mAi
e39c4eb62d fix(approvals): density-picker active state uses brand accent
m/paliad#52 (t-paliad-221) — the Compact/Comfortable segmented control
on /approvals was rendering its active pill in plain --color-surface
(white in light mode, midnight-tinted in dark). Switch to the brand
lime so the segmented controls speak the same primary-action language
as the rest of Paliad.

Introduces three semantic tokens (--color-segment-active-bg / fg /
border) so any future surface that adopts .filter-bar-segment
inherits the same accent treatment without a CSS rewrite. The tokens
resolve to --color-accent / --color-accent-dark in both themes,
keeping the midnight foreground WCAG-AA on lime.
2026-05-20 14:43:42 +02:00
mAi
dc5f11ddef feat(projects): add 'other' as a real type; drop synthetic Empty filter
m/paliad#51 (t-paliad-221) — the type chip filter on /projects used to
treat unclassified projects as a synthetic "Empty" bucket. Make 'other'
a first-class projects.type value so every row carries a meaningful
label and the filter UI stops needing a NULL/Empty shim.

- mig 110: extend projects.type CHECK to include 'other'; backfill any
  NULL rows defensively (production query confirmed zero, but the
  NOT NULL constraint isn't load-bearing once the IN-list changes).
- Go: add ProjectTypeOther constant; isValidProjectType + humanProjectType
  recognise it; handler doc lists 'other' in the ?type whitelist.
- Frontend: new chip in the projects.tsx type filter, new option in the
  Create-Project form, DE "Sonstiges" / EN "Other" labels for the
  projects.type and projects.chip.type i18n families.

Also drops a stray data-i18n-text attribute on the existing 'project'
chip checkbox (it had no consumer in i18n.ts and the surrounding markup
was nesting a <span> inside an <input>).
2026-05-20 14:43:42 +02:00
mAi
f99a32490d design(projects): t-paliad-222 — Client Role + auto-derived project codes
Design doc for paired m/paliad#47 (Client Role rework) + m/paliad#50
(auto-derived project codes from the ancestor tree). Two migrations
(110 widen our_side CHECK + backfill court/both → NULL; 111 add
opponent_code on litigations), one new BuildProjectCode helper that
walks the existing ltree path, plus form / submission-template /
Determinator wiring.

9 open design questions surfaced for the head; recommendations
default to the issue-body (R) picks unless a material concern is
flagged in §2.2 / §3.2.

Verified against live data (2026-05-20): all 12 projects have
our_side=NULL, so the backfill is a no-op on production today.
No 'opponent' field exists yet.
2026-05-20 14:27:09 +02:00
48 changed files with 3183 additions and 383 deletions

View File

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

View File

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

View File

@@ -76,12 +76,15 @@ 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" },
];
@@ -121,7 +124,7 @@ export async function openApprovalEditModal(
let eventTypePicker: PickerHandle | null = null;
let eventTypePickerLoaded = false;
if (args.entityType === "deadline") {
const pickerSection = renderEventTypePickerSection();
const pickerSection = renderEventTypePickerSection(original, preImage);
body.appendChild(pickerSection.section);
void (async () => {
try {
@@ -191,67 +194,94 @@ function renderFieldsSection(
section.appendChild(h);
for (const f of fields) {
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);
section.appendChild(renderSingleField(f, original, preImage));
}
return section;
}
function renderEventTypePickerSection(): { section: HTMLElement; host: HTMLElement } {
// 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 } {
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("deadlines.field.event_type");
h.textContent = t("approvals.suggest.section.event_type_rule");
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,8 +125,11 @@ 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" },
@@ -140,7 +143,7 @@ function statusOptionsFor(type: EventTypeChoice): StatusOption[] {
}
function defaultStatusFor(type: EventTypeChoice): string {
return type === "appointment" ? "upcoming" : "pending";
return type === "appointment" ? "today" : "pending";
}
let currentType: EventTypeChoice = "deadline";

View File

@@ -187,12 +187,21 @@ interface ProjectOption {
// (Slice 3b) can scope the cascade by the project's jurisdiction
// without an extra fetch.
proceeding_type_id?: number | null;
// our_side carries which side the firm represents on this project
// (t-paliad-164). When a user selects an Akte, the perspective chip
// pre-locks to this value; a small hint above the strip flags the
// our_side carries which side the firm represents on this case
// project (Client Role; t-paliad-164, widened in t-paliad-222).
// When a user selects an Akte, the perspective chip pre-locks via
// ourSideToPerspective(); a small hint above the strip flags the
// pre-selection and the user can still click another chip to
// override. NULL/undefined leaves the chip unset (free-pick).
our_side?: "claimant" | "defendant" | "court" | "both" | null;
our_side?:
| "claimant"
| "defendant"
| "applicant"
| "appellant"
| "respondent"
| "third_party"
| "other"
| null;
}
async function fetchProjects(): Promise<ProjectOption[]> {
@@ -3801,14 +3810,30 @@ function applyPerspective(p: Perspective) {
triggerCascadeRefresh();
}
// ourSideToPerspective maps the project-level "Wir vertreten" enum
// onto the chip-strip Perspective. 'court' / 'both' map to null
// (chip cleared) — court actions are neutral to the user's side and
// "both" is explicit no-filter intent.
// ourSideToPerspective maps the project-level "Client Role" enum
// (DB column: our_side) onto the chip-strip Perspective.
//
// Per t-paliad-222 (m/paliad#47) the field carries one of seven
// sub-role values grouped at display time:
// Active (we initiate) : claimant, applicant, appellant → "claimant"
// Reactive (we defend) : defendant, respondent → "defendant"
// Other : third_party, other, NULL → null
//
// Legacy 'court' / 'both' values no longer exist in the column
// (mig 110 backfilled them to NULL); both fall through to the null
// default arm if a stale value sneaks in.
function ourSideToPerspective(os: string | null | undefined): Perspective {
if (os === "claimant") return "claimant";
if (os === "defendant") return "defendant";
return null;
switch (os) {
case "claimant":
case "applicant":
case "appellant":
return "claimant";
case "defendant":
case "respondent":
return "defendant";
default:
return null;
}
}
// applyOurSidePredefine locks the perspective from project.our_side

View File

@@ -1210,9 +1210,30 @@ const translations: Record<Lang, Record<string, string>> = {
"projects.field.our_side.unset": "Unbekannt / nicht gesetzt",
"projects.field.our_side.claimant": "Klägerseite",
"projects.field.our_side.defendant": "Beklagtenseite",
"projects.field.our_side.applicant": "Antragsteller",
"projects.field.our_side.appellant": "Berufungsführer",
"projects.field.our_side.respondent": "Antragsgegner",
"projects.field.our_side.third_party": "Streithelfer / Dritter",
"projects.field.our_side.other": "Sonstige Beteiligte",
"projects.field.our_side.court": "Gericht / Tribunal",
"projects.field.our_side.both": "Beide Seiten",
"projects.field.our_side.none": "—",
"projects.field.client_role": "Mandantenrolle",
"projects.field.client_role.hint": "Bestimmt die Voreinstellung der Perspektive im Fristenrechner-Determinator: Aktiv → Klägerseite, Reaktiv → Beklagtenseite. Lässt sich dort jederzeit überschreiben.",
"projects.field.client_role.unset": "Unbekannt",
"projects.field.client_role.group.active": "Aktiv (wir greifen an)",
"projects.field.client_role.group.reactive": "Reaktiv (wir verteidigen)",
"projects.field.client_role.group.other": "Dritte / Sonstige",
"projects.field.client_role.claimant": "Klägerseite",
"projects.field.client_role.applicant": "Antragsteller",
"projects.field.client_role.appellant": "Berufungsführer",
"projects.field.client_role.defendant": "Beklagtenseite",
"projects.field.client_role.respondent": "Antragsgegner",
"projects.field.client_role.third_party": "Streithelfer / Dritter",
"projects.field.client_role.other": "Sonstige Beteiligte",
"projects.field.opponent_code": "Gegner-Kürzel",
"projects.field.opponent_code.placeholder": "z.B. OPNT",
"projects.field.opponent_code.hint": "Kurzes Kürzel der Gegenseite (Großbuchstaben, Ziffern, Bindestriche, max. 16 Zeichen). Wird als mittleres Segment in automatisch abgeleiteten Projekt-Codes verwendet (z.B. EXMPL.OPNT.567.INF.CFI).",
"projects.field.status": "Status",
"projects.error.title_required": "Titel erforderlich",
"projects.detail.edit.type_change_warning.title": "Diese Felder werden geleert:",
@@ -1407,6 +1428,7 @@ 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",
@@ -1414,10 +1436,15 @@ 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",
@@ -1467,6 +1494,7 @@ 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",
@@ -1809,6 +1837,14 @@ 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",
@@ -2295,6 +2331,7 @@ 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",
@@ -3887,9 +3924,30 @@ const translations: Record<Lang, Record<string, string>> = {
"projects.field.our_side.unset": "Unknown / not set",
"projects.field.our_side.claimant": "Claimant side",
"projects.field.our_side.defendant": "Defendant side",
"projects.field.our_side.applicant": "Applicant",
"projects.field.our_side.appellant": "Appellant",
"projects.field.our_side.respondent": "Respondent",
"projects.field.our_side.third_party": "Third Party",
"projects.field.our_side.other": "Other party",
"projects.field.our_side.court": "Court / tribunal",
"projects.field.our_side.both": "Both sides",
"projects.field.our_side.none": "—",
"projects.field.client_role": "Client Role",
"projects.field.client_role.hint": "Pre-selects the perspective chip in the Fristenrechner Determinator: Active → claimant side, Reactive → defendant side. Always overridable from there.",
"projects.field.client_role.unset": "Unknown",
"projects.field.client_role.group.active": "Active (we initiate)",
"projects.field.client_role.group.reactive": "Reactive (we defend)",
"projects.field.client_role.group.other": "Third Party / Other",
"projects.field.client_role.claimant": "Claimant side",
"projects.field.client_role.applicant": "Applicant",
"projects.field.client_role.appellant": "Appellant",
"projects.field.client_role.defendant": "Defendant side",
"projects.field.client_role.respondent": "Respondent",
"projects.field.client_role.third_party": "Third Party",
"projects.field.client_role.other": "Other party",
"projects.field.opponent_code": "Opponent code",
"projects.field.opponent_code.placeholder": "e.g. OPNT",
"projects.field.opponent_code.hint": "Short slug for the opposing party (uppercase letters, digits, dashes, max 16 chars). Used as the middle segment in auto-derived project codes (e.g. EXMPL.OPNT.567.INF.CFI).",
"projects.field.status": "Status",
"projects.error.title_required": "Title required",
"projects.detail.edit.type_change_warning.title": "These fields will be cleared:",
@@ -4083,6 +4141,7 @@ 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",
@@ -4090,10 +4149,15 @@ 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",
@@ -4143,6 +4207,7 @@ 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",
@@ -4482,6 +4547,12 @@ 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",
@@ -4968,6 +5039,7 @@ 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,6 +8,11 @@ export interface ProjectMini {
title: string;
type: string;
reference?: string | null;
// t-paliad-222 / m/paliad#50: auto-derived dotted project code from
// the ancestor tree. Populated by the service projection on every
// /api/projects response, so the picker can show the code without an
// extra fetch.
code?: string;
}
export interface ProjectFormState {
@@ -48,9 +53,11 @@ function tryGet(id: string): HTMLElement | null {
export function showFieldsForType(typeSel: string) {
const parentWrap = tryGet("projekt-parent-wrap") as HTMLDivElement | null;
const clientFields = tryGet("fields-client") as HTMLDivElement | null;
const litigationFields = tryGet("fields-litigation") as HTMLDivElement | null;
const patentFields = tryGet("fields-patent") as HTMLDivElement | null;
const caseFields = tryGet("fields-case") as HTMLDivElement | null;
if (clientFields) clientFields.style.display = typeSel === "client" ? "block" : "none";
if (litigationFields) litigationFields.style.display = typeSel === "litigation" ? "block" : "none";
if (patentFields) patentFields.style.display = typeSel === "patent" ? "block" : "none";
if (caseFields) caseFields.style.display = typeSel === "case" ? "block" : "none";
if (parentWrap) parentWrap.style.display = typeSel === "client" ? "none" : "block";
@@ -88,18 +95,28 @@ export function initParentPicker() {
}
const matches = parentCandidates
.filter((p) => {
const hay = (p.title + " " + (p.reference || "")).toLowerCase();
// Search across title + manual reference + auto-derived code
// so the user can type "EXMPL" or "INF.CFI" and find the row.
const hay = (p.title + " " + (p.reference || "") + " " + (p.code || "")).toLowerCase();
return hay.includes(q);
})
.slice(0, 8);
sugs.innerHTML = matches
.map(
(p) =>
`<div class="collab-suggestion" data-id="${esc(p.id)}" data-title="${esc(p.title)}">
.map((p) => {
// Render the auto-derived code (if any, and distinct from
// reference) as a small mono badge on the right so the user
// can disambiguate two same-titled projects by their tree
// position. Single template literal kept readable inline.
const code = p.code && p.code !== (p.reference || "") ? p.code : "";
const codeBadge = code
? `<span class="entity-ref entity-ref-code">${esc(code)}</span>`
: "";
return `<div class="collab-suggestion" data-id="${esc(p.id)}" data-title="${esc(p.title)}">
<strong>${esc(p.title)}</strong>
<span class="entity-type-chip entity-type-${esc(p.type)}">${esc(tDyn("projects.type." + p.type) || p.type)}</span>
</div>`,
)
${codeBadge}
</div>`;
})
.join("");
sugs.querySelectorAll<HTMLDivElement>(".collab-suggestion").forEach((el) => {
el.addEventListener("click", () => {
@@ -174,20 +191,32 @@ export function readPayload(
const gd = ($("project-grant-date") as HTMLInputElement).value;
if (gd) payload.grant_date = gd + "T00:00:00Z";
}
if (type === "litigation") {
// opponent_code is the litigation-only short slug used as the
// middle segment when BuildProjectCode auto-derives a project
// code from the ancestor tree (t-paliad-222 / m/paliad#50).
// Uppercased on submit so the user can type lowercase comfortably
// — the DB CHECK enforces the [A-Z0-9-]{1,16} pattern.
const ocEl = tryGet("project-opponent-code") as HTMLInputElement | null;
if (ocEl) {
const v = ocEl.value.trim().toUpperCase();
if (v) payload.opponent_code = v;
else if (!opts.omitEmpty) payload.opponent_code = "";
}
}
if (type === "case") {
stringField("project-court", "court");
stringField("project-case-number", "case_number");
}
// our_side is type-agnostic — every project type can carry "Wir
// vertreten" because the Determinator picks it up regardless of
// type. The select uses "" for the unset option; the service maps
// empty string to NULL via nullableOurSide.
const osSel = tryGet("project-our-side") as HTMLSelectElement | null;
if (osSel) {
const v = osSel.value.trim();
if (v) payload.our_side = v;
else if (!opts.omitEmpty) payload.our_side = "";
// Client Role (DB column: our_side) — case-only after t-paliad-222.
// The select uses "" for the unset option; the service maps empty
// string to NULL via nullableOurSide.
const osSel = tryGet("project-our-side") as HTMLSelectElement | null;
if (osSel) {
const v = osSel.value.trim();
if (v) payload.our_side = v;
else if (!opts.omitEmpty) payload.our_side = "";
}
}
const desc = ($("project-description") as HTMLTextAreaElement).value.trim();
@@ -228,6 +257,8 @@ export function prefillForm(p: Record<string, unknown>) {
get("project-case-number").value = String(p.case_number ?? "");
const osSel = tryGet("project-our-side") as HTMLSelectElement | null;
if (osSel) osSel.value = String(p.our_side ?? "");
const ocEl = tryGet("project-opponent-code") as HTMLInputElement | null;
if (ocEl) ocEl.value = String(p.opponent_code ?? "");
getTA("project-description").value = String(p.description ?? "");
getSel("project-status").value = String(p.status ?? "active");
}

View File

@@ -21,6 +21,12 @@ interface Project {
path: string;
title: string;
reference?: string | null;
// t-paliad-222 / m/paliad#50: auto-derived dotted project code from
// the ancestor tree (e.g. EXMPL.OPNT.789.INF.CFI). Populated by the
// service layer on every projection; equal to `reference` when the
// user typed an override.
code?: string;
opponent_code?: string | null;
description?: string | null;
status: string;
client_number?: string | null;
@@ -34,6 +40,12 @@ 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;
}
@@ -1089,6 +1101,24 @@ function renderHeader() {
(document.getElementById("project-title-display") as HTMLElement).textContent = project.title;
(document.getElementById("project-ref-display") as HTMLElement).textContent = project.reference || "";
// t-paliad-222 / m/paliad#50 — show the auto-derived project code
// as a second badge whenever it's non-empty AND distinct from the
// manual reference. Hides when the derived value equals reference
// (avoids visual duplication when the user typed the same string)
// or when no derivation produced a value.
const codeEl = document.getElementById("project-code-display") as HTMLElement | null;
if (codeEl) {
const code = project.code ?? "";
const ref = project.reference ?? "";
if (code && code !== ref) {
codeEl.textContent = code;
codeEl.style.display = "";
} else {
codeEl.textContent = "";
codeEl.style.display = "none";
}
}
// t-paliad-177 — link from Verlauf header to standalone chart page.
// Wired here (not in the TSX shell) because we need the resolved
// project id, which only exists after the detail fetch settles.
@@ -2494,6 +2524,11 @@ 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
@@ -2519,11 +2554,20 @@ 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><span class="projekt-team-responsibility">${esc(responsibilityLabel)}</span></td>
<td>${responsibilityCell}</td>
<td>${source}</td>
<td>${removeBtn}</td>
</tr>`;
@@ -2542,6 +2586,47 @@ 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;
}
});
});
@@ -2725,7 +2810,54 @@ function wireExportButton(projectID: string): void {
function canRemoveTeamMember(m: ProjectTeamMember): boolean {
if (!me) return false;
if (m.user_id === me.id) return true;
return me.global_role === "global_admin";
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);
}
function initTeamForm(id: string) {

View File

@@ -77,6 +77,25 @@ 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>';
@@ -403,8 +422,17 @@ 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">
<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>
<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>
@@ -418,6 +446,13 @@ 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
@@ -505,12 +540,22 @@ 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";
@@ -518,6 +563,223 @@ 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() {
@@ -547,6 +809,8 @@ 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

@@ -13,7 +13,6 @@ import { initSidebar } from "./sidebar";
import {
type DeadlineResponse,
calculateDeadlines,
escHtml,
formatDate,
populateCourtPicker,
renderColumnsBody,
@@ -158,19 +157,13 @@ async function doCalc() {
// the first event in the proceeding — e.g. Klageerhebung for
// upc.inf.cfi, Nichtigkeitsklage for upc.rev.cfi. Falls back to the
// active proceeding name if no root rule fires (shouldn't happen for
// healthy data, but safer than a blank). Fallback respects language —
// proceedingNameEN is consulted on EN before the DE proceedingName
// (m/paliad#58: prior fallback rendered DE on EN for sub-track
// proceedings like upc.ccr.cfi which had no rules → no root).
// healthy data, but safer than a blank).
function triggerEventLabelFor(data: DeadlineResponse): string {
const root = data.deadlines.find((d) => d.isRootEvent);
if (root) {
return getLang() === "en" ? (root.nameEN || root.name) : (root.name || root.nameEN);
}
if (getLang() === "en") {
return data.proceedingNameEN || data.proceedingName || "";
}
return data.proceedingName || data.proceedingNameEN || "";
return data.proceedingName || "";
}
function syncTriggerEventLabel() {
@@ -200,23 +193,11 @@ function renderResults(data: DeadlineResponse) {
<span class="timeline-trigger-date">${t("deadlines.trigger.label")}: ${formatDate(data.triggerDate)}</span>
</div>`;
// Sub-track contextual note (m/paliad#58). Surfaces above the
// timeline body when the server routed the user-picked proceeding
// through a parent (e.g. upc.ccr.cfi → upc.inf.cfi with with_ccr).
// Plain-text banner — server-side copy is plain text per the
// SubTrackRouting contract.
const noteText = getLang() === "en"
? (data.contextualNoteEN || data.contextualNote || "")
: (data.contextualNote || data.contextualNoteEN || "");
const noteHtml = noteText
? `<div class="timeline-context-note" role="note">${escHtml(noteText)}</div>`
: "";
const bodyHtml = procedureView === "columns"
? renderColumnsBody(data, { editable: true, showNotes })
: renderTimelineBody(data, { showParty: true, editable: true, showNotes });
container.innerHTML = headerHtml + noteHtml + bodyHtml;
container.innerHTML = headerHtml + bodyHtml;
if (printBtn) printBtn.style.display = "block";
if (toggle) toggle.style.display = "";

View File

@@ -95,21 +95,8 @@ export function priorityRendering(
export interface DeadlineResponse {
proceedingType: string;
proceedingName: string;
// proceedingNameEN: English label of the picked proceeding. Empty
// when not populated server-side; frontend falls back to
// proceedingName. Used for the "Trigger event" fallback when the
// timeline has no root rule. (m/paliad#58)
proceedingNameEN?: string;
triggerDate: string;
deadlines: CalculatedDeadline[];
// contextualNote / contextualNoteEN render as a banner above the
// timeline. Populated when the picked proceeding is a sub-track of
// another proceeding (e.g. upc.ccr.cfi runs inside upc.inf.cfi with
// with_ccr) — the server routes to the parent's rules but keeps the
// picked proceeding's identity in the response, and the note
// explains the framing. (m/paliad#58)
contextualNote?: string;
contextualNoteEN?: string;
}
export interface CourtRow {

View File

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

View File

@@ -82,15 +82,21 @@ export function renderDeadlinesDetail(): string {
<input type="date" id="deadline-due-edit" style="display:none" />
</dd>
<dt data-i18n="deadlines.detail.rule">Regel</dt>
<dd id="deadline-rule-display">&mdash;</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.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,18 +101,19 @@ export function renderDeadlinesNew(): string {
</p>
</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>
{/* 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">
<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">
<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">

View File

@@ -485,7 +485,10 @@ export function renderFristenrechner(): string {
<div className="date-input-group">
<div className="date-field-row">
<label htmlFor="trigger-event" className="date-label" data-i18n="deadlines.trigger.event">Ausl&ouml;sendes Ereignis:</label>
{/* 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>
<span id="trigger-event" className="trigger-event-name">&mdash;</span>
</div>
<div className="date-field-row">

View File

@@ -658,6 +658,7 @@ 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"
@@ -1974,6 +1975,7 @@ 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"
@@ -2161,6 +2163,19 @@ export type I18nKey =
| "projects.field.billing_reference"
| "projects.field.case_number"
| "projects.field.client_number"
| "projects.field.client_role"
| "projects.field.client_role.appellant"
| "projects.field.client_role.applicant"
| "projects.field.client_role.claimant"
| "projects.field.client_role.defendant"
| "projects.field.client_role.group.active"
| "projects.field.client_role.group.other"
| "projects.field.client_role.group.reactive"
| "projects.field.client_role.hint"
| "projects.field.client_role.other"
| "projects.field.client_role.respondent"
| "projects.field.client_role.third_party"
| "projects.field.client_role.unset"
| "projects.field.clientmatter.hint"
| "projects.field.collaborators"
| "projects.field.collaborators.hint"
@@ -2178,13 +2193,21 @@ export type I18nKey =
| "projects.field.matter_number"
| "projects.field.netdocuments_url"
| "projects.field.office"
| "projects.field.opponent_code"
| "projects.field.opponent_code.hint"
| "projects.field.opponent_code.placeholder"
| "projects.field.our_side"
| "projects.field.our_side.appellant"
| "projects.field.our_side.applicant"
| "projects.field.our_side.both"
| "projects.field.our_side.claimant"
| "projects.field.our_side.court"
| "projects.field.our_side.defendant"
| "projects.field.our_side.hint"
| "projects.field.our_side.none"
| "projects.field.our_side.other"
| "projects.field.our_side.respondent"
| "projects.field.our_side.third_party"
| "projects.field.our_side.unset"
| "projects.field.parent"
| "projects.field.parent.hint"
@@ -2231,6 +2254,9 @@ 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"
@@ -2241,6 +2267,8 @@ 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"
@@ -2289,6 +2317,7 @@ export type I18nKey =
| "projects.type.case"
| "projects.type.client"
| "projects.type.litigation"
| "projects.type.other"
| "projects.type.patent"
| "projects.type.project"
| "projects.unavailable"
@@ -2355,6 +2384,11 @@ 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,6 +50,14 @@ export function renderProjectsDetail(): string {
<div className="entity-detail-meta">
<span id="project-type-chip" className="entity-type-chip" />
<span className="entity-ref" id="project-ref-display" />
{/* Auto-derived project code (t-paliad-222 / m/paliad#50).
Rendered as a separate badge so the user can still
distinguish a custom reference (left badge) from a
tree-derived code (right badge); when reference is
blank, the derived code IS reference and only this
badge shows. Hidden via inline style until the
client populates it. */}
<span className="entity-ref entity-ref-code" id="project-code-display" style="display:none" title="Auto-derived project code" />
<span id="project-clientmatter" className="entity-ref" />
<span id="project-status-chip" className="entity-status-chip" />
<a id="project-netdocs" className="netdocs-link" target="_blank" rel="noopener" style="display:none">netDocuments &nearr;</a>
@@ -262,6 +270,7 @@ 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,7 +127,8 @@ 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" data-i18n-text="projects.chip.type.project"><span data-i18n="projects.chip.type.project">Projekt</span></input></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>
</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,6 +59,14 @@
--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
@@ -173,6 +181,13 @@
--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);
@@ -3289,23 +3304,6 @@ input[type="range"]::-moz-range-thumb {
font-size: 1rem;
}
/* Sub-track contextual note banner (m/paliad#58). Renders above the
timeline body when the picked proceeding is a sub-track of another
proceeding (e.g. UPC CCR rendered standalone). Plain-text content;
white-space: pre-line preserves paragraph breaks if server copy
ever uses them. */
.timeline-context-note {
margin: 0 0 1rem;
padding: 0.7rem 0.9rem;
background: rgba(198, 244, 28, 0.10);
border-left: 3px solid var(--brand-lime, #c6f41c);
border-radius: 4px;
color: var(--color-text, #222);
font-size: 0.9rem;
line-height: 1.4;
white-space: pre-line;
}
.timeline {
position: relative;
}
@@ -6795,6 +6793,17 @@ dialog.modal::backdrop {
max-width: 100%;
}
/* Auto-derived project code badge (t-paliad-222 / m/paliad#50).
Distinct from the user's manual reference badge — same mono shape,
subtly bracketed so the reader knows it's a derived/computed value
rather than something typed by hand. Renders only when distinct
from the manual reference (see renderHeader in projects-detail.ts). */
.entity-ref-code {
opacity: 0.75;
}
.entity-ref-code::before { content: "[ "; }
.entity-ref-code::after { content: " ]"; }
.entity-detail-actions {
display: flex;
gap: 0.5rem;
@@ -9599,7 +9608,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;
transition: border-color 0.15s, box-shadow 0.15s, background 0.15s;
}
.team-card:hover {
@@ -9607,6 +9616,95 @@ 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;
@@ -14175,8 +14273,9 @@ dialog.quick-add-sheet::backdrop {
border: 1px solid transparent;
}
.filter-bar-segment .filter-bar-chip.agenda-chip-active {
background: var(--color-surface, #ffffff);
border-color: var(--color-border, #e5e7eb);
background: var(--color-segment-active-bg);
color: var(--color-segment-active-fg);
border-color: var(--color-segment-active-border);
}
.filter-bar-chip-pending {

View File

@@ -75,6 +75,14 @@ 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,7 +163,10 @@ export function renderVerfahrensablauf(): string {
<div className="date-input-group">
<div className="date-field-row">
<label htmlFor="trigger-event" className="date-label" data-i18n="deadlines.trigger.event">Ausl&ouml;sendes Ereignis:</label>
{/* 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>
<span id="trigger-event" className="trigger-event-name">&mdash;</span>
</div>
<div className="date-field-row">

2
go.mod
View File

@@ -8,6 +8,7 @@ require (
github.com/jmoiron/sqlx v1.4.0
github.com/lib/pq v1.12.3
github.com/xuri/excelize/v2 v2.10.1
golang.org/x/text v0.34.0
)
require (
@@ -20,5 +21,4 @@ require (
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/net v0.50.0 // indirect
golang.org/x/text v0.34.0 // indirect
)

View File

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

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

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

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

@@ -0,0 +1,30 @@
-- Down migration for 112_client_role_rework.
--
-- Restores the original 4-value CHECK ('claimant','defendant',
-- 'court','both', NULL) and backfills any rows that landed on a new
-- sub-role value (applicant / appellant / respondent / third_party /
-- other) to NULL so the schema is internally consistent after the
-- step-down.
BEGIN;
-- Backfill new sub-role values to NULL so the old CHECK doesn't reject.
UPDATE paliad.projects
SET our_side = NULL
WHERE our_side IN ('applicant', 'appellant', 'respondent', 'third_party', 'other');
ALTER TABLE paliad.projects
DROP CONSTRAINT IF EXISTS projects_our_side_check;
ALTER TABLE paliad.projects
ADD CONSTRAINT projects_our_side_check
CHECK (our_side IS NULL
OR our_side IN ('claimant', 'defendant', 'court', 'both'));
COMMENT ON COLUMN paliad.projects.our_side IS
'Which side the firm represents on this project. Used by the '
'Fristenrechner Determinator (Slice 3c) to predefine the '
'perspective chip from the project context. Allowed: claimant, '
'defendant, court, both.';
COMMIT;

View File

@@ -0,0 +1,51 @@
-- mig 112 — t-paliad-222 / m/paliad#47 — Client Role rework.
--
-- Widens paliad.projects.our_side CHECK to seven sub-role values and
-- drops the legacy 'court' / 'both' entries. The DB column name stays
-- as 'our_side' (UI label changes only — see design doc §2.2 Q1).
--
-- New allowed sub-roles, grouped at display time:
-- Active (we initiate) : claimant, applicant, appellant
-- Reactive (we defend) : defendant, respondent
-- Third Party / Other : third_party, other
-- NULL : unknown / not set
--
-- Backfill: any rows still on 'court' / 'both' fall back to NULL.
-- Verified 2026-05-20: all 12 production rows are NULL, so this is
-- a no-op on prod; the UPDATE runs defensively for staging / test
-- fixtures that may carry the legacy values.
--
-- Idempotent so re-runs against a partially-applied state stay safe.
BEGIN;
-- 1. Backfill any 'court' / 'both' rows to NULL.
UPDATE paliad.projects
SET our_side = NULL
WHERE our_side IN ('court', 'both');
-- 2. Swap the CHECK constraint for the widened sub-role set.
ALTER TABLE paliad.projects
DROP CONSTRAINT IF EXISTS projects_our_side_check;
ALTER TABLE paliad.projects
ADD CONSTRAINT projects_our_side_check
CHECK (our_side IS NULL OR our_side IN (
'claimant', 'defendant',
'applicant', 'appellant',
'respondent',
'third_party', 'other'
));
COMMENT ON COLUMN paliad.projects.our_side IS
'Which side the firm represents on this case project (renamed in '
'the UI to "Client Role" / "Mandantenrolle" — t-paliad-222 / '
'm/paliad#47). Allowed sub-roles, grouped at display time: Active '
'(claimant, applicant, appellant); Reactive (defendant, '
'respondent); Third Party / Other (third_party, other). NULL = '
'unknown. The form hides the field on non-case project types. '
'Drives the Fristenrechner Determinator perspective chip — Active '
'group → claimant-perspective, Reactive → defendant-perspective, '
'Third Party / Other → null (chip free-pick).';
COMMIT;

View File

@@ -0,0 +1,11 @@
-- Down migration for 113_projects_opponent_code.
BEGIN;
ALTER TABLE paliad.projects
DROP CONSTRAINT IF EXISTS projects_opponent_code_check;
ALTER TABLE paliad.projects
DROP COLUMN IF EXISTS opponent_code;
COMMIT;

View File

@@ -0,0 +1,50 @@
-- mig 113 — t-paliad-222 / m/paliad#50 — auto-derived project codes.
--
-- Adds an opponent-code slug field on litigation projects. Used as
-- the middle segment when BuildProjectCode assembles an auto-derived
-- project code from the ancestor tree (e.g. EXMPL.OPNT.567.INF.CFI).
--
-- NULL = segment skipped silently. Existing litigation rows yield
-- codes without an opponent segment until the user fills the field.
-- No backfill from `title` — the litigation title is free-text
-- ("Siemens AG ./. Huawei", "Mandant vs Gegner") and any regex would
-- be brittle; the user enters the slug once at project creation /
-- next edit.
--
-- Slug shape: uppercase letters / digits / dashes, max 16 chars.
-- Constraint also gates on type='litigation' so a stray value on a
-- non-litigation row is rejected at the DB level (defence in depth;
-- the form already hides the field on other types).
--
-- Idempotent.
BEGIN;
ALTER TABLE paliad.projects
ADD COLUMN IF NOT EXISTS opponent_code text;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'projects_opponent_code_check'
AND conrelid = 'paliad.projects'::regclass
) THEN
ALTER TABLE paliad.projects
ADD CONSTRAINT projects_opponent_code_check
CHECK (opponent_code IS NULL
OR (opponent_code ~ '^[A-Z0-9-]{1,16}$'
AND type = 'litigation'));
END IF;
END $$;
COMMENT ON COLUMN paliad.projects.opponent_code IS
'Short slug for the opposing party on a litigation project '
'(uppercase letters, digits, dashes, max 16 chars). Used as the '
'middle segment when BuildProjectCode walks the ancestor tree to '
'assemble a dotted project code — e.g. EXMPL.OPNT.567.INF.CFI '
'(t-paliad-222 / m/paliad#50). NULL = segment skipped silently. '
'Only meaningful on type=''litigation'' rows; the CHECK enforces '
'that pairing.';
COMMIT;

View File

@@ -325,6 +325,7 @@ 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,6 +11,7 @@ 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"
)
@@ -104,6 +105,8 @@ 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"})
@@ -319,7 +322,24 @@ func handleGetProject(w http.ResponseWriter, r *http.Request) {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, p)
// 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,
})
}
// GET /api/projects/{id}/children — direct children.
@@ -351,7 +371,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 — type whitelist
// ?type=client,litigation,patent,case,project,other — 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,6 +473,8 @@ func humanProjectType(t string) string {
return "Verfahren"
case services.ProjectTypeProject:
return "Projekt"
case services.ProjectTypeOther:
return "Sonstiges"
}
return t
}

View File

@@ -93,6 +93,53 @@ 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,10 +159,35 @@ type Project struct {
// OurSide is which side the firm represents on this project. Used
// by the Fristenrechner Determinator to predefine the perspective
// chip from the project context (t-paliad-164). NULL = unknown /
// not set; Determinator falls back to free-pick. Allowed values:
// claimant, defendant, court, both.
// not set; Determinator falls back to free-pick.
//
// Allowed sub-roles (mig 112, t-paliad-222):
// Active : claimant, applicant, appellant
// Reactive : defendant, respondent
// Other : third_party, other
//
// The DB column name stays as `our_side`; the UI label has moved
// to "Client Role" / "Mandantenrolle" on case projects and is
// hidden on every other project type.
OurSide *string `db:"our_side" json:"our_side,omitempty"`
// OpponentCode is the short slug for the opposing party on a
// litigation project (uppercase letters / digits / dashes, max 16
// chars). Used as the middle segment when services.BuildProjectCode
// assembles an auto-derived project code from the ancestor tree —
// e.g. EXMPL.OPNT.567.INF.CFI (t-paliad-222 / m/paliad#50). NULL
// → segment skipped silently. Only meaningful on type='litigation'
// rows; CHECK constraint (mig 113) enforces the pairing.
OpponentCode *string `db:"opponent_code" json:"opponent_code,omitempty"`
// Code is the auto-derived (or override) project code, computed at
// projection time by services.BuildProjectCode. Not a DB column —
// no `db:` tag — populated by service-layer projection helpers
// after the row is loaded. Empty on rows for which the helper has
// not run (e.g. raw fixtures in tests, internal projection paths
// that don't call the helper).
Code string `db:"-" json:"code,omitempty"`
// CounterclaimOf is the parent project this row is a counterclaim
// (CCR) against (t-paliad-174 SmartTimeline Slice 3). NULL on
// regular projects; non-NULL rows are CCR sub-projects rendered as

View File

@@ -25,7 +25,14 @@ 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"
@@ -143,7 +150,7 @@ func IsValidProfession(p string) bool {
// recognised project-responsibility enum values. Used by TeamService.
func IsValidResponsibility(r string) bool {
switch r {
case ResponsibilityLead, ResponsibilityMember,
case ResponsibilityAdmin, ResponsibilityLead, ResponsibilityMember,
ResponsibilityObserver, ResponsibilityExternal:
return true
}

View File

@@ -190,7 +190,8 @@ func TestIsValidProfession(t *testing.T) {
}
func TestIsValidResponsibility(t *testing.T) {
for _, r := range []string{"lead", "member", "observer", "external"} {
// t-paliad-223 added 'admin'; the four legacy values stay valid.
for _, r := range []string{"admin", "lead", "member", "observer", "external"} {
t.Run(r, func(t *testing.T) {
if !IsValidResponsibility(r) {
t.Errorf("IsValidResponsibility(%q) must be true", r)
@@ -206,6 +207,30 @@ 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,7 +279,12 @@ 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 /
// upcoming / "" / overdue / completed — those are handled separately).
// "" / 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).
func bucketAppointmentWindow(status DeadlineStatusFilter, b deadlineBucketBounds) (*time.Time, *time.Time) {
switch status {
case DeadlineFilterToday:
@@ -293,6 +298,8 @@ 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

@@ -94,27 +94,10 @@ type UIDeadline struct {
// UIResponse matches the frontend's DeadlineResponse TypeScript interface.
type UIResponse struct {
ProceedingType string `json:"proceedingType"`
ProceedingName string `json:"proceedingName"`
// ProceedingNameEN carries the English label of the proceeding so
// the frontend can switch on lang. Empty when the proceeding has no
// English label populated; the frontend falls back to ProceedingName.
// Added 2026-05-20 (m/paliad#58) — previously the verfahrensablauf
// "Trigger event" label fell back to the DE proceedingName whenever
// the timeline had no root rule (e.g. for sub-track proceedings like
// upc.ccr.cfi that have no native rules).
ProceedingNameEN string `json:"proceedingNameEN,omitempty"`
TriggerDate string `json:"triggerDate"`
Deadlines []UIDeadline `json:"deadlines"`
// ContextualNote / ContextualNoteEN surface a banner above the
// timeline. Populated by sub-track routing (m/paliad#58): when the
// user picks a proceeding that is normally a sub-track of another
// proceeding (e.g. upc.ccr.cfi runs inside upc.inf.cfi with
// with_ccr), the renderer routes to the parent's rules but keeps
// the user-picked code/name as the response identity and surfaces a
// note explaining the framing.
ContextualNote string `json:"contextualNote,omitempty"`
ContextualNoteEN string `json:"contextualNoteEN,omitempty"`
ProceedingType string `json:"proceedingType"`
ProceedingName string `json:"proceedingName"`
TriggerDate string `json:"triggerDate"`
Deadlines []UIDeadline `json:"deadlines"`
}
// ErrUnknownProceedingType is returned when the UI sends an unrecognised code.
@@ -254,42 +237,6 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
return nil, fmt.Errorf("resolve proceeding %q: %w", proceedingCode, err)
}
// Sub-track routing (m/paliad#58). When the user picks a proceeding
// that has no native rules and is normally a sub-track of another
// proceeding (today: upc.ccr.cfi → upc.inf.cfi + with_ccr), route
// rule lookup to the parent and merge the default flags into the
// user's flag set. The response identity (Code/Name/NameEN) stays
// on the user-picked proceeding so the page header still reads
// "Counterclaim for Revocation", but the timeline body is the
// parent's full flow with the sub-track flag enabled. A note
// surfaces the framing.
var pickedProceeding = pt
var subTrackNote SubTrackRouting
var hasSubTrackNote bool
if route, ok := LookupSubTrackRouting(proceedingCode); ok {
subTrackNote = route
hasSubTrackNote = true
// Re-resolve to the parent proceeding for rule lookup.
err = s.rules.db.GetContext(ctx, &pt,
`SELECT id, code, name, name_en, jurisdiction
FROM paliad.proceeding_types
WHERE code = $1 AND is_active = true`, route.ParentCode)
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("sub-track %q routes to %q which is not active: %w", proceedingCode, route.ParentCode, ErrUnknownProceedingType)
}
if err != nil {
return nil, fmt.Errorf("resolve sub-track parent %q: %w", route.ParentCode, err)
}
// Merge default flags into the user's flag set so the gated
// rules render. User-supplied flags win on conflict (they're
// already in flagSet); default flags only add what's missing.
for _, f := range route.DefaultFlags {
if _, exists := flagSet[f]; !exists {
flagSet[f] = struct{}{}
}
}
}
// Resolve (country, regime) for non-working-day adjustment. Court wins
// when supplied; otherwise default by proceeding regime. UPC proceedings
// default to UPC München (DE+UPC) — most common HLC venue. DPMA / EPA /
@@ -597,18 +544,12 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
deadlines = append(deadlines, d)
}
resp := &UIResponse{
ProceedingType: pickedProceeding.Code,
ProceedingName: pickedProceeding.Name,
ProceedingNameEN: pickedProceeding.NameEN,
TriggerDate: triggerDateStr,
Deadlines: deadlines,
}
if hasSubTrackNote {
resp.ContextualNote = subTrackNote.NoteDE
resp.ContextualNoteEN = subTrackNote.NoteEN
}
return resp, nil
return &UIResponse{
ProceedingType: pt.Code,
ProceedingName: pt.Name,
TriggerDate: triggerDateStr,
Deadlines: deadlines,
}, nil
}
// ErrUnknownRule is returned when CalculateRule can't resolve the

View File

@@ -132,60 +132,8 @@ func MapLitigationToFristenrechner(litigationCode, jurisdiction string) (fristen
// "Regeln liegen auf upc.inf.cfi (with_ccr=true); wir leiten Sie dorthin
// weiter." in the UI.
func ResolveCounterclaimRouting(code string) (effectiveCode string, defaultFlags []string, routed bool) {
if route, ok := SubTrackRoutings[code]; ok {
return route.ParentCode, route.DefaultFlags, true
if code == CodeUPCCounterclaim {
return CodeUPCInfringement, []string{"with_ccr"}, true
}
return code, nil, false
}
// SubTrackRouting describes a proceeding type that has no native rules
// of its own and is normally rendered inside a parent proceeding's flow
// with one or more condition flags enabled. The Procedure Roadmap
// (verfahrensablauf) routes calc requests for these codes to the parent
// proceeding + default flags, but preserves the user-picked code/name
// in the response identity and surfaces a contextual note explaining
// the framing — see m/paliad#58 and the design doc cited above.
//
// Adding a new sub-track is a data-only change here: extend
// SubTrackRoutings with the (code, parent, flags, note) tuple and the
// renderer picks it up automatically. The note copy lives in this file
// because it's semantic to the routing, not UI chrome.
type SubTrackRouting struct {
// Code is the user-picked proceeding code (e.g. "upc.ccr.cfi").
Code string
// ParentCode is the proceeding whose rules to use (e.g. "upc.inf.cfi").
ParentCode string
// DefaultFlags are merged into the user's flag set so the
// gated rules render. Order is preserved.
DefaultFlags []string
// NoteDE / NoteEN are the contextual banner above the timeline,
// explaining that the proceeding type is normally a sub-track.
// Plain text — the frontend renders them as a banner.
NoteDE string
NoteEN string
}
// SubTrackRoutings — single-source-of-truth registry. Today: just CCR.
// The pattern generalises to other "sub-track" proceeding types (e.g.
// R.30 application to amend the patent as a standalone roadmap, R.46
// preliminary objection) once they have a proceeding-type code of their
// own. New entries here are picked up by the spawn-as-standalone
// renderer in FristenrechnerService.Calculate without further wiring.
var SubTrackRoutings = map[string]SubTrackRouting{
CodeUPCCounterclaim: {
Code: CodeUPCCounterclaim,
ParentCode: CodeUPCInfringement,
DefaultFlags: []string{"with_ccr"},
NoteDE: "Die Nichtigkeitswiderklage läuft normalerweise innerhalb eines UPC-Verletzungsverfahrens mit aktiver Nichtigkeitswiderklage. Diese Zeitleiste zeigt das Verletzungsverfahren mit gesetztem with_ccr-Flag.",
NoteEN: "The counterclaim for revocation normally runs inside a UPC infringement action with the counterclaim flag set. This timeline shows the infringement action with with_ccr automatically enabled.",
},
}
// LookupSubTrackRouting returns the sub-track routing for a proceeding
// code, or (zero, false) if the code is not a sub-track. Used by the
// fristenrechner Calculate path to spawn the parent flow with the sub-
// track's default flags.
func LookupSubTrackRouting(code string) (SubTrackRouting, bool) {
r, ok := SubTrackRoutings[code]
return r, ok
}

View File

@@ -81,43 +81,3 @@ func TestResolveCounterclaimRouting(t *testing.T) {
}
})
}
// TestSubTrackRoutings asserts the registry shape m/paliad#58 depends
// on: every entry's Code matches its map key, has a non-empty
// ParentCode + DefaultFlags + bilingual notes. Drift here silently
// breaks the spawn-as-standalone renderer (a CCR pick would 404 or
// render an empty timeline), so we pin the contract.
func TestSubTrackRoutings(t *testing.T) {
if len(SubTrackRoutings) == 0 {
t.Fatal("SubTrackRoutings is empty — at minimum upc.ccr.cfi must be registered")
}
for key, route := range SubTrackRoutings {
if route.Code != key {
t.Errorf("SubTrackRoutings[%q].Code = %q, want %q (key/value mismatch)", key, route.Code, key)
}
if route.ParentCode == "" {
t.Errorf("SubTrackRoutings[%q] has empty ParentCode", key)
}
if len(route.DefaultFlags) == 0 {
t.Errorf("SubTrackRoutings[%q] has no DefaultFlags — sub-track routing without flags is a no-op", key)
}
if route.NoteDE == "" || route.NoteEN == "" {
t.Errorf("SubTrackRoutings[%q] missing bilingual note: DE=%q EN=%q", key, route.NoteDE, route.NoteEN)
}
}
// CCR is the canonical entry — assert its exact shape so a future
// rename doesn't silently change semantics.
ccr, ok := LookupSubTrackRouting(CodeUPCCounterclaim)
if !ok {
t.Fatal("LookupSubTrackRouting(upc.ccr.cfi) returned ok=false; entry must be registered")
}
if ccr.ParentCode != CodeUPCInfringement {
t.Errorf("CCR.ParentCode = %q, want %q", ccr.ParentCode, CodeUPCInfringement)
}
if !reflect.DeepEqual(ccr.DefaultFlags, []string{"with_ccr"}) {
t.Errorf("CCR.DefaultFlags = %v, want [with_ccr]", ccr.DefaultFlags)
}
if _, miss := LookupSubTrackRouting(CodeUPCInfringement); miss {
t.Error("LookupSubTrackRouting(upc.inf.cfi) returned ok=true; non-sub-track codes must miss")
}
}

View File

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

View File

@@ -0,0 +1,376 @@
package services
import (
"testing"
"github.com/google/uuid"
)
// TestProjectCodeSegment pins the per-type segment derivation rules
// from t-paliad-222 design §3.2:
//
// client → reference if set, else sanitized title (cap 8 chars)
// litigation → opponent_code verbatim (empty → skipped)
// patent → last 3 digits of patent_number
// case → uppercase tail of proceeding_types.code
// project → ""
func TestProjectCodeSegment(t *testing.T) {
str := func(s string) *string { return &s }
intp := func(i int) *int { return &i }
cases := []struct {
name string
row projectChainRow
want string
}{
// Client rows.
{
"client with reference",
projectChainRow{Type: "client", Title: "Example Co.", Reference: str("EXMPL")},
"EXMPL",
},
{
"client without reference falls back to slug(title)",
projectChainRow{Type: "client", Title: "Example Co.", Reference: nil},
"EXAMPLEC",
},
{
"client without reference, diacritics stripped",
projectChainRow{Type: "client", Title: "Müller GmbH"},
"MULLERGM",
},
{
"client with empty reference falls back to title",
projectChainRow{Type: "client", Title: "ACME", Reference: str(" ")},
"ACME",
},
{
"client with empty title and no reference → empty",
projectChainRow{Type: "client", Title: ""},
"",
},
// Litigation rows.
{
"litigation with opponent_code",
projectChainRow{Type: "litigation", Title: "X v Y", OpponentCode: str("OPNT")},
"OPNT",
},
{
"litigation without opponent_code → empty",
projectChainRow{Type: "litigation", Title: "X v Y", OpponentCode: nil},
"",
},
// Patent rows.
{
"patent EP1234567 → 567",
projectChainRow{Type: "patent", PatentNumber: str("EP1234567")},
"567",
},
{
"patent with spaces EP 3 456 789 → 789",
projectChainRow{Type: "patent", PatentNumber: str("EP 3 456 789")},
"789",
},
{
"patent with kind code EP3456789A1 → 789",
projectChainRow{Type: "patent", PatentNumber: str("EP3456789A1")},
"789",
},
{
"patent WO2020/123456 → 456",
projectChainRow{Type: "patent", PatentNumber: str("WO2020/123456")},
"456",
},
{
"patent shorter than 3 digits → full",
projectChainRow{Type: "patent", PatentNumber: str("DE12")},
"12",
},
{
"patent nil → empty",
projectChainRow{Type: "patent", PatentNumber: nil},
"",
},
{
"patent empty digit-stream → empty",
projectChainRow{Type: "patent", PatentNumber: str("EP")},
"",
},
// Case rows.
{
"case upc.inf.cfi → INF.CFI",
projectChainRow{Type: "case", ProceedingTypeID: intp(8), ProceedingCode: str("upc.inf.cfi")},
"INF.CFI",
},
{
"case upc.apl.merits → APL.MERITS",
projectChainRow{Type: "case", ProceedingTypeID: intp(11), ProceedingCode: str("upc.apl.merits")},
"APL.MERITS",
},
{
"case de.inf.lg → INF.LG",
projectChainRow{Type: "case", ProceedingTypeID: intp(12), ProceedingCode: str("de.inf.lg")},
"INF.LG",
},
{
"case without proceeding_code → empty",
projectChainRow{Type: "case", ProceedingTypeID: nil, ProceedingCode: nil},
"",
},
{
"case with single-segment code → empty (no tail)",
projectChainRow{Type: "case", ProceedingCode: str("single")},
"",
},
// Generic project rows contribute nothing.
{
"generic project → empty",
projectChainRow{Type: "project", Title: "Whatever"},
"",
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := projectCodeSegment(c.row)
if got != c.want {
t.Errorf("projectCodeSegment() = %q, want %q", got, c.want)
}
})
}
}
// TestAssembleProjectCode covers the chain assembler, including the
// custom-override fast-path on the target row's `reference`.
func TestAssembleProjectCode(t *testing.T) {
str := func(s string) *string { return &s }
intp := func(i int) *int { return &i }
// The reference tree from the issue body: EXMPL → OPNT → EP3456789 → upc.inf.cfi.
fullChain := []projectChainRow{
{ID: uuid.New(), Type: "client", Title: "Example Co.", Reference: str("EXMPL")},
{ID: uuid.New(), Type: "litigation", Title: "Ex v Op", OpponentCode: str("OPNT")},
{ID: uuid.New(), Type: "patent", PatentNumber: str("EP3456789")},
{ID: uuid.New(), Type: "case", ProceedingTypeID: intp(8), ProceedingCode: str("upc.inf.cfi")},
}
cases := []struct {
name string
chain []projectChainRow
want string
}{
{
"reference tree → EXMPL.OPNT.789.INF.CFI",
fullChain,
"EXMPL.OPNT.789.INF.CFI",
},
{
"empty chain → empty",
nil,
"",
},
{
"override on target wins outright",
append(append([]projectChainRow{}, fullChain[:3]...), projectChainRow{
ID: uuid.New(), Type: "case", Reference: str("CUSTOM-CODE"),
ProceedingTypeID: intp(8), ProceedingCode: str("upc.inf.cfi"),
}),
"CUSTOM-CODE",
},
{
"override with surrounding whitespace is trimmed",
append(append([]projectChainRow{}, fullChain[:3]...), projectChainRow{
ID: uuid.New(), Type: "case", Reference: str(" TRIMMED "),
}),
"TRIMMED",
},
{
"override empty string falls through to derivation",
append(append([]projectChainRow{}, fullChain[:3]...), projectChainRow{
ID: uuid.New(), Type: "case", Reference: str(""),
ProceedingTypeID: intp(8), ProceedingCode: str("upc.inf.cfi"),
}),
"EXMPL.OPNT.789.INF.CFI",
},
{
"missing ancestors are skipped silently — case directly under client",
[]projectChainRow{
{Type: "client", Reference: str("EXMPL")},
{Type: "case", ProceedingCode: str("upc.inf.cfi")},
},
"EXMPL.INF.CFI",
},
{
"missing patent contributes nothing; client+litigation+case",
[]projectChainRow{
{Type: "client", Reference: str("EXMPL")},
{Type: "litigation", OpponentCode: str("OPNT")},
{Type: "case", ProceedingCode: str("upc.inf.cfi")},
},
"EXMPL.OPNT.INF.CFI",
},
{
"target itself is a litigation row (no case below) → up to opponent code",
[]projectChainRow{
{Type: "client", Reference: str("EXMPL")},
{Type: "litigation", OpponentCode: str("OPNT")},
},
"EXMPL.OPNT",
},
{
"litigation without opponent_code is skipped silently",
[]projectChainRow{
{Type: "client", Reference: str("EXMPL")},
{Type: "litigation", OpponentCode: nil},
{Type: "patent", PatentNumber: str("EP3456789")},
{Type: "case", ProceedingCode: str("upc.inf.cfi")},
},
"EXMPL.789.INF.CFI",
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := assembleProjectCode(c.chain)
if got != c.want {
t.Errorf("assembleProjectCode() = %q, want %q", got, c.want)
}
})
}
}
// TestPatentLast3 pins the digit-extraction rule across the common
// patent-number formats users type.
func TestPatentLast3(t *testing.T) {
cases := []struct {
in, want string
}{
{"EP1234567", "567"},
{"EP 1 234 567", "567"},
{"EP3456789A1", "789"},
{"WO2020/123456A1", "456"},
{"DE12", "12"},
{"EP", ""},
{"", ""},
{"NoDigitsAtAll", ""},
{"1", "1"},
{"12", "12"},
{"123", "123"},
{"1234", "234"},
}
for _, c := range cases {
t.Run(c.in, func(t *testing.T) {
if got := patentLast3(c.in); got != c.want {
t.Errorf("patentLast3(%q) = %q, want %q", c.in, got, c.want)
}
})
}
}
// TestSanitizeClientShort pins the client-short slug rule (uppercase,
// strip diacritics, drop non-alnum, cap 8).
func TestSanitizeClientShort(t *testing.T) {
cases := []struct {
in, want string
}{
{"EXMPL", "EXMPL"},
{"Example Co.", "EXAMPLEC"},
{"Müller GmbH", "MULLERGM"},
{" ACME ", "ACME"},
{"", ""},
{" ", ""},
{"Hogan Lovells International LLP", "HOGANLOV"},
{"A&B (Patents) Ltd.", "ABPATENT"},
{"Société Générale", "SOCIETEG"},
}
for _, c := range cases {
t.Run(c.in, func(t *testing.T) {
if got := sanitizeClientShort(c.in); got != c.want {
t.Errorf("sanitizeClientShort(%q) = %q, want %q", c.in, got, c.want)
}
})
}
}
// TestProceedingTail pins the jurisdiction-strip rule.
func TestProceedingTail(t *testing.T) {
cases := []struct {
in, want string
}{
{"upc.inf.cfi", "INF.CFI"},
{"upc.rev.cfi", "REV.CFI"},
{"upc.pi.cfi", "PI.CFI"},
{"upc.apl.merits", "APL.MERITS"},
{"de.inf.lg", "INF.LG"},
{"de.inf.olg", "INF.OLG"},
{"single", ""},
{"", ""},
{"a.b", "B"},
{" upc.inf.cfi ", "INF.CFI"},
}
for _, c := range cases {
t.Run(c.in, func(t *testing.T) {
if got := proceedingTail(c.in); got != c.want {
t.Errorf("proceedingTail(%q) = %q, want %q", c.in, got, c.want)
}
})
}
}
// TestValidateOpponentCode pins the slug-validation rule + the
// type='litigation' pairing. Empty string is the explicit clear
// sentinel and always passes.
func TestValidateOpponentCode(t *testing.T) {
cases := []struct {
name string
code string
ptype string
wantE bool
}{
{"empty clears, any type", "", "case", false},
{"empty clears, litigation", "", "litigation", false},
{"valid slug on litigation", "OPNT", "litigation", false},
{"valid slug with digits on litigation", "OPNT-2026", "litigation", false},
{"valid slug projectType empty (Update path)", "OPNT", "", false},
{"lowercase rejected", "opnt", "litigation", true},
{"underscore rejected", "OPNT_1", "litigation", true},
{"too long rejected", "OPNT-AND-A-VERY-LONG-NAME", "litigation", true},
{"non-litigation type rejected", "OPNT", "case", true},
{"non-litigation type rejected (patent)", "OPNT", "patent", true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
err := validateOpponentCode(c.code, c.ptype)
if (err != nil) != c.wantE {
t.Errorf("validateOpponentCode(%q, %q) error = %v, wantErr=%v",
c.code, c.ptype, err, c.wantE)
}
})
}
}
// TestValidateOurSideSubRoles pins the widened allowlist (mig 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,6 +23,7 @@ import (
"encoding/json"
"errors"
"fmt"
"regexp"
"slices"
"strings"
"time"
@@ -44,6 +45,12 @@ 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
@@ -54,12 +61,16 @@ 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.
@@ -104,7 +115,7 @@ func (s *ProjectService) DB() *sqlx.DB { return s.db }
const projectColumns = `id, type, parent_id, path, title, reference, description, status,
created_by, industry, country, billing_reference, client_number, matter_number,
netdocuments_url, patent_number, filing_date, grant_date, court, case_number,
proceeding_type_id, our_side, counterclaim_of, instance_level, metadata, ai_summary,
proceeding_type_id, our_side, opponent_code, counterclaim_of, instance_level, metadata, ai_summary,
created_at, updated_at`
// CreateProjectInput is the payload for Create.
@@ -130,6 +141,12 @@ type CreateProjectInput struct {
CaseNumber *string `json:"case_number,omitempty"`
ProceedingTypeID *int `json:"proceeding_type_id,omitempty"`
OurSide *string `json:"our_side,omitempty"`
// OpponentCode is the litigation-only short slug used as the middle
// segment when BuildProjectCode assembles a project code from the
// ancestor tree (t-paliad-222 / m/paliad#50). Empty / nil → segment
// skipped. Only meaningful on type='litigation' rows; the form
// hides the field elsewhere and the DB CHECK rejects it.
OpponentCode *string `json:"opponent_code,omitempty"`
// InstanceLevel is the procedural instance the project sits at:
// 'first' (default once the picker UI lands) | 'appeal' | 'cassation'.
// NULL = unset. Phase 3 Slice 8 (t-paliad-189, design §7) — the
@@ -169,6 +186,10 @@ type UpdateProjectInput struct {
CaseNumber *string `json:"case_number,omitempty"`
ProceedingTypeID *int `json:"proceeding_type_id,omitempty"`
OurSide *string `json:"our_side,omitempty"`
// OpponentCode — see CreateProjectInput.OpponentCode. UPDATE path:
// pointer to "" clears the column (NULL); pointer to a non-empty
// slug sets it.
OpponentCode *string `json:"opponent_code,omitempty"`
// InstanceLevel — see CreateProjectInput.InstanceLevel. UPDATE
// path: caller passes a pointer to the new value to swap; pass
// a pointer to "" to clear (NULL the column).
@@ -239,6 +260,9 @@ func (s *ProjectService) List(ctx context.Context, userID uuid.UUID, f ProjectFi
if err := stmt.SelectContext(ctx, &rows, args); err != nil {
return nil, fmt.Errorf("list projects: %w", err)
}
if err := PopulateProjectCodes(ctx, s.db, rows); err != nil {
return nil, err
}
return rows, nil
}
@@ -277,6 +301,11 @@ func (s *ProjectService) GetByID(ctx context.Context, userID, id uuid.UUID) (*mo
if err != nil {
return nil, fmt.Errorf("get project: %w", err)
}
code, err := BuildProjectCode(ctx, s.db, p.ID)
if err != nil {
return nil, err
}
p.Code = code
return &p, nil
}
@@ -337,6 +366,9 @@ func (s *ProjectService) ListAncestors(ctx context.Context, userID, id uuid.UUID
order[id] = i
}
sortByOrder(rows, order)
if err := PopulateProjectCodes(ctx, s.db, rows); err != nil {
return nil, err
}
return rows, nil
}
@@ -458,6 +490,9 @@ func (s *ProjectService) BuildTreeWithOptions(ctx context.Context, userID uuid.U
if err := s.db.SelectContext(ctx, &rows, query, userID); err != nil {
return nil, fmt.Errorf("build tree list: %w", err)
}
if err := PopulateProjectCodes(ctx, s.db, rows); err != nil {
return nil, err
}
// Step 2 — per-node deadline counts (always; cheap one-shot query).
type deadlineCount struct {
@@ -804,6 +839,9 @@ func (s *ProjectService) GetTree(ctx context.Context, userID, id uuid.UUID) ([]m
if err := s.db.SelectContext(ctx, &rows, query, root.Path, prefix, userID); err != nil {
return nil, fmt.Errorf("get tree: %w", err)
}
if err := PopulateProjectCodes(ctx, s.db, rows); err != nil {
return nil, err
}
return rows, nil
}
@@ -863,6 +901,11 @@ func (s *ProjectService) Create(ctx context.Context, userID uuid.UUID, input Cre
return nil, err
}
}
if input.OpponentCode != nil {
if err := validateOpponentCode(*input.OpponentCode, input.Type); err != nil {
return nil, err
}
}
if input.InstanceLevel != nil {
if err := validateInstanceLevel(*input.InstanceLevel); err != nil {
return nil, err
@@ -873,10 +916,10 @@ func (s *ProjectService) Create(ctx context.Context, userID uuid.UUID, input Cre
(id, type, parent_id, path, title, reference, description, status,
created_by, industry, country, billing_reference, client_number,
matter_number, netdocuments_url, patent_number, filing_date, grant_date,
court, case_number, proceeding_type_id, our_side, counterclaim_of,
instance_level, metadata, created_at, updated_at)
court, case_number, proceeding_type_id, our_side, opponent_code,
counterclaim_of, instance_level, metadata, created_at, updated_at)
VALUES ($1, $2, $3, '', $4, $5, $6, $7, $8, $9, $10, $11, $12, $13,
$14, $15, $16, $17, $18, $19, $20, $21, $22, $23, '{}'::jsonb, $24, $24)`,
$14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, '{}'::jsonb, $25, $25)`,
id, input.Type, input.ParentID,
input.Title, input.Reference, input.Description, status,
userID,
@@ -885,6 +928,7 @@ func (s *ProjectService) Create(ctx context.Context, userID uuid.UUID, input Cre
input.PatentNumber, input.FilingDate, input.GrantDate,
input.Court, input.CaseNumber, input.ProceedingTypeID,
nullableOurSide(input.OurSide),
nullableOpponentCode(input.OpponentCode),
input.CounterclaimOf,
nullableInstanceLevel(input.InstanceLevel),
now,
@@ -1029,6 +1073,12 @@ func (s *ProjectService) Update(ctx context.Context, userID, id uuid.UUID, input
}
appendSet("our_side", nullableOurSide(input.OurSide))
}
if input.OpponentCode != nil {
if err := validateOpponentCode(*input.OpponentCode, current.Type); err != nil {
return nil, err
}
appendSet("opponent_code", nullableOpponentCode(input.OpponentCode))
}
if input.InstanceLevel != nil {
if err := validateInstanceLevel(*input.InstanceLevel); err != nil {
return nil, err
@@ -1213,6 +1263,9 @@ func (s *ProjectService) LoadCounterclaimChildrenVisible(ctx context.Context, us
if err := s.db.SelectContext(ctx, &rows, query, parentID, userID); err != nil {
return nil, fmt.Errorf("load counterclaim children: %w", err)
}
if err := PopulateProjectCodes(ctx, s.db, rows); err != nil {
return nil, err
}
return rows, nil
}
@@ -1372,9 +1425,21 @@ func insertCounterclaimEvent(ctx context.Context, tx *sqlx.Tx, projectID, userID
// derivedCounterclaimOurSide computes the child's our_side from the
// parent's our_side and the opts.FlipOurSide override.
//
// Default (override nil OR override=true): claimant ↔ defendant, court
// and both pass through unchanged. NULL parent yields NULL child — the
// flip is meaningless without a known starting side.
// Default (override nil OR override=true): flip across the active /
// reactive axis using the t-paliad-222 sub-role table —
//
// claimant ↔ defendant
// applicant ↔ respondent
// appellant → respondent (the CCR-against-appellant is the
// defending position; appellant has no
// symmetric counter-role in the new set)
//
// Third Party / Other (third_party, other) and NULL pass through
// unchanged — the flip is meaningless without a clear active / reactive
// posture. Legacy 'court' / 'both' no longer exist in the column
// (mig 112) so they have no case arm; if a stale value sneaks in via a
// pre-migration in-memory row it falls through to the default branch
// and passes through unchanged, preserving previous behaviour.
//
// Override=false: keep parent's side as-is. R.49.2.b CCI is the named
// edge case where the CCR sub-project shares the parent's perspective.
@@ -1395,6 +1460,12 @@ func derivedCounterclaimOurSide(parentSide *string, override *bool) string {
return "defendant"
case "defendant":
return "claimant"
case "applicant":
return "respondent"
case "respondent":
return "applicant"
case "appellant":
return "respondent"
default:
return side
}
@@ -1890,7 +1961,7 @@ func typeSpecificColumns(t string) []string {
func isValidProjectType(t string) bool {
switch t {
case ProjectTypeClient, ProjectTypeLitigation, ProjectTypePatent,
ProjectTypeCase, ProjectTypeProject:
ProjectTypeCase, ProjectTypeProject, ProjectTypeOther:
return true
}
return false
@@ -1904,15 +1975,29 @@ func validateProjectStatus(s string) error {
return fmt.Errorf("%w: invalid status %q", ErrInvalidInput, s)
}
// validateOurSide checks the project-level "represented side" enum
// (t-paliad-164). Empty string is the explicit "clear" sentinel —
// callers pass the value as-is from the form payload, and the helper
// accepts it so an Update can null the column. The DB-level CHECK
// constraint enforces the same set; this validation gives a clearer
// error than relying on the constraint to fire.
// validateOurSide checks the project-level "Client Role" enum
// (t-paliad-164, widened in t-paliad-222 / m/paliad#47). Empty string
// is the explicit "clear" sentinel — callers pass the value as-is
// from the form payload, and the helper accepts it so an Update can
// null the column. The DB-level CHECK constraint (mig 112) enforces
// the same set; this validation gives a clearer error than relying
// on the constraint to fire.
//
// Allowed sub-roles, grouped at display time:
// Active (we initiate) : claimant, applicant, appellant
// Reactive (we defend) : defendant, respondent
// Third Party / Other : third_party, other
//
// Legacy 'court' / 'both' are no longer accepted (mig 112 backfills
// existing rows to NULL); callers that still send them get a clear
// validation error rather than a constraint violation.
func validateOurSide(s string) error {
switch strings.TrimSpace(s) {
case "", "claimant", "defendant", "court", "both":
case "",
"claimant", "defendant",
"applicant", "appellant",
"respondent",
"third_party", "other":
return nil
}
return fmt.Errorf("%w: invalid our_side %q", ErrInvalidInput, s)
@@ -1963,6 +2048,49 @@ func nullableOurSide(p *string) any {
return v
}
// opponentCodePattern matches the slug shape enforced by the
// projects_opponent_code_check constraint (mig 113): uppercase letters,
// digits, dashes, 1-16 chars. The DB CHECK is the source of truth; this
// helper surfaces a friendlier ErrInvalidInput error before the write.
var opponentCodePattern = regexp.MustCompile(`^[A-Z0-9-]{1,16}$`)
// validateOpponentCode checks the litigation-only opponent_code slug
// (t-paliad-222 / m/paliad#50). Empty string clears the column; a
// non-empty value must match opponentCodePattern AND the row must be
// type='litigation' (the DB CHECK enforces this pairing).
//
// projectType may be empty when the caller is doing a partial Update
// against the current row's type — in that case we skip the type gate
// (the Update layer passes current.Type instead, which always has it).
func validateOpponentCode(s, projectType string) error {
v := strings.TrimSpace(s)
if v == "" {
return nil
}
if projectType != "" && projectType != "litigation" {
return fmt.Errorf("%w: opponent_code only valid on type=litigation (got %q)",
ErrInvalidInput, projectType)
}
if !opponentCodePattern.MatchString(v) {
return fmt.Errorf("%w: invalid opponent_code %q (allowed: %s)",
ErrInvalidInput, s, "[A-Z0-9-]{1,16}")
}
return nil
}
// nullableOpponentCode mirrors nullableOurSide for opponent_code: nil
// or empty/whitespace → SQL NULL; otherwise the trimmed slug.
func nullableOpponentCode(p *string) any {
if p == nil {
return nil
}
v := strings.TrimSpace(*p)
if v == "" {
return nil
}
return v
}
func sortByOrder(xs []models.Project, order map[uuid.UUID]int) {
// Insertion sort — ancestor lists are short (<20).
for i := 1; i < len(xs); i++ {

View File

@@ -317,8 +317,10 @@ func TestChildTypeForAxis(t *testing.T) {
}
// TestDerivedCounterclaimOurSide pins the our_side flip semantics
// (t-paliad-174 §11 Q2):
// - Default (override nil): claimant ↔ defendant; court / both pass through.
// (t-paliad-174 §11 Q2, widened in t-paliad-222 / m/paliad#47):
// - Default (override nil): flip across the active / reactive axis —
// claimant ↔ defendant, applicant ↔ respondent, appellant →
// respondent. third_party / other / NULL pass through.
// - Override true: same default-flip semantics.
// - Override false (R.49.2.b CCI edge case): keep parent's side.
// - NULL parent_side yields empty string (no flip without a starting side).
@@ -337,11 +339,15 @@ func TestDerivedCounterclaimOurSide(t *testing.T) {
{"nil parent + override → empty", nil, &tru, ""},
{"claimant → defendant (default)", str("claimant"), nil, "defendant"},
{"defendant → claimant (default)", str("defendant"), nil, "claimant"},
{"court passes through", str("court"), nil, "court"},
{"both passes through", str("both"), nil, "both"},
{"applicant → respondent (default)", str("applicant"), nil, "respondent"},
{"respondent → applicant (default)", str("respondent"), nil, "applicant"},
{"appellant → respondent (default)", str("appellant"), nil, "respondent"},
{"third_party passes through", str("third_party"), nil, "third_party"},
{"other passes through", str("other"), nil, "other"},
{"explicit flip=true", str("claimant"), &tru, "defendant"},
{"explicit flip=false keeps parent's side", str("claimant"), &fal, "claimant"},
{"flip=false on defendant keeps defendant", str("defendant"), &fal, "defendant"},
{"flip=false on applicant keeps applicant", str("applicant"), &fal, "applicant"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {

View File

@@ -273,15 +273,24 @@ func TestLegalSourcePretty(t *testing.T) {
}
// TestOurSideTranslations pins the our_side enum → DE/EN prose
// mapping used by addProjectVars.
// mapping used by addProjectVars. Post t-paliad-222: seven sub-role
// values + the gender-neutral "-Seite" / "-Partei" suffix shape on
// DE. Legacy 'court' / 'both' yield "" (the column no longer accepts
// them after mig 112, but the function defensively handles stale
// in-memory values from older callers).
func TestOurSideTranslations(t *testing.T) {
cases := []struct {
in, wantDE, wantEN string
}{
{"claimant", "Klägerin", "Claimant"},
{"defendant", "Beklagte", "Defendant"},
{"court", "Gericht", "Court"},
{"both", "Klägerin und Beklagte", "Claimant and Defendant"},
{"claimant", "Klägerseite", "Claimant"},
{"defendant", "Beklagtenseite", "Defendant"},
{"applicant", "Antragstellerseite", "Applicant"},
{"appellant", "Berufungsklägerseite", "Appellant"},
{"respondent", "Antragsgegnerseite", "Respondent"},
{"third_party", "Drittpartei", "Third Party"},
{"other", "sonstige Verfahrensbeteiligte", "other party"},
{"court", "", ""},
{"both", "", ""},
{"", "", ""},
{"unknown", "", ""},
}

View File

@@ -262,6 +262,11 @@ func addUserVars(bag PlaceholderMap, u *models.User) {
func addProjectVars(bag PlaceholderMap, p *models.Project, pt *models.ProceedingType, lang string) {
bag["project.title"] = p.Title
bag["project.reference"] = derefString(p.Reference)
// project.code is the auto-derived (or override) dotted project
// code computed by services.BuildProjectCode. Populated upstream
// by the service projection; templates that want the explicit
// override should read project.reference instead.
bag["project.code"] = p.Code
bag["project.case_number"] = derefString(p.CaseNumber)
bag["project.court"] = derefString(p.Court)
bag["project.patent_number"] = derefString(p.PatentNumber)
@@ -388,16 +393,29 @@ func formatDatePtr(t *time.Time, layout string) string {
}
// ourSideDE returns the German legal-prose form of an our_side value.
//
// t-paliad-222: unified on the gender-neutral "-Seite" / "-Partei"
// suffix shape to match the form labels and to avoid implying the
// firm represents a single (female) natural person — a B2B patent
// practice almost always represents companies. The seven sub-roles
// map onto the post-mig-110 schema; legacy 'court' / 'both' no
// longer exist in the column.
func ourSideDE(side string) string {
switch strings.ToLower(side) {
case "claimant":
return "Klägerin"
return "Klägerseite"
case "defendant":
return "Beklagte"
case "court":
return "Gericht"
case "both":
return "Klägerin und Beklagte"
return "Beklagtenseite"
case "applicant":
return "Antragstellerseite"
case "appellant":
return "Berufungsklägerseite"
case "respondent":
return "Antragsgegnerseite"
case "third_party":
return "Drittpartei"
case "other":
return "sonstige Verfahrensbeteiligte"
}
return ""
}
@@ -409,10 +427,16 @@ func ourSideEN(side string) string {
return "Claimant"
case "defendant":
return "Defendant"
case "court":
return "Court"
case "both":
return "Claimant and Defendant"
case "applicant":
return "Applicant"
case "appellant":
return "Appellant"
case "respondent":
return "Respondent"
case "third_party":
return "Third Party"
case "other":
return "other party"
}
return ""
}

View File

@@ -13,6 +13,7 @@ package services
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
@@ -80,9 +81,13 @@ 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 ResponsibilityLead:
case ResponsibilityAdmin, ResponsibilityLead:
return "lead"
case ResponsibilityObserver:
return "observer"
@@ -99,11 +104,43 @@ 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
}
res, err := s.db.ExecContext(ctx,
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,
`DELETE FROM paliad.project_teams
WHERE project_id = $1 AND user_id = $2 AND inherited = false`,
projectID, userID)
@@ -113,6 +150,104 @@ 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
}
@@ -259,6 +394,27 @@ 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,