Compare commits
8 Commits
mai/knuth/
...
mai/archim
| Author | SHA1 | Date | |
|---|---|---|---|
| a0bcbd5b3d | |||
| a9119d4576 | |||
| 75b411749e | |||
| e9114f24b7 | |||
| 33c622b747 | |||
| e598759a34 | |||
| 7fb3538a8c | |||
| b2b9d51dac |
425
docs/design-excel-export-slice-2-project-subtree-2026-05-20.md
Normal file
425
docs/design-excel-export-slice-2-project-subtree-2026-05-20.md
Normal file
@@ -0,0 +1,425 @@
|
||||
# Slice 2 — project-subtree sync export (t-paliad-214)
|
||||
|
||||
Design: archimedes (inventor), 2026-05-20.
|
||||
Task: **t-paliad-214 Slice 2**.
|
||||
Branch: `mai/archimedes/inventor-excel-data` (continuation from Slice 1).
|
||||
Status: READY FOR REVIEW — no code yet, awaiting m go/no-go on §10 open decisions.
|
||||
|
||||
Builds on `docs/design-paliad-data-export-2026-05-19.md` (Slice 1 design + §12 m's decisions) which is now merged + shipped. **This doc covers only what changes for Slice 2.** Cross-reference §2.2 of the Slice 1 doc for the original project-scope sketch — this Slice 2 doc refines it with live-state verification + explicit picks on the questions Slice 1 left open.
|
||||
|
||||
---
|
||||
|
||||
## 0. Premise check (live state, 2026-05-20)
|
||||
|
||||
Verified directly against the youpc Postgres `paliad` schema + branch state.
|
||||
|
||||
**Slice 1 status:** merged at `bf31935` (Slice 1 main) + `f758537` (xlsx fix). System-audit-log table + `ExportService.WritePersonal` + `GET /api/me/export` + Datenexport tab on /settings are live on paliad.de.
|
||||
|
||||
**ExportService is scope-agnostic.** The Slice 1 implementation deliberately threaded the scope-aware predicate through `personalSheetQueries(actorID)`. Slice 2 adds a parallel `projectSheetQueries(actorID, rootProjectID)` and a new handler — the writer + zip-assembly + audit-row plumbing are all reused as-is. No refactor needed before adding scope #2.
|
||||
|
||||
**Subtree size at firm-scale today.** The largest single project subtree in the org (Siemens AG, the only one with a meaningful tree) carries:
|
||||
|
||||
| entity | rows (subtree) |
|
||||
|-------------------|---------------:|
|
||||
| deadlines | 29 |
|
||||
| appointments | 4 |
|
||||
| notes | 1 |
|
||||
| project_events | 80 |
|
||||
|
||||
Smallest non-trivial subtree (Mandant vs Gegner) is 1 + 1 + 1 + 26. **At firm-scale today every project subtree fits comfortably in a sub-megabyte synchronous response.** A "big firm with 1000 active projects each with 50 deadlines" would generate workbooks under 20MB — still synchronously serveable with a 30s watchdog.
|
||||
|
||||
**Migration tracker** at `106_add_madrid_office`; next free = `107`. Slice 2 does not need a new migration (system_audit_log already covers project scope via `scope='project' + scope_root=<root_id>`).
|
||||
|
||||
**Project responsibility enum** (`internal/services/approval_levels.go:29-32`) is the locked set: `lead` / `member` / `observer` / `external`. m's Slice 1 Q2 decision was "any team member with responsibility ∈ {lead, member}" — observers + externals see but don't extract.
|
||||
|
||||
**Visibility predicate.** `visibilityPredicatePositional(alias, $1)` is the canonical RLS-mirror used by every list endpoint. `projectDescendantPredicate(alias)` is the ltree subtree filter for sqlx-named queries. Slice 2 needs both: visibility gates the *caller's right to extract*; descendant filter gates *which rows belong in the export*.
|
||||
|
||||
---
|
||||
|
||||
## 1. Why Slice 2
|
||||
|
||||
Use cases that came up in Slice 1's design pass but couldn't be served by personal scope alone:
|
||||
|
||||
1. **Archival handover.** A matter closes; the partner wants a single artifact representing the entire project tree (Client → Litigation → Patent → Case) to drop into NetDocuments / Highvail.
|
||||
2. **Due-diligence package.** Outside counsel asks for "everything paliad knows about Acme v. Beta". The partner runs the project export, attaches the .zip to an email, done.
|
||||
3. **Per-matter audit response.** Compliance asks "what did paliad record about this proceeding between dates X and Y?" The export carries the audit trail (`project_events` + relevant `system_audit_log` rows) for the subtree, untouched.
|
||||
4. **Inter-firm handover** when a matter migrates to a different firm — the no-lock-in promise from Slice 1's framing.
|
||||
|
||||
Personal scope is *user-centric* ("everything I can see"). Project scope is *matter-centric* ("everything about this matter"). They are complementary, not redundant.
|
||||
|
||||
---
|
||||
|
||||
## 2. Scope definition (precise)
|
||||
|
||||
**Root:** the project whose UUID is passed in the URL path (`/api/projects/{id}/export`).
|
||||
|
||||
**Subtree:** root + all descendants via ltree path (`paliad.projects.path @> root.path` or, in the application-layer mirror, `projectDescendantPredicate("p")` bound to `:project_id = root_id`).
|
||||
|
||||
**Caller filter:** Visibility predicate is implicit because the caller must already pass `can_see_project(root_id)` to use the endpoint at all — but we additionally narrow the user-disclosure sheets (see "Restricted users sheet" below).
|
||||
|
||||
Per-sheet inclusion:
|
||||
|
||||
| sheet name | source table(s) | filter |
|
||||
|-----------------------|----------------------------------------------------------|--------|
|
||||
| `projects` | `paliad.projects` | `path @> root.path` (root + descendants) |
|
||||
| `project_teams` | `paliad.project_teams` | `project_id IN subtree` |
|
||||
| `project_partner_units` | `paliad.project_partner_units` | `project_id IN subtree` |
|
||||
| `deadlines` | `paliad.deadlines` | `project_id IN subtree` |
|
||||
| `appointments` | `paliad.appointments` | `project_id IN subtree` |
|
||||
| `parties` | `paliad.parties` | `project_id IN subtree` |
|
||||
| `notes` | `paliad.notes` (4-way polymorphic, resolved to project) | the note's effective project ∈ subtree |
|
||||
| `documents` | `paliad.documents` (metadata only — `ai_extracted` jsonb dropped) | `project_id IN subtree` |
|
||||
| `project_events` | `paliad.project_events` (audit) | `project_id IN subtree` |
|
||||
| `approval_requests` | `paliad.approval_requests` | `project_id IN subtree`, including completed + rejected |
|
||||
| `approval_policies` | `paliad.approval_policies` | union of: (project rows for subtree) + (ancestor rows of root) + (partner-unit defaults attached to any subtree project), with a `source` attribution column |
|
||||
| `checklist_instances` | `paliad.checklist_instances` | `project_id IN subtree` |
|
||||
| `partner_units` | `paliad.partner_units` | only units attached to any subtree project (via `project_partner_units`) |
|
||||
| `partner_unit_members`| `paliad.partner_unit_members` | only members of the attached units |
|
||||
| `users_referenced` | restricted id/email/display_name/office/profession | only users referenced as FK anywhere in the export |
|
||||
| `system_audit_log_subset` | `paliad.system_audit_log` | rows with `scope_root IN subtree` — captures who has exported this subtree (and when) historically |
|
||||
|
||||
**`__meta` sheet** + `__meta.json` + `README.txt`: identical shape to Slice 1, with `scope=project` + `scope_root_id=<root>` + `scope_root_label=<root.title>` added.
|
||||
|
||||
**Reference sheets (`ref__*`).** Same set as Slice 1: `proceeding_types`, `event_types`, `event_categories`, `deadline_rules`, `deadline_concepts`, `courts`, `countries`, `holidays`. Identical bytes across all exports of the same `__meta.generated_at` (reference tables don't change per-project).
|
||||
|
||||
**Explicit exclusions:**
|
||||
|
||||
- `users` (full user roster) — replaced by `users_referenced` (restricted).
|
||||
- `partner_units` (org-wide list) — replaced by attached-only subset.
|
||||
- Personal sidecars (`user_views`, `user_caldav_config`, `user_pinned_projects`, `user_card_layouts`, `paliadin_turns`) — these are per-user, not per-project. Calling user's caldav config + views do NOT belong in a project handover.
|
||||
- `invitations` — org-wide invite pipeline, not project-data.
|
||||
- `auth.*` schema — not paliad's.
|
||||
- Migration shadow tables (`*_pre_NNN`) — Slice 1 same.
|
||||
- Credential-shaped columns — same PII deny-regex as Slice 1.
|
||||
|
||||
---
|
||||
|
||||
## 3. Endpoint shape
|
||||
|
||||
```
|
||||
GET /api/projects/{id}/export
|
||||
```
|
||||
|
||||
**Auth:** existing protected mux middleware (`auth.Middleware` + `auth.WithUserID`).
|
||||
|
||||
**Path param:** `{id}` is the root project's UUID. Service errors → handler maps to 404 (`ErrNotVisible`) / 400 (`ErrInvalidInput`) per the existing `writeServiceError` pattern.
|
||||
|
||||
**Query params:**
|
||||
|
||||
| param | default | values | meaning |
|
||||
|---------------|---------|--------|---------|
|
||||
| `direct_only` | `false` | `0`/`1` | When `1`, narrow the export to the root project only (no descendants). Mirrors the existing `?direct_only=` on `/api/projects/{id}/events`. Default = subtree-inclusive. |
|
||||
| `format` | `zip` | `zip` only (v1) | Reserved for future `xlsx-only` / `json-only` flags. Documented in README only. |
|
||||
|
||||
**Response:**
|
||||
|
||||
- `200 OK`, `Content-Type: application/zip`, `Content-Disposition: attachment; filename="paliad-export-project-<slug>-<ts>.zip"`, `Content-Length: <size>`, `X-Paliad-Export-Audit-Id: <uuid>`.
|
||||
- `403 Forbidden` with `{code, message}` when caller fails the §4 profession + responsibility gate.
|
||||
- `404 Not Found` when `can_see_project(root_id)` returns false.
|
||||
- `500` on internal error (audit row marked `data_export_failed`).
|
||||
- `503` if DB / ExportService is unavailable (same `requireDB` pattern as every other handler).
|
||||
|
||||
**Filename:**
|
||||
|
||||
```
|
||||
paliad-export-project-<slug>-<short-uuid>-<timestamp>.zip
|
||||
slug = slugifyFilename(root.title), capped 40 chars
|
||||
short-uuid = last 8 hex chars of root.id (disambiguator for similar titles)
|
||||
timestamp = YYYY-MM-DDTHHMMZ UTC
|
||||
```
|
||||
|
||||
Example: `paliad-export-project-Siemens-AG-69e2cacb-2026-05-20T1042Z.zip`.
|
||||
|
||||
The short-uuid is new compared to Slice 1's `paliad-export-project-Siemens-AG-2026-05-19T1423Z.zip`. **Reasoning:** two projects can have identical titles (a partner running a long-lived "Standard NDA" project per client would produce filename collisions when archived together). 8 hex chars give 4 billion-class disambiguation space — overkill, but cheap.
|
||||
|
||||
---
|
||||
|
||||
## 4. Permission gate
|
||||
|
||||
Per Slice 1's Q2 lock-in (m's call 2026-05-19), the gate is **purely responsibility-based**, no profession floor:
|
||||
|
||||
```
|
||||
caller MUST satisfy ALL of:
|
||||
(a) auth.UserIDFromContext(r.Context()) — i.e. authenticated
|
||||
(b) can_see_project(root_id) — RLS visibility
|
||||
(c) EXISTS (paliad.project_teams pt
|
||||
WHERE pt.user_id = caller
|
||||
AND pt.project_id = root_id
|
||||
AND pt.responsibility IN ('lead', 'member'))
|
||||
OR caller is global_admin
|
||||
```
|
||||
|
||||
**Why a `project_teams` direct-membership check (and not effective-role via derivation)?** Derivation grants visibility (you can SEE the project) but not extraction authority. A PA member of an attached Partner Unit who is *derived* into the project via `project_partner_units.derive_grants_authority=true` can approve writes, but extracting the matter file is a different sovereignty axis — partner & lead/member explicitly committed to the matter own the data, derived-only viewers shouldn't be able to walk away with the bundle.
|
||||
|
||||
If m wants to loosen this to "anyone who can write is allowed to extract" (i.e. include derived-authority users), it's a one-line change on the SQL. Flagged as Q1 in §10.
|
||||
|
||||
**Observers + Externals:** read-only, no extraction. They can still see the project at runtime; they cannot walk away with the workbook.
|
||||
|
||||
**Global admins:** can extract anything anywhere — same as `/admin/*`. The audit row records this.
|
||||
|
||||
**Edge case — caller is on the root's team but not on a descendant's team.** Still allowed — the gate is at the *root*, not per-descendant. This mirrors how `can_see_project` extends visibility down the tree once you're on any ancestor. Pulling-the-tree from the root is the whole point.
|
||||
|
||||
---
|
||||
|
||||
## 5. Reused vs new code
|
||||
|
||||
What gets reused from Slice 1 (zero changes):
|
||||
|
||||
- `ExportService.writeBundle(ctx, w, sheets, &meta)` — scope-agnostic.
|
||||
- `buildXLSX`, `buildJSON`, `buildCSV`, `buildREADME`, `metaToKeyValueRows`, `byteBuf`.
|
||||
- `formatCellValue` — value coercion.
|
||||
- `piiColumnDenyRegex` + per-sheet `DropColumns` mechanism.
|
||||
- `WriteAuditRow` / `PatchAuditRowSuccess` / `PatchAuditRowFailure` — audit-chain.
|
||||
- `ExportFilename` — adds project-scope-specific behavior (already a switch on scope).
|
||||
- The `__meta` sheet + `__meta.json` shape.
|
||||
- The 30s context watchdog from Slice 1's handler.
|
||||
|
||||
What's new:
|
||||
|
||||
1. **`projectSheetQueries(actorID, rootID uuid.UUID, directOnly bool) []sheetQuery`** in `export_service.go` — returns the project-scope sheet registry. ~250 LoC of SQL recipes.
|
||||
2. **`ExportService.WriteProject(ctx, w, spec ExportSpec, directOnly bool) (ExportMeta, error)`** — mirror of `WritePersonal`, calls `writeBundle` with the new sheet set.
|
||||
3. **`handleProjectExport(w, r *http.Request)`** in `internal/handlers/export.go` — handler with the §4 gate. ~80 LoC of route plumbing + auth checks.
|
||||
4. **Route registration** in `handlers.go`:
|
||||
```go
|
||||
protected.HandleFunc("GET /api/projects/{id}/export", handleProjectExport)
|
||||
```
|
||||
5. **UI affordance** on `/projects/{id}` — a "Daten dieses Projekts exportieren" entry in the project's settings menu (the cog icon, or whatever menu the project-detail page already has). Triggers the same transient-`<a download>` pattern as Slice 1.
|
||||
6. **`ExportFilename` extension** — accept the short-uuid + slug. One-line change.
|
||||
|
||||
Estimated total: **~600 LoC backend + ~50 LoC frontend + ~10 i18n keys DE+EN**.
|
||||
|
||||
No new migration (system_audit_log already supports `scope='project'`).
|
||||
|
||||
---
|
||||
|
||||
## 6. Edge cases
|
||||
|
||||
### 6.1 Cross-project references
|
||||
|
||||
`paliad.projects.counterclaim_of` is a self-FK that can point at a project *outside* the subtree (a counterclaim under one matter referencing the parent matter elsewhere). Two policy options:
|
||||
|
||||
- **Inventor pick: keep the FK column with the foreign UUID; add a warning row in `__meta.warnings` listing every cross-subtree FK so the consumer knows.**
|
||||
Reasoning: silently severing references is the *opposite* of the no-lock-in promise. Importers can choose to keep the reference (resolving via UUID join) or strip it.
|
||||
- Alternative: NULL the column out. Simpler but lossier.
|
||||
|
||||
Same policy applies to any future self-FK column on `projects` or polymorphic FKs that escape the subtree.
|
||||
|
||||
### 6.2 Notes' 4-way polymorphism
|
||||
|
||||
`paliad.notes` has `project_id`, `deadline_id`, `appointment_id`, `project_event_id` — exactly one is non-NULL. To filter, resolve each to its effective `project_id` and intersect with the subtree:
|
||||
|
||||
```sql
|
||||
SELECT * FROM paliad.notes
|
||||
WHERE COALESCE(
|
||||
project_id,
|
||||
(SELECT d.project_id FROM paliad.deadlines d WHERE d.id = notes.deadline_id),
|
||||
(SELECT a.project_id FROM paliad.appointments a WHERE a.id = notes.appointment_id),
|
||||
(SELECT pe.project_id FROM paliad.project_events pe WHERE pe.id = notes.project_event_id)
|
||||
) IN <subtree>
|
||||
```
|
||||
|
||||
Same pattern as Slice 1's personal-scope notes query. No new code.
|
||||
|
||||
### 6.3 Partner-unit data
|
||||
|
||||
`partner_units` is org-wide (11 rows today). `project_partner_units` attaches specific units to specific projects, optionally with `derive_grants_authority=true` to extend approval power. For project export:
|
||||
|
||||
- `project_partner_units` rows for subtree projects → included.
|
||||
- `partner_units` → only the units referenced by those attachments.
|
||||
- `partner_unit_members` → only members of those units.
|
||||
- `partner_unit_events` (audit) → excluded (it's org-meta, not project-data; the user export from Slice 1 already gates this to admin-only).
|
||||
|
||||
This lets a recipient reconstruct "who could approve writes on this matter at the time of export" without dumping the full org chart.
|
||||
|
||||
### 6.4 Approval policies — full chain with attribution
|
||||
|
||||
A project's effective approval policy can come from three sources (per t-paliad-154 design):
|
||||
|
||||
1. Project-row policy on this project.
|
||||
2. Project-row policy on an ancestor.
|
||||
3. Partner-unit-default policy attached to this project.
|
||||
|
||||
For the export, we ship **all three sources** as separate rows in the `approval_policies` sheet, each tagged with a `source` column (`'project'` / `'ancestor'` / `'partner_unit_default'`). The recipient can reconstruct the effective policy by applying the same MAX-of-sources logic the live app uses.
|
||||
|
||||
Without all three sources, an importer asks "why is this approval required?" and has no answer.
|
||||
|
||||
### 6.5 paliadin_turns
|
||||
|
||||
Excluded from project scope. They are user-AI conversations, person-specific, not project-data. (Same hard-exclude as m's Q5 decision for org scope.)
|
||||
|
||||
### 6.6 Caller's `direct_only=true` semantics
|
||||
|
||||
When `?direct_only=1`:
|
||||
|
||||
- `projects` sheet contains exactly one row (the root).
|
||||
- All entity sheets filter by `project_id = root.id` (no IN-subquery).
|
||||
- `project_partner_units` + `partner_units` filter to those attached directly to the root.
|
||||
- Cross-project warnings for descendants don't apply (since descendants aren't in scope).
|
||||
- Filename slug stays unchanged (still derived from root.title).
|
||||
|
||||
Use case: an associate wants just this case's data, not the parent client or sibling matters. Useful for handover of one specific proceeding.
|
||||
|
||||
### 6.7 Concurrent edits during export
|
||||
|
||||
The export runs in a single Postgres transaction (default read-committed isolation). Inserts that land mid-export may or may not appear depending on the snapshot. We don't ship REPEATABLE READ or SERIALIZABLE — at sub-megabyte scope it doesn't matter, and adding transaction-level juggling for a corner case isn't worth the complexity. The `__meta.generated_at` is the snapshot anchor.
|
||||
|
||||
---
|
||||
|
||||
## 7. Audit row shape
|
||||
|
||||
Existing `paliad.system_audit_log` table from Slice 1's mig 102. The Slice 2 handler writes:
|
||||
|
||||
```
|
||||
event_type = 'data_export'
|
||||
actor_id = caller's uuid
|
||||
actor_email = caller's email captured at write time
|
||||
scope = 'project'
|
||||
scope_root = root project's uuid
|
||||
metadata = { "requested_at": "<rfc3339>",
|
||||
"direct_only": false,
|
||||
"root_label": "Siemens AG",
|
||||
"root_path": "00000000_..._.61e3fb9e_..." // ltree path for posterity
|
||||
}
|
||||
```
|
||||
|
||||
On success, `PatchAuditRowSuccess` adds:
|
||||
|
||||
```
|
||||
metadata.row_counts = { "projects": 1, "deadlines": 29, ... }
|
||||
metadata.file_size_bytes = <int>
|
||||
metadata.warnings = [ "subtree references project <uuid> via counterclaim_of",
|
||||
"sheet=foo column=token dropped (PII deny-list)", ... ]
|
||||
metadata.completed_at = "<rfc3339>"
|
||||
```
|
||||
|
||||
On failure, `event_type` flips to `data_export_failed`, `metadata.error = "<stringified error>"`.
|
||||
|
||||
The `system_audit_log` already surfaces on `/admin/audit-log` (6th union branch added in Slice 1). Project leads will see the export rows for *their* projects (because `scope_root` is forwarded as `project_id` in the union projection). Global admins see everything.
|
||||
|
||||
---
|
||||
|
||||
## 8. Trade-offs flagged
|
||||
|
||||
1. **Synchronous-only for now.** A pathological 1M-row subtree would block a request goroutine for >30s; the watchdog kicks in and the user gets a 503. We could lift to async (Slice 3 territory) when this actually happens. Not now.
|
||||
2. **Reference data ships with every project export.** ~1000 rows of `deadline_rules` + `event_types` + … = ~70KB compressed in every workbook. Acceptable cost for self-interpretability. A later optimization could split reference into a separate `paliad-reference-snapshot.zip` and have the project export `README` link to it.
|
||||
3. **Cross-subtree FK retention adds a warning surface.** Recipients of an export with cross-subtree counterclaim_of refs see warnings in `__meta` but no resolution path. That's correct behavior — but future "diff two exports" tooling will need to handle FK-to-non-present-row gracefully. Slice 6+ concern, not blocking.
|
||||
4. **The §4 gate is stricter than visibility.** A derived-only user can `GET /api/projects/{id}` but not `GET /api/projects/{id}/export`. They'll see a 403. Worth surfacing in the UI as a tooltip: "Datenexport ist nur Team-Mitgliedern (Lead / Member) vorbehalten." Otherwise users hit the 403 and don't know why.
|
||||
5. **`direct_only` is a power-user knob.** No UI for it in v1 — only accessible via query param. Documented in `README.txt` only. Avoids a confusing toggle on the export menu when 90% of exports want the subtree.
|
||||
6. **No streaming.** We buffer the whole bundle in memory before sending headers (so audit-row patch + `Content-Length` can be set before flush). At firm-scale today this is sub-megabyte. At firm-scale-100x this would still fit; at firm-scale-10000x we'd need to switch to chunked + skip the precise `Content-Length`.
|
||||
7. **`approval_policies` triple-source carries some redundancy.** A project with no own policy + an ancestor policy will show one row tagged `source='ancestor'`. A project with both will show two rows (one per source) with separate `required_role` values. Slightly more rows but it makes the workbook honest about provenance.
|
||||
|
||||
---
|
||||
|
||||
## 9. Slice scope vs deferred
|
||||
|
||||
**v1 (this slice ships):**
|
||||
|
||||
- `GET /api/projects/{id}/export` with `?direct_only=` query param.
|
||||
- UI affordance on `/projects/{id}` cog menu.
|
||||
- Subtree-inclusive xlsx + JSON + CSV bundle.
|
||||
- All §2 sheets including reference + restricted users + partner-unit subset.
|
||||
- Audit row in `system_audit_log` with row_counts + warnings.
|
||||
|
||||
**Deferred to Slice 3 (org export, async):**
|
||||
|
||||
- Async with job-tracking + on-disk artifact.
|
||||
- Cleanup goroutine + retention env.
|
||||
- Scope=`org` sheet registry (full schema dump).
|
||||
|
||||
**Deferred to later slices (no change from Slice 1's plan):**
|
||||
|
||||
- Slice 4 — scheduled exports.
|
||||
- Slice 5 — API ergonomics (PATs).
|
||||
- Slice 6 — DSR helper UI.
|
||||
- Slice 7 — document binary inclusion.
|
||||
|
||||
---
|
||||
|
||||
## 10. Open decisions for m
|
||||
|
||||
Per the head's instruction (2026-05-20 brief): **NO AskUserQuestion this round.** Head batches m's picks across 4 inventors today. These are listed for m to ratify in one combined session.
|
||||
|
||||
Each item: inventor pick first, alternative(s) after, with reasoning.
|
||||
|
||||
### Q1 — Authority gate: responsibility-only (lead/member) or include derived-authority users?
|
||||
|
||||
**Inventor pick: responsibility ∈ {lead, member} only.** A direct team commitment is the sovereignty axis for extraction. Derived-via-partner-unit users have approval authority but aren't matter owners.
|
||||
|
||||
Alternative: union `(responsibility ∈ {lead, member})` with `(EffectiveProjectRole returns DerivedPeer)`. Slightly broader; lets a PA on the Munich Lit unit extract every Munich Lit matter they're derived into.
|
||||
|
||||
This is the question Slice 1's Q2 locked at the surface level ("any team member with responsibility ∈ {lead, member}") but didn't address the derivation interaction. Confirming here.
|
||||
|
||||
### Q2 — `direct_only` query param: ship in v1 or defer?
|
||||
|
||||
**Inventor pick: ship in v1 as a query-only knob, no UI.** It's a one-line code path (predicate switch); deferring forces a follow-up slice for a power-user need.
|
||||
|
||||
Alternative: defer; v1 is subtree-always. Marginal UI simplicity gain (no `?direct_only=` mention in `README.txt`). Costs: future support tickets ("how do I export just this one case?").
|
||||
|
||||
### Q3 — Cross-subtree FK handling: keep with warning or NULL out?
|
||||
|
||||
**Inventor pick: keep the FK column, add a warning row in `__meta`.** Preserves the no-lock-in promise (an importer can choose to keep or strip the reference). NULL-ing is silent data loss.
|
||||
|
||||
Alternative: NULL the column on export. Simpler workbook; rejects "keep references for integrity" use case.
|
||||
|
||||
### Q4 — `approval_policies` sheet: include all 3 source-attributed rows, or just project rows?
|
||||
|
||||
**Inventor pick: all 3 sources, each tagged with `source` column.** A recipient needs to know "why is this approval required" without re-running paliad's MAX-resolver. Slice 1's design §2.2 already proposed this; Slice 2 lands it.
|
||||
|
||||
Alternative: project-row policies only. Recipient sees `required_role=NULL` and has no recourse to discover the ancestor / partner-unit-default policy that actually applies.
|
||||
|
||||
### Q5 — Filename short-uuid disambiguator: include 8-hex-suffix or just slug?
|
||||
|
||||
**Inventor pick: include short-uuid suffix.** Two projects with identical titles (common: "Standard NDA" per client) would otherwise produce filename collisions when archived together. 4 billion-class disambiguation is cheap.
|
||||
|
||||
Alternative: just the title slug. Cleaner-looking filename; collision-risk per long-lived firm.
|
||||
|
||||
### Q6 — System audit row: include the project's ltree path in metadata?
|
||||
|
||||
**Inventor pick: yes, include `metadata.root_path`.** The audit row outlives the project deletion; preserving the path lets a future audit query reconstruct ancestry even after the matter is closed.
|
||||
|
||||
Alternative: just `scope_root` (the UUID). Tighter audit row; ancestry recoverable only while the project still exists.
|
||||
|
||||
### Q7 — 403 messaging: bilingual or English only?
|
||||
|
||||
**Inventor pick: bilingual.** Paliad is German-first; the gate copy needs both languages. The pattern matches `mapApprovalError` (handlers/projects.go:96-101) which already emits bilingual error text.
|
||||
|
||||
Alternative: English. Smaller code; misaligned with paliad's product language.
|
||||
|
||||
---
|
||||
|
||||
## 11. Recommended implementer
|
||||
|
||||
Continuity matters here. Slice 1's writer abstraction is mine; Slice 2 generalises it. Same hands.
|
||||
|
||||
- archimedes (this worker) for the backend + UI + tests.
|
||||
- Fresh Sonnet coder is OK but would re-discover the writer-abstraction seams.
|
||||
|
||||
**NOT cronus** per memory directive 2026-05-06 (retired from paliad).
|
||||
|
||||
---
|
||||
|
||||
## 12. Adjacent work
|
||||
|
||||
- **Slice 1** is shipped + live on paliad.de (`/api/me/export`).
|
||||
- **Slice 3** (org async) — designed in Slice 1's §7; remains deferred until Slice 2 ships.
|
||||
- **t-paliad-215** (submission generator) — separate workstream; no overlap.
|
||||
- **t-paliad-216** (suggest-changes) — Slice C merged to main; no overlap.
|
||||
- The new `paliad.system_audit_log` table from Slice 1 is the audit substrate; Slice 2 reuses it untouched.
|
||||
|
||||
---
|
||||
|
||||
## 13. References
|
||||
|
||||
- `docs/design-paliad-data-export-2026-05-19.md` — Slice 1 design + §12 m's decisions.
|
||||
- `internal/services/export_service.go` — current ExportService impl (scope-agnostic).
|
||||
- `internal/services/visibility.go` — `visibilityPredicatePositional` + `projectDescendantPredicate`.
|
||||
- `internal/services/approval_levels.go:29-32` — responsibility enum.
|
||||
- `internal/services/team_service.go:47-95` — `AddMember` + `legacyRoleFromResponsibility`.
|
||||
- `internal/handlers/handlers.go` — protected-mux route registration.
|
||||
- `internal/db/migrations/102_system_audit_log.up.sql` — audit table.
|
||||
|
||||
---
|
||||
|
||||
**END OF DESIGN. Status: READY FOR REVIEW.**
|
||||
|
||||
Inventor parks until m's batched picks come back. No code touches the tree from this branch in this shift.
|
||||
52
docs/t-paliad-207-followup-scope.md
Normal file
52
docs/t-paliad-207-followup-scope.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# t-paliad-207 follow-up scope — close-out assessment
|
||||
|
||||
**Author:** fermi (inventor)
|
||||
**Date:** 2026-05-20
|
||||
**Verdict:** **(A) DONE** — interactive session scope is shipped; remaining tail is filed-or-fileable as discrete issues, not a fresh fermi slice.
|
||||
|
||||
---
|
||||
|
||||
## 0. What shipped under t-paliad-207
|
||||
|
||||
Six substantive deliveries on `mai/fermi/interactive-session`, all merged to main as of 2026-05-20 morning:
|
||||
|
||||
1. **Verfahrensablauf + Fristenrechner polish** — jurisdiction prefix on the picked proceeding, trigger-event label derived from the root rule, flag rows lifted to `/tools/verfahrensablauf`, rule references rendered as `youpc.org/laws#…` links via new `BuildLegalSourceURL`, `Vorab-Einrede → Einspruch` rename (DE i18n).
|
||||
2. **DE proceeding picker — sub-group headers** (`Verletzungsverfahren` / `Nichtigkeitsverfahren`) + parallel labels (`LG (1. Instanz)` / `OLG (Berufung)` / …).
|
||||
3. **mig 099** — drop the `with_po` flag from the two RoP 19 rules (Einspruch is always-available, not flag-gated).
|
||||
4. **mig 100** — `upc.inf.cfi.ccr` visible rule (`Nichtigkeitswiderklage`) so the CCR filing event surfaces when `with_ccr` is set; later corrected to `priority='optional'` via mig 101.
|
||||
5. **mig 101** — strip rule-cite brackets from the two Einspruch names + flip the CCR priority `informational → optional`.
|
||||
6. **mig 102** — track-aware sequence reshuffle on `upc.inf.cfi` so at any tied date the order is infringement (Replik) → revocation (Erwiderung Nichtigkeitswiderklage) → amendment.
|
||||
7. **Notes toggle** — `Hinweise anzeigen` checkbox in the view-toggle bar; compact ⓘ hover hint when off (default), inline `timeline-notes` block when on. `localStorage` shared across both tool pages.
|
||||
|
||||
Filed two follow-up issues during the session:
|
||||
|
||||
- **m/paliad#39** — link DE + EPA + EU rule references to `youpc.org/laws` (depends on youpc.org ingesting the corpus).
|
||||
- **m/paliad#41** — DE proceedings as one combined timeline per type (LG→OLG→BGH, BPatG→BGH) — corpus + spawn + de-duplication + multi-instance UI.
|
||||
|
||||
## 1. Why (A) DONE
|
||||
|
||||
Every concrete thing m surfaced in the session was addressed and merged. The two larger unaddressed asks — combined-timeline behaviour for DE proceedings, and DE/EPA rule-link coverage — are already captured in #39 and #41 with concrete scope notes. Neither belongs as a fermi "next slice" because:
|
||||
|
||||
- **#41** is a corpus + UI design pass of its own (3 new spawn rules, de-duplication of the existing `de.inf.lg.berufung ↔ de.inf.olg.berufung` pair, multi-court picker shape, instance markers in the timeline body). That's its own design ticket, not a fermi follow-up.
|
||||
- **#39** is primarily a youpc.org-side ingest task; the paliad-side change is a 5-line `switch` extension once youpc serves the URLs. Wait for the dependency, then small.
|
||||
|
||||
Everything else I surfaced in the read-only audit is either pre-existing (not introduced by this session) or speculative (no user complaint behind it).
|
||||
|
||||
## 2. Optional tail — would file as discrete issues, not a fermi slice
|
||||
|
||||
Surfacing these for completeness; none are blocking, and most would be small enough to either roll into the existing tickets or land as one-off polish:
|
||||
|
||||
| # | Candidate | Size | Already covered? |
|
||||
|---|---|---|---|
|
||||
| 1 | **`legal_source` backfill on 47 unsourced active rules** — query: 4 of `upc.inf.cfi`, 4 of `upc.pi.cfi` (100% gap), 6 of `upc.rev.cfi`, others. Pre-condition for #39's links to bite. | Medium — corpus research per rule | Partially: huygens did the broader citation backfill in t-paliad-208 / mig 097. This is the remaining tail. |
|
||||
| 2 | **`upc.pi.cfi` corpus completeness audit** — all 4 of its rules lack `legal_source`; likely also missing the analogous track-of-decision spawn rules to `upc.apl.merits`. | Small audit, medium fix | No — would be a fresh task. |
|
||||
| 3 | **Touch-device fallback for the ⓘ hover hint** — `title=` attribute degrades poorly on phones (no hover, no tap-to-show). Either a click-to-popover variant, or accept the gap. | Tiny | No, but no user complaint yet. |
|
||||
| 4 | **R.46 mutatis-mutandis distinction in `upc.rev.cfi.prelim` description** — when mig 101 stripped the `(R. 19 i.V.m. R. 46)` cite, the legal nuance dropped from the user-visible name. Could be surfaced in the description text where it doesn't crowd the timeline cell. | Tiny (one row update) | No. |
|
||||
| 5 | **Save-modal warning on SoD + CCR double-check** — with mig 100's new `upc.inf.cfi.ccr` rule, a user can save both `sod` and `ccr` from the same modal and get two `paliad.deadlines` rows on the same date. Today's pre-uncheck behaviour for optional priority mitigates accidental double-write but doesn't surface the duplication actively. | Small | No. |
|
||||
| 6 | **Deferred slices from earlier design docs that touch this surface**: t-paliad-179 Slice 2-4 (variant chips, lane view, side-by-side compare on `/tools/verfahrensablauf`); t-paliad-169 "+ Eintrag" CTA on the SmartTimeline (project-bound) path. | Each a separate slice. | Yes — parked from their original tasks; would be revisited when m prioritises. |
|
||||
|
||||
None of these warrant a "next fermi slice" right now. They're polish + corpus tail, and best handled as individual issues that m can pick from.
|
||||
|
||||
## 3. Recommendation
|
||||
|
||||
Close t-paliad-207. Fire fermi. The remaining tail (items 1–6 above) is appropriate as a small "polish backlog" m can dip into when relevant, but not a coherent unit of work that needs a parked inventor.
|
||||
@@ -1262,6 +1262,8 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"projects.detail.tab.notizen": "Notizen",
|
||||
"projects.detail.tab.checklisten": "Checklisten",
|
||||
"projects.detail.tab.submissions": "Schriftsätze",
|
||||
"projects.detail.export.button": "Daten exportieren",
|
||||
"projects.detail.export.tooltip": "Daten dieses Projekts (mit Unter-Projekten) als Excel + JSON + CSV herunterladen.",
|
||||
"projects.detail.submissions.empty": "Für dieses Verfahren sind keine Schriftsätze hinterlegt.",
|
||||
"projects.detail.submissions.empty.no_proceeding": "Bitte zuerst einen Verfahrenstyp setzen.",
|
||||
"projects.detail.submissions.col.name": "Schriftsatz",
|
||||
@@ -3879,6 +3881,8 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"projects.detail.tab.notizen": "Notes",
|
||||
"projects.detail.tab.checklisten": "Checklists",
|
||||
"projects.detail.tab.submissions": "Submissions",
|
||||
"projects.detail.export.button": "Export data",
|
||||
"projects.detail.export.tooltip": "Download this project's data (including sub-projects) as Excel + JSON + CSV.",
|
||||
"projects.detail.submissions.empty": "No submissions are configured for this proceeding.",
|
||||
"projects.detail.submissions.empty.no_proceeding": "Please set a proceeding type first.",
|
||||
"projects.detail.submissions.col.name": "Submission",
|
||||
|
||||
@@ -2064,6 +2064,7 @@ async function main() {
|
||||
initAttachUnitForm(id);
|
||||
initNotesContainer(id);
|
||||
mountVerlaufFilterBar(id);
|
||||
wireExportButton(id);
|
||||
showTab(parseTab());
|
||||
}
|
||||
|
||||
@@ -2686,6 +2687,41 @@ function canManagePartnerUnits(): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
// canExportProject mirrors the §4 server-side gate for /api/projects/{id}/export:
|
||||
// global_admin OR direct team responsibility ∈ {lead, member}. Used to
|
||||
// reveal the export button — server still re-enforces on the request.
|
||||
function canExportProject(): boolean {
|
||||
if (!me || !project) return false;
|
||||
if (me.global_role === "global_admin") return true;
|
||||
return teamMembers.some(
|
||||
(m) =>
|
||||
m.user_id === me!.id &&
|
||||
m.project_id === project!.id &&
|
||||
(m.responsibility === "lead" || m.responsibility === "member"),
|
||||
);
|
||||
}
|
||||
|
||||
// wireExportButton reveals + hooks up the project-export button on the
|
||||
// tabs nav. Triggers a download via a transient <a download> — same
|
||||
// pattern as the personal export in client/settings.ts.
|
||||
function wireExportButton(projectID: string): void {
|
||||
const btn = document.getElementById("project-export-btn") as HTMLButtonElement | null;
|
||||
if (!btn) return;
|
||||
if (!canExportProject()) {
|
||||
btn.style.display = "none";
|
||||
return;
|
||||
}
|
||||
btn.style.display = "";
|
||||
btn.addEventListener("click", () => {
|
||||
const a = document.createElement("a");
|
||||
a.href = `/api/projects/${encodeURIComponent(projectID)}/export`;
|
||||
a.download = "";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
});
|
||||
}
|
||||
|
||||
function canRemoveTeamMember(m: ProjectTeamMember): boolean {
|
||||
if (!me) return false;
|
||||
if (m.user_id === me.id) return true;
|
||||
|
||||
@@ -1954,6 +1954,8 @@ export type I18nKey =
|
||||
| "projects.detail.edit"
|
||||
| "projects.detail.edit.modal.title"
|
||||
| "projects.detail.edit.type_change_warning.title"
|
||||
| "projects.detail.export.button"
|
||||
| "projects.detail.export.tooltip"
|
||||
| "projects.detail.firmwide.off"
|
||||
| "projects.detail.firmwide.on"
|
||||
| "projects.detail.kinder.add"
|
||||
|
||||
@@ -81,6 +81,20 @@ export function renderProjectsDetail(): string {
|
||||
<a className="entity-tab" data-tab="notes" href="#" data-i18n="projects.detail.tab.notizen">Notizen</a>
|
||||
<a className="entity-tab" data-tab="checklists" href="#" data-i18n="projects.detail.tab.checklisten">Checklisten</a>
|
||||
<a className="entity-tab" data-tab="submissions" href="#" data-i18n="projects.detail.tab.submissions">Schriftsätze</a>
|
||||
{/* t-paliad-214 Slice 2 — project-subtree export button.
|
||||
Sits at the end of the tab nav. Hidden by default; the
|
||||
client unhides it after /api/me confirms the caller can
|
||||
extract (responsibility ∈ {lead, member} OR global_admin). */}
|
||||
<button
|
||||
type="button"
|
||||
id="project-export-btn"
|
||||
className="entity-tab entity-tab-action"
|
||||
style="display:none"
|
||||
title=""
|
||||
data-i18n-title="projects.detail.export.tooltip"
|
||||
data-i18n="projects.detail.export.button">
|
||||
Daten exportieren
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
{/* History (Verlauf) — t-paliad-171 SmartTimeline Slice 1.
|
||||
|
||||
@@ -2,16 +2,19 @@ package handlers
|
||||
|
||||
// Data-export handlers (t-paliad-214).
|
||||
//
|
||||
// Slice 1 ships the personal scope only:
|
||||
//
|
||||
// Slice 1: personal scope
|
||||
// GET /api/me/export → streams a personal-scope export .zip
|
||||
//
|
||||
// Slices 2 + 3 (project + org) layer onto this file when they ship.
|
||||
// Slice 2: project subtree scope
|
||||
// GET /api/projects/{id}/export?direct_only=0|1 → streams a project-subtree
|
||||
// export .zip
|
||||
//
|
||||
// Slice 3 (org, async) lands in a follow-up.
|
||||
//
|
||||
// Authentication: the existing protected mux middleware (auth.Middleware +
|
||||
// auth.WithUserID) populates the user UUID in the context. We do not gate
|
||||
// on global_role here — personal export is available to every authenticated
|
||||
// user.
|
||||
// auth.WithUserID) populates the user UUID in the context. Slice 1 gates
|
||||
// only on authentication; Slice 2 adds a §4 responsibility + global_admin
|
||||
// check via handleProjectExportGate.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -22,6 +25,8 @@ import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
)
|
||||
|
||||
@@ -102,7 +107,7 @@ func handleMeExport(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
filename := services.ExportFilename(services.ExportScopePersonal, "", spec.GeneratedAt)
|
||||
filename := services.ExportFilename(services.ExportScopePersonal, "", uuid.Nil, spec.GeneratedAt)
|
||||
size := int64(buf.Len())
|
||||
|
||||
if err := dbSvc.export.PatchAuditRowSuccess(ctx, auditID, meta, size); err != nil {
|
||||
@@ -123,3 +128,163 @@ func handleMeExport(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("export: response write failed for %s (audit=%s): %v", uid, auditID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// handleProjectExport streams the project-subtree export .zip for the
|
||||
// project named in the URL path.
|
||||
//
|
||||
// Authorization (Slice 2 §4):
|
||||
//
|
||||
// - caller must be authenticated (handled by the mux middleware),
|
||||
// - caller must pass paliad.can_see_project(rootID) — enforced via
|
||||
// ProjectService.GetByID returning ErrNotVisible → 404,
|
||||
// - caller must be on paliad.project_teams for the root with
|
||||
// responsibility ∈ {lead, member}, OR be a global_admin.
|
||||
// Observers + Externals see but cannot extract — 403 bilingual.
|
||||
//
|
||||
// Query params:
|
||||
// - ?direct_only=1 narrows the export to the root project only (no
|
||||
// descendants). Default = subtree-inclusive.
|
||||
func handleProjectExport(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if dbSvc.export == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
|
||||
"error": "export service not configured",
|
||||
})
|
||||
return
|
||||
}
|
||||
rootID, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{
|
||||
"error": "invalid project id",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
directOnly := false
|
||||
if q := r.URL.Query().Get("direct_only"); q == "1" || q == "true" {
|
||||
directOnly = true
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), exportRequestTimeout)
|
||||
defer cancel()
|
||||
|
||||
// Visibility gate (a + b): GetByID returns ErrNotVisible when the
|
||||
// caller can't see the project, which we map to 404. The handler
|
||||
// stays oblivious to whether the project doesn't exist or simply
|
||||
// isn't visible — that's by design (RLS-style opacity).
|
||||
project, err := dbSvc.projects.GetByID(ctx, uid, rootID)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Authority gate (c): direct-team responsibility ∈ {lead, member} OR
|
||||
// global_admin. Derived-only-via-partner-unit users (DerivedPeer)
|
||||
// don't qualify for extraction — m's Q1 lock-in.
|
||||
allowed, err := callerCanExportProject(ctx, uid, rootID)
|
||||
if err != nil {
|
||||
log.Printf("export: authority check failed for user=%s project=%s: %v", uid, rootID, err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{
|
||||
"error": "authority check failed",
|
||||
})
|
||||
return
|
||||
}
|
||||
if !allowed {
|
||||
// Bilingual 403 per Q7. Pattern matches mapApprovalError style.
|
||||
writeJSON(w, http.StatusForbidden, map[string]string{
|
||||
"code": "export_not_authorized",
|
||||
"message": "Datenexport ist nur Team-Mitgliedern (Lead / Member) vorbehalten. / Data export is restricted to project team members (lead / member).",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
user, err := dbSvc.users.GetByID(ctx, uid)
|
||||
if err != nil || user == nil {
|
||||
log.Printf("export: user lookup failed for %s: %v", uid, err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{
|
||||
"error": "user lookup failed",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
spec := services.ExportSpec{
|
||||
Scope: services.ExportScopeProject,
|
||||
ScopeRoot: &rootID,
|
||||
ScopeRootLabel: project.Title,
|
||||
ScopeRootPath: project.Path,
|
||||
DirectOnly: directOnly,
|
||||
ActorID: uid,
|
||||
ActorEmail: user.Email,
|
||||
ActorLabel: user.DisplayName,
|
||||
GeneratedAt: time.Now().UTC(),
|
||||
}
|
||||
|
||||
auditID, err := dbSvc.export.WriteAuditRow(ctx, spec)
|
||||
if err != nil {
|
||||
log.Printf("export: audit insert failed for %s/project=%s: %v", uid, rootID, err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{
|
||||
"error": "audit write failed",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
meta, err := dbSvc.export.WriteProject(ctx, &buf, spec)
|
||||
if err != nil {
|
||||
dbSvc.export.PatchAuditRowFailure(context.Background(), auditID, err.Error())
|
||||
log.Printf("export: WriteProject failed for %s/project=%s (audit=%s): %v", uid, rootID, auditID, err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{
|
||||
"error": "export generation failed",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
filename := services.ExportFilename(services.ExportScopeProject, project.Title, rootID, spec.GeneratedAt)
|
||||
size := int64(buf.Len())
|
||||
|
||||
if err := dbSvc.export.PatchAuditRowSuccess(ctx, auditID, meta, size); err != nil {
|
||||
log.Printf("export: audit patch failed for %s/project=%s (audit=%s): %v", uid, rootID, auditID, err)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/zip")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename=%q`, filename))
|
||||
w.Header().Set("Content-Length", strconv.FormatInt(size, 10))
|
||||
w.Header().Set("X-Paliad-Export-Audit-Id", auditID.String())
|
||||
if _, err := w.Write(buf.Bytes()); err != nil {
|
||||
log.Printf("export: response write failed for %s/project=%s (audit=%s): %v", uid, rootID, auditID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// callerCanExportProject is the §4 authority check:
|
||||
//
|
||||
// - global_admin can extract anything anywhere.
|
||||
// - else: caller must be on paliad.project_teams for the root with
|
||||
// responsibility ∈ {lead, member}.
|
||||
//
|
||||
// One query, parameterised; returns the boolean. Errors surface to the
|
||||
// handler as 500.
|
||||
func callerCanExportProject(ctx context.Context, userID, projectID uuid.UUID) (bool, error) {
|
||||
const q = `
|
||||
SELECT
|
||||
EXISTS (
|
||||
SELECT 1 FROM paliad.users u
|
||||
WHERE u.id = $1 AND u.global_role = 'global_admin'
|
||||
) OR EXISTS (
|
||||
SELECT 1 FROM paliad.project_teams pt
|
||||
WHERE pt.user_id = $1
|
||||
AND pt.project_id = $2
|
||||
AND pt.responsibility IN ('lead', 'member')
|
||||
)
|
||||
`
|
||||
var ok bool
|
||||
if err := dbSvc.projects.DB().QueryRowContext(ctx, q, userID, projectID).Scan(&ok); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return ok, nil
|
||||
}
|
||||
|
||||
@@ -284,6 +284,10 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
protected.HandleFunc("GET /api/projects/{id}/timeline", handleGetProjectTimeline)
|
||||
// t-paliad-177 Slice 2 — iCal feed (deadlines + appointments only).
|
||||
protected.HandleFunc("GET /api/projects/{id}/timeline.ics", handleGetProjectTimelineICS)
|
||||
// t-paliad-214 Slice 2 — project-subtree data export. ?direct_only=1
|
||||
// narrows to the root project only; default = root + descendants.
|
||||
// Permission gate: responsibility ∈ {lead, member} OR global_admin.
|
||||
protected.HandleFunc("GET /api/projects/{id}/export", handleProjectExport)
|
||||
protected.HandleFunc("POST /api/projects/{id}/timeline/milestone", handleCreateProjectTimelineMilestone)
|
||||
protected.HandleFunc("POST /api/projects/{id}/timeline/anchor", handleProjectTimelineAnchor)
|
||||
protected.HandleFunc("POST /api/projects/{id}/timeline/skip", handleProjectTimelineSkip)
|
||||
|
||||
215
internal/services/export_project_test.go
Normal file
215
internal/services/export_project_test.go
Normal file
@@ -0,0 +1,215 @@
|
||||
package services
|
||||
|
||||
// Tests for the Slice 2 (project-subtree) sheet registry. Pure-function
|
||||
// shape tests — live-DB integration coverage of the SQL itself stays in
|
||||
// the existing query patterns the personal-scope tests already cover.
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// TestProjectSheetQueries_RegistryShape pins the sheet inventory + the
|
||||
// design's §2 contract: every entity sheet binds rootID as $1, and the
|
||||
// approval_policies sheet ships with all three sources (project +
|
||||
// ancestor + partner_unit_default).
|
||||
func TestProjectSheetQueries_RegistryShape(t *testing.T) {
|
||||
rootID := uuid.MustParse("61e3fb9e-29fb-44aa-867e-a89469e2cacb")
|
||||
qs := projectSheetQueries(rootID, false)
|
||||
|
||||
wantSheets := []string{
|
||||
"projects",
|
||||
"project_teams",
|
||||
"project_partner_units",
|
||||
"deadlines",
|
||||
"appointments",
|
||||
"parties",
|
||||
"notes",
|
||||
"documents",
|
||||
"project_events",
|
||||
"approval_requests",
|
||||
"approval_policies",
|
||||
"checklist_instances",
|
||||
"partner_units",
|
||||
"partner_unit_members",
|
||||
"users_referenced",
|
||||
"system_audit_log_subset",
|
||||
"ref__proceeding_types",
|
||||
"ref__event_types",
|
||||
"ref__event_categories",
|
||||
"ref__deadline_rules",
|
||||
"ref__deadline_concepts",
|
||||
"ref__courts",
|
||||
"ref__countries",
|
||||
"ref__holidays",
|
||||
}
|
||||
gotSheets := []string{}
|
||||
for _, q := range qs {
|
||||
gotSheets = append(gotSheets, q.SheetName)
|
||||
}
|
||||
if len(gotSheets) != len(wantSheets) {
|
||||
t.Fatalf("sheet count = %d, want %d (got %v)", len(gotSheets), len(wantSheets), gotSheets)
|
||||
}
|
||||
for i, want := range wantSheets {
|
||||
if gotSheets[i] != want {
|
||||
t.Errorf("sheet[%d] = %q, want %q", i, gotSheets[i], want)
|
||||
}
|
||||
}
|
||||
|
||||
// Every NON-reference sheet binds rootID as $1.
|
||||
for _, q := range qs {
|
||||
if strings.HasPrefix(q.SheetName, "ref__") {
|
||||
if len(q.Args) != 0 {
|
||||
t.Errorf("ref sheet %q has %d args, want 0", q.SheetName, len(q.Args))
|
||||
}
|
||||
continue
|
||||
}
|
||||
if len(q.Args) != 1 {
|
||||
t.Errorf("entity sheet %q has %d args, want 1", q.SheetName, len(q.Args))
|
||||
continue
|
||||
}
|
||||
if got, ok := q.Args[0].(uuid.UUID); !ok || got != rootID {
|
||||
t.Errorf("entity sheet %q first arg = %v, want rootID %v", q.SheetName, q.Args[0], rootID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestProjectSheetQueries_ApprovalPoliciesTripleSource verifies that the
|
||||
// approval_policies sheet's SQL carries all three source tags so an
|
||||
// importer can reconstruct the effective gate (Q4 lock-in).
|
||||
func TestProjectSheetQueries_ApprovalPoliciesTripleSource(t *testing.T) {
|
||||
qs := projectSheetQueries(uuid.New(), false)
|
||||
var found *sheetQuery
|
||||
for i := range qs {
|
||||
if qs[i].SheetName == "approval_policies" {
|
||||
found = &qs[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
t.Fatal("approval_policies sheet missing from registry")
|
||||
}
|
||||
for _, src := range []string{
|
||||
`'project'::text AS source`,
|
||||
`'ancestor'::text AS source`,
|
||||
`'partner_unit_default'::text AS source`,
|
||||
} {
|
||||
if !strings.Contains(found.SQL, src) {
|
||||
t.Errorf("approval_policies SQL missing %q tag — Q4 triple-source attribution broken.\nSQL:\n%s",
|
||||
src, found.SQL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestProjectSheetQueries_DirectOnlyNarrowsSubtree pins that direct_only=true
|
||||
// produces a subtree subquery resolving to exactly the root (no LIKE-walk).
|
||||
func TestProjectSheetQueries_DirectOnlyNarrowsSubtree(t *testing.T) {
|
||||
subtreeAll := projectSubtreeProjectIDsSQL(false)
|
||||
subtreeRoot := projectSubtreeProjectIDsSQL(true)
|
||||
|
||||
if !strings.Contains(subtreeAll, `LIKE r.path`) {
|
||||
t.Errorf("default subtree SQL missing path-LIKE descendant walk:\n%s", subtreeAll)
|
||||
}
|
||||
if strings.Contains(subtreeRoot, `LIKE`) {
|
||||
t.Errorf("direct_only subtree SQL still has LIKE walk — should be root-only:\n%s", subtreeRoot)
|
||||
}
|
||||
if !strings.Contains(subtreeRoot, `$1::uuid`) {
|
||||
t.Errorf("direct_only subtree SQL missing $1::uuid root reference:\n%s", subtreeRoot)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProjectSheetQueries_NoPersonalSidecars guards against an accidental
|
||||
// inclusion of personal sidecars (caldav config, views, pins, paliadin
|
||||
// turns) in the project-scope export. These are per-user, not per-project,
|
||||
// and don't belong in a matter handover.
|
||||
func TestProjectSheetQueries_NoPersonalSidecars(t *testing.T) {
|
||||
qs := projectSheetQueries(uuid.New(), false)
|
||||
for _, q := range qs {
|
||||
switch q.SheetName {
|
||||
case "my_caldav_config", "my_views", "my_pinned_projects", "my_card_layouts", "my_paliadin_turns", "me":
|
||||
t.Errorf("project-scope export must not include personal sidecar sheet %q", q.SheetName)
|
||||
}
|
||||
// Also defence-in-depth on the SQL: no SELECT from
|
||||
// user_caldav_config or paliadin_turns from project scope.
|
||||
if strings.Contains(q.SQL, "user_caldav_config") {
|
||||
t.Errorf("sheet %q SQL touches user_caldav_config — never in project scope", q.SheetName)
|
||||
}
|
||||
if strings.Contains(q.SQL, "paliadin_turns") {
|
||||
t.Errorf("sheet %q SQL touches paliadin_turns — never in project scope", q.SheetName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestProjectSheetQueries_AttachedPartnerUnitsOnly pins that the
|
||||
// partner_units sheet is filtered to attached units only (not the full
|
||||
// org chart).
|
||||
func TestProjectSheetQueries_AttachedPartnerUnitsOnly(t *testing.T) {
|
||||
qs := projectSheetQueries(uuid.New(), false)
|
||||
for _, q := range qs {
|
||||
if q.SheetName != "partner_units" {
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(q.SQL, "project_partner_units") {
|
||||
t.Errorf("partner_units sheet SQL must filter via project_partner_units (got attached-only requirement):\n%s",
|
||||
q.SQL)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatal("partner_units sheet missing from registry")
|
||||
}
|
||||
|
||||
// TestShortUUIDSuffix_ReturnsLast8Hex pins the §3 filename disambiguator
|
||||
// shape — Q5 lock-in.
|
||||
func TestShortUUIDSuffix_ReturnsLast8Hex(t *testing.T) {
|
||||
cases := []struct {
|
||||
in uuid.UUID
|
||||
want string
|
||||
}{
|
||||
{uuid.Nil, ""},
|
||||
{uuid.MustParse("11111111-1111-1111-1111-aaaaaaaaaaaa"), "aaaaaaaaaaaa"},
|
||||
{uuid.MustParse("61e3fb9e-29fb-44aa-867e-a89469e2cacb"), "a89469e2cacb"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := shortUUIDSuffix(c.in)
|
||||
if got != c.want {
|
||||
t.Errorf("shortUUIDSuffix(%v) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMetaToKeyValueRows_ProjectScopeRows verifies that project-scope
|
||||
// meta picks up scope_root_label + scope_root_path + direct_only rows
|
||||
// (so the __meta sheet carries Q6 lock-in details).
|
||||
func TestMetaToKeyValueRows_ProjectScopeRows(t *testing.T) {
|
||||
rootID := uuid.MustParse("61e3fb9e-29fb-44aa-867e-a89469e2cacb")
|
||||
m := ExportMeta{
|
||||
SchemaVersion: 1,
|
||||
FirmName: "HLC",
|
||||
Scope: ExportScopeProject,
|
||||
ScopeRootID: &rootID,
|
||||
ScopeRootLabel: "Siemens AG",
|
||||
ScopeRootPath: "61e3fb9e_29fb_44aa_867e_a89469e2cacb",
|
||||
DirectOnly: false,
|
||||
GeneratedAt: time.Date(2026, 5, 20, 14, 23, 0, 0, time.UTC),
|
||||
RowCounts: map[string]int{},
|
||||
}
|
||||
rows := metaToKeyValueRows(m)
|
||||
want := map[string]string{
|
||||
"scope_root_label": "Siemens AG",
|
||||
"scope_root_path": "61e3fb9e_29fb_44aa_867e_a89469e2cacb",
|
||||
"direct_only": "FALSE",
|
||||
}
|
||||
seen := map[string]string{}
|
||||
for _, r := range rows {
|
||||
seen[r[0]] = r[1]
|
||||
}
|
||||
for k, v := range want {
|
||||
if seen[k] != v {
|
||||
t.Errorf("meta key %q = %q, want %q (full rows: %v)", k, seen[k], v, rows)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,6 +93,16 @@ type ExportMeta struct {
|
||||
FirmName string `json:"firm_name"`
|
||||
Scope string `json:"scope"`
|
||||
ScopeRootID *uuid.UUID `json:"scope_root_id,omitempty"`
|
||||
// ScopeRootLabel is the project title (project scope only). Empty
|
||||
// for personal + org scope.
|
||||
ScopeRootLabel string `json:"scope_root_label,omitempty"`
|
||||
// ScopeRootPath is the ltree path of the root project (project scope
|
||||
// only). Preserved in the audit row so closed-out projects retain a
|
||||
// usable ancestry pointer (Q6 lock-in).
|
||||
ScopeRootPath string `json:"scope_root_path,omitempty"`
|
||||
// DirectOnly is true when ?direct_only=1 was passed (project scope
|
||||
// only) — narrows the export to the root project, no descendants.
|
||||
DirectOnly bool `json:"direct_only,omitempty"`
|
||||
GeneratedAt time.Time `json:"generated_at"`
|
||||
GeneratedByID uuid.UUID `json:"generated_by_user_id"`
|
||||
GeneratedByEml string `json:"generated_by_user_email"`
|
||||
@@ -107,6 +117,14 @@ type ExportMeta struct {
|
||||
type ExportSpec struct {
|
||||
Scope string
|
||||
ScopeRoot *uuid.UUID // project_id when Scope==ExportScopeProject; nil otherwise
|
||||
// ScopeRootLabel + ScopeRootPath are populated by the project-export
|
||||
// handler (resolved from the root project row) so the audit + __meta
|
||||
// carry stable labels even if the project is later renamed.
|
||||
ScopeRootLabel string
|
||||
ScopeRootPath string
|
||||
// DirectOnly narrows the export to the root project only (project
|
||||
// scope, ?direct_only=1).
|
||||
DirectOnly bool
|
||||
ActorID uuid.UUID
|
||||
ActorEmail string
|
||||
ActorLabel string // display_name for the audit + meta
|
||||
@@ -173,6 +191,102 @@ func (s *ExportService) WritePersonal(ctx context.Context, w io.Writer, spec Exp
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
// WriteProject streams the project-subtree bundle for the project named
|
||||
// in spec.ScopeRoot into w. Returns the meta (incl. row_counts) for the
|
||||
// audit-row patch.
|
||||
//
|
||||
// Behavior contract (per Slice 2 design §2):
|
||||
//
|
||||
// - Every entity sheet is filtered to the subtree (project + descendants
|
||||
// via ltree path). When spec.DirectOnly is true, narrows to the root
|
||||
// project only (no descendants).
|
||||
// - approval_policies carries all 3 sources (project rows + ancestor
|
||||
// rows + partner-unit-default rows) tagged with a `source` column —
|
||||
// m's Q4 lock-in lets recipients reconstruct the effective gate.
|
||||
// - users_referenced restricts the user disclosure to FK-referenced
|
||||
// users only (avoids dumping the full firm roster into a per-matter
|
||||
// handover).
|
||||
// - Cross-subtree FKs (projects.counterclaim_of pointing outside the
|
||||
// subtree) are kept but warned about in __meta.warnings — m's Q3
|
||||
// lock-in preserves the no-lock-in promise.
|
||||
//
|
||||
// Permission gate (§4) lives on the handler, NOT here — the service
|
||||
// trusts the caller has already authorised. Wiring is in handlers/export.go.
|
||||
func (s *ExportService) WriteProject(ctx context.Context, w io.Writer, spec ExportSpec) (ExportMeta, error) {
|
||||
if spec.Scope == "" {
|
||||
spec.Scope = ExportScopeProject
|
||||
}
|
||||
if spec.GeneratedAt.IsZero() {
|
||||
spec.GeneratedAt = time.Now().UTC()
|
||||
}
|
||||
if spec.ScopeRoot == nil {
|
||||
return ExportMeta{}, fmt.Errorf("WriteProject: ScopeRoot is required")
|
||||
}
|
||||
meta := ExportMeta{
|
||||
SchemaVersion: ExportSchemaVersion,
|
||||
FirmName: s.firmName,
|
||||
Scope: spec.Scope,
|
||||
ScopeRootID: spec.ScopeRoot,
|
||||
ScopeRootLabel: spec.ScopeRootLabel,
|
||||
ScopeRootPath: spec.ScopeRootPath,
|
||||
DirectOnly: spec.DirectOnly,
|
||||
GeneratedAt: spec.GeneratedAt,
|
||||
GeneratedByID: spec.ActorID,
|
||||
GeneratedByEml: spec.ActorEmail,
|
||||
GeneratedByLbl: spec.ActorLabel,
|
||||
RowCounts: map[string]int{},
|
||||
}
|
||||
|
||||
sheets := projectSheetQueries(*spec.ScopeRoot, spec.DirectOnly)
|
||||
if err := s.writeBundle(ctx, w, sheets, &meta); err != nil {
|
||||
return meta, err
|
||||
}
|
||||
|
||||
// Cross-subtree FK detection (Q3 lock-in: keep FK + warn). After the
|
||||
// bundle is built we run one lightweight scan to surface
|
||||
// counterclaim_of references that escape the subtree. The result
|
||||
// gets appended to meta.Warnings so it lands in __meta + the audit
|
||||
// row + the README's warning list.
|
||||
if warns, err := s.detectCrossSubtreeFKs(ctx, *spec.ScopeRoot, spec.DirectOnly); err == nil && len(warns) > 0 {
|
||||
meta.Warnings = append(meta.Warnings, warns...)
|
||||
sort.Strings(meta.Warnings)
|
||||
}
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
// detectCrossSubtreeFKs scans subtree-resident projects for FKs that
|
||||
// point outside the subtree (today: only projects.counterclaim_of). One
|
||||
// warning row per outbound reference. Best-effort: a query error here
|
||||
// degrades silently (the export still ships) since the warning is
|
||||
// informational, not load-bearing.
|
||||
func (s *ExportService) detectCrossSubtreeFKs(ctx context.Context, rootID uuid.UUID, directOnly bool) ([]string, error) {
|
||||
subtreeSQL := projectSubtreeProjectIDsSQL(directOnly)
|
||||
q := `
|
||||
SELECT p.id, p.title, p.counterclaim_of
|
||||
FROM paliad.projects p
|
||||
WHERE p.id IN ` + subtreeSQL + `
|
||||
AND p.counterclaim_of IS NOT NULL
|
||||
AND p.counterclaim_of NOT IN ` + subtreeSQL + `
|
||||
ORDER BY p.id`
|
||||
type row struct {
|
||||
ID uuid.UUID `db:"id"`
|
||||
Title string `db:"title"`
|
||||
CounterclaimOf uuid.UUID `db:"counterclaim_of"`
|
||||
}
|
||||
var rows []row
|
||||
if err := s.db.SelectContext(ctx, &rows, q, rootID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]string, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, fmt.Sprintf(
|
||||
"cross-subtree FK: project %q (%s).counterclaim_of → %s (not in this export)",
|
||||
r.Title, r.ID, r.CounterclaimOf,
|
||||
))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// collectedSheet holds one sheet's data after column-discovery + row
|
||||
// materialisation. Used to hand data from writeBundle to buildXLSX +
|
||||
// buildJSON + buildCSV.
|
||||
@@ -565,6 +679,20 @@ func metaToKeyValueRows(m ExportMeta) [][2]string {
|
||||
} else {
|
||||
rows = append(rows, [2]string{"scope_root_id", ""})
|
||||
}
|
||||
// Project-scope-only rows (Slice 2 §2.4). Surface as empty rows for
|
||||
// other scopes so the __meta layout stays stable + Excel users can
|
||||
// see "this field exists but doesn't apply here".
|
||||
rows = append(rows,
|
||||
[2]string{"scope_root_label", m.ScopeRootLabel},
|
||||
[2]string{"scope_root_path", m.ScopeRootPath},
|
||||
)
|
||||
if m.Scope == ExportScopeProject {
|
||||
if m.DirectOnly {
|
||||
rows = append(rows, [2]string{"direct_only", "TRUE"})
|
||||
} else {
|
||||
rows = append(rows, [2]string{"direct_only", "FALSE"})
|
||||
}
|
||||
}
|
||||
rows = append(rows,
|
||||
[2]string{"generated_at", m.GeneratedAt.UTC().Format(time.RFC3339)},
|
||||
[2]string{"generated_by_user_id", m.GeneratedByID.String()},
|
||||
@@ -637,6 +765,19 @@ func buildREADME(m ExportMeta) string {
|
||||
fmt.Fprintf(&b, "Erstellt am : %s\n", m.GeneratedAt.UTC().Format(time.RFC3339))
|
||||
fmt.Fprintf(&b, "Erstellt von : %s <%s>\n", m.GeneratedByLbl, m.GeneratedByEml)
|
||||
fmt.Fprintf(&b, "Umfang : %s\n", m.Scope)
|
||||
if m.Scope == ExportScopeProject {
|
||||
if m.ScopeRootLabel != "" {
|
||||
fmt.Fprintf(&b, "Projekt : %s\n", m.ScopeRootLabel)
|
||||
}
|
||||
if m.ScopeRootID != nil {
|
||||
fmt.Fprintf(&b, "Projekt-ID : %s\n", m.ScopeRootID.String())
|
||||
}
|
||||
if m.DirectOnly {
|
||||
fmt.Fprintf(&b, "Hinweis : nur das Root-Projekt (?direct_only=1), keine Unter-Projekte.\n")
|
||||
} else {
|
||||
fmt.Fprintf(&b, "Hinweis : Root-Projekt + alle Unter-Projekte.\n")
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(&b, "Schema-Version: %d\n", m.SchemaVersion)
|
||||
fmt.Fprintf(&b, "\n")
|
||||
fmt.Fprintf(&b, "Inhalt\n------\n")
|
||||
@@ -681,7 +822,16 @@ func buildREADME(m ExportMeta) string {
|
||||
// ExportFilename returns the canonical filename for a download. Slugify is
|
||||
// minimal — only the project-scope variant has a free-text component to
|
||||
// sanitise.
|
||||
func ExportFilename(scope string, scopeLabel string, generatedAt time.Time) string {
|
||||
//
|
||||
// Project-scope filenames include an 8-hex-char disambiguator derived from
|
||||
// the root project's UUID (Slice 2 §3 Q5). Two projects with identical
|
||||
// titles (common: "Standard NDA" per client) would otherwise produce
|
||||
// filename collisions when archived together; 4-billion-class disambiguation
|
||||
// is cheap insurance.
|
||||
//
|
||||
// rootID is consumed only for ExportScopeProject; pass uuid.Nil for the
|
||||
// other scopes.
|
||||
func ExportFilename(scope string, scopeLabel string, rootID uuid.UUID, generatedAt time.Time) string {
|
||||
ts := generatedAt.UTC().Format("2006-01-02T1504Z")
|
||||
switch scope {
|
||||
case ExportScopePersonal:
|
||||
@@ -693,12 +843,30 @@ func ExportFilename(scope string, scopeLabel string, generatedAt time.Time) stri
|
||||
if slug == "" {
|
||||
slug = randomSlug()
|
||||
}
|
||||
return fmt.Sprintf("paliad-export-project-%s-%s.zip", slug, ts)
|
||||
short := shortUUIDSuffix(rootID)
|
||||
if short == "" {
|
||||
return fmt.Sprintf("paliad-export-project-%s-%s.zip", slug, ts)
|
||||
}
|
||||
return fmt.Sprintf("paliad-export-project-%s-%s-%s.zip", slug, short, ts)
|
||||
default:
|
||||
return fmt.Sprintf("paliad-export-%s.zip", ts)
|
||||
}
|
||||
}
|
||||
|
||||
// shortUUIDSuffix returns the last 8 hex chars of the UUID's canonical
|
||||
// representation (the trailing block after the final dash). Empty string
|
||||
// for uuid.Nil so callers can fall back to the slug-only variant.
|
||||
func shortUUIDSuffix(id uuid.UUID) string {
|
||||
if id == uuid.Nil {
|
||||
return ""
|
||||
}
|
||||
s := id.String()
|
||||
if i := strings.LastIndex(s, "-"); i != -1 && i+1 < len(s) {
|
||||
return s[i+1:]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
var filenameSafeRegex = regexp.MustCompile(`[^A-Za-z0-9-]+`)
|
||||
|
||||
func slugifyFilename(s string) string {
|
||||
@@ -943,10 +1111,25 @@ func personalSheetQueries(actorID uuid.UUID) []sheetQuery {
|
||||
// WriteAuditRow inserts a system_audit_log row before the export runs and
|
||||
// returns the new row id. The handler PATCHes the row with file_size_bytes
|
||||
// + final row_counts on success or marks it failed on error.
|
||||
//
|
||||
// For project-scope exports the metadata jsonb carries the ltree path
|
||||
// (Q6 lock-in) so the audit row remains interpretable after a project
|
||||
// deletion: scope_root → just the UUID; metadata.root_path → the
|
||||
// ancestry. Same goes for root_label + direct_only so dashboards don't
|
||||
// need to round-trip back to paliad.projects on render.
|
||||
func (s *ExportService) WriteAuditRow(ctx context.Context, spec ExportSpec) (uuid.UUID, error) {
|
||||
meta := map[string]any{
|
||||
"requested_at": spec.GeneratedAt.UTC().Format(time.RFC3339),
|
||||
}
|
||||
if spec.Scope == ExportScopeProject {
|
||||
if spec.ScopeRootLabel != "" {
|
||||
meta["root_label"] = spec.ScopeRootLabel
|
||||
}
|
||||
if spec.ScopeRootPath != "" {
|
||||
meta["root_path"] = spec.ScopeRootPath
|
||||
}
|
||||
meta["direct_only"] = spec.DirectOnly
|
||||
}
|
||||
mb, _ := json.Marshal(meta)
|
||||
var id uuid.UUID
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
@@ -1005,3 +1188,285 @@ func (s *ExportService) PatchAuditRowFailure(ctx context.Context, id uuid.UUID,
|
||||
id, string(mb),
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Project-scope sheet registry (Slice 2).
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// Subtree-aware queries via paliad.projects.path (ltree as text). The
|
||||
// subtree predicate works on the materialised path column:
|
||||
//
|
||||
// p.path LIKE root.path || '%' -- descendants + self
|
||||
// p.path = root.path -- self only (direct_only=true)
|
||||
//
|
||||
// We use the path-prefix-LIKE form instead of ltree `<@` because the
|
||||
// schema stores path as text (the underlying ltree is materialised in
|
||||
// the projects.path column). The LIKE pattern is anchored at the start
|
||||
// and uses indexes built on path.
|
||||
//
|
||||
// Ordering: every SELECT uses ORDER BY id (or another stable tuple) so
|
||||
// byte-determinism holds across runs.
|
||||
|
||||
// projectSubtreeProjectIDsSQL returns a SQL subquery expression that
|
||||
// resolves to "the set of project ids in the subtree of $1". Use as the
|
||||
// right-hand side of `IN`. The $1 placeholder must bind the root
|
||||
// project's UUID.
|
||||
//
|
||||
// When directOnly is true, narrows to the root project itself only.
|
||||
func projectSubtreeProjectIDsSQL(directOnly bool) string {
|
||||
if directOnly {
|
||||
// Tighter: just the root, no descendants. Still framed as a
|
||||
// subquery so the outer SQL can be uniformly composed.
|
||||
return `(SELECT $1::uuid AS id)`
|
||||
}
|
||||
// Subtree = root + descendants. The materialised path column on
|
||||
// every project includes its own UUID as the trailing label, so the
|
||||
// LIKE pattern matches both the root and every descendant in one
|
||||
// expression. r.path is read from the root row keyed by $1.
|
||||
return `(
|
||||
SELECT p.id
|
||||
FROM paliad.projects p
|
||||
JOIN paliad.projects r ON r.id = $1::uuid
|
||||
WHERE p.path = r.path
|
||||
OR p.path LIKE r.path || '.%'
|
||||
)`
|
||||
}
|
||||
|
||||
// projectSheetQueries returns the sheet registry for a project-scope
|
||||
// export. rootID is bound to $1 in every query; directOnly narrows the
|
||||
// subtree to just the root project.
|
||||
//
|
||||
// Sheet inclusion follows design §2.2. Same shape as personalSheetQueries
|
||||
// but with subtree filtering instead of RLS-visibility and a tighter
|
||||
// users-disclosure profile.
|
||||
func projectSheetQueries(rootID uuid.UUID, directOnly bool) []sheetQuery {
|
||||
subtree := projectSubtreeProjectIDsSQL(directOnly)
|
||||
|
||||
queries := []sheetQuery{
|
||||
// --- entity sheets (subtree-scoped) ---
|
||||
{
|
||||
SheetName: "projects",
|
||||
SQL: `SELECT * FROM paliad.projects
|
||||
WHERE id IN ` + subtree + `
|
||||
ORDER BY id`,
|
||||
Args: []any{rootID},
|
||||
},
|
||||
{
|
||||
SheetName: "project_teams",
|
||||
SQL: `SELECT * FROM paliad.project_teams
|
||||
WHERE project_id IN ` + subtree + `
|
||||
ORDER BY project_id, user_id`,
|
||||
Args: []any{rootID},
|
||||
},
|
||||
{
|
||||
SheetName: "project_partner_units",
|
||||
SQL: `SELECT * FROM paliad.project_partner_units
|
||||
WHERE project_id IN ` + subtree + `
|
||||
ORDER BY project_id, partner_unit_id`,
|
||||
Args: []any{rootID},
|
||||
},
|
||||
{
|
||||
SheetName: "deadlines",
|
||||
SQL: `SELECT * FROM paliad.deadlines
|
||||
WHERE project_id IN ` + subtree + `
|
||||
ORDER BY id`,
|
||||
Args: []any{rootID},
|
||||
},
|
||||
{
|
||||
SheetName: "appointments",
|
||||
SQL: `SELECT * FROM paliad.appointments
|
||||
WHERE project_id IN ` + subtree + `
|
||||
ORDER BY id`,
|
||||
Args: []any{rootID},
|
||||
},
|
||||
{
|
||||
SheetName: "parties",
|
||||
SQL: `SELECT * FROM paliad.parties
|
||||
WHERE project_id IN ` + subtree + `
|
||||
ORDER BY id`,
|
||||
Args: []any{rootID},
|
||||
},
|
||||
{
|
||||
SheetName: "notes",
|
||||
SQL: `SELECT * FROM paliad.notes
|
||||
WHERE COALESCE(project_id,
|
||||
(SELECT d.project_id FROM paliad.deadlines d WHERE d.id = notes.deadline_id),
|
||||
(SELECT a.project_id FROM paliad.appointments a WHERE a.id = notes.appointment_id),
|
||||
(SELECT pe.project_id FROM paliad.project_events pe WHERE pe.id = notes.project_event_id)
|
||||
) IN ` + subtree + `
|
||||
ORDER BY id`,
|
||||
Args: []any{rootID},
|
||||
},
|
||||
{
|
||||
SheetName: "documents",
|
||||
SQL: `SELECT id, project_id, title, doc_type, file_path, file_size, mime_type, uploaded_by, created_at, updated_at
|
||||
FROM paliad.documents
|
||||
WHERE project_id IN ` + subtree + `
|
||||
ORDER BY id`,
|
||||
Args: []any{rootID},
|
||||
},
|
||||
{
|
||||
SheetName: "project_events",
|
||||
SQL: `SELECT * FROM paliad.project_events
|
||||
WHERE project_id IN ` + subtree + `
|
||||
ORDER BY id`,
|
||||
Args: []any{rootID},
|
||||
},
|
||||
{
|
||||
SheetName: "approval_requests",
|
||||
SQL: `SELECT * FROM paliad.approval_requests
|
||||
WHERE project_id IN ` + subtree + `
|
||||
ORDER BY id`,
|
||||
Args: []any{rootID},
|
||||
},
|
||||
|
||||
// Approval policies — m's Q4 lock: ship all three sources with
|
||||
// `source` attribution column so an importer can reconstruct
|
||||
// "what gate applies" without re-running paliad's resolver.
|
||||
//
|
||||
// Source 1: project rows for any project in the subtree.
|
||||
// Source 2: project rows for ancestors of the root (so a
|
||||
// descendant export still sees the gate inherited
|
||||
// from above the subtree).
|
||||
// Source 3: partner-unit-default rows for units attached to
|
||||
// any subtree project.
|
||||
//
|
||||
// One UNION query, with a `source` column tagged per branch.
|
||||
// We hand-pick the columns to keep the shape stable across the
|
||||
// three sources (approval_policies.project_id is nullable when
|
||||
// the row is a partner-unit-default, etc.).
|
||||
{
|
||||
SheetName: "approval_policies",
|
||||
SQL: `
|
||||
SELECT 'project'::text AS source,
|
||||
id, project_id, partner_unit_id, entity_type, lifecycle_event,
|
||||
required_role, requires_approval, min_role,
|
||||
created_by, created_at, updated_at
|
||||
FROM paliad.approval_policies
|
||||
WHERE project_id IN ` + subtree + `
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT 'ancestor'::text AS source,
|
||||
ap.id, ap.project_id, ap.partner_unit_id, ap.entity_type, ap.lifecycle_event,
|
||||
ap.required_role, ap.requires_approval, ap.min_role,
|
||||
ap.created_by, ap.created_at, ap.updated_at
|
||||
FROM paliad.approval_policies ap
|
||||
JOIN paliad.projects r ON r.id = $1::uuid
|
||||
WHERE ap.project_id IS NOT NULL
|
||||
AND ap.project_id <> $1::uuid
|
||||
AND ap.project_id IN (
|
||||
SELECT pa.id
|
||||
FROM paliad.projects pa
|
||||
WHERE r.path LIKE pa.path || '.%'
|
||||
)
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT 'partner_unit_default'::text AS source,
|
||||
ap.id, ap.project_id, ap.partner_unit_id, ap.entity_type, ap.lifecycle_event,
|
||||
ap.required_role, ap.requires_approval, ap.min_role,
|
||||
ap.created_by, ap.created_at, ap.updated_at
|
||||
FROM paliad.approval_policies ap
|
||||
WHERE ap.partner_unit_id IS NOT NULL
|
||||
AND ap.partner_unit_id IN (
|
||||
SELECT ppu.partner_unit_id
|
||||
FROM paliad.project_partner_units ppu
|
||||
WHERE ppu.project_id IN ` + subtree + `
|
||||
)
|
||||
|
||||
ORDER BY source, id`,
|
||||
Args: []any{rootID},
|
||||
},
|
||||
|
||||
{
|
||||
SheetName: "checklist_instances",
|
||||
SQL: `SELECT * FROM paliad.checklist_instances
|
||||
WHERE project_id IN ` + subtree + `
|
||||
ORDER BY id`,
|
||||
Args: []any{rootID},
|
||||
},
|
||||
|
||||
// --- attached partner-unit subset ---
|
||||
// Only units attached to any subtree project (avoids dumping
|
||||
// the full org chart into a per-matter handover).
|
||||
{
|
||||
SheetName: "partner_units",
|
||||
SQL: `SELECT * FROM paliad.partner_units pu
|
||||
WHERE pu.id IN (
|
||||
SELECT ppu.partner_unit_id
|
||||
FROM paliad.project_partner_units ppu
|
||||
WHERE ppu.project_id IN ` + subtree + `
|
||||
)
|
||||
ORDER BY pu.id`,
|
||||
Args: []any{rootID},
|
||||
},
|
||||
{
|
||||
SheetName: "partner_unit_members",
|
||||
SQL: `SELECT * FROM paliad.partner_unit_members pum
|
||||
WHERE pum.partner_unit_id IN (
|
||||
SELECT ppu.partner_unit_id
|
||||
FROM paliad.project_partner_units ppu
|
||||
WHERE ppu.project_id IN ` + subtree + `
|
||||
)
|
||||
ORDER BY partner_unit_id, user_id`,
|
||||
Args: []any{rootID},
|
||||
},
|
||||
|
||||
// --- restricted users sheet ---
|
||||
// Limit user disclosure to those referenced by some FK in the
|
||||
// export. Keeps a per-matter handover from leaking the full
|
||||
// firm roster (47 users → typically 3-5 per matter).
|
||||
{
|
||||
SheetName: "users_referenced",
|
||||
SQL: `SELECT id, email, display_name, office, profession
|
||||
FROM paliad.users u
|
||||
WHERE u.id IN (
|
||||
SELECT created_by FROM paliad.projects WHERE id IN ` + subtree + `
|
||||
UNION SELECT created_by FROM paliad.deadlines WHERE project_id IN ` + subtree + `
|
||||
UNION SELECT created_by FROM paliad.appointments WHERE project_id IN ` + subtree + `
|
||||
UNION SELECT created_by FROM paliad.project_events WHERE project_id IN ` + subtree + `
|
||||
UNION SELECT user_id FROM paliad.project_teams WHERE project_id IN ` + subtree + `
|
||||
UNION SELECT requested_by FROM paliad.approval_requests WHERE project_id IN ` + subtree + `
|
||||
UNION SELECT decided_by FROM paliad.approval_requests WHERE project_id IN ` + subtree + ` AND decided_by IS NOT NULL
|
||||
UNION SELECT created_by FROM paliad.notes WHERE COALESCE(project_id,
|
||||
(SELECT d.project_id FROM paliad.deadlines d WHERE d.id = notes.deadline_id),
|
||||
(SELECT a.project_id FROM paliad.appointments a WHERE a.id = notes.appointment_id),
|
||||
(SELECT pe.project_id FROM paliad.project_events pe WHERE pe.id = notes.project_event_id)
|
||||
) IN ` + subtree + `
|
||||
UNION SELECT uploaded_by FROM paliad.documents WHERE project_id IN ` + subtree + ` AND uploaded_by IS NOT NULL
|
||||
UNION SELECT user_id FROM paliad.partner_unit_members pum
|
||||
WHERE pum.partner_unit_id IN (
|
||||
SELECT ppu.partner_unit_id
|
||||
FROM paliad.project_partner_units ppu
|
||||
WHERE ppu.project_id IN ` + subtree + `
|
||||
)
|
||||
)
|
||||
ORDER BY id`,
|
||||
Args: []any{rootID},
|
||||
},
|
||||
|
||||
// --- system_audit_log subset (the export's own audit trail) ---
|
||||
// Includes prior export events scoped to this subtree's
|
||||
// projects — lets a recipient see "who has previously
|
||||
// exported this matter".
|
||||
{
|
||||
SheetName: "system_audit_log_subset",
|
||||
SQL: `SELECT * FROM paliad.system_audit_log
|
||||
WHERE scope_root IN ` + subtree + `
|
||||
ORDER BY created_at, id`,
|
||||
Args: []any{rootID},
|
||||
},
|
||||
|
||||
// --- reference data (same set as personal scope) ---
|
||||
{SheetName: "ref__proceeding_types", SQL: `SELECT * FROM paliad.proceeding_types ORDER BY id`},
|
||||
{SheetName: "ref__event_types", SQL: `SELECT * FROM paliad.event_types ORDER BY id`},
|
||||
{SheetName: "ref__event_categories", SQL: `SELECT * FROM paliad.event_categories ORDER BY id`},
|
||||
{SheetName: "ref__deadline_rules", SQL: `SELECT * FROM paliad.deadline_rules ORDER BY id`},
|
||||
{SheetName: "ref__deadline_concepts", SQL: `SELECT * FROM paliad.deadline_concepts ORDER BY id`},
|
||||
{SheetName: "ref__courts", SQL: `SELECT * FROM paliad.courts ORDER BY id`},
|
||||
{SheetName: "ref__countries", SQL: `SELECT * FROM paliad.countries ORDER BY code`},
|
||||
{SheetName: "ref__holidays", SQL: `SELECT * FROM paliad.holidays ORDER BY date, country`},
|
||||
}
|
||||
return queries
|
||||
}
|
||||
|
||||
@@ -269,22 +269,51 @@ func TestMetaToKeyValueRows_StableOrder(t *testing.T) {
|
||||
|
||||
func TestExportFilename_PerScope(t *testing.T) {
|
||||
ts := time.Date(2026, 5, 19, 14, 23, 0, 0, time.UTC)
|
||||
// Project-scope filenames carry an 8-hex disambiguator (last UUID
|
||||
// block); personal + org omit it.
|
||||
rootID := uuid.MustParse("61e3fb9e-29fb-44aa-867e-a89469e2cacb")
|
||||
cases := []struct {
|
||||
scope, label, want string
|
||||
scope, label string
|
||||
id uuid.UUID
|
||||
want string
|
||||
}{
|
||||
{ExportScopePersonal, "", "paliad-export-personal-2026-05-19T1423Z.zip"},
|
||||
{ExportScopeOrg, "", "paliad-export-org-2026-05-19T1423Z.zip"},
|
||||
{ExportScopeProject, "Siemens AG", "paliad-export-project-Siemens-AG-2026-05-19T1423Z.zip"},
|
||||
{ExportScopeProject, "Hügel & Söhne", "paliad-export-project-H-gel-S-hne-2026-05-19T1423Z.zip"},
|
||||
{ExportScopePersonal, "", uuid.Nil, "paliad-export-personal-2026-05-19T1423Z.zip"},
|
||||
{ExportScopeOrg, "", uuid.Nil, "paliad-export-org-2026-05-19T1423Z.zip"},
|
||||
{ExportScopeProject, "Siemens AG", rootID, "paliad-export-project-Siemens-AG-a89469e2cacb-2026-05-19T1423Z.zip"},
|
||||
{ExportScopeProject, "Hügel & Söhne", rootID, "paliad-export-project-H-gel-S-hne-a89469e2cacb-2026-05-19T1423Z.zip"},
|
||||
// Nil UUID falls back to the slug-only variant — same as Slice 1's
|
||||
// pre-disambiguator filename. Useful for unit tests of label-only
|
||||
// behaviour.
|
||||
{ExportScopeProject, "Siemens AG", uuid.Nil, "paliad-export-project-Siemens-AG-2026-05-19T1423Z.zip"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := ExportFilename(c.scope, c.label, ts)
|
||||
got := ExportFilename(c.scope, c.label, c.id, ts)
|
||||
if got != c.want {
|
||||
t.Errorf("ExportFilename(%q, %q) → %q, want %q", c.scope, c.label, got, c.want)
|
||||
t.Errorf("ExportFilename(%q, %q, %q) → %q, want %q", c.scope, c.label, c.id, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportFilename_ShortUUIDDisambiguator(t *testing.T) {
|
||||
// Two projects with identical titles must produce different filenames
|
||||
// when the UUID suffix is present — that's the whole point of Q5's
|
||||
// disambiguator.
|
||||
ts := time.Date(2026, 5, 19, 14, 23, 0, 0, time.UTC)
|
||||
idA := uuid.MustParse("11111111-1111-1111-1111-aaaaaaaaaaaa")
|
||||
idB := uuid.MustParse("22222222-2222-2222-2222-bbbbbbbbbbbb")
|
||||
a := ExportFilename(ExportScopeProject, "Standard NDA", idA, ts)
|
||||
b := ExportFilename(ExportScopeProject, "Standard NDA", idB, ts)
|
||||
if a == b {
|
||||
t.Fatalf("same-title same-ts filenames collide: %q", a)
|
||||
}
|
||||
if !strings.Contains(a, "aaaaaaaaaaaa") {
|
||||
t.Errorf("filename missing UUID-A suffix: %q", a)
|
||||
}
|
||||
if !strings.Contains(b, "bbbbbbbbbbbb") {
|
||||
t.Errorf("filename missing UUID-B suffix: %q", b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlugifyFilename_StripsUnsafe(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
{"Siemens AG", "Siemens-AG"},
|
||||
|
||||
Reference in New Issue
Block a user