diff --git a/docs/design-hierarchy-aggregation-2026-05-06.md b/docs/design-hierarchy-aggregation-2026-05-06.md new file mode 100644 index 0000000..9c37585 --- /dev/null +++ b/docs/design-hierarchy-aggregation-2026-05-06.md @@ -0,0 +1,1015 @@ +# Design — Project hierarchy aggregation, effective team, partner-unit derivation + +**Author:** noether (inventor) +**Date:** 2026-05-06 +**Task:** t-paliad-139 (Gitea m/paliad#4) +**Branch:** `mai/noether/inventor-project` +**Status:** LOCKED 2026-05-06 — m signed off on all 19 §6 recommendations. Ready for coder shift on head's assignment. + +--- + +## 0. TL;DR + +Three coordinated sub-designs in one doc, scoped tight per the locked constraints (m, 2026-05-06): + +1. **Surface-by-surface aggregation policy** — a per-view decision table for whether a project filter narrows to the row's own `project_id` or rolls up the whole subtree under it. Fixes the bug m hit on `/projects/{client_id}` (descendant deadlines invisible) and pins the contract everywhere else. +2. **Effective-team semantics** — direct team ∪ all descendants' teams ∪ partner-unit-derived members. Adds two pieces of schema the current data model is missing (a project↔unit junction and a unit-role distinction) so derivation has somewhere to live, and keeps derivation **honest** (annotated everywhere, never silent). +3. **Approval-policy interaction with hierarchy and derivation** — explicit coordination with t-paliad-138 (cronus's just-shipped 4-eye design). Resolves "if a deadline lives on a Case but the policy is on the Client, does the policy inherit down?" and "do derived members carry approval authority?". + +### Locked m decisions (2026-05-06, per the issue body) + +- Behaviour is **surface-specific**. +- Must cover events / deadlines / termine. +- **"Effective Team of a Client" = direct ∪ all descendants' teams ∪ partner-unit-derived PAs.** +- **PA derivation rule:** a partner-unit on a project auto-derives its PAs onto the project team. +- **Derivation honesty:** derived members are annotated in project team overviews, never silently granted access. +- **paliad-only scope.** + +### Locked m decisions on §6 (2026-05-06, design pass) + +m signed off on **all 19 recommendations** in §6 verbatim. Implementation may proceed on head's assignment without further design Q&A. Highlights of the locks (full table is §6): + +- **Aggregate-by-default** with "nur direkt" toggle on /events, /deadlines, /appointments, and the four /projects/{id} sections (Q1, Q4, Q5, Q16). +- **Sidebar dual counts** `(direct + descendants)` (Q2). +- **Dashboard / CalDAV / email reminders unchanged** — visibility-scope already correct (Q3, Q7, Q8). +- **Search subtree-default in project context, global otherwise** (Q6). +- **Unit-level derivation** (Q11) — pair-level rejected; new `partner_unit_members.unit_role` + `project_partner_units` junction. +- **Compute-on-read** derivation via extended `can_see_project()` (Q11b) — no materialised state, no drift. +- **Sectioned Team-tab rendering** with explicit "Abgeleitet (Partner Unit)" subsection + per-row source label (Q13). +- **Derived membership cascades visibility down** via existing path-walk (Q14). +- **Retroactive churn on current state, immutable history** (Q15). +- **No automatic policy inheritance** from parent projects (Q10) — keep cronus's t-138 stance, harden with "Eltern-Politik (zur Information)" panel. +- **Derived authority is opt-in** per (project, unit) via `derive_grants_authority` flag (Q12) — `decision_kind` extends with `derived_peer` for honest audit. +- **Attribution chip** on every aggregated row showing the descendant project (Q19). +- **No depth bound, no materialised counts in v1** (Q17, Q18) — revisit if real telemetry says so. + +### Out of scope + +- Changing the project tree shape (Client → Litigation → Patent → Case stays). +- Changing RLS direction (descendants visible to ancestors stays as today). +- Cross-project aggregation (rolling rows up across unrelated projects). +- Multi-tenancy / firm-tier hierarchy. +- Generalising the derivation model to non-partner-unit entities (e.g. external counsel teams) — out of v1. + +--- + +## 1. Verified live state (what's actually shipped, vs the issue body and memory) + +A design built on a wrong premise rots fast. Three premises in the issue / memory needed verifying against the live codebase before this doc anchors anything on them: + +### 1.1 The descendant-aggregation primitive already exists + +`internal/services/visibility.go:68` defines `projectDescendantPredicate(alias)`: + +```sql +:project_id = ANY(CAST(string_to_array(.path, '.') AS uuid[])) +``` + +It matches "this row OR any descendant" against `paliad.projects.path` (text-shaped materialised path; not the `ltree` extension). Already wired in: + +- `DeadlineService.ListVisibleForUser` (`deadline_service.go:133`) — when `filter.ProjectID` is set, the predicate is added. +- `DeadlineService.SummaryCounts` (`deadline_service.go:647`) — same. +- `EventService.ListVisibleForUser` (`event_service.go:438`) — same, for the deadlines+appointments union. +- `EventService.SummaryCounts` (`event_service.go:485`) — same. +- `EventService.appointmentBuckets` (visible just below) — same for the appointment side of the bucket math. + +The contract is pinned by `internal/services/project_filter_descendants_test.go` (TEST_DATABASE_URL test, t-paliad-124): filtering a Client UUID returns 3 deadlines + 3 appointments across Client / Litigation / Case levels. **So the union endpoints do aggregate**; the bug is somewhere else. + +### 1.2 Where the bug actually lives + +`/projects/{client_id}` renders deadlines via `frontend/src/client/projects-detail.ts:262`: + +```ts +const resp = await fetch(`/api/projects/${id}/deadlines`); +``` + +That route is `internal/handlers/handlers.go:253` → `handleListDeadlinesForProject` → `DeadlineService.ListForProject`. The service body (`deadline_service.go`): + +```sql +SELECT … FROM paliad.deadlines WHERE project_id = $1 ORDER BY due_date ASC, created_at DESC +``` + +Exact-match, no path walk. Same shape on `AppointmentService.ListForProject` and on `ProjectService.ListProjectEvents` (the audit-trail Verlauf feed via `/api/projects/{id}/events`): + +```sql +SELECT … FROM paliad.project_events WHERE project_id = $1 … +``` + +So three legacy "per-project narrow" service methods bypass the modern subtree-aware union path: + +1. `DeadlineService.ListForProject` +2. `AppointmentService.ListForProject` +3. `ProjectService.ListProjectEvents` (used by the Verlauf card on `/projects/{id}` — same exact-match scope) + +The bug is in those three paths. The fix isn't "build descendant aggregation"; the primitive is shipped and tested. It's "make the per-project read paths honour it" — see §2. + +### 1.3 The PA-derivation premise has no schema today + +The issue states: "PAs assigned to a lawyer in a partner-unit auto-derive team membership on every project where that partner-unit is involved" — and asks (Q11): "Where is 'lawyer ↔ PA assignment' stored today?" + +Answer: **nowhere.** Verified by reading migration 027 (`027_rename_to_partner_units.up.sql`) and `internal/services/partner_unit_service.go`: + +```sql +-- paliad.partner_unit_members (renamed from department_members in 027, +-- table shape unchanged from migration 018): +PRIMARY KEY (partner_unit_id, user_id) +columns: partner_unit_id, user_id, created_at +``` + +There's no `unit_role` column. There's no `lawyer_id`/`pa_id` pairing table. There's no `project_partner_units` junction either — `paliad.partner_units` and `paliad.projects` have no relation tying a unit to a matter. + +So the issue describes a derivation rule whose three structural pieces all need to be added: + +1. **A way to mark a partner-unit as involved on a project** (some `project_partner_units(project_id, partner_unit_id, …)` junction). +2. **A way to distinguish PAs from lawyers within a unit** (a role distinction on the unit roster, since the codebase has been deliberate about NOT introducing a firm-wide rank column — see t-paliad-051 / t-paliad-138 three-axis principle). +3. **A way to express the "PA ↔ Lawyer" pairing** if derivation is per-pair rather than per-unit. + +This doc proposes shapes for #1 and #2 (§3). It argues that #3 is **not needed** for v1: per-unit derivation is the simpler, more honest model and matches m's plain-English statement once the unit-on-project condition is in place. + +### 1.4 The project_teams.role enum, post-t-138 + +Migration 018 ships: +``` +'lead','associate','pa','of_counsel','local_counsel','expert','observer' +``` + +Migration 054 (cronus's t-138, currently authored on `mai/cronus/inventor-dual-control` @ commit `b3401ec`, **not yet merged to main**) adds `'senior_pa'`. Approval-eligible by default = `{lead, of_counsel, associate}`; `senior_pa`+`pa` only eligible if the per-project policy lowers `required_role`. Strict ladder: `lead(5) > of_counsel(4) > associate(3) > senior_pa(2) > pa(1) > local_counsel/expert/observer(0)`. + +The `inherited boolean DEFAULT false` column on `project_teams` (migration 018) exists but is **unused** today — `team_service.go:11` documents it as "reserved for potential future caching of inherited rows. This service does not write inherited=true rows." This becomes load-bearing in §3 if m chooses the materialised-derivation path. + +### 1.5 Effective-team direction is currently flipped vs m's brief + +`TeamService.ListEffectiveMembers` (`team_service.go:110`) walks **UP** the path: it returns "direct + ancestor team", which is the right semantic for the visibility predicate (a Client member is "effectively" on every descendant Case for `can_see_project`). + +m's brief says **"Effective Team of a Client = direct ∪ all descendants' teams ∪ partner-unit-derived"**. That walks **DOWN** (descendants), not UP. The two are different aggregations: + +- The **visibility-effective team** of a Case includes that Case's own team plus its ancestors' teams (so a Client lead can see the Case). +- The **display-effective team** of a Client (m's brief) includes that Client's own team plus its descendants' teams plus partner-unit-derived members. + +These are not the same set, and the current method's name is too generic. §3 proposes renaming and adding a sibling method. + +--- + +## 2. Sub-design 1 — Surface-by-surface aggregation policy + +### 2.1 Default-direction principle + +When a surface filters by `project_id`, the question is: does it return only rows whose `project_id == $1` exactly, or rows whose project is `$1` or any descendant? + +m's locked constraint: behaviour is surface-specific. The default for surfaces where **the answer isn't obvious** is: **aggregate downward** ("safer no missed information"). Reasons: + +- The bug m hit was caused by the narrow default. Missed deadlines are a malpractice exposure surface. +- Aggregation is a strict superset of narrowing — a user can always narrow visually with a "nur direkte" toggle if they want focus. +- Narrowing can quietly hide critical information; aggregation can't. + +A surface escapes this default only when narrowing has a concrete justification (editing context, attribution, drilling). + +### 2.2 The surface decision table + +Each surface either **aggregates** (descendants roll up), **narrows** (exact match only), **toggles** (user picks; default specified), or is **N/A** (surface doesn't take a project filter). + +| Surface | Filter axis | Decision | Notes | +|---|---|---|---| +| `/projects/{id}` Fristen section (Q1) | implicit `project_id` | **Aggregate** with attribution | Bug surface. Switch fetch from `/api/projects/{id}/deadlines` to `/api/events?type=deadline&project_id={id}` (already aggregates). Each row carries an attribution chip (§2.4) so the user knows which descendant it lives on. | +| `/projects/{id}` Termine section (Q1) | implicit `project_id` | **Aggregate** with attribution | Same fix shape, /api/events?type=appointment. | +| `/projects/{id}` Aktivität / Verlauf section (Q1) | implicit `project_id` | **Aggregate** with attribution | Today narrow via `ProjectService.ListProjectEvents` — fix to walk descendants via the same path predicate. Attribution chip "auf: Case 14-vs-Müller" on each event row. | +| Project tree sidebar count badges (Q2) | per-node | **Show both** | "Acme Corp (3+12 Fristen)" — direct count + descendant subtree. Single number is ambiguous; both numbers are honest and cheap (one CTE per page load). | +| Dashboard widgets — traffic light, upcoming, recent (Q3) | "all visible projects" | **Stay as-is** | Today scoped to all visible. A per-project subtree filter is a follow-up. The dashboard is a global home, not a project drill-down. | +| `/events` list filtered by `project_id` (Q4) | `?project_id=` | **Aggregate** | Already does (t-124 contract). Add a "nur direkte" toggle for narrowing (§2.5). | +| `/deadlines` and `/appointments` filtered by `project_id` (Q5) | `?project_id=` | **Aggregate** | Same as `/events`. Already does via the same union path. Toggle for narrowing. | +| Search results (Q6) | active project context | **Aggregate** | When the user opens Cmd-K from a `/projects/{id}/...` URL, search across that project's subtree by default (no extra UI). When opened from elsewhere, search is global as today. | +| CalDAV sync (Q7) | per-user calendar feed | **Stay as-is** | Direct memberships only ∪ ancestors (current visibility) — no change. CalDAV is for "what should appear on my external calendar"; that's already the right scope (the user sees a deadline on a Case they're on the team for, regardless of who else's tree it lives in). Adding subtree aggregation here would over-include and cause calendar noise. | +| Email reminders / digest (Q8) | per-user | **Stay as-is** | Same scope rule as CalDAV — the user receives reminders for deadlines they have visibility on, which already includes descendants of any project they're on. No change. | +| `/deadlines/new` and `/appointments/new` (creation) | implicit project | **Narrow** | A new deadline lives on exactly one project — never "on all of these descendants". The project picker on the form is the precise project the row anchors to. | +| Inline-edit forms on detail pages | implicit project | **Narrow** | Same — editing operates on the row's anchor project. | +| Reparent / move form | implicit project | **Narrow** | Operates on the project entity itself, not its descendants. | +| `/admin/audit-log` global (cross-project) | filter chips | **N/A — global** | Already pulls union across `project_events` + sibling sources. Stays global, optional `project_id` filter narrows to subtree (aggregate). | +| `/admin/team` (firm-wide directory) | N/A | **N/A** | Lists all users; no project filter. | + +### 2.3 Why CalDAV and email reminders stay as-is (Q7, Q8) + +Surface intent matters. CalDAV and reminders are **personal information feeds** — the user's calendar shows things the user is responsible for. The visibility predicate already gives them coverage of every descendant of any project they're on. Adding subtree aggregation on top would create double-rollup: a Client lead would receive every Case-level deadline reminder twice (once via her own membership, once via subtree rollup). Worse: a Client lead would receive notifications for trivial Case-level scheduling she has no operational role in, just because she's on the parent. + +The dashboard rule (Q3) is the same shape: it's an "all my work today" home, not a project drill-down. Stays scoped to "all visible to me." + +The bright line is: **list/detail surfaces of a project node** aggregate downward; **personal/global surfaces** keep their scope as-is. + +### 2.4 Attribution badge (Q19) + +When a `/projects/{client_id}` Fristen list shows 47 deadlines that mostly live on descendants, each row needs to disclose where it actually anchors. Proposed: a chip in the "Projekt" column (or alongside the title) with the descendant project's title: + +``` +⚠ Erwiderung Hauptverhandlung auf: Case 14-vs-Müller 23.05.2026 +Stellungnahme zur Replik (direkt) 29.05.2026 +``` + +CSS: small grey chip, click navigates to that descendant's page. The chip is suppressed if the row's project is the current page's project ("(direkt)" stays optional). Pattern reusable across all `/projects/{id}/*` aggregating surfaces. + +i18n keys (DE primary, EN secondary): + +- `aggregation.attribution.on` — "auf: {title}" / "on: {title}" +- `aggregation.attribution.direct` — "direkt" / "direct" +- `aggregation.toggle.subtree` — "Inkl. Unterprojekte" / "Incl. sub-projects" +- `aggregation.toggle.direct_only` — "Nur direkt" / "Direct only" +- `aggregation.subtree_count` — "{direct} direkt + {descendants} aus Unterprojekten" / "{direct} direct + {descendants} from sub-projects" + +### 2.5 Toggle pattern (narrow vs aggregate) + +For surfaces where both modes are useful (`/events`, `/deadlines`, `/appointments`, `/projects/{id}/*` sections), a small toggle in the filter bar lets the user flip: + +``` +[ Inkl. Unterprojekte ] (default) [ Nur direkt ] +``` + +URL state: `?subtree=true` (default omitted) | `?subtree=false`. Backend respects via a new `SubtreeOnly bool` flag on the existing filter structs — when false, the service uses `f.project_id = :project_id` (exact) instead of `projectDescendantPredicate("p")`. Cheaper than the path-walk; the predicate only changes shape, not cost. + +The `/deadlines/new` / inline-edit forms have no toggle — they're narrow by definition. + +### 2.6 Project tree sidebar dual counts (Q2) + +The project tree page (`/projects` Tree view, t-paliad-028 / 056) already renders a count badge per node. Today: direct deadline count. Proposal: render two values when they differ: + +``` +Acme Corp (3 + 12) +├── Acme v. Foo (Litigation) (1 + 9) +│ └── EP1234 B1 (Patent) (0 + 8) +│ └── 14-vs-Müller (Case) (8) +``` + +Where format is `(direct)` when no descendants OR `(direct + descendants)` when both. Single number when descendants is zero. The data shape is already produced by `ProjectService.GetTree` — it only needs the secondary count from a single CTE that walks each subtree: + +```sql +WITH RECURSIVE counts AS ( + SELECT p.id, p.path, + (SELECT COUNT(*) FROM paliad.deadlines f + WHERE f.project_id = p.id AND f.status = 'pending') AS direct_count, + (SELECT COUNT(*) FROM paliad.deadlines f + JOIN paliad.projects p2 ON p2.id = f.project_id + WHERE p.id = ANY(string_to_array(p2.path, '.')::uuid[]) + AND f.status = 'pending') AS subtree_count + FROM paliad.projects p + WHERE +) +``` + +Subtree count includes self by definition (`p.id` appears in its own path's last label). Direct = self only. Difference = pure-descendant. + +### 2.7 Per-surface defaults — formatting summary + +``` +Aggregating surfaces (with toggle): /events, /deadlines, /appointments, /projects/{id}/{section} +Aggregating surfaces (no toggle): project tree dual counts, attribution chips +Narrowing surfaces: Create / inline-edit forms, reparent +Personal/global (no change): Dashboard, CalDAV, email reminders, /admin/audit-log +Search: Subtree default when in project context, global otherwise +``` + +### 2.8 Performance and scale (Q17, Q18) + +`projectDescendantPredicate` is a `<@`-style ancestor check on a text-materialised path with `text_pattern_ops` (migration 018) — sub-millisecond for any realistic tree (paliad's largest expected tree is one Client with ~100 Cases). The path-walk uses `string_to_array(... )::uuid[]` then `= ANY(...)`, which Postgres planner reduces to a bitmap-scan on `project_teams.project_id`. The shipped t-124 test runs cleanly against the live DB. + +**No depth bound.** m's brief is silent on this and the schema doesn't enforce one. We don't impose a depth cap unless a real perf bottleneck shows up (it hasn't). + +**No materialised counts in v1.** The dual-count CTE in §2.6 is one query per page-load of `/projects` Tree view; profile it before adding caching. The dashboard summary already uses CTEs and stays performant. + +**Escape hatch (Q18):** if a tree gets pathologically large (1000+ nodes), the read path's bottleneck would show up first in `ListVisibleForUser` joining over the tree. Mitigation if it ever bites: a `paliad.project_subtree_counts` materialised view refreshed on insert/update/delete via trigger. **Not in v1** — premature optimisation. + +### 2.9 Implementation sketch — read-path fix + +Three legacy methods to fix: + +1. **`DeadlineService.ListForProject`** → either remove it (callers redirect to `EventService.ListVisibleForUser`) or switch its body to use `projectDescendantPredicate` and add a `direct_only bool` arg. Recommend: remove + redirect callers (1 site: `handleListDeadlinesForProject`). Same for `AppointmentService.ListForProject`. +2. **`ProjectService.ListProjectEvents`** → switch the WHERE clause from `project_id = $1` to the descendant predicate. Add a `direct_only` arg for the rare narrow case. The audit-trail Verlauf is the right place for subtree aggregation (project events from descendants are part of the matter's history). Default = aggregate. +3. **Frontend `projects-detail.ts:262/271/216`** → switch the three fetch calls to the union endpoints (`/api/events?type=deadline&project_id=`, `/api/events?type=appointment&project_id=`, `/api/projects/{id}/events?subtree=true`). + +Each row in the response already carries `project_id`. The frontend reads it; if `row.project_id !== currentProjectID`, render the attribution chip with the row's project title (already in the response payload via the `…WithProject` projection used by `ListVisibleForUser`). + +This is mechanical work, ~300-400 LoC across 4 files. No schema change. + +--- + +## 3. Sub-design 2 — Effective team semantics + partner-unit derivation + +### 3.1 The two distinct "effective team" senses + +m's brief: **"Effective Team of a Client" = direct ∪ all descendants' teams ∪ partner-unit-derived PAs.** This is a **display-time aggregation** — it answers "who's on this matter, when I'm looking at the Client?". + +The current `TeamService.ListEffectiveMembers` answers a different question: it returns "direct + ancestor team", which is the visibility-side semantic ("who can see this Case", from the perspective of a child node). Both are useful; both should exist. Naming proposal: + +- **`TeamService.ListEffectiveMembers(projectID)`** → keep current behaviour, **rename in name only** to `ListVisibilityEffectiveMembers` so the semantic is explicit. Used by visibility-direction queries and by the existing project detail "Inherited from parent" team UI. Rename is mechanical (1 method, 1 caller in `team_pages.go`). +- **`TeamService.ListSubtreeMembers(projectID)`** (new) → returns "direct + descendants + partner-unit-derived". This is the m-brief "Effective Team of a Client". Used by the new aggregating "Project Team" overview on `/projects/{id}`. + +Rendering: the project detail page's Team tab shows three sections, in order: + +1. **Direkt** — direct memberships on this project. +2. **Geerbt aus Eltern-Projekten** — ancestor-inherited (today's "Inherited" rows). Only visible on non-root projects. +3. **Aus Unterprojekten** — descendant-inherited (NEW). Only visible on projects that have descendants. Each row carries the descendant project title in the "Quelle" column. +4. **Abgeleitet (Partner Unit)** — derived from a partner-unit on this project (NEW, see §3.2). Each row carries `Aus: ` annotation. + +Each section sorts by role then name; sections render only if they have rows. + +### 3.2 Partner-unit derivation — the missing schema + +The issue says (Q11): "PAs are automatically part of teams when a partner unit is involved." For this to work, two pieces of data are needed that don't exist today: + +- A way to mark **a unit as involved on a project**. +- A way to identify **which members of that unit derive** (m's intent: PAs only, not all members). + +#### 3.2.1 Schema addition: `paliad.project_partner_units` (NEW) + +```sql +CREATE TABLE paliad.project_partner_units ( + project_id uuid NOT NULL REFERENCES paliad.projects(id) ON DELETE CASCADE, + partner_unit_id uuid NOT NULL REFERENCES paliad.partner_units(id) ON DELETE CASCADE, + -- Roles in the unit that auto-derive onto the project team. + -- Default {'pa','senior_pa'} — m's locked rule. + -- Can be widened per project (e.g. ['pa','senior_pa','attorney']) if a + -- specific matter needs the whole unit, or narrowed (e.g. just ['pa']). + derive_unit_roles text[] NOT NULL DEFAULT ARRAY['pa','senior_pa'], + attached_at timestamptz NOT NULL DEFAULT now(), + attached_by uuid REFERENCES paliad.users(id) ON DELETE SET NULL, + PRIMARY KEY (project_id, partner_unit_id) +); + +CREATE INDEX project_partner_units_unit_idx + ON paliad.project_partner_units (partner_unit_id, project_id); +``` + +This is a thin, additive junction. RLS = `paliad.can_see_project(project_id)` for SELECT; INSERT/UPDATE/DELETE gated to project lead OR global_admin (consistent with how the team roster is managed today). + +#### 3.2.2 Schema addition: `paliad.partner_unit_members.unit_role` (NEW column) + +```sql +ALTER TABLE paliad.partner_unit_members ADD COLUMN unit_role text NOT NULL DEFAULT 'attorney' + CHECK (unit_role IN ('lead','attorney','senior_pa','pa','paralegal')); +``` + +This is the **only place a firm-rank concept enters the schema**. It is intentionally **scoped to the unit, not to the user**. The same human can be `attorney` in Müller-Unit and `lead` in Schmidt-Unit — though in practice that's rare. The column is unit-scoped because: + +- Per-unit roles preserve the three-axis principle (t-paliad-051, t-paliad-138). It is not a global firm-wide rank; it is a per-unit organisational role. +- A user's `paliad.users.job_title` (free-text display) and `paliad.users.global_role` (`standard | global_admin`) stay untouched. Three-axis principle holds: job_title, global_role, project_role — unit_role is a fourth axis but it's a unit-property, not a user-property. +- Derivation can target specific tiers (default: PAs) without committing to a firm-wide rank. + +Initial values: every existing row defaults to `'attorney'`. Admins re-tier as needed via the existing `/admin/partner-units` UI (small follow-up — see §3.5). Because `attorney` is not in the default `derive_unit_roles`, no existing membership starts deriving anywhere on day one. Opt-in by re-tagging. + +#### 3.2.3 Why not a `lawyer ↔ PA assignment` table? + +The issue's wording mentions "PAs assigned to a lawyer." A literal reading would add `paliad.user_pa_assignments(lawyer_id, pa_id, …)`. **Recommended against** for three reasons: + +1. **The data doesn't exist.** No table, no spreadsheet, no source-of-truth captures "Anna PA reports to Bert Lawyer" today. Adding the schema means asking m to populate ~30 PAs' lawyer-assignments by hand. +2. **It double-encodes the unit.** If Anna PA is in Müller-Unit, and Bert Lawyer is in Müller-Unit, and the matter has Müller-Unit attached, you don't need the explicit pairing — the unit captures the affiliation already. +3. **The derivation rule m wants is unit-level, not pair-level.** "Müller-Unit is on the matter" → "all Müller-Unit PAs derive". The pair-level form would add per-PA precision that creates more friction (every PA reassignment touches projects) for marginal value. + +If a project team genuinely needs PA-level precision (e.g. Anna PA derives but Bert PA doesn't), the right tool is **explicit team membership**: add Anna directly to the project team as `role='pa'`. The derivation mechanism is a default; explicit membership always wins. + +**Open Q for m:** confirm unit-level derivation is the intent, vs pair-level? (Recommend: unit-level. See §6 Q11.) + +### 3.3 Visibility semantics — display vs authority (Q9) + +m's Q9: "(a) display-time aggregation only, OR (b) does it grant visibility / authority somewhere new?" + +The current state today: `paliad.can_see_project()` walks ancestors; a user on Client A can see all descendants of A. So a derived member of a Client (via Müller-Unit) automatically gets visibility of the entire subtree, **provided their derived membership materialises into something `can_see_project` can read**. + +Two architectural options for materialisation: + +#### Option A — Materialise derived rows into `paliad.project_teams` + +Use the existing `paliad.project_teams.inherited boolean DEFAULT false` column (currently unused, reserved). A derivation insert / sync writes `inherited=true` rows tagged with a new `derivation_source jsonb` column. + +```sql +ALTER TABLE paliad.project_teams ADD COLUMN derivation_source jsonb; +-- e.g. {"type": "partner_unit", "partner_unit_id": "", "synced_at": "..."} +-- NULL on direct rows, populated on derived rows. +``` + +Sync is service-layer, in the same tx as any of: +- INSERT on `project_partner_units` → INSERT a row into `project_teams` for every derive-eligible unit member. +- DELETE on `project_partner_units` → DELETE the matching derived rows. +- INSERT on `partner_unit_members` (when unit is on N projects) → INSERT N derived rows. +- DELETE on `partner_unit_members` → DELETE matching derived rows. +- UPDATE on `partner_unit_members.unit_role` → INSERT/DELETE based on whether the new role is in `derive_unit_roles`. + +Pros: +- `can_see_project` works unchanged (derived rows are real `project_teams` rows). +- Read-side queries (every list endpoint that JOINs `project_teams`) need zero changes. +- The dashboard / agenda / any visibility-keyed query stays fast. + +Cons: +- Sync is many-to-many: 1 unit on 5 projects with 8 PAs = 40 derived rows on a single attach. Manageable but not trivial. +- Drift risk: if a sync misses (transient FK error, concurrent write race), the derived state diverges from the source-of-truth. Need a periodic reconciliation job. +- Admin reading direct memberships sees a polluted list unless the UI filters `derivation_source IS NULL`. + +#### Option B — Compute on read (extend `can_see_project()`) + +Don't materialise. Extend the SQL function to UNION in derived members: + +```sql +CREATE OR REPLACE FUNCTION paliad.can_see_project(target_id uuid) RETURNS boolean AS $$ + -- existing branches (global_admin, ancestor team membership) + -- + new branch: direct or ancestor partner-unit attached, with derive role match + EXISTS ( + SELECT 1 FROM paliad.project_partner_units ppu + JOIN paliad.partner_unit_members pum ON pum.partner_unit_id = ppu.partner_unit_id + JOIN paliad.projects p ON p.id = target_id + WHERE pum.user_id = auth.uid() + AND pum.unit_role = ANY(ppu.derive_unit_roles) + AND ppu.project_id = ANY(string_to_array(p.path, '.')::uuid[]) + ) +$$ LANGUAGE SQL STABLE; +``` + +Plus the Go mirror in `services.visibilityPredicate` (and `visibilityPredicatePositional`). + +Pros: +- Zero sync. The source-of-truth is `partner_unit_members` + `project_partner_units`; no derived state to keep in sync. +- No drift possible. +- Removing a unit member instantly removes their derived membership everywhere. + +Cons: +- The visibility predicate gets larger (one more EXISTS branch). Read-cost increases on every project-scoped query. +- Index strategy: `paliad.partner_unit_members(user_id)` (already there as `partner_unit_members_user_idx`); plus `paliad.project_partner_units(partner_unit_id, project_id)` (new index above). Both small tables; should stay sub-ms. +- Listing the project team for display still needs the Go-side compute (UNION in the materialised query body). But that's already what `ListSubtreeMembers` does — it's not a regression. + +#### Recommendation: Option B (compute on read) + +Cleaner architecture, no drift, no sync. The read-cost is small (two more EXISTS branches in the visibility predicate, both backed by existing indexes). Over the lifetime of the system, **every** sync-based system eventually drifts; computed-on-read is honest by construction. + +The downside (slightly slower visibility queries) is mitigated by the fact that paliad's hot path (list-with-visibility) already pays for the existing path-walk; one more EXISTS is in the noise. + +**Open Q for m:** confirm Option B (compute on read) over Option A (materialise)? (Recommend: B. See §6 Q11b.) + +### 3.4 Authority of derived members (Q12) + +m's Q12: "If a derived PA is on a project at level=pa, can they approve?" + +Two stances: + +- **Strict:** derivation = visibility-only. Derived members can read but cannot mutate / approve. Authority requires **explicit** project_teams membership. +- **Permissive:** derivation = full membership. A derived PA who's on a project where the policy is `required_role='pa'` can approve — same as if they were directly added. + +**Recommendation: strict by default; permissive opt-in.** + +Reasoning: + +- Derivation is a **convenience layer** — saving the firm from re-staffing every PA on every matter. Convenience layers shouldn't grant authority silently. +- The 4-eye design (t-paliad-138) is about accountability. An approval signed by a derived member who didn't even know they were on the matter is a worse signature than no approval. +- The honesty principle (Q13 — derivation visible everywhere) is incoherent with silent authority. If the user can sign off, they should know they're on the team. +- **Mutation** rights (create / update / delete) are similar — derived members can read but not mutate by default. + +Schema: a per-row column on `project_partner_units`: + +```sql +ALTER TABLE paliad.project_partner_units ADD COLUMN derive_grants_authority boolean NOT NULL DEFAULT false; +``` + +When `false`: derived members are visibility-only, omitted from the approver-eligibility query and from mutation-allowed lists. +When `true`: derived members are treated as direct members for both visibility and authority. Their effective `project_teams.role` for the approval ladder is the **map** of their `unit_role`: + +| `unit_role` | Mapped `project_teams.role` (for approval-ladder purposes) | +|---|---| +| `lead` | `lead` | +| `attorney` | `associate` | +| `senior_pa` | `senior_pa` | +| `pa` | `pa` | +| `paralegal` | `observer` (level 0 = ineligible to approve) | + +Service-layer helper: `effectiveProjectRole(userID, projectID)` returns `(role, source)` where `source ∈ {direct, ancestor, descendant, derived}`. Used by the approval canApprove check (§4.2). + +The strict-default is consistent with how RLS in paliad treats edge cases (deny by default, opt-in to grant). It also makes the "Aus: Müller-Unit" annotation unambiguous: if the row says "derived", the user is reading-only unless `derive_grants_authority=true`, in which case the badge upgrades to "abgeleitet & stimmberechtigt". + +**Open Q for m:** confirm strict default + per-unit-on-project opt-in for authority? (See §6 Q12.) + +### 3.5 Display annotation (Q13) + +The honesty rule: every derived member is annotated everywhere their membership shows. Three patterns: + +#### 3.5.1 Project team overview (Team tab on /projects/{id}) + +Sectioned rendering (§3.1). Derived rows under "Abgeleitet (Partner Unit)" with full annotation: + +``` +Anna Schmidt PA über Müller-Unit (5 Projekte gemeinsam) [Sicht] +Bert Müller Senior PA über Müller-Unit [Sicht & 4-Augen] +``` + +Where `[Sicht]` and `[Sicht & 4-Augen]` are small badges for visibility-only vs authority-granted. Right column shows the unit name; hover/click opens unit details. Badge colour matches the derivation chip palette (subtle grey-blue, distinct from direct-team and ancestor-inherited rows). + +#### 3.5.2 Inline annotation on entity rows + +When a deadline/appointment row is approved by a derived member, the Verlauf line renders the source explicitly: + +``` +Frist Erwiderung — genehmigt von Anna Schmidt (PA, abgeleitet aus Müller-Unit) 2026-05-12 +``` + +Not just "von Anna Schmidt" — the audit trail must disclose the derivation. This is a Verlauf rendering concern (`translateEvent` in `projects-detail.ts`); the `decision_source` field on the approval_request row carries the chain. + +#### 3.5.3 /team directory grouping + +The existing `/team` directory page groups by office / partner_unit. Derived-membership-on-project doesn't show here (the directory is firm-wide). No change needed. + +### 3.6 Cascading + hierarchy interaction (Q14) + +Q14: "If Anna PA derives onto Client A through Müller-Unit, does Anna inherit visibility of all descendants of Client A?" + +**Yes.** The visibility predicate walks ancestors (already does). Once Anna has a path-membership on Client A's row (via the derivation predicate in Option B, §3.3), descendants are visible by the existing rule: + +``` +ppu.project_id = ANY(string_to_array(target.path, '.')::uuid[]) +``` + +Where `target` is any descendant of Client A. Client A's id appears in every descendant's path (path includes self), so the predicate matches. + +This is the **right** behaviour: derivation makes you a team member at the level it derives, and visibility then walks down per the existing rules. Computing derivation per-descendant is unnecessary. + +However: **subtree aggregation in §2's Team-tab "Aus Unterprojekten" subsection should NOT include derived members of descendants in the parent's view.** Reasoning: the parent's team overview should show "who's directly on this Client (the partner unit involvement)" + "who's on a Case below me (real explicit staffing)". Cascading derived members up the tree would show every PA in every unit attached anywhere in the subtree as "in the Client team", which dilutes the signal. + +So the rendering rule for the parent's display: + +- **Direct + ancestor** rows: as today. +- **Descendant** rows: include direct-team rows of descendants (explicit staffing on a Case shows up on the Client view). **Exclude** derived rows of descendants (those don't surface up). +- **Derived (this project)** rows: this project's own `project_partner_units` derivations. + +This preserves "explicit staffing wins" as the structural read. + +### 3.7 Removal / churn (Q15) + +Q15: "When Anna is reassigned to a different lawyer, does her derived membership lapse on all old projects retroactively, or only on new projects going forward?" + +The pair-level "PA-assigned-to-Lawyer" model is rejected (§3.2.3), so the sub-question evaporates: there is no per-pair assignment to reassign. The unit-level model has cleaner churn semantics: + +- **Anna leaves Müller-Unit** (`DELETE FROM partner_unit_members WHERE user_id=anna AND partner_unit_id=mueller`) → Anna's derivation evaporates everywhere immediately (computed-on-read; or sync-on-write if Option A). All projects where Müller-Unit is attached drop her from derivation. +- **Anna's `unit_role` changes** (e.g. `pa` → `attorney`) → if `attorney` is not in `derive_unit_roles` for a project, her derivation drops there. Per-project; some may keep her, others drop. +- **Müller-Unit detached from Project X** → all unit members lose their derivation on X. + +**Audit trail:** every change to `paliad.partner_unit_members` already emits a `paliad.partner_unit_events` row (member_added / member_removed) per migration 027. No new audit table needed. We add four new event_types on that side: `derivation_started_on_project`, `derivation_ended_on_project`, `unit_attached_to_project`, `unit_detached_from_project`. These show up in the user's profile timeline + the project's Verlauf (cross-emission to `paliad.project_events`). + +**Retroactivity:** churn affects current state only — past approvals signed by Anna stay valid (the audit row records `decision_source: derived`). She doesn't lose the signature; she just stops being able to make new ones. + +This is the natural, honest semantic — "you stop deriving when the chain stops being true," but the historical record is immutable. + +### 3.8 Service shape + +```go +// internal/services/derivation_service.go (NEW) + +type DerivationService struct { + db *sqlx.DB + projects *ProjectService + units *PartnerUnitService +} + +// AttachUnitToProject creates a project_partner_units row (with optional +// custom derive_unit_roles override). Emits unit_attached_to_project event. +func (s *DerivationService) AttachUnitToProject(ctx, callerID, projectID, unitID, opts AttachOpts) error + +// DetachUnitFromProject deletes the row, emits unit_detached_from_project. +func (s *DerivationService) DetachUnitFromProject(ctx, callerID, projectID, unitID) error + +// DerivedMembersForProject computes the derived list at read time (Option B). +// Returns rows enriched with unit name + unit_role + derives_authority flag. +func (s *DerivationService) DerivedMembersForProject(ctx, callerID, projectID) ([]DerivedMember, error) + +// EffectiveProjectRole — used by ApprovalService.canApprove and elsewhere. +// Returns (role, source) where source is one of: direct | ancestor | descendant | derived. +// Resolves direct first, then derived (only if derive_grants_authority=true on the +// matching project_partner_units row), then ancestor. +func (s *DerivationService) EffectiveProjectRole(ctx, userID, projectID uuid.UUID) (string, string, error) +``` + +The service is small; the bulk of the logic is the visibility predicate extension and the team-listing UNION. ~150-200 LoC. + +Frontend: `/admin/partner-units/{id}` gains a "Mitglieder" tab where each row has a `unit_role` select. `/projects/{id}/settings/team` gains a "Partner Units" section listing attached units, with attach/detach buttons + a per-unit toggle for "Stimmrecht abgeben (4-Augen)". + +### 3.9 The "Effective Team of a Client" definition — pinned + +**Display-effective team** of project P (used by the new `ListSubtreeMembers` and the project detail Team tab): + +``` +direct_team(P) + ∪ ancestor_team(P) + ∪ descendant_direct_team(P) -- explicitly staffed on any descendant of P + ∪ derived_members(P, depth=self) -- partner-unit-attached on P only +``` + +Where: +- `direct_team(P)` = `project_teams WHERE project_id = P.id`. +- `ancestor_team(P)` = `project_teams WHERE project_id IN ancestors(P.id)`. +- `descendant_direct_team(P)` = `project_teams WHERE project_id IN descendants(P.id) AND derivation_source IS NULL` (or, in Option B world: just `WHERE project_id IN descendants(P.id)`). +- `derived_members(P)` = users from any unit attached to P whose `unit_role IN derive_unit_roles`. + +**Visibility-effective** is a different set (used by `can_see_project`): + +``` +direct_team(P) ∪ ancestor_team(P) ∪ derived_members(any ancestor of P, P inclusive) +``` + +So a member of any ancestor's team OR a derived member of any ancestor (or P itself) can see P. Descendant-direct-team is NOT in the visibility set — that's the existing direction (a Client team member sees Cases, but a Case team member doesn't automatically see the Client header data unless they're explicitly on the Client team or visible via path-walk-ancestors which they won't be from a descendant). + +These two sets are intentionally different. Display answers "who's on this matter to a viewer of P?"; visibility answers "who can read P?". Both are honest. + +--- + +## 4. Sub-design 3 — Approval-policy interaction with hierarchy + derivation + +This section coordinates with cronus's t-paliad-138 design (`docs/design-approvals-2026-05-06.md`, branch `mai/cronus/inventor-dual-control`). Two cross-cuts: hierarchy (Q10) and derivation (Q12). + +### 4.1 Q10 — does a Client-level policy inherit down to a Case? + +**t-138's locked answer: NO inheritance.** Each project authors its own policy independently, with an "Aus Eltern-Projekt übernehmen" button as a one-shot copy. Reasons cronus gave: (a) reparenting would silently change behaviour, (b) per-project authoring is the right default, (c) path-walking adds complexity for marginal value. + +**This design's recommendation: keep no-auto-inheritance, but harden the UX.** Two reasons to keep cronus's call: + +- **Predictability over convenience.** When a deadline is created on a Case, the user authoring it should see the policy that applies, not a hidden inherited rule. Inheritance with overrides creates "where is this rule defined?" confusion that compliance hates. +- **Reparenting cost.** Moving a Case from Litigation A to Litigation B silently re-policies the Case if ancestors inherit. Today reparent is a project-detail action (t-paliad-018); we don't want it to also flip the approval gate. + +What we add on top of cronus's design (UX hardening): + +#### 4.1.1 Policy stack visualisation + +On `/projects/{id}/settings/approvals`, render the project's own 8-cell policy table AND the inherited-from-ancestors stack as an explicit "Eltern-Politik (zur Information)" panel: + +``` +DIESES PROJEKT — Acme v. Foo Patent Verfahren +Frist: create=associate update=associate complete=associate delete=lead +Termin: — update=associate — — + +ELTERN-POLITIK (KEIN AUTOMATISCHER EINSCHLUSS) — Acme v. Foo Litigation +Frist: create=lead … + [Übernehmen] + +ELTERN-POLITIK — Acme Corp Client +Frist: create=lead … + [Übernehmen] +``` + +The "Übernehmen" button is the one-shot copy from cronus's §5.3. The display is informational: nothing inherits silently, but the parent's authoritarian decisions are visible to the child author. + +#### 4.1.2 The "policy as default" alternative + +If m later wants real inheritance, a clean shape is **"the closest ancestor's policy applies if this project has no row"**: + +``` +For a request on (project=Case, entity=deadline, lifecycle=update): + walk path from Case up: Case → Patent → Litigation → Client → root + return the first project row that has a matching approval_policies row + (else: no approval needed) +``` + +Service-layer implementation is one CTE. Trade-off: silent rules. Reparenting changes behaviour. Authoring becomes ambiguous ("does this policy override an inherited one or define a new one?"). + +**Recommendation for v1: don't inherit silently.** Use the policy-stack panel for visibility. Re-evaluate in 6 months if firm experience says inheritance is needed. + +### 4.2 Q12 — does derivation carry approval authority? + +Per §3.4, **strict default**: derived members are visibility-only. Authority requires `derive_grants_authority=true` on the `project_partner_units` row. + +When `derive_grants_authority=true`, the `canApprove(callerID, projectID, requiredRole)` helper from t-138 §3.4 needs an extension: + +```go +func (s *ApprovalService) canApprove(ctx, callerID, projectID, requiredRole, requesterID) (bool, kind string, err error) { + if callerID == requesterID { + return false, "", ErrSelfApprovalBlocked + } + user, _ := s.users.GetByID(ctx, callerID) + if user.GlobalRole == "global_admin" { + return true, "admin_override", nil + } + role, source, err := s.derivation.EffectiveProjectRole(ctx, callerID, projectID) + if err != nil { return false, "", err } + if role == "" { + return false, "", nil // not on team in any form + } + if levelOf(role) >= levelOf(requiredRole) { + kind := "peer" + if source == "derived" { kind = "derived_peer" } + return true, kind, nil + } + return false, "", nil +} +``` + +`decision_kind` enum on `paliad.approval_requests` extends to: `peer | admin_override | derived_peer`. The third value is honest about the chain — Verlauf rendering can render it as "Genehmigt von Anna (PA, abgeleitet aus Müller-Unit)". The audit trail discloses every derivation. + +Single-qualified-approver deadlock count (t-138 §4.2) gets the same extension: count derived authority-granting members alongside direct ones. + +**Coordination consequence for cronus's t-138 implementation:** §3.4's `canApprove` and §7.4's inbox SQL both need to be widened to honour `EffectiveProjectRole`. The change is small (re-use `DerivationService.EffectiveProjectRole`) but it's a coordination point: t-138 should land first (without derivation awareness), then this design's coder shift extends t-138's helpers. Or: t-139 lands first (schema only, no behaviour) and t-138 lands on top of it. + +**Recommended phasing (§7):** ship t-138 as designed (no derivation), then extend `canApprove` here when t-139 lands. + +### 4.3 What about deadlines that live on a Case but the policy is on the Client? + +This is the same as Q10 — no inheritance, the Case authors its own policy. If a Case has no `(deadline, update)` row, no approval is needed for date edits on that Case (today's behaviour). The Client's policy is informational on the Case's settings panel but doesn't apply. + +If the firm's policy is "all Cases under Acme need 4-eye on date edits", the right v1 workflow is: when the Litigation is created, propagate-and-author the policy onto each Case (manual, one-shot). The "Übernehmen" button supports this. Future automation: a "Auf alle Unterprojekte anwenden" button on the parent's policy authoring page that bulk-creates rows on every direct + descendant project. Out of scope for v1. + +### 4.4 Approval-RLS interaction + +Per t-138 §9: `paliad.approval_requests` RLS = `paliad.can_see_project(project_id)`. Since `can_see_project` is extended (Option B) to include partner-unit derivation, derived members CAN read approval requests on projects they derive into. This is desirable — they can see the queue, just can't act on it (unless `derive_grants_authority=true`). + +The inbox SQL from t-138 §7.4 needs the eligibility branch widened to honour derivation: + +```sql +SELECT ar.* FROM paliad.approval_requests ar + JOIN paliad.projects p ON p.id = ar.project_id + WHERE ar.status = 'pending' + AND ar.requested_by != $callerID + AND (p) for callerID + AND ( + -- caller is global_admin + EXISTS (SELECT 1 FROM paliad.users u WHERE u.id = $callerID AND u.global_role = 'global_admin') + OR + -- caller has eligible direct role (own + ancestors) + EXISTS (SELECT 1 FROM paliad.project_teams pt + WHERE pt.user_id = $callerID + AND pt.project_id = ANY(string_to_array(p.path, '.')::uuid[]) + AND paliad.approval_role_level(pt.role) >= paliad.approval_role_level(ar.required_role)) + OR + -- caller has eligible derived role (only on projects with derive_grants_authority=true) + EXISTS (SELECT 1 FROM paliad.project_partner_units ppu + JOIN paliad.partner_unit_members pum ON pum.partner_unit_id = ppu.partner_unit_id + WHERE pum.user_id = $callerID + AND ppu.project_id = ANY(string_to_array(p.path, '.')::uuid[]) + AND ppu.derive_grants_authority = true + AND pum.unit_role = ANY(ppu.derive_unit_roles) + AND paliad.approval_role_level( + paliad.approval_role_from_unit_role(pum.unit_role) + ) >= paliad.approval_role_level(ar.required_role)) + ) + ORDER BY ar.requested_at ASC; +``` + +`paliad.approval_role_from_unit_role` is a small SQL function: + +```sql +CREATE FUNCTION paliad.approval_role_from_unit_role(unit_role text) RETURNS text LANGUAGE SQL IMMUTABLE AS $$ + SELECT CASE unit_role + WHEN 'lead' THEN 'lead' + WHEN 'attorney' THEN 'associate' + WHEN 'senior_pa' THEN 'senior_pa' + WHEN 'pa' THEN 'pa' + ELSE 'observer' + END +$$; +``` + +This goes into migration 055 (this design's schema) and is referenced from t-138's inbox handler when t-139 lands. + +--- + +## 5. Schema additions (migration 055) + +A clean follow-up migration after t-138's 054 lands. Single tx, modest footprint. + +```sql +-- ============================================================================ +-- 055_hierarchy_aggregation.up.sql +-- Adds: project↔unit junction, unit-role distinction, derivation predicates, +-- and small helper functions used by t-138's approval SQL. +-- ============================================================================ + +-- 1. Unit-role distinction on partner_unit_members. +ALTER TABLE paliad.partner_unit_members ADD COLUMN unit_role text NOT NULL DEFAULT 'attorney' + CHECK (unit_role IN ('lead','attorney','senior_pa','pa','paralegal')); + +-- 2. Project ↔ partner_unit junction (the "involved on a project" axis). +CREATE TABLE paliad.project_partner_units ( + project_id uuid NOT NULL REFERENCES paliad.projects(id) ON DELETE CASCADE, + partner_unit_id uuid NOT NULL REFERENCES paliad.partner_units(id) ON DELETE CASCADE, + derive_unit_roles text[] NOT NULL DEFAULT ARRAY['pa','senior_pa'], + derive_grants_authority boolean NOT NULL DEFAULT false, + attached_at timestamptz NOT NULL DEFAULT now(), + attached_by uuid REFERENCES paliad.users(id) ON DELETE SET NULL, + PRIMARY KEY (project_id, partner_unit_id) +); + +CREATE INDEX project_partner_units_unit_idx + ON paliad.project_partner_units (partner_unit_id, project_id); + +ALTER TABLE paliad.project_partner_units ENABLE ROW LEVEL SECURITY; +CREATE POLICY project_partner_units_select + ON paliad.project_partner_units FOR SELECT + USING (paliad.can_see_project(project_id)); +CREATE POLICY project_partner_units_write + ON paliad.project_partner_units FOR ALL + USING ( + EXISTS (SELECT 1 FROM paliad.users u + WHERE u.id = auth.uid() AND u.global_role = 'global_admin') + OR EXISTS (SELECT 1 FROM paliad.project_teams pt + WHERE pt.user_id = auth.uid() + AND pt.project_id = project_partner_units.project_id + AND pt.role = 'lead') + ); + +-- 3. Helper: approval_role_from_unit_role (used by t-138 inbox SQL). +CREATE FUNCTION paliad.approval_role_from_unit_role(unit_role text) RETURNS text LANGUAGE SQL IMMUTABLE AS $$ + SELECT CASE unit_role + WHEN 'lead' THEN 'lead' + WHEN 'attorney' THEN 'associate' + WHEN 'senior_pa' THEN 'senior_pa' + WHEN 'pa' THEN 'pa' + ELSE 'observer' + END +$$; + +-- 4. Extend can_see_project() to honour partner-unit derivation. +-- Replaces migration 023's body. Identical structure plus one EXISTS branch. +CREATE OR REPLACE FUNCTION paliad.can_see_project(target_id uuid) RETURNS boolean +LANGUAGE SQL STABLE AS $$ + SELECT EXISTS ( + SELECT 1 FROM paliad.users u + WHERE u.id = auth.uid() AND u.global_role = 'global_admin' + ) OR EXISTS ( + SELECT 1 FROM paliad.project_teams pt + JOIN paliad.projects p ON p.id = target_id + WHERE pt.user_id = auth.uid() + AND pt.project_id = ANY(string_to_array(p.path, '.')::uuid[]) + ) OR EXISTS ( + SELECT 1 FROM paliad.project_partner_units ppu + JOIN paliad.partner_unit_members pum ON pum.partner_unit_id = ppu.partner_unit_id + JOIN paliad.projects p ON p.id = target_id + WHERE pum.user_id = auth.uid() + AND pum.unit_role = ANY(ppu.derive_unit_roles) + AND ppu.project_id = ANY(string_to_array(p.path, '.')::uuid[]) + ); +$$; + +-- 5. Audit table extension — partner_unit_events gets two new event types +-- (unit_attached_to_project, unit_detached_from_project). No DDL change; +-- the events table already accepts arbitrary event_type strings. +``` + +Down migration drops the new function, restores the migration-023 `can_see_project` body, drops `paliad.project_partner_units`, drops `paliad.partner_unit_members.unit_role`. If any project has `project_partner_units` rows, the down would orphan those — fail loudly (intentional, mirrors t-138's down strategy). + +### 5.1 Bug-fix-only migration (if m wants the bug fix decoupled) + +If m wants the `/projects/{id}` "Keine Fristen" bug fix to land standalone before any of the §3 / §4 work, that's a code-only change (no schema). Just: + +1. Switch `projects-detail.ts` fetches to the union endpoints. +2. Update `ProjectService.ListProjectEvents` to use `projectDescendantPredicate`. +3. Optionally add the `direct_only` toggle. + +That's a 1-PR fix, no migration. The §3 / §4 work stacks on top. + +Recommended phasing (§7) reflects this option. + +--- + +## 6. Locked answers (m, 2026-05-06) + +m signed off on all 19 recommendations verbatim. Implementation proceeds on these answers. + +### Surface-by-surface aggregation policy (Q1–Q8, Q16–Q19) + +| Q | Question | Recommendation | +|---|---|---| +| Q1 | `/projects/{id}` Fristen / Termine / Aktivität / Verlauf — aggregate descendants by default? Per-section toggle? | **Aggregate by default**, narrow toggle ("nur direkt") on each section. With attribution chip per row. | +| Q2 | Project tree sidebar count badges — direct, descendant, or both? | **Both**: render `(direct + descendants)` when both are non-zero. Single number when descendants is zero. | +| Q3 | Dashboard widgets — per-project subtree filter? | **No change v1.** Dashboard is the global home. Subtree filter is a plausible follow-up (paged off the existing `/dashboard?project_id=` query param). | +| Q4 | `/events` filtered by project — include descendants? | **Yes**, default. Already does. Toggle for narrow. | +| Q5 | `/deadlines` and `/appointments` lists — same? | **Yes**, same shape as `/events`. | +| Q6 | Search results across descendants? | **Subtree default when in project context**, global otherwise. Cmd-K opened from /projects/X searches X's subtree; opened from / searches global. | +| Q7 | CalDAV — direct memberships only, or path-reachable? | **No change.** CalDAV scope = visibility (path-reachable, current behaviour). Subtree aggregation would over-include. | +| Q8 | Email reminders — same scope? | **No change.** Same rule as CalDAV. | +| Q16 | Default direction when an answer isn't obvious — aggregate or narrow? | **Aggregate.** Safer "no missed information" outweighs noise risk. | +| Q17 | Path-walk query shape scales? | **Yes** for current sizes. Confirmed by t-paliad-124 test on live DB. No materialised counts in v1. | +| Q18 | Performance escape hatch? | **No bound v1.** Materialised view if a real bottleneck appears. Don't pre-optimise. | +| Q19 | Aggregation indicator per row? | **Yes.** Attribution chip in the row showing the descendant project ("auf: Case 14-vs-Müller"). | + +### Effective-team semantics (Q9, Q11, Q13–Q15) + +| Q | Question | Recommendation | +|---|---|---| +| Q9 | "Effective team of a Client" — display-time only, or grants visibility/authority somewhere new? | **Display-time aggregation by default**; visibility already covered by the existing path-walk + new derivation predicate; authority is opt-in (Q12). | +| Q11 | Derivation rule shape — pair-level or unit-level? Where is lawyer↔PA stored? | **Unit-level derivation.** Pair-level rejected (the data doesn't exist; the unit is the right granularity). Schema: new `partner_unit_members.unit_role` column + new `project_partner_units` junction. | +| Q11b (new) | Materialise derived rows into `project_teams`, or compute on read? | **Compute on read.** Extend `can_see_project()` with one EXISTS branch. No drift; sub-ms cost. | +| Q13 | Display annotation? | **Sectioned rendering on the Team tab** with explicit "Abgeleitet (Partner Unit)" subsection + per-row source label. Verlauf annotates approval rows from derived members. Inline badges on row context too. | +| Q14 | Cascading + hierarchy — does derived membership inherit visibility down? | **Yes** via the existing path-walk predicate. The derived membership behaves like any other team membership at the level it derives. | +| Q15 | Removal / churn — retroactive or going forward? | **Retroactive on current state** (computed-on-read drops the derivation immediately) but **historical record is immutable** (a past approval signed by Anna stays valid; she just can't sign new ones). | + +### Approval interaction (Q10, Q12) + +| Q | Question | Recommendation | +|---|---|---| +| Q10 | Does a Client policy inherit down to a Case? | **No automatic inheritance.** Per t-138 design. Add a "Eltern-Politik (zur Information)" panel on the child's policy page so the parent's rules are visible without applying. "Übernehmen" button stays the explicit copy mechanism. | +| Q12 | Does derivation carry approval authority? | **No by default**; opt-in per project via `derive_grants_authority` flag on the `project_partner_units` row. Strict default = visibility-only is consistent with paliad's deny-by-default safety stance. When opted in, derived members count as `peer` approvers with mapped role; `decision_kind` distinguishes `derived_peer` from regular `peer` for audit clarity. | + +--- + +## 7. Implementation phasing + +This is a meaty piece. Recommended split into three branches, three PRs: + +### Phase 1 — Bug fix (read-path subtree compliance) + +Smallest, highest value. Lands first regardless of how the rest plays out. + +1. `DeadlineService.ListForProject` → remove + redirect callers to `EventService.ListVisibleForUser({ProjectID, Type: deadline})`. Or switch body to use `projectDescendantPredicate`. +2. `AppointmentService.ListForProject` → same. +3. `ProjectService.ListProjectEvents` → switch WHERE to `projectDescendantPredicate`. Optional `direct_only bool` arg. +4. `frontend/src/client/projects-detail.ts:216/262/271` → switch fetch to union endpoints. +5. Add attribution chip rendering when row's `project_id !== currentProjectID`. +6. Add the "Inkl. Unterprojekte / Nur direkt" toggle on each section. Default = subtree. +7. Update t-paliad-124 test to also exercise the per-project narrow path (so the bug-class can never re-emerge). + +No schema. ~400 LoC. Single PR. **m's bug closes.** + +### Phase 2 — Schema for partner-unit derivation + +Migration 055 — adds `partner_unit_members.unit_role`, `project_partner_units`, `approval_role_from_unit_role`, extends `can_see_project()`. Backfill: every existing unit member defaults to `unit_role='attorney'` (no immediate derivation since `derive_unit_roles` defaults to `{pa, senior_pa}`). + +Backend services: `DerivationService` (Attach, Detach, DerivedMembersForProject, EffectiveProjectRole). Wire into `TeamService.ListSubtreeMembers`. Update `partner_unit_service.go` for unit_role tagging. + +Frontend: `/admin/partner-units/{id}` Mitglieder tab gets the unit_role select. `/projects/{id}/settings/team` Partner Units section. Project detail Team tab gains the new "Aus Unterprojekten" + "Abgeleitet (Partner Unit)" sections. + +i18n keys, audit-event types, integration tests. Single PR, ~1500 LoC. + +### Phase 3 — Approval extension (depends on Phase 2 and on t-138 landing) + +Extends t-138's `canApprove` to use `EffectiveProjectRole`. Updates the inbox SQL with the derivation eligibility branch. Adds `derived_peer` to `decision_kind`. Verlauf rendering for "abgeleitet" approvals. + +Single PR, ~400 LoC. Depends on t-138 (migration 054 + ApprovalService) being on main. + +### Sequencing + +``` + phase 1 (bug fix) + ↓ + phase 2 (schema 055 + derivation service) + ↓ + t-138 lands (cronus's PR 054) + ↓ + phase 3 (approval extension) +``` + +Phases 1 and 2 are independent of t-138 and can land in either order. Phase 3 is gated. + +### Implementation recommendation + +**Phase 1**: any coder. Mechanical refactor, no design judgement needed once locked. + +**Phase 2**: noether (this worktree) or a fresh coder. Schema + service + frontend; pattern-fluent. + +**Phase 3**: cronus (already deep in t-138 internals) or noether. Coordination heavy. + +Head decides splits. If a single coder takes all three, recommend phases 1 → 2 → 3 in sequence on a single rolling branch, with a self-merge of phase 1 first so the bug fix ships quickly. + +--- + +## 8. Trade-offs and known limitations + +### 8.1 Aggregate-by-default risks information overload + +A Client with 200 deadlines across 100 Cases shows 200 rows on `/projects/{client_id}`. This is "right" by the no-missed-information rule but can be visually overwhelming. Mitigations in v1: + +- Status filter chips (already present) narrow to "overdue / today / week / etc.". +- Attribution chip lets the user click into the descendant for focus. +- "Nur direkt" toggle is one click away. + +If real-world use shows the firm wants different defaults per surface (e.g. Termine narrow by default but Fristen aggregate), the policy table in §2.2 is the clean place to flip individual cells. Not all-or-nothing. + +### 8.2 Per-unit-role column re-introduces a rank concept + +§3.2.2 adds `unit_role` to `partner_unit_members`. The codebase has been deliberate (t-paliad-051, t-paliad-138) about NOT having a firm-wide rank column. The design argues this is a **per-unit role** rather than a firm-wide one — the same person can be `lead` in one unit and `attorney` in another. In practice, paliad has ~5-10 partner units and most users belong to exactly one, so the per-unit distinction is a fig leaf for what's effectively a firm-wide tier in 95% of cases. m should be aware of this: + +- If m is OK with the per-unit framing as honest enough, this design works. +- If m wants stricter adherence to no-rank-on-user, an alternative is to derive PA-ness from `project_teams.role` history ("if user has role='pa' on any project, they're a PA"). But that's circular — the whole point of derivation is to create memberships from out-of-band data. +- A third alternative: drop the role distinction entirely and derive ALL unit members onto the project. Wider derivation, simpler schema, but breaks m's "only PAs derive" intent. + +Recommendation: per-unit role. Revisit if it grows pain. + +### 8.3 Derived authority is a per-project flag — small UX cost + +`project_partner_units.derive_grants_authority` is a boolean per (project, unit) row. To turn on PA approvals on a specific matter, the project lead has to attach the unit AND flip the flag. Two-step. The default is safe but the flow is one click longer than "derivation = full membership". Trade-off chosen for honesty and audit clarity. + +### 8.4 No retroactive backfill of unit_role + +Migration 055 sets every existing partner_unit_members row to `unit_role='attorney'` and walks away. Admins re-tier as needed. Active derivation only starts when both (a) units get re-tiered AND (b) units get attached to projects via the new junction. Day-1 deploy = zero behaviour change. Slow opt-in is intentional. + +### 8.5 Computed-on-read visibility predicate adds query cost + +§3.3 Option B extends `can_see_project()` with one EXISTS branch. Every project-scoped query pays this cost. Estimate: <1ms on the small tables (`project_partner_units` < 100 rows, `partner_unit_members` < 200 rows). Should be in the noise. If it isn't, fall back to Option A (materialised) — but only if real production telemetry says so. + +### 8.6 Dashboard / CalDAV / email don't get subtree aggregation + +Per §2.3, those surfaces stay as-is. A user looking at `/dashboard` doesn't see "all deadlines under any Client they're on". Reasonable for v1 (the dashboard is the user's personal home), but if m wants the dashboard to also offer a per-Client subtree filter, that's a small follow-up (one query param, one CTE branch). + +### 8.7 Coordination cost with t-138 + +Phase 3 waits on t-138 landing. If t-138 takes weeks to merge for any reason, Phase 3 is parked. The bug fix (Phase 1) and the schema groundwork (Phase 2) are independent and can ship without t-138. + +--- + +## 9. End-of-design checklist + +- [x] Locked constraints summarised (§0) +- [x] Live state verified vs issue claims (§1) +- [x] Sub-design 1 — surface aggregation policy (§2) +- [x] Sub-design 2 — effective team + partner-unit derivation (§3) +- [x] Sub-design 3 — approval × hierarchy × derivation (§4) +- [x] Migration shape (§5) +- [x] 19 open questions with proposed answers (§6) +- [x] Implementation phasing (§7) +- [x] Trade-offs (§8) + +**Inventor stays parked.** Design LOCKED 2026-05-06 — m signed off on all 19 §6 recommendations. Awaiting head's coder-shift assignment. No `/mai-coder` self-load. diff --git a/frontend/src/client/i18n.ts b/frontend/src/client/i18n.ts index a611f77..c362f67 100644 --- a/frontend/src/client/i18n.ts +++ b/frontend/src/client/i18n.ts @@ -649,6 +649,11 @@ const translations: Record> = { "deadlines.urgency.later": "Sp\u00e4ter", "deadlines.complete.action": "Erledigen", + // t-paliad-139 \u2014 subtree aggregation toggle and attribution chip + "aggregation.toggle.subtree": "Inkl. Unterprojekte", + "aggregation.toggle.direct_only": "Nur direkt", + "aggregation.attribution.on": "auf", + "deadlines.neu.title": "Neue Frist \u2014 Paliad", "deadlines.neu.heading": "Neue Frist anlegen", "deadlines.neu.subtitle": "Eine persistente Frist an einer Akte. Sichtbar f\u00fcr alle Personen, die die Akte sehen k\u00f6nnen.", @@ -2334,6 +2339,11 @@ const translations: Record> = { "deadlines.urgency.later": "Later", "deadlines.complete.action": "Complete", + // t-paliad-139 \u2014 subtree aggregation toggle and attribution chip + "aggregation.toggle.subtree": "Incl. sub-projects", + "aggregation.toggle.direct_only": "Direct only", + "aggregation.attribution.on": "on", + "deadlines.neu.title": "New deadline \u2014 Paliad", "deadlines.neu.heading": "Create new deadline", "deadlines.neu.subtitle": "A persistent deadline attached to a matter. Visible to anyone who can see that matter.", diff --git a/frontend/src/client/projects-detail.ts b/frontend/src/client/projects-detail.ts index 6edd358..4454bd0 100644 --- a/frontend/src/client/projects-detail.ts +++ b/frontend/src/client/projects-detail.ts @@ -71,6 +71,10 @@ interface ProjectEvent { created_at: string; created_by?: string; metadata?: Record; + // Populated only when the response was joined to paliad.projects (Verlauf + // subtree-aggregating queries on /projects/{id}, t-paliad-139). Used to + // render the attribution chip when the event lives on a descendant. + project_title?: string; } interface Deadline { @@ -81,6 +85,10 @@ interface Deadline { status: string; rule_id?: string; rule_code?: string; + // Populated by the union endpoint (/api/events) which is what the project + // detail page calls — used for attribution when the row lives on a + // descendant project (t-paliad-139). + project_title?: string; } interface Appointment { @@ -91,6 +99,7 @@ interface Appointment { end_at?: string; location?: string; appointment_type?: string; + project_title?: string; } interface Me { @@ -167,6 +176,35 @@ const EVENTS_PAGE_SIZE = 50; let eventsHasMore = false; let eventsLoadingMore = false; +// Subtree aggregation mode (t-paliad-139). Default true → Fristen, Termine, +// Verlauf show rows from this project AND all descendant projects with an +// attribution chip per non-direct row. URL param `?subtree=false` flips to +// narrow (this project's own rows only). +let subtreeMode: boolean = true; + +function parseSubtreeMode(): boolean { + try { + const raw = new URLSearchParams(window.location.search).get("subtree"); + return raw !== "false"; + } catch { + return true; + } +} + +function persistSubtreeMode() { + try { + const url = new URL(window.location.href); + if (subtreeMode) { + url.searchParams.delete("subtree"); + } else { + url.searchParams.set("subtree", "false"); + } + window.history.replaceState({}, "", url.toString()); + } catch { + // ignore + } +} + function parseProjectID(): string | null { const parts = window.location.pathname.split("/").filter(Boolean); if (parts[0] !== "projects" || !parts[1]) return null; @@ -211,9 +249,18 @@ async function loadParties(id: string) { } } +// Build a query string suffix conveying the current subtree mode. The +// backend defaults to subtree (direct_only=false), so we only emit the +// param when the user has flipped to direct. +function subtreeParam(): string { + return subtreeMode ? "" : "&direct_only=true"; +} + async function loadEvents(id: string) { try { - const resp = await fetch(`/api/projects/${id}/events?limit=${EVENTS_PAGE_SIZE}`); + const resp = await fetch( + `/api/projects/${id}/events?limit=${EVENTS_PAGE_SIZE}${subtreeParam()}`, + ); if (resp.ok) { events = (await resp.json()) ?? []; eventsHasMore = events.length === EVENTS_PAGE_SIZE; @@ -238,7 +285,7 @@ async function loadMoreEvents(id: string) { } try { const resp = await fetch( - `/api/projects/${id}/events?before=${encodeURIComponent(cursor)}&limit=${EVENTS_PAGE_SIZE}`, + `/api/projects/${id}/events?before=${encodeURIComponent(cursor)}&limit=${EVENTS_PAGE_SIZE}${subtreeParam()}`, ); if (resp.ok) { const page: ProjectEvent[] = await resp.json(); @@ -257,10 +304,50 @@ async function loadMoreEvents(id: string) { } } +// Shape returned by /api/events — matches EventListItem in +// frontend/src/client/events.ts. Only the fields projects-detail needs. +interface UnionEvent { + type: "deadline" | "appointment"; + id: string; + title: string; + project_id?: string; + project_title?: string; + due_date?: string; + status?: string; + rule_id?: string; + rule_code?: string; + start_at?: string; + end_at?: string; + location?: string; + appointment_type?: string; +} + async function loadDeadlines(id: string) { try { - const resp = await fetch(`/api/projects/${id}/deadlines`); - if (resp.ok) deadlines = (await resp.json()) ?? []; + // t-paliad-139: switched from /api/projects/{id}/deadlines (legacy + // narrow path) to the union endpoint, which already aggregates + // descendants and enriches each row with project_title for the + // attribution chip. + const resp = await fetch( + `/api/events?type=deadline&project_id=${encodeURIComponent(id)}${subtreeParam()}`, + ); + if (resp.ok) { + const items: UnionEvent[] = (await resp.json()) ?? []; + deadlines = items + .filter((it) => it.type === "deadline") + .map((it) => ({ + id: it.id, + project_id: it.project_id ?? "", + title: it.title, + due_date: it.due_date ?? "", + status: it.status ?? "pending", + rule_id: it.rule_id, + rule_code: it.rule_code, + project_title: it.project_title, + })); + } else { + deadlines = []; + } } catch { deadlines = []; } @@ -268,8 +355,27 @@ async function loadDeadlines(id: string) { async function loadAppointments(id: string) { try { - const resp = await fetch(`/api/projects/${id}/appointments`); - if (resp.ok) appointments = (await resp.json()) ?? []; + // t-paliad-139: same migration as loadDeadlines. + const resp = await fetch( + `/api/events?type=appointment&project_id=${encodeURIComponent(id)}${subtreeParam()}`, + ); + if (resp.ok) { + const items: UnionEvent[] = (await resp.json()) ?? []; + appointments = items + .filter((it) => it.type === "appointment") + .map((it) => ({ + id: it.id, + project_id: it.project_id, + title: it.title, + start_at: it.start_at ?? "", + end_at: it.end_at, + location: it.location, + appointment_type: it.appointment_type, + project_title: it.project_title, + })); + } else { + appointments = []; + } } catch { appointments = []; } @@ -310,7 +416,7 @@ function renderAppointments() { return ` ${esc(fmtDateTimeLocal(tt.start_at))} - ${esc(tt.title)} + ${esc(tt.title)}${attributionChip(tt.project_id, tt.project_title)} ${esc(tt.location ?? "")} ${esc(typeLabel)} `; @@ -443,7 +549,7 @@ function renderDeadlines() { aria-label="${esc(t("deadlines.complete.action"))}" /> ${fmtDateOnly(f.due_date)} - ${esc(f.title)} + ${esc(f.title)}${attributionChip(f.project_id, f.project_title)} ${f.rule_code ? esc(f.rule_code) : "—"} ${esc(statusLabel)} `; @@ -477,6 +583,19 @@ function renderDeadlines() { }); } +// attributionChip renders a small inline chip showing which descendant +// project a row actually anchors on, when the row is from an aggregated +// subtree result and not from the project being viewed (t-paliad-139). +// Returns "" when the row's project is the current page or attribution +// data is missing. +function attributionChip(rowProjectID?: string, rowProjectTitle?: string): string { + if (!project) return ""; + if (!rowProjectID || !rowProjectTitle) return ""; + if (rowProjectID === project.id) return ""; + const label = t("aggregation.attribution.on") || "auf"; + return ` ${esc(label)}: ${esc(rowProjectTitle)}`; +} + function esc(s: string): string { const d = document.createElement("div"); d.textContent = s; @@ -580,7 +699,7 @@ function renderEvents() { return `
  • ${fmtDateTime(e.created_at)}
    -
    ${titleHTML}
    +
    ${titleHTML}${attributionChip(e.project_id, e.project_title)}
    ${description ? `
    ${esc(description)}
    ` : ""}
  • `; @@ -1117,6 +1236,10 @@ async function main() { return; } + // Read subtree mode from URL once at startup; subsequent toggles update + // the URL via persistSubtreeMode (replaceState — back-button friendly). + subtreeMode = parseSubtreeMode(); + await loadMe(); const ok = await loadProject(id); if (!ok || !project) { @@ -1155,10 +1278,44 @@ async function main() { initTeamForm(id); initDelete(); initEventsLoadMore(); + initSubtreeToggles(id); initNotesContainer(id); showTab(parseTab()); } +// initSubtreeToggles wires the "Inkl. Unterprojekte / Nur direkt" buttons +// in the History, Deadlines, and Appointments sections. State is shared +// across the three sections (one toggle flips all) and persisted in the +// URL via ?subtree=false. Default = subtree (true). +function initSubtreeToggles(id: string) { + const buttons = document.querySelectorAll(".subtree-toggle"); + if (buttons.length === 0) return; + + const refreshLabels = () => { + buttons.forEach((btn) => { + btn.textContent = subtreeMode + ? t("aggregation.toggle.subtree") + : t("aggregation.toggle.direct_only"); + btn.setAttribute("aria-pressed", subtreeMode ? "true" : "false"); + btn.classList.toggle("subtree-toggle--active", !subtreeMode); + }); + }; + + refreshLabels(); + + buttons.forEach((btn) => { + btn.addEventListener("click", async () => { + subtreeMode = !subtreeMode; + persistSubtreeMode(); + refreshLabels(); + await Promise.all([loadEvents(id), loadDeadlines(id), loadAppointments(id)]); + renderEvents(); + renderDeadlines(); + renderAppointments(); + }); + }); +} + // ----- Breadcrumb + ancestor resolution ----------------------------------- function inheritedClientNumber(): string | null { diff --git a/frontend/src/i18n-keys.ts b/frontend/src/i18n-keys.ts index d3da53d..e0afb98 100644 --- a/frontend/src/i18n-keys.ts +++ b/frontend/src/i18n-keys.ts @@ -254,6 +254,9 @@ export type I18nKey = | "agenda.urgency.this_week" | "agenda.urgency.today" | "agenda.urgency.tomorrow" + | "aggregation.attribution.on" + | "aggregation.toggle.direct_only" + | "aggregation.toggle.subtree" | "appointments.col.akte" | "appointments.col.location" | "appointments.col.start" diff --git a/frontend/src/projects-detail.tsx b/frontend/src/projects-detail.tsx index ade942f..9307d07 100644 --- a/frontend/src/projects-detail.tsx +++ b/frontend/src/projects-detail.tsx @@ -83,6 +83,11 @@ export function renderProjectsDetail(): string { {/* History (Verlauf) */}
    +
    + +
      @@ -252,6 +260,9 @@ export function renderProjectsDetail(): string { + diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index 5809fc0..92fe50e 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -6183,6 +6183,36 @@ input[type="range"]::-moz-range-thumb { font-size: 0.82rem; } +/* t-paliad-139 — subtree aggregation toggle (Inkl. Unterprojekte / Nur direkt). + Lives in .party-controls beside the section CTA. The active state shows + "Nur direkt" with a darker chrome so the user can see at a glance that + they've narrowed the view. Default (subtree) keeps standard secondary + styling. */ +.subtree-toggle { + margin-left: 0.5rem; +} +.subtree-toggle--active { + background: var(--color-accent-soft, var(--color-surface)); + border-color: var(--color-accent, var(--color-border)); + color: var(--color-text); +} + +/* Attribution chip — shows the descendant project a row anchors on when + the project detail page is aggregating. Inline-flush with the title cell; + readable on both light and dark themes. */ +.aggregation-chip { + display: inline-block; + margin-left: 0.5rem; + padding: 0.1rem 0.45rem; + border-radius: 9999px; + background: var(--color-bg-muted, rgba(0, 0, 0, 0.04)); + color: var(--color-text-muted, #555); + font-size: 0.75rem; + font-weight: normal; + vertical-align: baseline; + white-space: nowrap; +} + .party-form { margin-bottom: 1.25rem; padding: 1rem; diff --git a/internal/handlers/appointments.go b/internal/handlers/appointments.go index dfe3c9b..e868f3e 100644 --- a/internal/handlers/appointments.go +++ b/internal/handlers/appointments.go @@ -108,7 +108,8 @@ func handleListAppointmentsForProject(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"}) return } - rows, err := dbSvc.appointment.ListForProject(r.Context(), uid, projectID) + directOnly := parseDirectOnly(r.URL.Query().Get("direct_only")) + rows, err := dbSvc.appointment.ListForProject(r.Context(), uid, projectID, directOnly) if err != nil { writeServiceError(w, err) return diff --git a/internal/handlers/deadlines.go b/internal/handlers/deadlines.go index ced549c..8ed529f 100644 --- a/internal/handlers/deadlines.go +++ b/internal/handlers/deadlines.go @@ -128,7 +128,8 @@ func handleListDeadlinesForProject(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"}) return } - rows, err := dbSvc.deadline.ListForProject(r.Context(), uid, projectID) + directOnly := parseDirectOnly(r.URL.Query().Get("direct_only")) + rows, err := dbSvc.deadline.ListForProject(r.Context(), uid, projectID, directOnly) if err != nil { writeServiceError(w, err) return diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 12fea45..f1be7b3 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -415,3 +415,15 @@ func writeJSON(w http.ResponseWriter, status int, data any) { w.WriteHeader(status) json.NewEncoder(w).Encode(data) } + +// parseDirectOnly reads a `direct_only=true|false` query value. Returns true +// only for the explicit "true" / "1" forms; everything else (including empty) +// is the subtree-aggregating default per t-paliad-139. +func parseDirectOnly(raw string) bool { + switch raw { + case "true", "1": + return true + default: + return false + } +} diff --git a/internal/handlers/projects.go b/internal/handlers/projects.go index 6deb7ec..081d361 100644 --- a/internal/handlers/projects.go +++ b/internal/handlers/projects.go @@ -383,7 +383,8 @@ func handleListProjectEvents(w http.ResponseWriter, r *http.Request) { } limit = n } - rows, err := dbSvc.projects.ListEvents(r.Context(), uid, id, before, limit) + directOnly := parseDirectOnly(q.Get("direct_only")) + rows, err := dbSvc.projects.ListEvents(r.Context(), uid, id, before, limit, directOnly) if err != nil { writeServiceError(w, err) return diff --git a/internal/models/models.go b/internal/models/models.go index e0c206d..b6d3c4d 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -147,17 +147,23 @@ type PartnerUnitMember struct { // ProjectEvent is one row in the per-Project audit trail // (paliad.project_events, renamed from paliad.project_events in migration 018). +// +// ProjectTitle is populated only by readers that join paliad.projects (e.g. +// ProjectService.ListEvents — Verlauf attribution for descendant events on +// /projects/{id}, t-paliad-139). Other readers leave it nil and the JSON +// serialiser omits it. type ProjectEvent struct { - ID uuid.UUID `db:"id" json:"id"` - ProjectID uuid.UUID `db:"project_id" json:"project_id"` - EventType *string `db:"event_type" json:"event_type,omitempty"` - Title string `db:"title" json:"title"` - Description *string `db:"description" json:"description,omitempty"` - EventDate *time.Time `db:"event_date" json:"event_date,omitempty"` - CreatedBy *uuid.UUID `db:"created_by" json:"created_by,omitempty"` - Metadata json.RawMessage `db:"metadata" json:"metadata"` - CreatedAt time.Time `db:"created_at" json:"created_at"` - UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + ID uuid.UUID `db:"id" json:"id"` + ProjectID uuid.UUID `db:"project_id" json:"project_id"` + EventType *string `db:"event_type" json:"event_type,omitempty"` + Title string `db:"title" json:"title"` + Description *string `db:"description" json:"description,omitempty"` + EventDate *time.Time `db:"event_date" json:"event_date,omitempty"` + CreatedBy *uuid.UUID `db:"created_by" json:"created_by,omitempty"` + Metadata json.RawMessage `db:"metadata" json:"metadata"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + ProjectTitle *string `db:"project_title" json:"project_title,omitempty"` } // Deadline is one persistent deadline attached to a Project (typically a diff --git a/internal/services/appointment_service.go b/internal/services/appointment_service.go index c8b4932..6a8699c 100644 --- a/internal/services/appointment_service.go +++ b/internal/services/appointment_service.go @@ -177,16 +177,33 @@ func (s *AppointmentService) ListVisibleForUser(ctx context.Context, userID uuid return rows, nil } -// ListForProject returns Appointments for a specific Project, visibility-checked. -func (s *AppointmentService) ListForProject(ctx context.Context, userID, projectID uuid.UUID) ([]models.Appointment, error) { +// ListForProject returns Appointments for a Project (visibility-checked). +// +// When directOnly is false (default), the result aggregates appointments +// from the Project itself AND every descendant Project (per the +// t-paliad-139 hierarchy aggregation contract). When directOnly is true, +// only appointments whose project_id exactly equals the filter are +// returned. +// +// The descendant aggregation mirrors DeadlineService.ListForProject — see +// the doc comment there for the rationale. +func (s *AppointmentService) ListForProject(ctx context.Context, userID, projectID uuid.UUID, directOnly bool) ([]models.Appointment, error) { if _, err := s.projects.GetByID(ctx, userID, projectID); err != nil { return nil, err } rows := []models.Appointment{} + var filter string + if directOnly { + filter = `WHERE project_id = $1` + } else { + filter = `WHERE project_id IN ( + SELECT p.id FROM paliad.projects p + WHERE $1 = ANY(string_to_array(p.path, '.')::uuid[]))` + } if err := s.db.SelectContext(ctx, &rows, `SELECT `+appointmentColumns+` FROM paliad.appointments - WHERE project_id = $1 + `+filter+` ORDER BY start_at ASC, created_at DESC`, projectID); err != nil { return nil, fmt.Errorf("list appointments for project: %w", err) } diff --git a/internal/services/deadline_service.go b/internal/services/deadline_service.go index dc383a6..0d471f1 100644 --- a/internal/services/deadline_service.go +++ b/internal/services/deadline_service.go @@ -241,16 +241,35 @@ func (s *DeadlineService) ListVisibleForUser(ctx context.Context, userID uuid.UU return rows, nil } -// ListForProject returns Deadlines for a specific Project (visibility-checked). -func (s *DeadlineService) ListForProject(ctx context.Context, userID, projectID uuid.UUID) ([]models.Deadline, error) { +// ListForProject returns Deadlines for a Project (visibility-checked). +// +// When directOnly is false (default), the result aggregates deadlines from +// the Project itself AND every descendant Project (per the t-paliad-139 +// hierarchy aggregation contract). When directOnly is true, only deadlines +// whose project_id exactly equals the filter are returned — useful for +// edit / attribution surfaces that want exact narrowing. +// +// The descendant aggregation reuses the materialised path on +// paliad.projects (text-shaped, t-paliad-018). The visibility check on +// the filter Project is sufficient: paliad.can_see_project walks ancestors, +// so a user who can see Project P can see every descendant of P. +func (s *DeadlineService) ListForProject(ctx context.Context, userID, projectID uuid.UUID, directOnly bool) ([]models.Deadline, error) { if _, err := s.projects.GetByID(ctx, userID, projectID); err != nil { return nil, err } rows := []models.Deadline{} + var filter string + if directOnly { + filter = `WHERE project_id = $1` + } else { + filter = `WHERE project_id IN ( + SELECT p.id FROM paliad.projects p + WHERE $1 = ANY(string_to_array(p.path, '.')::uuid[]))` + } if err := s.db.SelectContext(ctx, &rows, `SELECT `+deadlineColumns+` FROM paliad.deadlines - WHERE project_id = $1 + `+filter+` ORDER BY due_date ASC, created_at DESC`, projectID); err != nil { return nil, fmt.Errorf("list deadlines for project: %w", err) } diff --git a/internal/services/project_filter_descendants_test.go b/internal/services/project_filter_descendants_test.go index a3bde8a..ffe1ec0 100644 --- a/internal/services/project_filter_descendants_test.go +++ b/internal/services/project_filter_descendants_test.go @@ -18,11 +18,24 @@ import ( // rows attached to that Project AND every descendant Project (Litigation, // Patent, Case below it). The descendant set is derived from // paliad.projects.path, which the schema's path trigger keeps in sync from -// parent_id. The check is exercised against four entry points: -// - DeadlineService.ListVisibleForUser -// - DeadlineService.SummaryCounts -// - AppointmentService.ListVisibleForUser -// - EventService.ListVisibleForUser (union of deadlines + appointments) +// parent_id. +// +// t-paliad-139 extends the contract to the per-project narrow read paths +// that the /projects/{id} detail surfaces use: +// - DeadlineService.ListForProject +// - AppointmentService.ListForProject +// - ProjectService.ListEvents (audit / Verlauf) +// All three default to subtree aggregation (directOnly=false). When +// directOnly=true, only rows with project_id == filter are returned. +// +// The check is exercised against: +// - DeadlineService.ListVisibleForUser (union, t-124) +// - DeadlineService.SummaryCounts (union, t-124) +// - AppointmentService.ListVisibleForUser (union, t-124) +// - EventService.ListVisibleForUser (union, t-124) +// - DeadlineService.ListForProject (per-project narrow, t-139) +// - AppointmentService.ListForProject (per-project narrow, t-139) +// - ProjectService.ListEvents (per-project narrow, t-139) // // Skipped when TEST_DATABASE_URL is unset. func TestProjectFilter_IncludesDescendants(t *testing.T) { @@ -275,6 +288,72 @@ func TestProjectFilter_IncludesDescendants(t *testing.T) { if gotEA != c.wantAppointmts { t.Errorf("events.ListVisibleForUser appointments: got %d, want %d", gotEA, c.wantAppointmts) } + + // t-paliad-139: per-project narrow paths must match the union path + // when directOnly=false (subtree default), and must collapse to + // just the direct row when directOnly=true. + + // DeadlineService.ListForProject — subtree (default). + dlfp, err := deadlines.ListForProject(ctx, adminID, pid, false) + if err != nil { + t.Fatalf("deadlines.ListForProject subtree: %v", err) + } + gotDFP := 0 + for _, r := range dlfp { + if seedDeadlines[r.ID] { + gotDFP++ + } + } + if gotDFP != c.wantDeadlines { + t.Errorf("deadlines.ListForProject subtree: got %d, want %d", gotDFP, c.wantDeadlines) + } + + // DeadlineService.ListForProject — directOnly=true. + dlfpDirect, err := deadlines.ListForProject(ctx, adminID, pid, true) + if err != nil { + t.Fatalf("deadlines.ListForProject direct: %v", err) + } + gotDFPDirect := 0 + for _, r := range dlfpDirect { + if seedDeadlines[r.ID] { + gotDFPDirect++ + } + } + // directOnly: only the deadline whose project_id == filter is + // returned (the seeded direct-row at this level). + if gotDFPDirect != 1 { + t.Errorf("deadlines.ListForProject directOnly: got %d, want 1", gotDFPDirect) + } + + // AppointmentService.ListForProject — subtree (default). + alfp, err := appointments.ListForProject(ctx, adminID, pid, false) + if err != nil { + t.Fatalf("appointments.ListForProject subtree: %v", err) + } + gotAFP := 0 + for _, r := range alfp { + if seedAppointments[r.ID] { + gotAFP++ + } + } + if gotAFP != c.wantAppointmts { + t.Errorf("appointments.ListForProject subtree: got %d, want %d", gotAFP, c.wantAppointmts) + } + + // AppointmentService.ListForProject — directOnly=true. + alfpDirect, err := appointments.ListForProject(ctx, adminID, pid, true) + if err != nil { + t.Fatalf("appointments.ListForProject direct: %v", err) + } + gotAFPDirect := 0 + for _, r := range alfpDirect { + if seedAppointments[r.ID] { + gotAFPDirect++ + } + } + if gotAFPDirect != 1 { + t.Errorf("appointments.ListForProject directOnly: got %d, want 1", gotAFPDirect) + } }) } } diff --git a/internal/services/project_service.go b/internal/services/project_service.go index dc61a0a..bac697b 100644 --- a/internal/services/project_service.go +++ b/internal/services/project_service.go @@ -704,7 +704,14 @@ const DefaultEventsPageLimit = 50 // ListEvents returns the audit trail for the Project, newest first, with // cursor pagination (before = uuid of last seen event). -func (s *ProjectService) ListEvents(ctx context.Context, userID, id uuid.UUID, before *uuid.UUID, limit int) ([]models.ProjectEvent, error) { +// +// When directOnly is false (default), the result aggregates events from +// the Project itself AND every descendant Project (per the t-paliad-139 +// hierarchy aggregation contract — Verlauf on a Client should show the +// matter's complete history, not just rows attached at the root). When +// directOnly is true, only events whose project_id exactly equals the +// filter are returned. +func (s *ProjectService) ListEvents(ctx context.Context, userID, id uuid.UUID, before *uuid.UUID, limit int, directOnly bool) ([]models.ProjectEvent, error) { if _, err := s.GetByID(ctx, userID, id); err != nil { return nil, err } @@ -718,16 +725,27 @@ func (s *ProjectService) ListEvents(ctx context.Context, userID, id uuid.UUID, b if before != nil { beforeArg = *before } + var projectFilter string + if directOnly { + projectFilter = `project_id = $1` + } else { + // Inner alias `pp` to avoid shadowing the outer `p` JOIN below. + projectFilter = `project_id IN ( + SELECT pp.id FROM paliad.projects pp + WHERE $1 = ANY(string_to_array(pp.path, '.')::uuid[]))` + } var events []models.ProjectEvent err := s.db.SelectContext(ctx, &events, - `SELECT id, project_id, event_type, title, description, event_date, - created_by, metadata, created_at, updated_at - FROM paliad.project_events - WHERE project_id = $1 - AND ($2::uuid IS NULL OR (created_at, id) < ( + `SELECT pe.id, pe.project_id, pe.event_type, pe.title, pe.description, pe.event_date, + pe.created_by, pe.metadata, pe.created_at, pe.updated_at, + p.title AS project_title + FROM paliad.project_events pe + LEFT JOIN paliad.projects p ON p.id = pe.project_id + WHERE pe.`+projectFilter+` + AND ($2::uuid IS NULL OR (pe.created_at, pe.id) < ( SELECT created_at, id FROM paliad.project_events WHERE id = $2::uuid )) - ORDER BY created_at DESC, id DESC + ORDER BY pe.created_at DESC, pe.id DESC LIMIT $3`, id, beforeArg, limit) if err != nil { return nil, fmt.Errorf("list project events: %w", err)