Comprehensive design doc for the replacement of flat paliad.akten with:
- paliad.mandanten (Clients as first-class table)
- paliad.projekte (single self-referential typed tree, ltree materialised
path, 5 project types: mandat/litigation/patent/verfahren/projekt)
- paliad.teams + paliad.team_mitglieder (Dezernate + project teams in one
table with kind-shape CHECK)
- paliad.projekt_mitglieder (hot-path junction replacing akten.collaborators)
Polymorphic FK strategy: single project_id FK on fristen/termine/dokumente/
parteien/akten_events/checklist_instances. Notizen keeps its 4-way polymorphic
shape (akte_id renamed to project_id).
Visibility model: tree-connected — seeing any node grants access to the whole
tree (ancestors + descendants). Office-scope stays at project level; Mandant-
level firm_wide_visible / collaborators override.
Migration plan: 6 phases, non-destructive. UUIDs preserved between akten and
projekte rows so child tables only need column renames, no data moves.
Opinionated: German naming throughout (mandanten, projekte, teams,
team_mitglieder, projekt_mitglieder); /akten URLs alias to /projekte
indefinitely; akten_events table name kept for continuity.
Deliverable: docs/design-data-model-v2.md (920 lines, 14 sections).
921 lines
54 KiB
Markdown
921 lines
54 KiB
Markdown
# Data Model v2 — Clients, nestable Projects, Teams
|
||
|
||
**Author:** cronus (inventor)
|
||
**Date:** 2026-04-20
|
||
**Task:** t-paliad-023
|
||
**Status:** Design draft for review. Supersedes the flat `paliad.akten` model in `design-kanzlai-integration.md` §2–§3.
|
||
**Scope:** Schema + migration plan. No implementation in this change.
|
||
|
||
---
|
||
|
||
## Executive summary
|
||
|
||
**Recommendation.** Replace the flat `paliad.akten` table with a two-table core:
|
||
|
||
1. `paliad.mandanten` — clients (companies or people who instruct HLC).
|
||
2. `paliad.projekte` — a **single, self-referential, typed tree** of all work. Every row has a `project_type` (`mandat`, `litigation`, `patent`, `verfahren`, `projekt`), an optional `parent_project_id`, and a required `client_id` that points to the Mandant at the root of the tree. Fristen, Termine, Notizen, Dokumente and Parteien all hang off a **single polymorphic `project_id`** — "polymorphic" only in the sense that any node in the tree can own them, not in the sense of multi-table FKs.
|
||
|
||
**Teams** become explicit rows. `paliad.teams` holds both Dezernate (structural, one partner-led unit per row) and — as a separate concept — Project Teams (ad-hoc, per-project roster). The `paliad.users.dezernat` free-text field is superseded by `paliad.team_mitglieder`. The `paliad.akten.collaborators uuid[]` array on every Akte is replaced by `paliad.projekt_mitglieder`, giving us per-user roles inside a project and a sane target for audit + invitations.
|
||
|
||
**Visibility** stays office-scoped, but the predicate now walks the tree: seeing *any* node in a project grants the viewer access to the whole connected tree (root, siblings, descendants). This matches how lawyers actually use the data — a Munich associate put on one UPC Case of a Siemens litigation must see the parent litigation and the sibling Cases to do their job, and a lead partner put on the litigation root must see everything below.
|
||
|
||
**Naming.** Stay German, matching everything shipped so far — `mandanten`, `projekte`, `teams`, `team_mitglieder`, `projekt_mitglieder`. German plural for table names, singular for Go structs (`Mandant`, `Projekt`, `Team`). `User` stays English (Supabase concept).
|
||
|
||
**Migration** is phased and non-destructive. Existing `paliad.akten` rows survive as `projekte` rows with `project_type='verfahren'` (best match for the flat-Akte pattern), the same primary key UUIDs, and `client_id` nullable during a cleanup window (the partner UI collects real Mandant assignment). Every child table (`fristen`, `termine`, `notizen`, `dokumente`, `parteien`, `akten_events`, `checklist_instances`) gets its FK column renamed from `akte_id` to `project_id` in a follow-on migration — no data move, just a DDL rename. The existing `/akten` URLs stay live as aliases of `/projekte`.
|
||
|
||
**This is the foundation for Paliad v2.** It is opinionated. The trade-offs are called out inline.
|
||
|
||
---
|
||
|
||
## 1. Entity-Relationship
|
||
|
||
### 1.1 Mermaid
|
||
|
||
```mermaid
|
||
erDiagram
|
||
USERS ||--o{ TEAM_MITGLIEDER : "belongs to"
|
||
USERS ||--o{ PROJEKT_MITGLIEDER : "staffed on"
|
||
TEAMS ||--o{ TEAM_MITGLIEDER : "has members"
|
||
|
||
MANDANTEN ||--o{ PROJEKTE : "owns"
|
||
PROJEKTE ||--o{ PROJEKTE : "parent of"
|
||
PROJEKTE ||--o{ PROJEKT_MITGLIEDER : "has team"
|
||
|
||
PROJEKTE ||--o{ FRISTEN : "1:N"
|
||
PROJEKTE ||--o{ TERMINE : "1:N (nullable)"
|
||
PROJEKTE ||--o{ NOTIZEN : "1:N (polymorphic parent)"
|
||
PROJEKTE ||--o{ DOKUMENTE : "1:N"
|
||
PROJEKTE ||--o{ PARTEIEN : "1:N"
|
||
PROJEKTE ||--o{ AKTEN_EVENTS : "1:N (audit)"
|
||
PROJEKTE ||--o{ CHECKLIST_INSTANCES : "1:N (nullable)"
|
||
|
||
FRISTEN ||--o{ NOTIZEN : "parent (optional)"
|
||
TERMINE ||--o{ NOTIZEN : "parent (optional)"
|
||
AKTEN_EVENTS ||--o{ NOTIZEN : "parent (optional)"
|
||
|
||
TEAMS {
|
||
uuid id PK
|
||
text type "dezernat | project_team"
|
||
text name
|
||
uuid partner_id FK "for dezernat; NULL for project team"
|
||
uuid projekt_id FK "for project team; NULL for dezernat"
|
||
text office "seat of the dezernat; NULL for project team"
|
||
bool is_active
|
||
}
|
||
|
||
MANDANTEN {
|
||
uuid id PK
|
||
text name
|
||
text legal_form "e.g., AG, GmbH, Einzelerfinder"
|
||
text industry
|
||
text country
|
||
text billing_reference
|
||
jsonb key_contacts
|
||
text owning_office "Default office for new Projekte"
|
||
uuid[] collaborators "Firm-wide people with explicit Mandant access"
|
||
bool firm_wide_visible
|
||
text status "active | archived"
|
||
}
|
||
|
||
PROJEKTE {
|
||
uuid id PK
|
||
uuid client_id FK "nullable during migration"
|
||
uuid parent_project_id FK "self"
|
||
text project_type "mandat|litigation|patent|verfahren|projekt"
|
||
text title
|
||
text reference "human-readable ref (Aktenzeichen)"
|
||
text external_ref "EP no., UPC docket, etc."
|
||
text court
|
||
text court_ref
|
||
text status "active | pending | closed | archived"
|
||
text owning_office
|
||
bool firm_wide_visible
|
||
uuid created_by FK
|
||
jsonb metadata
|
||
ltree path "materialised ancestor path"
|
||
int depth "0 = root"
|
||
}
|
||
|
||
PROJEKT_MITGLIEDER {
|
||
uuid projekt_id PK FK
|
||
uuid user_id PK FK
|
||
text role "lead|associate|pa|of_counsel|local_counsel|expert|observer"
|
||
timestamptz added_at
|
||
uuid added_by FK
|
||
}
|
||
|
||
TEAM_MITGLIEDER {
|
||
uuid team_id PK FK
|
||
uuid user_id PK FK
|
||
text role "partner|associate|pa|trainee|of_counsel|secretariat"
|
||
timestamptz added_at
|
||
}
|
||
```
|
||
|
||
### 1.2 ASCII worked example
|
||
|
||
Showing the Siemens portfolio from the brief. `[P]` = partner, `[A]` = associate, `[LC]` = local counsel. Node IDs are illustrative.
|
||
|
||
```
|
||
MANDANTEN: M1 "Siemens AG" (industry=industrial, owning_office=munich)
|
||
│
|
||
└── PROJEKTE (client_id=M1)
|
||
│
|
||
└── P0 project_type=mandat "Siemens — Overall relationship" (path=P0, depth=0)
|
||
│ owning_office=munich
|
||
│ Team: [P Lead=partner_a@munich] [A associate_b@munich]
|
||
│
|
||
└── P1 project_type=litigation "Siemens v. Huawei — SEP Portfolio" (path=P0.P1, depth=1)
|
||
│ owning_office=munich, firm_wide_visible=false
|
||
│ Team: [P Lead=partner_a@munich] [A associate_c@duesseldorf] [LC local_uk@london]
|
||
│
|
||
├── P2 project_type=patent "EP 1 234 567" (path=P0.P1.P2, depth=2)
|
||
│ │ external_ref=EP1234567
|
||
│ │
|
||
│ ├── P3 project_type=verfahren "UPC Infringement UPC_CFI_123/2026"(path=P0.P1.P2.P3, depth=3)
|
||
│ │ court=UPC_CFI_Munich, court_ref=UPC_CFI_123/2026
|
||
│ │ └─ Fristen, Termine, Notizen, Dokumente all project_id=P3
|
||
│ │
|
||
│ ├── P4 project_type=verfahren "EPO Opposition W 0001/26"
|
||
│ └── P5 project_type=verfahren "BPatG Nullity 3 Ni 45/26"
|
||
│
|
||
├── P6 project_type=patent "EP 2 345 678"
|
||
│ └── P7 project_type=verfahren "UPC Infringement UPC_CFI_456/2026"
|
||
│
|
||
└── P8 project_type=patent "EP 3 456 789"
|
||
└── P9 project_type=verfahren "LG München I 21 O 12345/26"
|
||
```
|
||
|
||
The key insight: **every box is a row in `paliad.projekte`**. Fristen/Termine/Notizen live at whichever node is the right home. Dashboard aggregates across all nodes the user can see by walking the visibility predicate.
|
||
|
||
---
|
||
|
||
## 2. Table schemas
|
||
|
||
### 2.1 `paliad.mandanten`
|
||
|
||
```sql
|
||
CREATE TABLE paliad.mandanten (
|
||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
name text NOT NULL,
|
||
legal_form text, -- 'AG', 'GmbH', 'Inc.', 'Einzelerfinder', …
|
||
industry text, -- free text, not an enum
|
||
country text, -- ISO 3166-1 alpha-2 ('DE','US',…)
|
||
billing_reference text, -- matches the firm-wide billing system
|
||
key_contacts jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||
-- [{"name":"…","role":"…","email":"…","phone":"…"}]
|
||
owning_office text NOT NULL CHECK (owning_office IN (
|
||
'munich','duesseldorf','hamburg',
|
||
'amsterdam','london','paris','milan')),
|
||
-- Visibility knobs, analogous to paliad.akten today:
|
||
collaborators uuid[] NOT NULL DEFAULT '{}',
|
||
firm_wide_visible boolean NOT NULL DEFAULT false,
|
||
status text NOT NULL DEFAULT 'active'
|
||
CHECK (status IN ('active','archived')),
|
||
metadata jsonb NOT NULL DEFAULT '{}',
|
||
created_by uuid REFERENCES auth.users(id) ON DELETE SET NULL,
|
||
created_at timestamptz NOT NULL DEFAULT now(),
|
||
updated_at timestamptz NOT NULL DEFAULT now()
|
||
);
|
||
|
||
CREATE INDEX mandanten_owning_office_idx ON paliad.mandanten (owning_office);
|
||
CREATE INDEX mandanten_firm_wide_idx ON paliad.mandanten (firm_wide_visible) WHERE firm_wide_visible = true;
|
||
CREATE INDEX mandanten_collaborators_gin ON paliad.mandanten USING GIN (collaborators);
|
||
CREATE INDEX mandanten_name_trgm ON paliad.mandanten USING GIN (name gin_trgm_ops);
|
||
```
|
||
|
||
Notes:
|
||
- `key_contacts` is JSONB not a child table. Contacts don't have their own identity (they're denormalised name/email/phone); pulling them into `paliad.contacts` would be overkill and block nothing. If HLC later wants CRM-style contact records, promote to a table in a follow-on.
|
||
- `owning_office` on `mandanten` is the **default** for new Projekte; individual Projekte can override.
|
||
- Duplicated visibility knobs (`collaborators`, `firm_wide_visible`) are intentional: a user can have Mandant-level visibility without being on any particular Projekt (e.g., the relationship partner who hasn't been staffed on a specific case yet). The predicate OR-fans these in.
|
||
|
||
### 2.2 `paliad.projekte`
|
||
|
||
```sql
|
||
-- Enable the ltree extension in Supabase (first migration to use it).
|
||
CREATE EXTENSION IF NOT EXISTS ltree;
|
||
|
||
-- project_type is a text + CHECK, not an enum type. Enums are painful to extend
|
||
-- in Postgres migrations; text + CHECK gives us the same validation with room
|
||
-- to add a new type by replacing the constraint.
|
||
CREATE TABLE paliad.projekte (
|
||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
client_id uuid REFERENCES paliad.mandanten(id) ON DELETE RESTRICT,
|
||
-- NULLABLE during migration;
|
||
-- enforced NOT NULL in a later phase.
|
||
parent_project_id uuid REFERENCES paliad.projekte(id) ON DELETE CASCADE,
|
||
|
||
project_type text NOT NULL CHECK (project_type IN
|
||
('mandat','litigation','patent','verfahren','projekt')),
|
||
title text NOT NULL,
|
||
reference text, -- firm-internal human ref (Aktenzeichen)
|
||
external_ref text, -- EP no., UPC docket, BPatG ref, …
|
||
court text,
|
||
court_ref text,
|
||
|
||
status text NOT NULL DEFAULT 'active'
|
||
CHECK (status IN ('active','pending','closed','archived')),
|
||
|
||
owning_office text NOT NULL CHECK (owning_office IN (
|
||
'munich','duesseldorf','hamburg',
|
||
'amsterdam','london','paris','milan')),
|
||
firm_wide_visible boolean NOT NULL DEFAULT false,
|
||
|
||
-- Materialised tree state. Maintained by a BEFORE INSERT/UPDATE trigger.
|
||
-- `path` is the ltree of ancestor ids ending with this row's id.
|
||
path ltree NOT NULL,
|
||
depth int NOT NULL CHECK (depth >= 0),
|
||
|
||
created_by uuid REFERENCES auth.users(id) ON DELETE SET NULL,
|
||
metadata jsonb NOT NULL DEFAULT '{}',
|
||
ai_summary text, -- unused today; kept from akten
|
||
created_at timestamptz NOT NULL DEFAULT now(),
|
||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||
|
||
-- Sanity: a project's client must match its parent's client (and root of
|
||
-- tree carries the single source of truth).
|
||
CONSTRAINT projekte_parent_self_differs CHECK (parent_project_id IS NULL OR parent_project_id <> id)
|
||
);
|
||
|
||
-- Trees are navigated by path. GiST over ltree makes ancestor / descendant
|
||
-- lookups <@ / @> work in O(log n) — critical for the visibility predicate.
|
||
CREATE INDEX projekte_path_gist ON paliad.projekte USING GIST (path);
|
||
CREATE INDEX projekte_parent_idx ON paliad.projekte (parent_project_id);
|
||
CREATE INDEX projekte_client_idx ON paliad.projekte (client_id);
|
||
CREATE INDEX projekte_client_type_idx ON paliad.projekte (client_id, project_type)
|
||
WHERE status <> 'archived';
|
||
CREATE INDEX projekte_owning_office_idx ON paliad.projekte (owning_office);
|
||
CREATE INDEX projekte_firm_wide_idx ON paliad.projekte (firm_wide_visible) WHERE firm_wide_visible = true;
|
||
CREATE INDEX projekte_status_idx ON paliad.projekte (status);
|
||
CREATE INDEX projekte_reference_trgm ON paliad.projekte USING GIN (reference gin_trgm_ops);
|
||
CREATE INDEX projekte_title_trgm ON paliad.projekte USING GIN (title gin_trgm_ops);
|
||
```
|
||
|
||
**Why ltree and not a recursive CTE?** RLS is called once per candidate row on every SELECT. A recursive CTE per row is O(depth) repeated per predicate call. `path @> ancestor_path` uses the GiST index and collapses to one index scan. This is the biggest performance decision in the doc; ltree is the right tool.
|
||
|
||
**Why a materialised path *and* `parent_project_id`?** The parent FK is the source of truth for the tree (used by `ON DELETE CASCADE`). The `path` + `depth` columns are derived state maintained by a trigger. Keeping both is redundant on purpose — the FK guarantees referential integrity; the path gives us fast traversal. Updates to `parent_project_id` re-compute path for the subtree in the trigger.
|
||
|
||
**Tree trigger sketch:**
|
||
|
||
```sql
|
||
CREATE FUNCTION paliad.projekte_sync_path() RETURNS trigger LANGUAGE plpgsql AS $$
|
||
DECLARE
|
||
parent_path ltree;
|
||
BEGIN
|
||
IF NEW.parent_project_id IS NULL THEN
|
||
NEW.path := text2ltree(replace(NEW.id::text, '-', '_'));
|
||
NEW.depth := 0;
|
||
ELSE
|
||
SELECT path, depth + 1 INTO parent_path, NEW.depth
|
||
FROM paliad.projekte
|
||
WHERE id = NEW.parent_project_id;
|
||
IF parent_path IS NULL THEN
|
||
RAISE EXCEPTION 'parent project % not found', NEW.parent_project_id;
|
||
END IF;
|
||
NEW.path := parent_path || text2ltree(replace(NEW.id::text, '-', '_'));
|
||
END IF;
|
||
RETURN NEW;
|
||
END;
|
||
$$;
|
||
|
||
CREATE TRIGGER projekte_sync_path_ins
|
||
BEFORE INSERT ON paliad.projekte
|
||
FOR EACH ROW EXECUTE FUNCTION paliad.projekte_sync_path();
|
||
|
||
-- On parent change, re-path both this row and every descendant.
|
||
CREATE FUNCTION paliad.projekte_rewrite_subtree() RETURNS trigger LANGUAGE plpgsql AS $$
|
||
BEGIN
|
||
IF NEW.parent_project_id IS DISTINCT FROM OLD.parent_project_id THEN
|
||
PERFORM paliad.projekte_sync_path() FROM paliad.projekte WHERE id = NEW.id;
|
||
UPDATE paliad.projekte
|
||
SET path = NEW.path || subpath(path, nlevel(OLD.path) - 1),
|
||
depth = NEW.depth + (nlevel(path) - nlevel(OLD.path))
|
||
WHERE path <@ OLD.path
|
||
AND id <> NEW.id;
|
||
END IF;
|
||
RETURN NEW;
|
||
END;
|
||
$$;
|
||
```
|
||
|
||
(Sketch — implementer will refine. The constraint that uuid-hyphens aren't valid in ltree labels means we encode UUIDs with `-`→`_`. Alternative: use `hashtext(id::text)::text` to keep labels short — discuss with implementer.)
|
||
|
||
### 2.3 `paliad.teams`
|
||
|
||
**Two kinds, one table.** The shape is similar enough that splitting into `dezernate` + `project_teams` would mostly duplicate columns. A `type` column + partial CHECK constraints is cheaper.
|
||
|
||
```sql
|
||
CREATE TABLE paliad.teams (
|
||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
type text NOT NULL CHECK (type IN ('dezernat','project_team')),
|
||
name text NOT NULL,
|
||
|
||
-- For type = 'dezernat':
|
||
partner_id uuid REFERENCES auth.users(id) ON DELETE SET NULL,
|
||
office text CHECK (office IS NULL OR office IN (
|
||
'munich','duesseldorf','hamburg',
|
||
'amsterdam','london','paris','milan')),
|
||
|
||
-- For type = 'project_team':
|
||
projekt_id uuid REFERENCES paliad.projekte(id) ON DELETE CASCADE,
|
||
|
||
is_active boolean NOT NULL DEFAULT true,
|
||
metadata jsonb NOT NULL DEFAULT '{}',
|
||
created_at timestamptz NOT NULL DEFAULT now(),
|
||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||
|
||
-- Shape invariants per type.
|
||
CONSTRAINT teams_dezernat_shape CHECK (
|
||
type <> 'dezernat'
|
||
OR (partner_id IS NOT NULL AND office IS NOT NULL AND projekt_id IS NULL)
|
||
),
|
||
CONSTRAINT teams_project_team_shape CHECK (
|
||
type <> 'project_team'
|
||
OR (projekt_id IS NOT NULL AND partner_id IS NULL AND office IS NULL)
|
||
)
|
||
);
|
||
|
||
-- One project_team per Projekt (if any).
|
||
CREATE UNIQUE INDEX teams_one_team_per_projekt
|
||
ON paliad.teams (projekt_id) WHERE type = 'project_team';
|
||
|
||
CREATE INDEX teams_partner_idx ON paliad.teams (partner_id) WHERE type = 'dezernat';
|
||
CREATE INDEX teams_office_idx ON paliad.teams (office) WHERE type = 'dezernat';
|
||
CREATE INDEX teams_projekt_idx ON paliad.teams (projekt_id) WHERE type = 'project_team';
|
||
```
|
||
|
||
**Critique already anticipated.** Mixing two entities in one table is a smell. I accept the smell because: (a) the queries we actually run split cleanly by `type`; (b) the join from a user to "every team I'm on" wants a single table; (c) project teams and dezernate both feed the same `team_mitglieder` roster table and the same per-team role enum overlap is ~80%. If we discover real divergence (project teams grow a `stage` field, dezernate grow a `parent_dezernat_id`), split then.
|
||
|
||
### 2.4 `paliad.team_mitglieder`
|
||
|
||
Roster for **both** kinds of team. Dezernat and project-team memberships coexist — a user is typically in exactly one Dezernat *and* on multiple project teams.
|
||
|
||
```sql
|
||
CREATE TABLE paliad.team_mitglieder (
|
||
team_id uuid NOT NULL REFERENCES paliad.teams(id) ON DELETE CASCADE,
|
||
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||
role text NOT NULL CHECK (role IN (
|
||
'partner','associate','pa','trainee','of_counsel',
|
||
'secretariat','lead','local_counsel','expert','observer'
|
||
)),
|
||
added_at timestamptz NOT NULL DEFAULT now(),
|
||
added_by uuid REFERENCES auth.users(id) ON DELETE SET NULL,
|
||
PRIMARY KEY (team_id, user_id)
|
||
);
|
||
|
||
CREATE INDEX team_mitglieder_user_idx ON paliad.team_mitglieder (user_id);
|
||
```
|
||
|
||
Role values overlap intentionally: `partner`, `associate`, `pa`, `trainee`, `of_counsel`, `secretariat` are the typical Dezernat roles; `lead`, `associate`, `pa`, `of_counsel`, `local_counsel`, `expert`, `observer` are the typical project-team roles. The union is finite and small — don't over-engineer with separate role enums per team type.
|
||
|
||
### 2.5 `paliad.projekt_mitglieder` (replaces `collaborators uuid[]`)
|
||
|
||
I recommend **flattening project-team rosters into a dedicated junction table** instead of going through `teams` + `team_mitglieder` for the project-team case. Reason: project-team membership is the hot path for RLS. A dedicated two-column junction with a covering index beats any indirection through `teams`.
|
||
|
||
```sql
|
||
CREATE TABLE paliad.projekt_mitglieder (
|
||
projekt_id uuid NOT NULL REFERENCES paliad.projekte(id) ON DELETE CASCADE,
|
||
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||
role text NOT NULL CHECK (role IN (
|
||
'lead','associate','pa','of_counsel','local_counsel','expert','observer'
|
||
)),
|
||
added_at timestamptz NOT NULL DEFAULT now(),
|
||
added_by uuid REFERENCES auth.users(id) ON DELETE SET NULL,
|
||
PRIMARY KEY (projekt_id, user_id)
|
||
);
|
||
|
||
CREATE INDEX projekt_mitglieder_user_idx ON paliad.projekt_mitglieder (user_id);
|
||
```
|
||
|
||
**So what's `paliad.teams` type='project_team' for?** Two things the junction can't express alone: (a) the *identity* of the team as a first-class object (naming, status, metadata, invitations); (b) a place to attach team-level settings (e.g., a project-team Slack channel URL, a CalDAV group calendar id). If we decide we don't need either, `teams` shrinks to dezernate only and `project_team` rows go away. That's fine too — implementation can start with junction-only and add `teams` rows on demand.
|
||
|
||
**Opinion:** start with `projekt_mitglieder` only. Add `teams` rows for project teams if/when we ship team-scoped features (project Slack channel, group calendar). For the purposes of visibility and role, the junction table is sufficient.
|
||
|
||
### 2.6 Child tables — single `project_id` polymorphic FK
|
||
|
||
No polymorphic magic. Every node in the tree is a `projekte` row. A Frist attached to "the Siemens SEP litigation" just FKs to the litigation-level projekt. A Frist on the root Mandat-level projekt is rare but expressible. Polymorphic multi-FK tables (with multiple nullable parent columns + a CHECK) are a pattern we have today on `notizen`, and they create RLS pain — we avoid extending it.
|
||
|
||
Revised child tables (columns that change only):
|
||
|
||
| Table | Today | v2 |
|
||
|---|---|---|
|
||
| `paliad.parteien` | `akte_id NOT NULL` | `project_id NOT NULL` |
|
||
| `paliad.fristen` | `akte_id NOT NULL` | `project_id NOT NULL` |
|
||
| `paliad.termine` | `akte_id NULL` | `project_id NULL` (personal stays NULL) |
|
||
| `paliad.dokumente` | `akte_id NOT NULL` | `project_id NOT NULL` |
|
||
| `paliad.akten_events` | `akte_id NOT NULL` | `project_id NOT NULL` (table stays `akten_events` for history; see §10) |
|
||
| `paliad.checklist_instances` | `akte_id NULL` | `project_id NULL` (personal stays NULL) |
|
||
| `paliad.notizen` | 4 nullable FKs (akte/frist/termin/event), 1-of CHECK | **Keep as-is** — still polymorphic across frist/termin/event/*project*; `akte_id`→`project_id`. |
|
||
|
||
`notizen` stays polymorphic because notes attach to three different kinds of entity (Projekt, Frist, Termin, AktenEvent) — a note on a Frist is *not* the same thing as a note on the owning Projekt. The alternative (always attach at Projekt and store `frist_id` / `termin_id` in metadata) loses referential integrity; reject it.
|
||
|
||
### 2.7 `paliad.users` changes
|
||
|
||
- **Drop:** `dezernat text` (free-text, only introduced in migration 015).
|
||
- **Keep:** `office`, `role`, `practice_group`, `lang`, `email_preferences`.
|
||
- **New:** nothing — Dezernat membership moves to `team_mitglieder` with `team_id` pointing at a `teams` row of type `dezernat`.
|
||
|
||
Migration 015 left `dezernat` as free text precisely because the partner might not have registered yet. v2 keeps the freedom differently: during onboarding, the user either (a) picks an existing Dezernat from a dropdown (fed from `paliad.teams WHERE type='dezernat'`) or (b) types a new one, and the onboarding service auto-creates a `teams` row with `partner_id = NULL` and a note "claim this partnership by signing up with role=partner". The partner claims on their own first-login.
|
||
|
||
---
|
||
|
||
## 3. Polymorphic FK strategy (the explicit recommendation)
|
||
|
||
> **Single `project_id` on all child tables (except `notizen`).**
|
||
|
||
Rationale:
|
||
- Everything a Frist/Termin/Dokument/Partei could "attach to" is now a row in `paliad.projekte`. A Mandant-level Frist FKs to the `project_type='mandat'` root. A verfahren-level Frist FKs to the `project_type='verfahren'` leaf. No discriminator column, no CHECK constraint juggling.
|
||
- RLS becomes one predicate: `paliad.can_see_project(project_id)`. Today's `can_see_akte()` + `notiz_is_visible()` split goes away for 6 of 7 child tables.
|
||
- Client visibility (a Mandant-level Frist like "send yearly renewal reminder") is uniform: it just lives on the Mandant-level projekt — no `client_id` FK on child tables, no third polymorphic branch.
|
||
- Query aggregation across a client's work ("show me all deadlines in the next 30 days for Siemens") is a single JOIN: `fristen JOIN projekte ON fristen.project_id = projekte.id WHERE projekte.path <@ (SELECT path FROM projekte WHERE id = <mandat-projekt-id>)`.
|
||
|
||
Alternatives I considered and rejected:
|
||
- **Multiple nullable FKs (`client_id`, `litigation_id`, `patent_id`, `verfahren_id`) with a 1-of CHECK.** Reproduces the notizen pain for every child table. Harder to index, harder for RLS. Rejected.
|
||
- **`parent_type text` + `parent_id uuid` (classic polymorphic)**. Kills foreign-key integrity. Rejected.
|
||
- **Separate child tables per level (`mandat_fristen`, `litigation_fristen`, …)**. Absurd proliferation. Rejected on sight.
|
||
|
||
`notizen` is the one exception because a note genuinely attaches to one of four *kinds of entity*, not to four different positions in the same tree. Keep the 4-FK-one-nullable shape (akte_id → **project_id**, frist_id, termin_id, akten_event_id; CHECK = 1-of-4).
|
||
|
||
---
|
||
|
||
## 4. Visibility model
|
||
|
||
### 4.1 Design principles
|
||
|
||
1. Visibility is **tree-connected**: if you can see one node, you can see the whole tree (root → all descendants). Mimics how litigation teams actually work.
|
||
2. Office-scoping stays **at the project level**, not the Mandant level, because different Projekte under one client may legitimately belong to different offices (e.g., the client's Munich patent prosecution vs. their Düsseldorf enforcement).
|
||
3. Project-team membership **grants visibility**, including for users outside `owning_office`.
|
||
4. Mandant-level visibility (`mandanten.collaborators`, `mandanten.firm_wide_visible`) grants visibility to the **Mandant** and its **entire project tree**. This is the firm-wide or relationship-partner override.
|
||
5. `admin` role sees everything.
|
||
|
||
### 4.2 The predicate, in English
|
||
|
||
A user U can see a Projekt P iff **any** of the following:
|
||
|
||
- `P.firm_wide_visible = true`, **or**
|
||
- `P.owning_office = U.office`, **or**
|
||
- U is in `projekt_mitglieder` for P, **or**
|
||
- U is in `projekt_mitglieder` for **any ancestor or descendant of P** (tree-connected visibility), **or**
|
||
- `P.client_id` points to a Mandant M where:
|
||
- `M.firm_wide_visible = true`, **or**
|
||
- U's uuid ∈ `M.collaborators`, **or**
|
||
- `U.role = 'admin'`.
|
||
|
||
A user U can see a Mandant M iff **any** of:
|
||
- `M.firm_wide_visible = true`, **or**
|
||
- `M.owning_office = U.office`, **or**
|
||
- U's uuid ∈ `M.collaborators`, **or**
|
||
- U can see **any** Projekt under M (inductive), **or**
|
||
- `U.role = 'admin'`.
|
||
|
||
### 4.3 SQL predicate
|
||
|
||
```sql
|
||
-- Canonical visibility predicate for projects. Used in RLS and mirrored at
|
||
-- the service layer (AkteService.ListVisibleForUser equivalent).
|
||
CREATE OR REPLACE FUNCTION paliad.can_see_project(_project_id uuid)
|
||
RETURNS boolean
|
||
LANGUAGE sql
|
||
STABLE
|
||
SECURITY DEFINER
|
||
SET search_path = paliad, public
|
||
AS $$
|
||
WITH me AS (
|
||
SELECT id, office, role FROM paliad.users WHERE id = auth.uid()
|
||
),
|
||
tgt AS (
|
||
SELECT id, client_id, owning_office, firm_wide_visible, path
|
||
FROM paliad.projekte
|
||
WHERE id = _project_id
|
||
)
|
||
SELECT EXISTS (SELECT 1 FROM tgt WHERE tgt.firm_wide_visible)
|
||
OR EXISTS (SELECT 1 FROM tgt, me WHERE tgt.owning_office = me.office)
|
||
OR EXISTS (SELECT 1 FROM me WHERE me.role = 'admin')
|
||
OR EXISTS (
|
||
-- membership at target, or at any ancestor/descendant
|
||
SELECT 1 FROM paliad.projekt_mitglieder pm, tgt
|
||
WHERE pm.user_id = auth.uid()
|
||
AND pm.projekt_id IN (
|
||
SELECT id FROM paliad.projekte
|
||
WHERE path @> tgt.path OR path <@ tgt.path
|
||
)
|
||
)
|
||
OR EXISTS (
|
||
SELECT 1 FROM paliad.mandanten m, tgt
|
||
WHERE m.id = tgt.client_id
|
||
AND (m.firm_wide_visible
|
||
OR auth.uid() = ANY (m.collaborators))
|
||
);
|
||
$$;
|
||
```
|
||
|
||
The `path @> tgt.path OR path <@ tgt.path` clause is the tree-connected check: any project whose path is an ancestor or descendant of the target's path. Uses the GiST index on `projekte.path`.
|
||
|
||
**Performance note.** For a litigation tree of ~20 nodes with ~100 members, the predicate runs a handful of index probes per row. Measurably worse than today's flat `can_see_akte()` (one EXISTS), but still sub-millisecond in Postgres. If we ever see RLS cost dominate a listing page, the follow-on is to cache "visible project ids for user X" in a session-scoped CTE at the application layer (same trick we use in `ListVisibleForUser`).
|
||
|
||
### 4.4 Cross-office teams — concretely
|
||
|
||
Example: Munich partner (Dezernat A) leads; Düsseldorf associate and London local counsel are staffed in.
|
||
|
||
- The Litigation-level Projekt is created with `owning_office = 'munich'`.
|
||
- The Munich partner, the Düsseldorf associate, and the London local counsel all get rows in `projekt_mitglieder` (roles `lead`, `associate`, `local_counsel`).
|
||
- Result: the Düsseldorf associate can see the whole litigation tree even though `owning_office <> 'duesseldorf'`. Munich-office colleagues not on the team can still see it (office-scope). London colleagues not on the team cannot see it.
|
||
|
||
**Edge case: "Chinese-walled" cases.** If a single Akte needs to be hidden from the rest of `owning_office` (conflict of interest), `owning_office` can't carry the day. Add a boolean `restricted` column in a later iteration that flips the predicate to team-only. Don't build now — wait for the first real conflict.
|
||
|
||
---
|
||
|
||
## 5. Migration plan
|
||
|
||
Non-destructive, phased. Data survives at every step.
|
||
|
||
### Phase 1 — Add new tables (no FK rewrites)
|
||
|
||
Migration `018_v2_core_tables`:
|
||
- `CREATE EXTENSION IF NOT EXISTS ltree;`
|
||
- Create `paliad.mandanten`, `paliad.projekte`, `paliad.teams`, `paliad.team_mitglieder`, `paliad.projekt_mitglieder`.
|
||
- Create the path trigger.
|
||
- No change to `paliad.akten` or its children yet. Old code keeps running.
|
||
|
||
Acceptance: `\dt paliad.*` shows the new tables. Smoke test: insert a Mandant + one Projekt tree, query via `path @>`.
|
||
|
||
### Phase 2 — Backfill `paliad.projekte` from `paliad.akten` (synthetic Mandanten)
|
||
|
||
Migration `019_v2_backfill_projects_from_akten`:
|
||
- For each distinct `owning_office` with any Akte, create one synthetic Mandant: `name = 'Unbekannter Mandant (<office>)'`, `status = 'active'`, `owning_office = <office>`, `metadata = {"synthetic": true}`.
|
||
- Insert a `paliad.projekte` row for every `paliad.akten` row. **Same UUID** (`projekte.id = akten.id`), `client_id = synthetic mandant of matching office`, `parent_project_id = NULL`, `project_type = 'verfahren'` (best-match to current flat Akte semantics — most are single proceedings), `title`, `reference = aktenzeichen`, `owning_office`, `firm_wide_visible`, `created_by` copied 1:1. The path trigger populates `path` and `depth`.
|
||
- Also backfill `projekt_mitglieder` from `paliad.akten.collaborators` (array → rows with `role='associate'`).
|
||
|
||
Acceptance: `(SELECT COUNT(*) FROM paliad.projekte) = (SELECT COUNT(*) FROM paliad.akten)`; every akten id survives as a projekte id. Visibility-predicate returns the same answers as `can_see_akte` for every (user, akte) pair (spot-check).
|
||
|
||
### Phase 3 — Rename FK columns on child tables to `project_id`
|
||
|
||
Migration `020_v2_rename_akte_id_to_project_id`:
|
||
- For `parteien`, `fristen`, `termine`, `dokumente`, `akten_events`, `checklist_instances`: `ALTER TABLE … RENAME COLUMN akte_id TO project_id;` and `ALTER TABLE … RENAME CONSTRAINT <akte_fk> TO <project_fk>;` plus rewrite the REFERENCES target from `paliad.akten` to `paliad.projekte`.
|
||
- For `notizen`: `RENAME COLUMN akte_id TO project_id;` similarly; keep `frist_id/termin_id/akten_event_id` intact.
|
||
- Because of the shared UUID trick in Phase 2, no data moves. Indexes are renamed in the same migration.
|
||
|
||
Acceptance: `\d paliad.fristen` shows `project_id uuid NOT NULL REFERENCES paliad.projekte(id)`. Existing SELECTs joining to `paliad.akten` now break — that's the signal to cut the application code over in Phase 4.
|
||
|
||
### Phase 4 — Cut application code over to `paliad.projekte`
|
||
|
||
- Rename Go types: `models.Akte` → `models.Projekt`. Keep `models.Akte` as a deprecated type alias for one release for external API compatibility if needed.
|
||
- `services.AkteService` → `services.ProjektService`. Preserve method signatures; internals switch to `paliad.projekte`.
|
||
- Update handlers. `/api/akten` becomes an alias to `/api/projekte` (same handler, same JSON shape during transition — `projekte` additionally exposes `client_id`, `parent_project_id`, `project_type`, `path` fields).
|
||
- Update the dashboard query to aggregate by `projekte` (tree-walk already shown in §4.3).
|
||
|
||
Acceptance: the app runs end-to-end on the new schema; old routes still resolve; old JSON shapes still accepted (new fields additive).
|
||
|
||
### Phase 5 — New Mandant UI + partner cleanup
|
||
|
||
- `/mandanten` list + detail pages. Partners assign every synthetic-Mandant project to a real Mandant row (bulk "Change Mandant" on the project-detail page, or a dedicated migration UI at `/einstellungen/migration`).
|
||
- After every `projekte.client_id` in `metadata->>'synthetic'=true` has been reassigned, drop the synthetic Mandanten and enforce `paliad.projekte.client_id SET NOT NULL` (migration 021).
|
||
|
||
Acceptance: no synthetic Mandanten remain. `client_id` is NOT NULL.
|
||
|
||
### Phase 6 — Decommission `paliad.akten`
|
||
|
||
- `DROP TABLE paliad.akten` (migration 022). Everything that referenced it has been rewritten.
|
||
- Drop the legacy `paliad.can_see_akte()` function; the one-to-one function becomes `can_see_project()`.
|
||
|
||
Acceptance: `\dt paliad.akten` → not found. All FK constraints still satisfy.
|
||
|
||
### Rollback
|
||
|
||
Every migration has a `down`:
|
||
- Phases 3 and 6 are destructive DDL (drop column rename → rename back; drop table → re-create). Data preservation in those down-migrations is **not guaranteed** after the migration completes; the safe rollback window is "before Phase 3 runs in production". Document loudly.
|
||
- Phases 1, 2, 4, 5 are additive or app-level, rollback by reverting code or running `DELETE FROM paliad.projekte WHERE …`.
|
||
|
||
---
|
||
|
||
## 6. RLS policy updates
|
||
|
||
### 6.1 New policies
|
||
|
||
`paliad.projekte` — enable RLS. Policies:
|
||
|
||
```sql
|
||
CREATE POLICY projekte_select ON paliad.projekte
|
||
FOR SELECT TO authenticated
|
||
USING (paliad.can_see_project(id));
|
||
|
||
-- Non-admins can only create Projekte rooted in an office they belong to
|
||
-- (or a tree whose existing parent they can already see).
|
||
CREATE POLICY projekte_insert ON paliad.projekte
|
||
FOR INSERT TO authenticated
|
||
WITH CHECK (
|
||
-- Creating under an existing parent? — must already see it.
|
||
(parent_project_id IS NOT NULL AND paliad.can_see_project(parent_project_id))
|
||
OR
|
||
-- Root project: own office, or admin.
|
||
(parent_project_id IS NULL
|
||
AND (owning_office = (SELECT office FROM paliad.users WHERE id = auth.uid())
|
||
OR (SELECT role FROM paliad.users WHERE id = auth.uid()) = 'admin'))
|
||
);
|
||
|
||
CREATE POLICY projekte_update ON paliad.projekte
|
||
FOR UPDATE TO authenticated
|
||
USING (paliad.can_see_project(id))
|
||
WITH CHECK (paliad.can_see_project(id));
|
||
|
||
-- Delete: partner/admin only. Cascades down the tree.
|
||
CREATE POLICY projekte_delete ON paliad.projekte
|
||
FOR DELETE TO authenticated
|
||
USING (
|
||
paliad.can_see_project(id)
|
||
AND (SELECT role FROM paliad.users WHERE id = auth.uid()) IN ('partner','admin')
|
||
);
|
||
```
|
||
|
||
`paliad.mandanten` — enable RLS. Visibility: any user who can see at least one of the Mandant's Projekte, or who is in `collaborators`, or `firm_wide_visible`, or admin.
|
||
|
||
```sql
|
||
CREATE OR REPLACE FUNCTION paliad.can_see_mandant(_mandant_id uuid)
|
||
RETURNS boolean LANGUAGE sql STABLE SECURITY DEFINER
|
||
SET search_path = paliad, public AS $$
|
||
SELECT EXISTS (
|
||
SELECT 1 FROM paliad.mandanten m, paliad.users u
|
||
WHERE m.id = _mandant_id
|
||
AND u.id = auth.uid()
|
||
AND (
|
||
m.firm_wide_visible
|
||
OR m.owning_office = u.office
|
||
OR auth.uid() = ANY (m.collaborators)
|
||
OR u.role = 'admin'
|
||
OR EXISTS (
|
||
SELECT 1 FROM paliad.projekte p
|
||
WHERE p.client_id = _mandant_id
|
||
AND paliad.can_see_project(p.id)
|
||
)
|
||
)
|
||
);
|
||
$$;
|
||
|
||
CREATE POLICY mandanten_select ON paliad.mandanten
|
||
FOR SELECT TO authenticated
|
||
USING (paliad.can_see_mandant(id));
|
||
|
||
CREATE POLICY mandanten_insert ON paliad.mandanten
|
||
FOR INSERT TO authenticated
|
||
WITH CHECK (
|
||
owning_office = (SELECT office FROM paliad.users WHERE id = auth.uid())
|
||
OR (SELECT role FROM paliad.users WHERE id = auth.uid()) = 'admin'
|
||
);
|
||
|
||
-- Update/Delete policies analogous; delete is partner/admin-gated.
|
||
```
|
||
|
||
### 6.2 Child-table policies — converge on `can_see_project`
|
||
|
||
Every child table's policy changes from `paliad.can_see_akte(akte_id)` to `paliad.can_see_project(project_id)`. `notizen` loses its dedicated `notiz_is_visible()` helper in favour of an inline check that dispatches by which FK is set:
|
||
|
||
```sql
|
||
CREATE POLICY notizen_all ON paliad.notizen
|
||
FOR ALL TO authenticated
|
||
USING (
|
||
CASE
|
||
WHEN project_id IS NOT NULL THEN paliad.can_see_project(project_id)
|
||
WHEN frist_id IS NOT NULL THEN paliad.can_see_project(
|
||
(SELECT project_id FROM paliad.fristen WHERE id = frist_id))
|
||
WHEN termin_id IS NOT NULL THEN
|
||
CASE
|
||
WHEN (SELECT project_id FROM paliad.termine WHERE id = termin_id) IS NULL
|
||
THEN (SELECT created_by FROM paliad.termine WHERE id = termin_id) = auth.uid()
|
||
ELSE paliad.can_see_project(
|
||
(SELECT project_id FROM paliad.termine WHERE id = termin_id))
|
||
END
|
||
WHEN akten_event_id IS NOT NULL THEN paliad.can_see_project(
|
||
(SELECT project_id FROM paliad.akten_events WHERE id = akten_event_id))
|
||
ELSE false
|
||
END
|
||
)
|
||
WITH CHECK (...same...);
|
||
```
|
||
|
||
(We may extract a helper `notiz_is_visible(project_id, frist_id, termin_id, akten_event_id)` again — symmetric to today's.)
|
||
|
||
### 6.3 Admin bootstrap
|
||
|
||
Unchanged. The `pg_advisory_xact_lock(7346298141)` onboarding gate that lets the first user self-assign `role='admin'` still works. Nothing to do.
|
||
|
||
### 6.4 Defense-in-depth at the service layer
|
||
|
||
Same pattern as today: every service mirrors the predicate in `ListVisibleForUser` for indexed performance. The SQL is wordier for the tree variant — we do it once in `ProjektService.listVisibleIDsForUser` (returns a `map[uuid.UUID]struct{}`) and re-use across list endpoints.
|
||
|
||
---
|
||
|
||
## 7. API surface changes
|
||
|
||
### 7.1 New endpoints
|
||
|
||
| Method + path | Purpose |
|
||
|---|---|
|
||
| `GET /api/mandanten` | List Mandanten the user can see. |
|
||
| `POST /api/mandanten` | Create Mandant. |
|
||
| `GET /api/mandanten/{id}` | Detail. |
|
||
| `PATCH /api/mandanten/{id}` | Update. |
|
||
| `DELETE /api/mandanten/{id}` | Delete (partner/admin only). |
|
||
| `GET /api/mandanten/{id}/projekte` | List root-level Projekte under this Mandant. |
|
||
| `GET /api/projekte` | List top-level visible Projekte (flat root list). |
|
||
| `POST /api/projekte` | Create a Projekt (optionally nested via `parent_project_id`). |
|
||
| `GET /api/projekte/{id}` | Detail + immediate children. |
|
||
| `GET /api/projekte/{id}/tree` | Full subtree (depth-first). |
|
||
| `PATCH /api/projekte/{id}` | Update. |
|
||
| `DELETE /api/projekte/{id}` | Cascade delete subtree (partner/admin). |
|
||
| `GET /api/projekte/{id}/kinder` | Direct children (for lazy-loaded UI). |
|
||
| `POST /api/projekte/{id}/team` | Add project team member. |
|
||
| `DELETE /api/projekte/{id}/team/{user_id}` | Remove member. |
|
||
| `GET /api/teams` | List Dezernate (+ optionally project teams). |
|
||
| `POST /api/teams` | Create Dezernat (admin-gated). |
|
||
| `GET /api/teams/{id}/mitglieder` | List members. |
|
||
|
||
### 7.2 Aliased endpoints
|
||
|
||
During transition:
|
||
- `GET /api/akten` → alias of `GET /api/projekte?project_type=verfahren` (or all, with `akte_type`/`court` fields surfaced) for clients still using the old shape.
|
||
- `POST /api/akten` → alias of `POST /api/projekte` with `project_type='verfahren'` default.
|
||
- `/api/akten/{id}/fristen` → alias of `/api/projekte/{id}/fristen`.
|
||
|
||
Remove aliases after 60 days + 1 green deployment.
|
||
|
||
### 7.3 Retired endpoints
|
||
|
||
None immediately. Keeping aliases means no 404s for the UI during the Phase 4 cutover.
|
||
|
||
### 7.4 New query parameters
|
||
|
||
- `?client_id=<uuid>` on `GET /api/projekte` — scope to one Mandant's tree.
|
||
- `?project_type=<type>` — filter by type.
|
||
- `?ancestor=<uuid>` — subtree of a given root (uses `path <@`).
|
||
- `?include_children=true` on `GET /api/projekte/{id}` — one-shot detail + subtree (cheap because of the path index).
|
||
|
||
---
|
||
|
||
## 8. UI implications
|
||
|
||
### 8.1 Sidebar
|
||
|
||
Insert "Mandanten" above "Akten" in the "ARBEIT" group. Rename "Akten" → "Projekte" in a second phase once partners get used to the concept — initially, keep "Akten" as the label and add the Mandanten entry only.
|
||
|
||
```
|
||
— ARBEIT —
|
||
Dashboard
|
||
Mandanten ← NEW
|
||
Projekte ← renamed from "Akten" (phase 2)
|
||
Fristen
|
||
Termine
|
||
```
|
||
|
||
### 8.2 New pages
|
||
|
||
- `/mandanten` — list. Columns: Name, Büro, #Projekte, #aktive Fristen, letzte Aktivität.
|
||
- `/mandanten/neu` — create form.
|
||
- `/mandanten/{id}` — detail with tabs: Übersicht, Projekte (the tree at this root), Fristen (aggregated), Termine (aggregated), Notizen (aggregated), Team (aggregated from all child projekt_mitglieder).
|
||
- `/projekte` — flat list of root projects + filter by Mandant, type, office.
|
||
- `/projekte/{id}` — detail. Tabs today (`verlauf`, `parteien`, `fristen`, `termine`, `dokumente`, `notizen`, `checklisten`) stay. **New first tab: "Untergeordnet"** — renders the subtree of child projekte as a collapsible list. A "Neues Untervorhaben" button under each node creates a child.
|
||
- `/projekte/neu` — create. Form adapts to `project_type`: `mandat`/`litigation`/`patent` surface no `court` / `court_ref`; `verfahren` does.
|
||
|
||
### 8.3 Detail-page tree rendering
|
||
|
||
On `/projekte/{id}` the sidebar (left sub-nav) renders the ancestor path (breadcrumbs: Mandant → Litigation → Patent → Verfahren). Children render in the Untergeordnet tab as a collapsible tree. Keep click-depth low: clicking a child navigates to that child's detail page; siblings render as flat siblings on the current page.
|
||
|
||
### 8.4 Team editor
|
||
|
||
On `/projekte/{id}` under the Team tab: list of team members with role, "Mitglied hinzufügen" modal with autocomplete fed by `GET /api/users`. Removing the `collaborators uuid[]` array means the multi-pick UI gets a proper role column and an "added_by/at" audit line, which today's model can't show.
|
||
|
||
### 8.5 Dashboard impact
|
||
|
||
Dashboard queries aggregate across **every** Projekt the user can see (all tree levels). Fristen summary widget: `SELECT * FROM paliad.fristen f JOIN paliad.projekte p ON f.project_id = p.id WHERE can_see_project(p.id) AND f.status='pending' AND f.due_date <= now() + interval '30 days'`. Same tree-agnostic pattern for Termine.
|
||
|
||
"Neu auf ..." — add a Mandanten-level aggregate: "Siemens AG: 3 neue Fristen diese Woche, 1 Verhandlung am Donnerstag". Requires a `client_id` join via `projekte` — same index pattern.
|
||
|
||
### 8.6 Fristenrechner "Save to Akte"
|
||
|
||
Rename button to "Zur Verfahren-Akte speichern" (or simpler: "Zur Akte speichern" still works because Verfahren are the most common target). The target picker is now a two-step autocomplete: Mandant → Projekt (scoped to that Mandant's tree). Or skip Mandant picker entirely and autocomplete across all visible projekte — simpler, lets the user jump straight to the right leaf by typing the court-ref.
|
||
|
||
### 8.7 Checklisten Akten-link
|
||
|
||
Minimal change: `akte_id` → `project_id` on the service layer and UI picker. Picker is the same cross-tree autocomplete.
|
||
|
||
### 8.8 CalDAV sync
|
||
|
||
No material change. Today each Termin's iCal includes the Akte's `aktenzeichen` in the DESCRIPTION. v2 includes the full path: `Siemens AG · Siemens v. Huawei · EP 1 234 567 · UPC_CFI_123/2026`. Lawyers will find events in their calendar far more easily.
|
||
|
||
---
|
||
|
||
## 9. Impact on existing features
|
||
|
||
| Feature | Change |
|
||
|---|---|
|
||
| Dashboard | Queries shift from `akten` to `projekte`; widgets aggregate tree-wide. Mandanten-level "Neu auf ..." widget added. |
|
||
| Fristenrechner | Save-to-Projekt instead of save-to-Akte. Autocomplete is cross-tree (all visible projekte). |
|
||
| Fristen list | Columns show `Mandant · Projekt` chain instead of flat Aktenzeichen. Filter by Mandant. |
|
||
| Termine list | Same. |
|
||
| Notizen | FK rename only (akte_id → project_id). Polymorphic shape preserved. |
|
||
| Checklisten | FK rename only. Picker widened to cross-tree autocomplete. |
|
||
| CalDAV | Richer DESCRIPTION (full path). Termin ↔ calendar event mapping unchanged. |
|
||
| Akten detail page | Gains Untergeordnet tab + tree sub-nav. Existing tabs keep working. |
|
||
| Audit trail (Verlauf) | `akten_events` stays as a table name (history); `akte_id` → `project_id`. New event types: `projekt_nested`, `projekt_reparented`, `mandant_assigned`, `team_member_added`, `team_member_removed`. |
|
||
| Dokumente (placeholder) | No change today (still placeholder). Future implementation attaches to the right level of the tree. |
|
||
|
||
---
|
||
|
||
## 10. Naming conventions — German, with one English holdover
|
||
|
||
**Decision: German throughout. Match everything shipped so far.**
|
||
|
||
| Concept | DB table | Go struct | Go service | URL | German UI |
|
||
|---|---|---|---|---|---|
|
||
| Client | `paliad.mandanten` | `Mandant` | `MandantService` | `/mandanten` | "Mandant" / "Mandanten" |
|
||
| Project (generic) | `paliad.projekte` | `Projekt` | `ProjektService` | `/projekte` | "Projekt" / "Projekte" |
|
||
| Project sub-type "Mandat" | (row in projekte) | — | — | (row variant) | "Mandat" (Gesamtbeziehung) |
|
||
| Project sub-type "Litigation" | (row in projekte) | — | — | (row variant) | "Streitsache" |
|
||
| Project sub-type "Patent" | (row in projekte) | — | — | (row variant) | "Patent" |
|
||
| Project sub-type "Verfahren" | (row in projekte) | — | — | (row variant) | "Verfahren" |
|
||
| Project sub-type "Projekt" | (row in projekte) | — | — | (row variant) | "Projekt" (generisch) |
|
||
| Team (structural / project) | `paliad.teams` | `Team` | `TeamService` | `/teams` (admin) | "Team" / "Teams" |
|
||
| Team member | `paliad.team_mitglieder` | `TeamMitglied` | — | (sub) | "Teammitglied" |
|
||
| Project roster | `paliad.projekt_mitglieder` | `ProjektMitglied` | — | (sub of Projekt) | "Projektmitglied" |
|
||
| Party | `paliad.parteien` | `Partei` | `ParteienService` | (sub of Projekt) | "Partei" / "Parteien" |
|
||
| Deadline | `paliad.fristen` | `Frist` | `FristService` | `/fristen` | "Frist" / "Fristen" |
|
||
| Appointment | `paliad.termine` | `Termin` | `TerminService` | `/termine` | "Termin" / "Termine" |
|
||
| Document | `paliad.dokumente` | `Dokument` | `DokumentService` | (sub) | "Dokument" |
|
||
| Audit event | `paliad.akten_events` | `AkteEvent` | (in `ProjektService`) | n/a | "Verlauf" |
|
||
| Note | `paliad.notizen` | `Notiz` | `NotizService` | cross-cutting | "Notiz" / "Notizen" |
|
||
| User | `paliad.users` | `User` | `UserService` | n/a | n/a |
|
||
|
||
**The one English holdover:** `paliad.akten_events` table name. Rationale:
|
||
- It's the audit-trail table name already shipped.
|
||
- "Verlauf" is the UI label; the table name is invisible to users.
|
||
- Migrating to `projekt_events` would churn migration history for no gain, and any historical tools (Grafana, ad-hoc SQL) keep working. Preserve the name; update the comment and the Go struct semantics.
|
||
|
||
**Why not `projekt_*` everywhere?**
|
||
- `projekt_mitglieder` reads cleanly; kept.
|
||
- `projekt_events` would be clean too but see above — churn:benefit ratio unfavourable.
|
||
|
||
**URL aliases.** `/akten` and `/akten/{id}` keep redirecting to `/projekte` and `/projekte/{id}` indefinitely (bookmark preservation). No hard break. 301 from `/akten/neu` → `/projekte/neu`.
|
||
|
||
---
|
||
|
||
## 11. Open questions & deferrable decisions
|
||
|
||
1. **Patent registry.** Should `external_ref` on a `patent`-type projekt be a FK to a firm-wide `paliad.patente` table (EP number + metadata, shared across Mandanten)? Not today — a single litigation's view of a patent is legitimately separate from the firm-wide "patents we've ever seen" list. Revisit when HLC asks for firm-wide IP inventory. If/when built, add `patent_registry_id uuid` on `projekte` where `project_type='patent'`.
|
||
|
||
2. **Conflict of interest / Chinese walls.** Partner-level override to restrict a single project to its team roster only (strip office-scope). Not built now; add `restricted boolean` in a follow-on migration, and extend the predicate to skip the office-scope branch when `restricted = true`.
|
||
|
||
3. **Practice-group scoping.** `paliad.users.practice_group` is already free-text. If HLC splits Patents Litigation vs. Patents Prosecution and wants wall-like isolation, add `visible_to_groups text[]` on `projekte` + predicate extension. Not now.
|
||
|
||
4. **Matrix management.** A user belongs to one Dezernat (structural) today. If partners share associates (e.g., a "tax-patent" associate is on both the Patents and the Tax Dezernate), relax `team_mitglieder` — it already allows multiple memberships. The onboarding UI currently picks one Dezernat; relax when needed.
|
||
|
||
5. **Billing hooks.** `mandanten.billing_reference` is provisioned but not wired. Deliberate: HLC has firm-wide billing; Paliad does not compete. Field exists so the UI can show it and the future Outlook/Exchange integration can look it up.
|
||
|
||
6. **External collaborators.** A Milan boutique working on a case today would go into `projekt_mitglieder` only if they have Supabase accounts. Building external-party access (email-only, scoped, audit-logged) is a post-foundation feature; deferred.
|
||
|
||
7. **Hard delete vs. archive.** `status='archived'` on Mandanten and Projekte exists; hard-delete is cascade via FK. Consider a `archived_at` + soft-delete semantics once we have retention-policy rules. Not now.
|
||
|
||
8. **ltree label encoding.** UUIDs with hyphens aren't valid ltree labels. Replace `-` with `_`, or hash. Implementer's call; both work, hash is shorter but loses traceability.
|
||
|
||
---
|
||
|
||
## 12. Trade-off summary (for the head)
|
||
|
||
| Choice | Alternative | Why I picked this | Cost |
|
||
|---|---|---|---|
|
||
| Single `projekte` tree with type enum | Separate tables per type (mandate/litigation/patent/case) | Polymorphic FK pain, cross-tree queries, UI shared components | `project_type` CHECK has to grow carefully |
|
||
| ltree materialised path | Recursive CTE | RLS is the hottest call site; O(log n) tree queries matter | Extension dependency; label encoding quirk |
|
||
| Single `project_id` on child tables | Multi-level polymorphic FKs | RLS simplicity, uniform service code | Discipline: every Fristen/Termin has a Projekt, even "client-level" rare cases |
|
||
| `mandanten` as a separate table | Project with `type='mandat'` as the conceptual client | Clients have no deadlines/termine/parteien; they're a different shape. Also: Mandant outlives any specific matter. | One extra table |
|
||
| `teams` shared between Dezernat + project_team | Two separate tables | Single roster table (`team_mitglieder`), UI/service reuse | Partial CHECK constraints are a minor smell |
|
||
| `projekt_mitglieder` junction in addition to `teams` | Route project-team membership through `teams` | Hot path for RLS wants a dedicated two-column junction | Small duplication of concept |
|
||
| German naming | Mixed EN/DE | Continuity with everything shipped; audience speaks German | German plural forms (`mandanten`, `projekte`) in URLs |
|
||
| Tree-connected visibility | Downward-only (seeing ancestor grants ancestor+descendants only) | Matches how associate-on-one-case actually needs parent context | Slightly bigger RLS query |
|
||
| Phased non-destructive migration with preserved UUIDs | Dump-transform-reload | Zero downtime; every child-table row survives untouched | Requires discipline: match UUIDs in the backfill exactly |
|
||
|
||
---
|
||
|
||
## 13. Who implements?
|
||
|
||
Recommendation: **I (cronus) can implement the foundation** — migrations 018–022, the predicate function, the new `ProjektService`, and the API alias shim. Reasons:
|
||
- I wrote the design; I know the edge cases.
|
||
- The schema work is security-critical (RLS policy + path trigger); having design-context on the implementer cuts review cycles.
|
||
- The pragmatic split: cronus does schema + services + aliases + RLS (Phase 1–4). A parallel coder worker does the Mandanten UI (`/mandanten` list + detail + create + partner cleanup wizard) in Phase 5. Cronus does Phase 6 decommission.
|
||
|
||
If the head prefers to keep cronus on design duty and hand implementation to a coder, the design is detailed enough to hand off — every schema has columns, constraints, triggers, RLS snippets, and migration acceptance criteria. I'd still want to review the RLS + path trigger PR before merge.
|
||
|
||
---
|
||
|
||
## 14. Acceptance criteria for the design itself
|
||
|
||
A "yes" on this design means head agrees to:
|
||
|
||
- [ ] Mandanten as a first-class table (not just a Projekt type).
|
||
- [ ] Single `projekte` tree with 5-value type enum.
|
||
- [ ] ltree materialised path + GiST index.
|
||
- [ ] Single `project_id` FK on fristen/termine/dokumente/parteien/akten_events/checklist_instances; `notizen` keeps its polymorphic shape with `akte_id` renamed to `project_id`.
|
||
- [ ] Tree-connected visibility predicate (ancestors + descendants both reachable from any team node).
|
||
- [ ] `paliad.teams` as a single table for Dezernat + project-team, with the two-kind shape CHECK.
|
||
- [ ] `projekt_mitglieder` as a hot-path junction, *and* optional `teams` rows of type `project_team` for team-level features.
|
||
- [ ] Phased migration with preserved UUIDs between `akten` and `projekte` rows.
|
||
- [ ] German naming throughout; `akten_events` table name preserved for continuity.
|
||
- [ ] `/akten` URLs alias to `/projekte` indefinitely.
|