Compare commits

..

7 Commits

Author SHA1 Message Date
mAi
716f6d7ece fix(events): t-paliad-255 — kill /events horizontal scroll on mobile
A native <select> sizes itself to the widest <option> text. With long
project titles in the matters filter, the select grew wider than the
viewport and the /events page scrolled horizontally on mobile.

The existing 480px media query forced .entity-select to width:100% on
phones, but the 481-1000px range (tablet portrait + landscape phones)
had no constraint at all and inherited the intrinsic select width.

Fix: cap .filter-group and .entity-select at max-width:100% with
min-width:0 so the cell can shrink to fit its flex container at every
viewport. Desktop layout is preserved — normal-length options still
sit in one row across the page; only pathological content (a single
title wider than the row) wraps onto its own line.

Approach: A — let the trigger respect its container at every width.

Verified: zero horizontal scroll at 320 / 375 / 414 / 768 px with a
realistic 130-character project title injected into the matters
selector. Desktop (1280px) keeps all four filter-groups in one row.
2026-05-25 14:08:44 +02:00
mAi
206f2917ea Merge: t-paliad-253 — Submissions /generate runs the merge engine (m/paliad#84) 2026-05-25 13:55:14 +02:00
mAi
5df87f4129 fix(submissions): t-paliad-253 — /generate runs the merge engine
The "Generieren" button on the project Schriftsätze tab posts to
/api/projects/{id}/submissions/{code}/generate. Pre-fix that handler
called `fetchHLPatentsStyleBytes` unconditionally and streamed the
result after a format-only .dotm→.docx convert — it never touched
`submissionTemplateRegistry` (added in t-paliad-241 for the draft
editor) and never ran the SubmissionRenderer merge. m's report on
m/paliad#84 ("the document generator still has no variables in the
template") was the lawyer-facing manifestation: HL Patents Style has
no {{…}} placeholders, so the downloaded .docx had nothing to
substitute and looked like a generic firm-style fixture.

The "Bearbeiten" path (/projects/{id}/submissions/{code}/draft) was
unaffected — it uses `resolveSubmissionTemplate` + the renderer
already, which is why the editor preview shows the 48 placeholders
resolved correctly. Only the one-click /generate side missed the
wire-up.

Fix:

- `internal/services/submission_draft_service.go` — add
  `RenderProjectSubmission(ctx, userID, projectID, submissionCode,
  templateBytes)` that wraps `vars.Build` + `renderer.Render` for the
  no-saved-draft path. Returns the merged bytes plus the resolved
  SubmissionVarsResult (rule, project, user, lang) so the handler can
  derive filename + audit metadata without a second DB round-trip.

- `internal/handlers/submissions.go` — rewrite
  `handleGenerateProjectSubmission` to resolve the template via
  `resolveSubmissionTemplate` (per-firm slug → HL Patents Style
  fallback, same as the editor draft) and run the new service method.
  Visibility / rule-not-found semantics route through
  `SubmissionVarsService` errors so the gate behavior matches every
  other project endpoint. Removed `loadPublishedRuleByCode` and
  `errRuleNotFound` — both were only used by the old handler.

- `scripts/gen-demo-submission-template/main.go` + the regenerated
  `de.inf.lg.erwidg.docx` on mWorkRepo (HL/mWorkRepo @ 3e3e828f) now
  exercise the bare `{{today}}` alias too. The demo template covers
  every one of the 48 keys SubmissionVarsService can resolve (firm 2,
  today 4, user 3, project 18, parties 6, rule 8, deadline 7).

The renderer is a no-op on placeholder substitution when the
fallback HL Patents Style is fetched (it has none) — but it still
runs the .dotm→.docx pre-pass via `ConvertDotmToDocx`, so the
non-per-firm code path streams a byte-for-byte equivalent download.

Build + vet + tests clean (go test ./internal/...; bun run build).
2026-05-25 13:51:45 +02:00
mAi
898348a64a Merge: t-paliad-245 — Daten Exportieren demoted into Verwaltung tab (m/paliad#76) 2026-05-25 13:34:53 +02:00
mAi
1714b788d2 feat(projects-detail): t-paliad-245 — demote Daten Export into Verwaltung tab
m/paliad#76. The export button no longer pokes out of the tabs nav with a
non-tab styling — instead it lives inside a new "Verwaltung" tab (last in
the project tab list) as a normal section with heading, description, and a
plain btn-secondary trigger. Same gate as before (canExportProject).

Archive co-locates in the same tab as a pointer to the Edit-modal danger
zone: click "Bearbeiten öffnen" → modal opens scrolled to the archive
button. Single source of truth for the destructive action stays in the
modal; the Verwaltung pointer just gives it discoverability.

If neither sub-section is visible to the caller (no export entitlement,
not global_admin), the Verwaltung tab hides itself — an empty tab is
worse UX than no tab.
2026-05-25 13:33:14 +02:00
mAi
db8335253b Merge: t-paliad-244 — Team View mailto: link for non-admin members (m/paliad#75) 2026-05-25 13:31:52 +02:00
mAi
5589cbb477 mAi: #75 - team view mailto: link for non-admin members
t-paliad-244 / m/paliad#75. Both "E-Mail an Auswahl senden" actions on
/team (filter-bar + bottom selection footer) now branch on canBroadcast():
- Admin path keeps the in-app compose modal (POST /api/team/broadcast).
- Non-admin path renders a native <a href="mailto:..."> with the
  recipient list pre-filled, comma-joined and URL-encoded via
  buildMailtoHref (already exported from broadcast.ts).

Filter-bar button used to hide for non-admins; it now shows as the
mailto: anchor and its href refreshes on every filter change so the link
always matches what's visible. Empty visible set disables the affordance
visually (aria-disabled + pointer-events:none) so a click can't open an
empty composer. Bottom selection footer mirrors the same shape.

No new i18n keys, no backend changes, admin compose flow untouched.
2026-05-25 13:30:32 +02:00
10 changed files with 263 additions and 860 deletions

View File

@@ -1,726 +0,0 @@
# Paliad Backup Mode — system-wide admin "Admin Excel" snapshot
Design: cronus (inventor), 2026-05-25.
Task: **t-paliad-246** / m/paliad#77.
Branch: `mai/cronus/inventor-backup-mode`.
Status: READY FOR REVIEW — no code yet, awaiting head/m go-no-go on §11 material picks.
---
## 0. TL;DR
Paliad already ships a per-scope data export (`/api/me/export`, `/api/projects/{id}/export`) built on `ExportService` with sheet-registry + xlsx + JSON + CSV bundling and a `system_audit_log` audit chain. Slice 3 (`org` scope, async) was the last unbuilt slice of t-paliad-214 — and that is exactly what "Backup Mode" wants, **plus** Slice 4 (scheduler). This design folds them together:
- **`org` scope of `ExportService`** — new `WriteOrg` + `orgSheetQueries(...)` registry. No new service.
- **On-demand admin trigger** — `POST /api/admin/backups/run` from `/admin/backups`, gated by the existing `adminGate(users, ...)` middleware.
- **Nightly scheduled trigger** — new `BackupScheduler` goroutine modelled on `ReminderService` (top-of-hour ticker, fires at 03:00 UTC).
- **Persisted artifact catalog** — new table `paliad.backups` (one row per backup, `kind ∈ {scheduled, on_demand}`, status, storage URL, size, row counts).
- **Storage** — open. (R from issue) Supabase Storage bucket `paliad-backups` with 90-day lifecycle; (R from prior design) local disk `PALIAD_EXPORT_DIR`. **Material pick — escalated.** Defaulting to **Supabase Storage** in this draft because m's issue text named it explicitly; m's prior pick favoured local disk to avoid object-store provisioning. See §11 Q1.
- **Format** — open. (R from issue) single `.xlsx`; (R from prior design + shipped infra) `.zip` of xlsx + JSON + CSV. **Material pick — escalated.** Defaulting to **`.zip`** in this draft because (a) the writer abstraction already produces a zip, (b) JSON twin is the no-lock-in promise, (c) "Admin Excel" framing is satisfied by the xlsx-inside-the-zip. See §11 Q2.
- **`paliadin_turns`** — open. (R from issue) "no redaction for admin"; (R from t-paliad-214 m decision) "never include in org export." **Material pick — escalated**, but defaulting to **EXCLUDE** because the prior decision was explicit, structural, and the most-sensitive-PII rationale still holds. See §11 Q3.
- **Audit chain** — write to `paliad.system_audit_log` with `event_type ∈ {backup_created, backup_downloaded, backup_failed}`. No new audit infra.
Three slices: **A** (on-demand + workbook) ships the whole pipeline against the existing audit chain. **B** (scheduler + storage) adds nightly automation and the chosen artifact store. **C** (admin UI polish) makes `/admin/backups` a proper catalog browser.
---
## 1. Premises verified live (2026-05-25)
Verified against the codebase, not memory.
### 1.1 What's already shipped (t-paliad-214 Slices 1 + 2)
- `internal/services/export_service.go` — 1472 lines. `ExportService` with `WritePersonal`, `WriteProject`, sheet-registry abstraction (`sheetQuery`, `collectedSheet`), `buildXLSX/JSON/CSV/README` writers, `writeBundle` outer-zip writer, `WriteAuditRow` + `PatchAuditRowSuccess` + `PatchAuditRowFailure`.
- `internal/handlers/export.go` — 290 lines. `GET /api/me/export` (personal), `GET /api/projects/{id}/export?direct_only=0|1` (project). Audit row written **before** the artifact generation; patched on success/failure.
- `paliad.system_audit_log` (mig 102, shipped) — generic `event_type` text + `metadata jsonb`; `scope ∈ {org, project, personal}`; `actor_id` FK to `paliad.users` with `ON DELETE SET NULL`, `actor_email` captured at write time. RLS: self-read + admin-read. **Already supports `scope='org'`** — backup writes land here trivially.
- `github.com/xuri/excelize/v2 v2.10.1` — already in `go.mod`. No new xlsx dependency needed.
- Reference design doc: `docs/design-paliad-data-export-2026-05-19.md` — exhaustive (~600 lines), with m's Q-by-Q decisions captured in §12.
- `adminGate(users, gateOnboarded(h))` — the standard admin gate (10+ admin routes; e.g. `handlers.go:567-598`). Just wire the new routes through it.
### 1.2 What's NOT shipped
- **No `WriteOrg`** in `ExportService`. No `orgSheetQueries(...)` registry. The org scope is declared in the `ExportScope*` constants but no handler/writer references it yet.
- **No `PALIAD_EXPORT_DIR`** env var. No `/var/lib/paliad/exports/` mount on Dokploy. Greenfield on storage.
- **No Supabase Storage Go client** in `go.mod`. No `STORAGE_*` env vars. The Go side talks Postgres-only today; Supabase Storage would be a new HTTP-client dependency.
- **No backup scheduler.** The only in-process scheduler is `ReminderService` (`internal/services/reminder_service.go`) — top-of-hour aligned ticker with startup catch-up. Same shape works for backups.
- **No `paliad.backups`** table. The system_audit_log row alone is too sparse to back a "list past backups + click to download" UI; a dedicated catalog table is wanted.
### 1.3 New `paliad.*` tables since t-paliad-214 design (mig 103-120)
The org-scope sheet registry must enumerate these or fall behind reality. Mostly straightforward additions:
- `approval_suggest_changes` (103)
- `user_dashboard_layouts` (109)
- `user_checklists`, `checklist_shares`, `checklist_versioning_*` (114-116)
- `firm_dashboard_default` (117)
- `paliadin_aichat_conversation` (118) — sensitive, treat like `paliadin_turns`
- `submission_drafts` (119/120) — content potentially sensitive (draft pleading text)
The coder enumerates `information_schema.tables WHERE table_schema='paliad'` at registry-build time and reconciles with the static list, raising a warning when the live schema introduces a table the registry doesn't know about (forces a registry update on every new migration touching paliad data).
### 1.4 Live row counts (informational)
Per the t-paliad-214 design's premise check (2026-05-19), full-org content was **< 600 user-content rows** + ~1000 reference rows. Total xlsx after compression: ~100 KB. A daily snapshot at this size is cheap to store, cheap to transfer, and well below any storage-lifecycle threshold.
### 1.5 Conclusion
**No new service abstraction is needed.** `ExportService` is the right home; extend it with `WriteOrg` and a small handler + scheduler + catalog table around it.
---
## 2. m's decisions (2026-05-25, via head)
m walked the 4 material picks from §11 via the head; relayed back at 15:10 Berlin. All four landed; no follow-up round-trip needed.
- **Q1 Storage backend: local disk `PALIAD_EXPORT_DIR`.** **Deviation** from the inventor draft (Supabase Storage). m's call "for now" skip the Supabase Storage HTTP-client dep entirely for v1. Single env var, same shape as t-paliad-214 §6.1 originally specified. Disaster-recovery argument deferred; backup volume can move to Supabase Storage later by switching the `ArtifactStore` implementation under the unchanged interface (the §3.2 abstraction was designed for exactly this).
- **Q2 Bundle format: `.zip`** (xlsx + JSON + CSV + README). matches the inventor draft. No-lock-in promise preserved.
- **Q3 `paliadin_turns` + `paliadin_aichat_conversation`: EXCLUDE.** matches the inventor draft and defers to m's prior t-paliad-214 Q5 decision. Structural exclusion in `orgSheetQueries()` (the registry doesn't list those tables at all not just a column-level drop).
- **Q4 Scheduler cadence: nightly 03:00 UTC**, env-tunable `PALIAD_BACKUP_HOUR_UTC`. matches the inventor draft.
### Net effect on the design
- **§3.2 `ArtifactStore`:** ship `LocalDiskStore` only. `SupabaseStorageStore` removed from Slice B; the interface stays so a future swap is one impl away.
- **§3 architecture / §5.3 `BackupRunner`:** unchanged. `store.Put` writes to local disk.
- **Env-var set shrinks:** drop `PALIAD_BACKUP_STORAGE`, `SUPABASE_SERVICE_ROLE_KEY` (storage path), `PALIAD_BACKUP_BUCKET`. Keep `PALIAD_EXPORT_DIR` (default `/var/lib/paliad/exports`), `PALIAD_BACKUP_HOUR_UTC` (default `3`), `PALIAD_BACKUP_RETENTION_DAYS` (default `90`).
- **§9 slice plan:** Slice A is fully unblocked (it was always local-disk-only). Slice B's storage half collapses to "cleanup goroutine + retention" no Supabase impl to write.
### Coder shift gating
Head also authorised the coder shift for the same worker (cronus) on a fresh branch `mai/cronus/coder-backup-mode` off main. Slice A only this shift; Slices B + C land as separate follow-up tasks per head's call. Implementation details (migration slot, audit `event_type` casing, hard rules) per head's instruction message in the mai inbox.
---
## 3. Architecture
```
┌──────────────────────────────────────────────────────────────────┐
│ Trigger surfaces │
│ │
│ (a) on-demand: POST /api/admin/backups/run │
│ ─ admin only via adminGate(users, ...) │
│ ─ enqueues a backup job, returns 202 + {job_id, audit_id} │
│ │
│ (b) scheduled: BackupScheduler.Start(ctx) │
│ ─ top-of-hour ticker, fires at PALIAD_BACKUP_HOUR_UTC=3 │
│ ─ runs the same code path as (a) under a synthetic admin id │
│ ("system" user, FIRM_NAME), kind='scheduled' │
└─────────────────────────┬────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────┐
│ BackupRunner (the job body, shared by (a) + (b)) │
│ │
│ 1. INSERT paliad.backups (status='running', kind, requested_by) │
│ INSERT paliad.system_audit_log │
│ (event_type='backup_created', scope='org', metadata=…) │
│ │
│ 2. BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ │
│ (snapshot consistency for the dump — see §3.3) │
│ │
│ 3. ExportService.WriteOrg(ctx, &buf, spec) │
│ ─ orgSheetQueries(): registry of every paliad.* sheet │
│ ─ uses the existing writeBundle() to produce a .zip OR │
│ writes a bare .xlsx if §11 Q2 lands on xlsx-only │
│ ─ no can_see_project predicate (org scope bypasses RLS via │
│ the service-role DB handle paliad already uses) │
│ │
│ 4. ROLLBACK (read-only tx) │
│ │
│ 5. ArtifactStore.Put(ctx, key, body) — see §3.2 │
│ │
│ 6. UPDATE paliad.backups SET status='done', storage_uri=…, │
│ size_bytes=…, row_counts=…, finished_at=now() │
│ UPDATE paliad.system_audit_log metadata │
│ (row_counts, file_size_bytes, storage_uri) │
│ │
│ On failure at any step ≥ 3: │
│ UPDATE paliad.backups SET status='failed', error=… │
│ INSERT paliad.system_audit_log │
│ (event_type='backup_failed', scope='org', metadata={error}) │
└──────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────┐
│ Catalog + download │
│ │
│ GET /admin/backups — page (chronological list) │
│ GET /api/admin/backups — JSON list of paliad.backups │
│ GET /api/admin/backups/{id} — single row + download URL │
│ GET /api/admin/backups/{id}/file — streams the artifact; │
│ writes event_type= │
│ 'backup_downloaded' to audit │
└──────────────────────────────────────────────────────────────────┘
```
### 3.1 Why extend `ExportService` and not introduce `BackupService`
m's issue text uses the noun "BackupService" once. That naming pulls a separate code path into the tree. But functionally, a "backup" is exactly what t-paliad-214 called an `org`-scope export same sheets, same writer, same audit table, same xlsx library. Splitting into two services creates two parallel registries that drift apart on every new migration (the patterns we just fixed in §1.3).
**Pick: extend `ExportService`.** New methods:
- `WriteOrg(ctx, w, spec) (ExportMeta, error)` mirror of `WritePersonal`/`WriteProject`.
- `orgSheetQueries() []sheetQuery` the full-schema registry 7).
The "backup" branding lives at the **handler + UI + scheduler + catalog table** layer. The data-generation layer stays unified.
### 3.2 Storage abstraction (`ArtifactStore`)
To survive the §11 Q1 decision without rewriting the handler, isolate storage behind a small interface:
```go
type ArtifactStore interface {
Put(ctx context.Context, key string, body io.Reader, size int64) (uri string, err error)
Get(ctx context.Context, uri string) (io.ReadCloser, int64, error)
Delete(ctx context.Context, uri string) error
// List is only needed for the cleanup goroutine; can scan via the
// catalog table instead, so this is optional.
}
```
Two implementations:
- **`LocalDiskStore`** writes to `$PALIAD_EXPORT_DIR/{backup_id}.{ext}`. `uri` = `file://...`. The bind-mount lives on the Dokploy host (Hostinger VPS disk encryption at-rest). Configure via `PALIAD_EXPORT_DIR`.
- **`SupabaseStorageStore`** uploads via Supabase Storage REST (`POST /storage/v1/object/paliad-backups/{key}`). `uri` = `supabase://paliad-backups/{key}` (resolved to a signed URL at download time). Configure via `SUPABASE_URL` + `SUPABASE_SERVICE_ROLE_KEY` (env var only used by the backend; never sent to clients) + `PALIAD_BACKUP_BUCKET` (default `paliad-backups`).
Pick **one** at boot via `PALIAD_BACKUP_STORAGE ∈ {local, supabase}`. Default = whichever §11 Q1 lands on. The handler and scheduler are oblivious they just call `ArtifactStore.Put(...)`.
The interface also keeps the door open for a future S3/MinIO store without touching the runner.
### 3.3 Snapshot consistency (REPEATABLE READ)
Without a snapshot tx, a backup that runs while users are editing produces an internally inconsistent workbook (e.g. a `deadlines` row references a `project_id` that the `projects` sheet just deleted). At paliad's data shape today the window is tiny, but the failure mode is silent and the whole point of a backup is that you can trust it.
**Pick: wrap the entire read pass in `BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ; ... ROLLBACK;`.** No data is mutated by the backup, so the rollback is just bookkeeping. Postgres holds a consistent snapshot for the duration; concurrent writers see no lock contention (snapshot is per-tx).
Cost: an open read-only tx for ~1-2s at firm-scale, ~minutes at thousands-of-projects scale. Acceptable.
### 3.4 What runs the scheduled backup as
The scheduled backup needs an `actor_id` for the audit row. We don't have a "system" user today. Two options:
- **Use the `FIRM_NAME` env value as `actor_email`** with `actor_id = NULL` (the FK is `ON DELETE SET NULL`, NULL is allowed at write too). The audit row reads `system / HLC`.
- **Seed a `paliad.users` row** with `email='system@<firm-domain>'`, `display_name='Paliad Backup System'`, `global_role='global_admin'`, never bound to Supabase Auth.
**Pick: option 1 (NULL `actor_id`, `actor_email='system@paliad'`).** Avoids polluting the user list with a phantom; the audit row stays auditable. The `paliad.backups` table mirrors this with a nullable `requested_by` and a `kind='scheduled'` discriminator that any UI can use to render "system" instead of an empty user pill.
---
## 4. Schema additions
One new table, one migration (mig 121, the next free slot per `internal/db/migrations/120_*` being the latest).
```sql
-- 121_backups.up.sql
SELECT set_config(
'paliad.audit_reason',
'mig 121: add paliad.backups catalog table for Backup Mode (t-paliad-246)',
true);
CREATE TABLE IF NOT EXISTS paliad.backups (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
kind text NOT NULL CHECK (kind IN ('scheduled', 'on_demand')),
status text NOT NULL CHECK (status IN ('running', 'done', 'failed')),
requested_by uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
-- requested_by_email captured at write time so the row survives user-deletion
requested_by_email text NOT NULL,
-- Pointer back into system_audit_log for cross-reference. Nullable so a
-- backup row can be inserted even if audit write somehow fails first.
audit_id uuid REFERENCES paliad.system_audit_log(id) ON DELETE SET NULL,
storage_uri text, -- NULL until status='done'
size_bytes bigint, -- NULL until status='done'
row_counts jsonb NOT NULL DEFAULT '{}'::jsonb,
sheet_count int, -- NULL until status='done'
warnings jsonb NOT NULL DEFAULT '[]'::jsonb,
error text, -- NULL unless status='failed'
started_at timestamptz NOT NULL DEFAULT now(),
finished_at timestamptz,
-- deleted_at marks artifacts the lifecycle cleanup removed from storage
-- (the catalog row stays forever — it's part of the audit chain). Without
-- this column we can't distinguish "still on disk" from "expired".
deleted_at timestamptz
);
CREATE INDEX IF NOT EXISTS backups_started_at_desc_idx
ON paliad.backups (started_at DESC);
CREATE INDEX IF NOT EXISTS backups_kind_status_idx
ON paliad.backups (kind, status);
ALTER TABLE paliad.backups ENABLE ROW LEVEL SECURITY;
-- Admin-only read (consistent with system_audit_log_select_admin).
DROP POLICY IF EXISTS backups_select_admin ON paliad.backups;
CREATE POLICY backups_select_admin ON paliad.backups
FOR SELECT USING (
EXISTS (
SELECT 1 FROM paliad.users u
WHERE u.id = auth.uid() AND u.global_role = 'global_admin'
)
);
-- No INSERT/UPDATE/DELETE policies: all writes go through the Go service path
-- under the migration-runner role (the same role that writes to
-- system_audit_log).
COMMENT ON TABLE paliad.backups IS
'Catalog of org-scope backup runs. One row per scheduled or on-demand backup. status transitions: running → done | failed. storage_uri resolves through the ArtifactStore interface (local file:// or supabase://). audit_id links to system_audit_log; the catalog row is duplicate-with-richer-shape, the audit row is the trust signal.';
```
**Why a separate catalog and not just the audit row?**
- The `/admin/backups` UI needs row-count / size / status as columns, not as nested `metadata` JSON. Reading `system_audit_log` and parsing JSON for every list-row is the wrong shape.
- The catalog has a distinct `kind` discriminator (scheduled vs on_demand) the audit doesn't model that today, and bolting it onto generic `event_type` is fragile.
- The catalog's `deleted_at` separates artifact-lifecycle from audit-retention cleanly. Audit rows are eternal; catalog rows mark when the artifact disappeared.
**No other migrations.** `system_audit_log` already has the right shape; we just write new `event_type` values into it.
---
## 5. Service layer
### 5.1 `ExportService.WriteOrg`
```go
// WriteOrg streams the full-schema org-scope bundle into w. Returns the
// meta (incl. row_counts) for the catalog row + audit-row patching.
//
// Bypasses paliad.can_see_project — this is admin-only and runs under the
// service-role DB handle. The handler/scheduler is responsible for the
// admin gate before calling this method.
//
// Wraps the entire read pass in a REPEATABLE READ transaction (see
// design-backup-mode-2026-05-25.md §3.3).
func (s *ExportService) WriteOrg(ctx context.Context, w io.Writer, spec ExportSpec) (ExportMeta, error) {
if spec.Scope != ExportScopeOrg {
return ExportMeta{}, fmt.Errorf("WriteOrg: wrong scope %q", spec.Scope)
}
tx, err := s.db.BeginTxx(ctx, &sql.TxOptions{
Isolation: sql.LevelRepeatableRead,
ReadOnly: true,
})
if err != nil { return ExportMeta{}, fmt.Errorf("backup tx: %w", err) }
defer tx.Rollback()
sheets := orgSheetQueries()
meta, err := s.writeBundleWithTx(ctx, tx, w, sheets, spec) // mirrors writeBundle
if err != nil { return ExportMeta{}, err }
return meta, nil
}
```
The existing `writeBundle` takes a `*sqlx.DB`; we factor a `runSheetQueryWithTx` variant that takes `*sqlx.Tx`. The xlsx/JSON/CSV writers are pure (no DB) so they're untouched.
### 5.2 `orgSheetQueries()`
Returns the full registry. Mirrors `personalSheetQueries` and `projectSheetQueries` in shape. Sheet ordering: entity sheets alphabetical, then `ref__*` reference sheets alphabetical, then `__meta` (handled separately by writeBundle).
Concrete sheet list (post-§11 Q3 resolution; default EXCLUDE paliadin_turns):
```
appointments
approval_policies
approval_requests
approval_suggest_changes (new since t-paliad-214)
backups (the catalog itself — self-reflexive, low rows, useful for backup-of-backups)
caldav_sync_log
checklist_instances
checklist_shares (new)
checklist_versions (new)
deadlines
documents (metadata only, ai_extracted dropped)
email_broadcasts
email_templates
email_template_versions
firm_dashboard_default (new)
invitations (without raw tokens — Q7 from t-paliad-214)
notes
parties
partner_unit_events
partner_unit_members
partner_units
project_events
project_partner_units
project_teams
projects
reminder_log
submission_drafts (new — see §6 redaction note if §11 Q3 picks redact)
system_audit_log
user_caldav_config (without encrypted_password — covered by piiColumnDenyRegex)
user_card_layouts
user_checklists (new)
user_dashboard_layouts (new)
user_pinned_projects
user_preferences
user_views
users
-- ref:
ref__countries
ref__courts
ref__deadline_concept_event_types
ref__deadline_concepts
ref__deadline_event_types
ref__deadline_rules
ref__event_categories
ref__event_category_concepts
ref__event_types
ref__holidays
ref__proceeding_types
ref__trigger_events
```
**Excluded unconditionally:**
- `paliadin_turns`, `paliadin_aichat_conversation` per §11 Q3 default (prior m decision).
- `auth.*` not ours.
- `paliad.paliad_schema_migrations` operational, no business meaning.
- Any `*_pre_NNN` shadow / pre-migration tables duplicates of live tables.
- Anything matched by the existing `piiColumnDenyRegex` (passwords/tokens/secrets/api keys/private keys) at column-discovery time.
### 5.3 `BackupRunner`
A new file `internal/services/backup_service.go` holds the orchestration the handler + scheduler share:
```go
// BackupRunner orchestrates one backup run. Used by both the on-demand
// handler and the scheduled goroutine.
type BackupRunner struct {
db *sqlx.DB
export *ExportService
store ArtifactStore
}
// Run performs one backup. Writes catalog + audit rows; uploads to storage;
// returns the catalog row id on success.
//
// kind discriminates 'scheduled' vs 'on_demand'.
// actor: for on_demand, the calling admin's id+email; for scheduled, NULL+system.
func (r *BackupRunner) Run(ctx context.Context, kind string, actor Actor) (uuid.UUID, error)
// Actor is the (caller-id, email, label) tuple for audit + catalog writes.
type Actor struct {
ID *uuid.UUID
Email string
Label string
}
```
### 5.4 `BackupScheduler`
```go
// BackupScheduler fires a scheduled org backup once per day at the
// configured UTC hour. Modelled on ReminderService: top-of-hour aligned
// ticker + startup catch-up + dedup via paliad.backups.
type BackupScheduler struct {
runner *BackupRunner
hour int // 0-23, from PALIAD_BACKUP_HOUR_UTC (default 3)
clock func() time.Time
}
func (s *BackupScheduler) Start(ctx context.Context) // spawns goroutine
// On every tick: if local hour == s.hour AND no 'done'/'running' backup
// exists for today's UTC date AND kind='scheduled' → spawn r.Run.
// Startup catch-up: same check, fires immediately if today's slot has
// already passed but no row exists yet (covers redeploys).
```
Reuses the `nextTopOfHour` + `loop` shape from `ReminderService`. Default hour = 3 (UTC).
### 5.5 `ArtifactStore` implementations
`internal/services/artifact_store.go` defines the interface + the two impls. ~150 LoC each.
- `LocalDiskStore` is straightforward: `os.WriteFile`, `os.Open`, `os.Remove`. The directory is created at boot (`os.MkdirAll($PALIAD_EXPORT_DIR, 0700)`).
- `SupabaseStorageStore` uses `net/http` against `${SUPABASE_URL}/storage/v1/object/${bucket}/${key}` with the service-role key in the `Authorization` header. No SDK dependency plain REST. The download path issues a signed URL via `POST /storage/v1/object/sign/${bucket}/${key}` so the browser fetches directly from Storage without proxying through paliad. Signed-URL TTL = 5 min (single-shot click-to-download).
---
## 6. UI surface
### 6.1 `/admin/backups` page
New page registered alongside the existing admin routes (`handlers.go:567-598` is the admin block):
```
protected.HandleFunc("GET /admin/backups", adminGate(users, gateOnboarded(handleAdminBackupsPage)))
protected.HandleFunc("POST /api/admin/backups/run", adminGate(users, handleAdminRunBackup))
protected.HandleFunc("GET /api/admin/backups", adminGate(users, handleAdminListBackups))
protected.HandleFunc("GET /api/admin/backups/{id}", adminGate(users, handleAdminGetBackup))
protected.HandleFunc("GET /api/admin/backups/{id}/file", adminGate(users, handleAdminDownloadBackup))
```
Page layout (rough):
```
Admin · Backups [+ Backup jetzt erstellen]
Aktuelle Backups
┌──────────────┬────────────┬───────────┬─────────┬────────────┬──────────┐
│ Erstellt │ Kind │ Status │ Größe │ Zeilen │ Aktion │
├──────────────┼────────────┼───────────┼─────────┼────────────┼──────────┤
│ 2026-05-25 │ Geplant │ ✓ Fertig │ 142 KB │ 1842 │ Download │
│ 03:00 UTC │ │ │ │ │ │
├──────────────┼────────────┼───────────┼─────────┼────────────┼──────────┤
│ 2026-05-24 │ Manuell │ ✓ Fertig │ 138 KB │ 1793 │ Download │
│ 14:22 UTC │ │ │ │ (m@hl…) │ │
├──────────────┼────────────┼───────────┼─────────┼────────────┼──────────┤
│ 2026-05-24 │ Geplant │ ✓ Fertig │ 137 KB │ 1791 │ Download │
│ 03:00 UTC │ │ │ │ │ │
└──────────────┴────────────┴───────────┴─────────┴────────────┴──────────┘
Footer: "Geplante Backups laufen täglich um 03:00 UTC. Aufbewahrung: 90 Tage."
```
Frontend lives in `frontend/src/client/admin-backups.ts` + a `frontend/src/admin/backups.tsx` page entry. Reuses the existing `.entity-table` pattern (deadlines / projects detail). Per `.claude/CLAUDE.md` frontend conventions: row-click handler navigating to a detail view if we want one (deferred v1 just has the action column).
The "Backup jetzt erstellen" button POSTs `/api/admin/backups/run`, then polls `/api/admin/backups/{id}` every 2s until `status != 'running'`, then refreshes the list. (At firm-scale this resolves in <2s; the polling pattern still degrades gracefully if a backup grows.)
### 6.2 Sidebar entry
The admin sidebar group (per `client/sidebar.ts initAdminGroup`, revealed after `/api/me` confirms `global_role='global_admin'`) gets a "Backups" entry pointing at `/admin/backups`.
### 6.3 i18n
New keys under `admin.backups.*`:
```
admin.backups.title "Backups" / "Backups"
admin.backups.run_now "Backup jetzt erstellen" / "Run backup now"
admin.backups.kind.scheduled "Geplant" / "Scheduled"
admin.backups.kind.on_demand "Manuell" / "Manual"
admin.backups.status.running "Läuft …" / "Running …"
admin.backups.status.done "✓ Fertig" / "✓ Done"
admin.backups.status.failed "✗ Fehlgeschlagen" / "✗ Failed"
admin.backups.download "Download" / "Download"
admin.backups.empty "Noch keine Backups vorhanden." / "No backups yet."
admin.backups.footer.note "Geplante Backups laufen täglich um {hour}:00 UTC. Aufbewahrung: {days} Tage."
/ "Scheduled backups run daily at {hour}:00 UTC. Retention: {days} days."
```
---
## 7. Workbook layout
### 7.1 Inside the bundle
Inherits the t-paliad-214 layout verbatim, with one addition:
```
paliad-backup-2026-05-25T0300Z.zip
├── README.txt # human-readable: what this is, how to read it
├── paliad-export.xlsx # canonical workbook
├── paliad-export.json # JSON twin (machine-readable)
├── csv/
│ ├── projects.csv
│ ├── deadlines.csv
│ ├── ...
│ └── ref/
│ ├── proceeding_types.csv
│ └── ...
└── __meta.json # standalone meta (same content as __meta sheet)
```
If §11 Q2 picks xlsx-only, the bundle collapses to a single `paliad-backup-{timestamp}.xlsx` and the JSON/CSV twins disappear. (Inventor recommends against see Q2 reasoning.)
### 7.2 Filename convention
```
paliad-backup-{timestamp}.zip (or .xlsx if Q2 = single-file)
timestamp = YYYY-MM-DDTHHMMZ # UTC, no colons (Windows-safe)
```
Note the prefix is `paliad-backup-` (not `paliad-export-org-`) so the audience reads it as a backup, not a generic export. Both still produced by `ExportService`; the filename is the disambiguator.
### 7.3 The xlsx sheet list
Per the registry in §5.2. Sheet 1 = `__meta` (frozen header + key-value pairs from `ExportMeta`). The next several sheets are entity tables (alphabetical), then `ref__*` reference sheets, then a final `__lookup` sheet pairing every FK UUID to a human-readable label (project title, user email) so the workbook is self-joining in Power Query / Excel pivot tables.
Inherits all formatting decisions from t-paliad-214 §3.1: ISO 8601 strings for dates, `TRUE`/`FALSE` for booleans, semicolon-joined arrays, snake_case sheet names, frozen header row, column 1 always `id`.
### 7.4 The README.txt
A backup-specific variant of the t-paliad-214 README:
```
Paliad Backup — {firm_name}
Erzeugt am {generated_at} ({kind}: {scheduled | on-demand by {actor_email}})
Paliad-Version: {git_sha}
Dieses Archiv enthält einen vollständigen Snapshot der paliad-Datenbank
zum Zeitpunkt der Erstellung. Es ist auf den Admin-Bereich beschränkt
und enthält potenziell vertrauliche Mandantendaten.
Inhalt
- paliad-export.xlsx — kanonisches Excel-Workbook (eine Sheet pro Tabelle)
- paliad-export.json — JSON-Variante für maschinelle Re-Ingestion
- csv/ — Pro-Tabelle CSV-Dateien (UTF-8 mit BOM)
- __meta.json — Snapshot-Metadaten
Sheet-Übersicht
{row_counts as YAML}
Aufbewahrung
Dieser Snapshot wird automatisch nach {retention_days} Tagen aus dem
Speicherort entfernt. Der Audit-Eintrag in paliad.system_audit_log
bleibt dauerhaft erhalten.
Weitergabe
Die Weitergabe dieses Backups an Dritte erfolgt in eigener Verantwortung
des Empfängers. Watermarks oder DRM sind nicht enthalten.
```
(English mirror appended below.)
---
## 8. Permissions + audit
### 8.1 Permissions
- All `/admin/backups*` routes go through `adminGate(users, gateOnboarded(h))`. Pattern is set; no new middleware.
- Service-role DB handle is the same one paliad uses today (it's just `*sqlx.DB` connected via `DATABASE_URL`). No RLS bypass machinery the writer just runs queries; RLS isn't applied because `auth.uid()` is unset at this connection layer.
- `paliad.backups` has admin-only SELECT RLS as a defense-in-depth (no end-user write surface; INSERT/UPDATE happen via the Go service path under the migration-runner role, same as `system_audit_log`).
- The Supabase Storage bucket `paliad-backups` (if §11 Q1 picks Supabase): bucket policy restricts to service-role only; no anon read. Signed URLs are issued at download time by the backend with 5-min TTL.
### 8.2 Audit chain
Three event types on `paliad.system_audit_log`:
| event_type | when | metadata.* fields |
|----------------------|-----------------------------------------|-------------------|
| `backup_created` | catalog row INSERT (status='running') | `{kind, paliad_version, requested_by_email}` |
| (patched on success) | catalog row UPDATE (status='done') | `+ {row_counts, file_size_bytes, sheet_count, storage_uri}` |
| `backup_failed` | catalog row UPDATE (status='failed') | `+ {error, partial_row_counts}` |
| `backup_downloaded` | per download click on /admin/backups | `{backup_id, downloaded_by_email}` |
`backup_created` reuses the same row across success/failure (UPDATE the metadata), matching the `data_export` pattern in `ExportService.WriteAuditRow` + `PatchAuditRowSuccess`/`PatchAuditRowFailure`. `backup_downloaded` is a new row per click.
Audit rows persist forever the artifact lifecycle (90-day cleanup of the file on Storage) does **not** touch them. The chain is the trust signal.
---
## 9. Slice plan
Tracer-bullet: each slice ships and is reviewable alone. v1 = A + B + C.
### Slice A — On-demand backup + workbook generator (MVP path)
- `paliad.backups` migration (mig 121).
- `ExportService.WriteOrg` + `orgSheetQueries()`.
- `BackupRunner` struct + `Run(ctx, kind, actor)` method.
- `LocalDiskStore` only (defer Supabase Store to Slice B even if §11 Q1 picks Supabase Slice A still works against `/var/lib/paliad/exports/` and lets the rest of the pipeline ship).
- Handlers: `POST /api/admin/backups/run`, `GET /api/admin/backups`, `GET /api/admin/backups/{id}`, `GET /api/admin/backups/{id}/file`.
- Tests: unit on the registry shape (one entity sheet per known table), integration on the runner (insert tx upload patch), handler-level test on the admin gate.
- **Ships** an admin-only "click to generate a full backup, download it" flow against local disk. No scheduler, no Supabase Storage. Already meets the issue's on-demand requirement.
Estimated LoC: ~800 (most is the registry + WriteOrg, which mirrors the existing WritePersonal).
### Slice B — Scheduler + final storage backend
- `BackupScheduler` (modelled on `ReminderService`).
- `SupabaseStorageStore` (only if §11 Q1 picks Supabase; otherwise reuse `LocalDiskStore`).
- Cleanup goroutine: daily scan of `paliad.backups WHERE finished_at < now() - INTERVAL '90 days' AND deleted_at IS NULL`; calls `store.Delete`; sets `deleted_at`. (For Supabase Storage, this is redundant with bucket-side lifecycle rules but having paliad assert ownership keeps the catalog accurate even if a bucket rule is misconfigured.)
- Env wiring: `PALIAD_BACKUP_STORAGE`, `PALIAD_EXPORT_DIR` (local), `PALIAD_BACKUP_BUCKET` (supabase), `PALIAD_BACKUP_HOUR_UTC`, `PALIAD_BACKUP_RETENTION_DAYS`. All documented in CLAUDE.md "Environment variables" table.
- Tests: scheduler dedup (don't fire twice in the same UTC day), storage round-trip (Put Get Delete), cleanup goroutine (mark-deleted logic).
- **Ships** the daily snapshot to the chosen store + lifecycle.
Estimated LoC: ~500.
### Slice C — Admin UI polish
- `/admin/backups` page TSX + client TS bundle.
- "Backup jetzt erstellen" button with polling for the running status.
- Empty state + error-state rendering.
- Sidebar entry.
- i18n keys.
- e2e (Playwright) on the page: load click run poll see new row click download.
- **Ships** the catalog UI; before this slice, admins use the API directly.
Estimated LoC: ~400.
---
## 10. Out of scope
- **Restore-from-backup** tooling separate phase per issue.
- **Per-firm / multi-tenant** separation paliad is single-firm; not relevant until that changes.
- **App-layer encryption at rest** Supabase Storage encrypts at the bucket layer (or Hostinger disk encrypts at the host layer for local-disk). v1 does not add per-file encryption.
- **Differential / incremental backups** v1 is full-snapshot only. Differentials are appealing only at firm-scale that we're nowhere near (>1M rows).
- **Backup retention by count** (e.g. "keep last 30") — v1 is age-based only via the retention env var.
- **Export-as-email-attachment** — would require attaching ~150 KB to a daily mail; out of v1.
- **Public signed URLs that bypass cookie auth** — the download endpoint requires the admin cookie; no anon download surface in v1.
- **A second audit chain table** — `paliad.system_audit_log` is the only chain. The `paliad.backups` catalog is operational metadata, not audit.
---
## 11. Material picks for head → m
Most of the issue's R answers are non-material and the design defaults to them silently. Four are material and get escalated. The design's draft picks (above) reflect the inventor's best guess; m's calls supersede.
### Q1 — Storage backend: Supabase Storage (issue R) vs. local disk (prior design pick)?
**Inventor draft pick: Supabase Storage** (matches issue R; m named it explicitly).
Trade-offs:
- **Supabase Storage** — new HTTP-client dep (~200 LoC), but no provisioning (the youpc Supabase already has Storage available). Lifecycle rules are bucket-side. Signed-URL downloads bypass paliad → less bandwidth on the Dokploy host. Backups survive a Dokploy host loss.
- **Local disk** — zero external dep, simpler code. Tied to the Dokploy compose volume; backup is lost if the host is lost (which is exactly the disaster a backup defends against). Cleanup is Go-side, slightly more code.
Material because: the artifact-store choice has a real disaster-recovery implication (the backup of paliad should not live on the same host as paliad). And the prior t-paliad-214 design's "no MinIO provisioning" rationale doesn't apply — the youpc Supabase is already provisioned.
### Q2 — Bundle format: single `.xlsx` (issue R) vs. `.zip` (shipped infra)?
**Inventor draft pick: `.zip` bundle** (xlsx + JSON + CSV + README).
Trade-offs:
- **Single `.xlsx`** — matches m's "Admin Excel" framing literally. One file, double-click to open. Loses the JSON twin (Excel-independent re-ingest) — for org scope this re-introduces the lock-in problem the personal/project exports were designed to avoid.
- **`.zip` bundle** — preserves no-lock-in. Slight UX friction: admins unzip first to get the xlsx. The .xlsx inside still satisfies "Admin Excel" — m gets the workbook by clicking through one zip layer.
Material because: the no-lock-in promise was a load-bearing rationale in t-paliad-214 §1. Cutting it for the admin scope only is a real product decision, not a default.
### Q3 — `paliadin_turns` (+ `paliadin_aichat_conversation`) in the backup: include (issue R) or exclude (prior m decision)?
**Inventor draft pick: EXCLUDE** (matches prior m decision from t-paliad-214 Q5 addendum).
Reasoning: the prior m decision was structural ("the `paliadin_turns` row drops from the org-scope sheet table entirely — no `?include=paliadin_turns` query param") with a precedent-setting rationale ("the *moment* Paliadin opens beyond owner-only, the AI conversation history per user is the most sensitive personal data we carry"). That rationale still holds — `paliadin_aichat_conversation` (mig 118) extends the conversation history surface.
Issue R says "nothing for the admin role; this is an internal backup, not a sharable artifact." That argument is reasonable but undercuts the prior precedent. The conflict is real; flagging it.
Two flavours of "include":
- **All AI conversation tables included as-is** — full backup. Convenient. Most leaky.
- **Tables included but with `assistant_response` / message-body columns dropped** — keeps the metadata (who talked to Paliadin when), drops the content. Middle ground.
### Q4 — Scheduled backup time: nightly 03:00 UTC (draft) or a different hour / cadence?
**Inventor draft pick: nightly 03:00 UTC.**
Reasoning: 03:00 UTC = 04:00/05:00 Europe/Berlin (winter/summer), well outside business hours for every paliad office (Munich, Düsseldorf, Amsterdam, London, Paris, Milan, Hamburg). Low write traffic → snapshot-tx contention is minimal. Daily cadence is the issue's stated cadence.
Material if m wants weekly + monthly + on-status-change (the t-paliad-214 §5.2 sketch). Defaulting to daily keeps Slice B small; multi-cadence belongs in a follow-up.
The hour is `PALIAD_BACKUP_HOUR_UTC` (default 3), m-tunable per deploy.
---
## 11. Open questions for m (FLAG only — no escalation)
These are coder-time choices that don't move the design. Listed for the coder shift to resolve in implementation chat with m.
- Exact bucket name if Q1=supabase (`paliad-backups`? `paliad-{firm-slug}-backups`?).
- Whether the on-demand button rate-limits (e.g. "only one running backup at a time"). Default: yes, the runner rejects on overlap with a friendly 409.
- Whether the `/admin/backups` row-click navigates to a backup-detail page (showing per-sheet row counts, warnings, the originating audit row). Default: no detail page in v1; the table columns are sufficient.
- Whether the README.txt also embeds a one-line list of every sheet name. Default: yes (it's two lines of code and very useful for "what tables were in scope on this date").
- Whether `submission_drafts.body_md` (the actual draft pleading text) is included verbatim or dropped. Default: included — admins are by definition allowed to see this. Flag to m so the precedent is recorded.
---
## 12. References
- `docs/design-paliad-data-export-2026-05-19.md` — the prior design this extends (Slices 1-2 SHIPPED, Slice 3-4 deferred until this design).
- `internal/services/export_service.go` — the writer abstraction we extend with `WriteOrg`.
- `internal/services/reminder_service.go` — the in-process scheduler template for `BackupScheduler`.
- `internal/db/migrations/102_system_audit_log.up.sql` — the audit chain we reuse.
- `internal/handlers/handlers.go:567-598` — the existing admin-gate block; new `/admin/backups*` routes register alongside.
- m/paliad#77 — the originating ask.
- m/paliad#214 — the prior data-export task (Slices 1-2 shipped).
---
**END OF DESIGN. Status: READY FOR REVIEW pending §11 Q1-Q4 material picks.**
Inventor parks here. Head's `mai-head` skill gates the coder shift; this draft uses the issue's R defaults everywhere except where they conflict with prior shipped m decisions (Q1, Q2, Q3) or where the issue text named no default (Q4).

View File

@@ -1426,8 +1426,14 @@ 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.tab.settings": "Verwaltung",
"projects.detail.export.button": "Daten exportieren",
"projects.detail.export.tooltip": "Daten dieses Projekts (mit Unter-Projekten) als Excel + JSON + CSV herunterladen.",
"projects.detail.settings.export.heading": "Daten exportieren",
"projects.detail.settings.export.description": "Lade alle Daten dieses Projekts (inkl. Unter-Projekten) als Excel + JSON + CSV-Archiv herunter.",
"projects.detail.settings.archive.heading": "Projekt archivieren",
"projects.detail.settings.archive.description": "Archivieren erfolgt aus dem Bearbeiten-Dialog (Gefahrenbereich).",
"projects.detail.settings.archive.cta": "Bearbeiten öffnen",
"projects.detail.submissions.empty": "Es sind aktuell keine Schriftsatzvorlagen hinterlegt.",
"projects.detail.submissions.empty.no_proceeding": "Für dieses Projekt ist noch kein Verfahrenstyp gesetzt — der Katalog unten zeigt trotzdem alle Vorlagen.",
"projects.detail.submissions.empty.no_proceeding.cta": "Projekt bearbeiten",
@@ -4347,8 +4353,14 @@ const translations: Record<Lang, Record<string, string>> = {
"projects.detail.tab.notizen": "Notes",
"projects.detail.tab.checklisten": "Checklists",
"projects.detail.tab.submissions": "Submissions",
"projects.detail.tab.settings": "Settings",
"projects.detail.export.button": "Export data",
"projects.detail.export.tooltip": "Download this project's data (including sub-projects) as Excel + JSON + CSV.",
"projects.detail.settings.export.heading": "Export data",
"projects.detail.settings.export.description": "Download all data for this project (including sub-projects) as an Excel + JSON + CSV archive.",
"projects.detail.settings.archive.heading": "Archive project",
"projects.detail.settings.archive.description": "Archiving happens in the edit dialog (danger zone).",
"projects.detail.settings.archive.cta": "Open edit dialog",
"projects.detail.submissions.empty": "No submission templates are configured yet.",
"projects.detail.submissions.empty.no_proceeding": "No proceeding type is set for this project yet — the catalog below still lists every template.",
"projects.detail.submissions.empty.no_proceeding.cta": "Edit project",

View File

@@ -175,7 +175,8 @@ type TabId =
| "appointments"
| "notes"
| "checklists"
| "submissions";
| "submissions"
| "settings";
const VALID_TABS: TabId[] = [
"history",
@@ -187,6 +188,7 @@ const VALID_TABS: TabId[] = [
"notes",
"checklists",
"submissions",
"settings",
];
// Legacy German tab slugs that may appear in bookmarked URLs after the
@@ -1185,13 +1187,16 @@ function renderHeader() {
netdocs.style.display = "none";
}
// Delete visibility: partner/admin only
// Delete visibility: partner/admin only. The Verwaltung tab's archive
// sub-section mirrors the same gate (t-paliad-245) — it only points at
// the Edit-modal danger zone, so it's pointless to show when the danger
// zone itself is hidden.
const deleteWrap = document.getElementById("project-delete-wrap")!;
if (me && (me.global_role === "global_admin")) {
deleteWrap.style.display = "";
} else {
deleteWrap.style.display = "none";
}
const archiveSection = document.getElementById("project-settings-archive");
const canArchive = !!me && me.global_role === "global_admin";
deleteWrap.style.display = canArchive ? "" : "none";
if (archiveSection) archiveSection.style.display = canArchive ? "" : "none";
updateSettingsTabVisibility();
}
// wrapEventTitleLink — kept for the dashboard activity feed which reuses
@@ -2045,6 +2050,17 @@ function initEditModal() {
});
}
// Verwaltung → Projekt archivieren — opens the edit modal scrolled to
// the danger-zone archive button (t-paliad-245).
const archiveLink = document.getElementById(
"project-settings-archive-link",
) as HTMLButtonElement | null;
if (archiveLink) {
archiveLink.addEventListener("click", () => {
openEditModal("project-delete-btn");
});
}
form.addEventListener("submit", async (e) => {
e.preventDefault();
if (!project) return;
@@ -2991,17 +3007,21 @@ function canExportProject(): boolean {
);
}
// 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.
// wireExportButton reveals the Export sub-section of the Verwaltung tab
// (t-paliad-245) and hooks up the project-export button. Triggers a
// download via a transient <a download> — same pattern as the personal
// export in client/settings.ts.
function wireExportButton(projectID: string): void {
const section = document.getElementById("project-settings-export") as HTMLElement | null;
const btn = document.getElementById("project-export-btn") as HTMLButtonElement | null;
if (!btn) return;
if (!section || !btn) return;
if (!canExportProject()) {
btn.style.display = "none";
section.style.display = "none";
updateSettingsTabVisibility();
return;
}
btn.style.display = "";
section.style.display = "";
updateSettingsTabVisibility();
btn.addEventListener("click", () => {
const a = document.createElement("a");
a.href = `/api/projects/${encodeURIComponent(projectID)}/export`;
@@ -3012,6 +3032,17 @@ function wireExportButton(projectID: string): void {
});
}
// updateSettingsTabVisibility hides the Verwaltung tab when none of its
// sub-sections are visible to the current user — an empty tab is worse
// UX than no tab. Called whenever a sub-section's visibility flips.
function updateSettingsTabVisibility(): void {
const tab = document.querySelector<HTMLElement>('.entity-tab[data-tab="settings"]');
if (!tab) return;
const exportShown = document.getElementById("project-settings-export")?.style.display !== "none";
const archiveShown = document.getElementById("project-settings-archive")?.style.display !== "none";
tab.style.display = exportShown || archiveShown ? "" : "none";
}
function canRemoveTeamMember(m: ProjectTeamMember): boolean {
if (!me) return false;
if (m.user_id === me.id) return true;

View File

@@ -1,6 +1,6 @@
import { initI18n, onLangChange, t, tDyn } from "./i18n";
import { initSidebar } from "./sidebar";
import { openBroadcastModal, firstName, type BroadcastRecipient } from "./broadcast";
import { openBroadcastModal, firstName, buildMailtoHref, type BroadcastRecipient } from "./broadcast";
interface User {
id: string;
@@ -341,28 +341,64 @@ function buildProjectFilter() {
function buildBroadcastButton() {
const wrap = document.getElementById("team-broadcast-wrap");
if (!wrap) return;
if (!canBroadcast()) {
// Wait for /api/me so the affordance never flickers between admin (form)
// and non-admin (mailto) on initial paint. canBroadcast() already returns
// false when me is null but we'd briefly render the mailto anchor before
// the admin form, which is visually jarring.
if (!me) {
wrap.innerHTML = "";
wrap.style.display = "none";
return;
}
wrap.style.display = "";
wrap.innerHTML = `
<button type="button" class="btn btn-primary" id="team-broadcast-btn">
${esc(t("team.broadcast.button") || "E-Mail an Auswahl")} <span class="team-broadcast-count" id="team-broadcast-count">0</span>
</button>
`;
document.getElementById("team-broadcast-btn")?.addEventListener("click", () => onBroadcastClick());
const label = esc(t("team.broadcast.button") || "E-Mail an Auswahl");
const counter = `<span class="team-broadcast-count" id="team-broadcast-count">0</span>`;
if (canBroadcast()) {
// Admin path (global_admin or project-lead-of-selected): opens the
// in-app compose modal that POSTs to /api/team/broadcast.
wrap.innerHTML = `
<button type="button" class="btn btn-primary" id="team-broadcast-btn">
${label} ${counter}
</button>
`;
document.getElementById("team-broadcast-btn")?.addEventListener("click", () => onBroadcastClick());
} else {
// Non-admin path (t-paliad-244): native mailto: anchor pre-filled with
// the current filter set. href is refreshed in updateBroadcastButton()
// whenever filters change so the link always reflects what's visible.
wrap.innerHTML = `
<a class="btn btn-primary" id="team-broadcast-btn" href="mailto:">
${label} ${counter}
</a>
`;
}
}
function updateBroadcastButton() {
buildBroadcastButton();
const recipients = displayedRecipients();
const countEl = document.getElementById("team-broadcast-count");
if (countEl) {
const n = displayedRecipients().length;
countEl.textContent = String(n);
const btn = document.getElementById("team-broadcast-btn") as HTMLButtonElement | null;
if (btn) btn.disabled = n === 0;
if (countEl) countEl.textContent = String(recipients.length);
const btn = document.getElementById("team-broadcast-btn");
if (!btn) return;
if (btn.tagName === "BUTTON") {
(btn as HTMLButtonElement).disabled = recipients.length === 0;
} else {
// Anchor (non-admin): regenerate the mailto: href against the current
// visible recipients, and disable the affordance when empty so a click
// doesn't open an empty mail composer.
const a = btn as HTMLAnchorElement;
if (recipients.length === 0) {
a.setAttribute("href", "mailto:");
a.setAttribute("aria-disabled", "true");
a.style.pointerEvents = "none";
a.style.opacity = "0.5";
} else {
a.setAttribute("href", buildMailtoHref(recipients));
a.removeAttribute("aria-disabled");
a.style.pointerEvents = "";
a.style.opacity = "";
}
}
}
@@ -673,14 +709,21 @@ function renderSelectionFooter(): void {
"{n}",
String(n),
);
const sendLabel = esc(t("team.selection.send") || "E-Mail an Auswahl");
// t-paliad-244: mirror buildBroadcastButton() so the bottom send button
// behaves the same as the filter-bar one. Admin (canBroadcast) opens the
// compose modal; non-admin gets a native mailto: anchor pre-filled with
// the explicit selection.
const adminPath = canBroadcast();
const sendAction = adminPath
? `<button type="button" class="btn-primary" id="team-selection-send">${sendLabel}</button>`
: `<a class="btn-primary" id="team-selection-send" href="${buildMailtoHref(selectedRecipients())}">${sendLabel}</a>`;
footer.innerHTML = `
<span class="team-selection-count">${esc(countLabel)}</span>
<button type="button" class="btn-secondary btn-small" id="team-selection-clear">
${esc(t("team.selection.clear") || "Auswahl aufheben")}
</button>
<button type="button" class="btn-primary" id="team-selection-send">
${esc(t("team.selection.send") || "E-Mail an Auswahl")}
</button>
${sendAction}
`;
footer.style.display = "";
document.body.classList.add("team-has-selection");
@@ -691,9 +734,12 @@ function renderSelectionFooter(): void {
syncMasterCheckbox();
renderSelectionFooter();
});
document.getElementById("team-selection-send")?.addEventListener("click", () => {
onBroadcastFromSelection();
});
if (adminPath) {
document.getElementById("team-selection-send")?.addEventListener("click", () => {
onBroadcastFromSelection();
});
}
// Anchor path has no click handler — native href open is the action.
}
// selectedRecipients maps the explicit selection Set into the

View File

@@ -2188,6 +2188,11 @@ export type I18nKey =
| "projects.detail.parteien.role.defendant"
| "projects.detail.parteien.role.thirdparty"
| "projects.detail.save"
| "projects.detail.settings.archive.cta"
| "projects.detail.settings.archive.description"
| "projects.detail.settings.archive.heading"
| "projects.detail.settings.export.description"
| "projects.detail.settings.export.heading"
| "projects.detail.smarttimeline.add.cancel"
| "projects.detail.smarttimeline.add.choice.amend"
| "projects.detail.smarttimeline.add.choice.appointment"
@@ -2277,6 +2282,7 @@ export type I18nKey =
| "projects.detail.tab.kinder"
| "projects.detail.tab.notizen"
| "projects.detail.tab.parteien"
| "projects.detail.tab.settings"
| "projects.detail.tab.submissions"
| "projects.detail.tab.team"
| "projects.detail.tab.termine"

View File

@@ -89,20 +89,9 @@ 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>
{/* Verwaltung — rare admin actions (export, archive). Sits
last in the tab list per t-paliad-245. */}
<a className="entity-tab" data-tab="settings" href="#" data-i18n="projects.detail.tab.settings">Verwaltung</a>
</nav>
{/* History (Verlauf) — t-paliad-171 SmartTimeline Slice 1.
@@ -666,6 +655,39 @@ export function renderProjectsDetail(): string {
Schriftsätze werden direkt aus dem Projekt heraus als .docx generiert. Anpassen, drucken, einreichen.
</p>
</section>
{/* Verwaltung — rare admin actions (export, archive). Each
sub-section hides itself if the caller is not entitled
(export: §4 gate; archive: global_admin). */}
<section className="entity-tab-panel" id="tab-settings" style="display:none">
<div className="settings-section" id="project-settings-export" style="display:none">
<h3 className="entity-section-heading" data-i18n="projects.detail.settings.export.heading">Daten exportieren</h3>
<p className="tool-subtitle" data-i18n="projects.detail.settings.export.description">
Lade alle Daten dieses Projekts (inkl. Unter-Projekten) als Excel + JSON + CSV-Archiv herunter.
</p>
<button
type="button"
id="project-export-btn"
className="btn-secondary"
data-i18n="projects.detail.export.button">
Daten exportieren
</button>
</div>
<div className="settings-section" id="project-settings-archive" style="display:none">
<h3 className="entity-section-heading" data-i18n="projects.detail.settings.archive.heading">Projekt archivieren</h3>
<p className="tool-subtitle" data-i18n="projects.detail.settings.archive.description">
Archivieren erfolgt aus dem Bearbeiten-Dialog (Gefahrenbereich).
</p>
<button
type="button"
id="project-settings-archive-link"
className="btn-secondary"
data-i18n="projects.detail.settings.archive.cta">
Bearbeiten öffnen
</button>
</div>
</section>
</div>
{/* Full edit modal — same form as /projects/new, pre-filled. */}

View File

@@ -6545,12 +6545,18 @@ dialog.modal::backdrop {
/* Each filter is a label-above-control cell so the caption sits on top of
its select / button. The whole filter-row stays a horizontal flex-wrap
of these column-cells (t-paliad-117). */
of these column-cells (t-paliad-117).
min-width: 0 + max-width: 100% lets the cell shrink to fit its flex
container and prevents a native <select> with long option text from
blowing the cell wider than the viewport (t-paliad-255). */
.filter-group {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 0.25rem;
min-width: 0;
max-width: 100%;
}
.filter-label {
@@ -6564,6 +6570,10 @@ dialog.modal::backdrop {
.filter-group .entity-select { width: 100%; }
}
/* max-width: 100% caps the intrinsic width of a native <select> at its
parent — without it, browsers size the select to the longest <option>
text and a very long project title overflows the viewport on tablet
widths above the 480px breakpoint (t-paliad-255). */
.entity-select {
padding: 0.4rem 0.75rem;
font-size: 0.85rem;
@@ -6572,6 +6582,8 @@ dialog.modal::backdrop {
background: var(--color-surface);
color: var(--color-text);
cursor: pointer;
max-width: 100%;
min-width: 0;
}
.entity-select:focus {
@@ -7291,6 +7303,20 @@ dialog.modal::backdrop {
padding: 0.5rem 0 2rem;
}
/* Verwaltung tab — rare admin actions (export, archive) live here as
stacked sections. No accent, no oversized buttons (t-paliad-245). */
.settings-section {
margin-bottom: 2rem;
}
.settings-section:last-child {
margin-bottom: 0;
}
.settings-section .tool-subtitle {
margin-bottom: 0.75rem;
}
.entity-events {
list-style: none;
padding: 0;

View File

@@ -2,7 +2,8 @@ package handlers
// Submission generator HTTP layer (t-paliad-230 — format-only scope
// reduction of t-paliad-215; t-paliad-242 broadened the list endpoint
// to the full cross-proceeding catalog).
// to the full cross-proceeding catalog; t-paliad-253 promoted /generate
// from format-only to the same merge engine the draft editor uses).
//
// Endpoints:
//
@@ -15,17 +16,17 @@ package handlers
// editor falls back to the universal HL Patents Style.
//
// POST /api/projects/{id}/submissions/{code}/generate
// Fetches the cached HL Patents Style .dotm (same proxy used
// by /files/hl-patents-style.dotm), converts it to a clean
// .docx via services.ConvertDotmToDocx, writes one
// paliad.system_audit_log row, and streams the result as an
// attachment download.
//
// No variable substitution, no per-submission templates, no
// project_events/documents writes. Those layers are deferred to a
// future "merge engine" slice; today's generator hands the lawyer a
// clean .docx of the firm style and lets them edit and save under
// their own filename.
// Resolves the template through the cronus fallback chain
// (per-firm `submissionTemplateRegistry[code]` first, HL
// Patents Style as the universal fallback), builds a fresh
// variable bag via SubmissionVarsService.Build, and runs the
// SubmissionRenderer merge so every {{placeholder}} resolves
// to project state (or `[KEIN WERT: key]` for empties). Writes
// one paliad.system_audit_log row and streams the .docx as an
// attachment download. The HL Patents Style fallback has no
// placeholders today, so for codes without a per-firm template
// the renderer is a no-op on substitution but still runs the
// .dotm→.docx pre-pass.
//
// Visibility: every endpoint runs through ProjectService.GetByID
// (paliad.can_see_project gate). Unauthorised callers get 404 — same
@@ -265,10 +266,16 @@ func hasPerSubmissionTemplate(submissionCode string) bool {
return ok
}
// handleGenerateProjectSubmission fetches the universal HL Patents
// Style .dotm, converts it to a clean .docx, writes one audit row, and
// streams the result. No variable substitution; the bytes that go down
// the wire are the firm style template with macros stripped.
// handleGenerateProjectSubmission resolves the per-submission template
// (per-firm first, HL Patents Style fallback), builds a fresh variable
// bag from project state via SubmissionVarsService, runs the merge
// engine so every {{placeholder}} substitutes, writes one audit row,
// and streams the result. Pre-t-paliad-253 this handler ignored the
// per-firm registry and returned the bare HL Patents Style .dotm with
// no substitution — the "Generieren" button on the Schriftsätze tab
// therefore produced a generic firm-style .docx instead of a
// project-merged Klageerwiderung, which is what m noticed in
// m/paliad#84.
func handleGenerateProjectSubmission(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
@@ -277,6 +284,12 @@ func handleGenerateProjectSubmission(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
if dbSvc.submissionDraft == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
"error": "submissions not configured",
})
return
}
projectID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid project id"})
@@ -291,60 +304,37 @@ func handleGenerateProjectSubmission(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), submissionRenderTimeout)
defer cancel()
project, err := dbSvc.projects.GetByID(ctx, uid, projectID)
tplBytes, _, err := resolveSubmissionTemplate(ctx, submissionCode)
if err != nil {
writeServiceError(w, err)
log.Printf("submissions: template fetch (project=%s code=%s): %v", projectID, submissionCode, err)
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "template upstream unreachable"})
return
}
rule, err := loadPublishedRuleByCode(ctx, submissionCode)
docx, resolved, err := dbSvc.submissionDraft.RenderProjectSubmission(ctx, uid, projectID, submissionCode, tplBytes)
if err != nil {
if errors.Is(err, errRuleNotFound) {
if errors.Is(err, services.ErrSubmissionRuleNotFound) {
writeJSON(w, http.StatusNotFound, map[string]string{
"error": fmt.Sprintf("no published rule for submission_code %q", submissionCode),
})
return
}
log.Printf("submissions: load rule %q: %v", submissionCode, err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "rule lookup failed"})
// ErrNotVisible / project ErrNotFound from the visibility gate
// surface through writeServiceError as 404, matching the rest
// of the project surfaces.
log.Printf("submissions: render (project=%s code=%s): %v", projectID, submissionCode, err)
writeServiceError(w, err)
return
}
dotm, err := fetchHLPatentsStyleBytes(ctx)
if err != nil {
log.Printf("submissions: fetch HL Patents Style .dotm: %v", err)
writeJSON(w, http.StatusBadGateway, map[string]string{
"error": "template upstream unreachable",
})
return
}
docx, err := services.ConvertDotmToDocx(dotm)
if err != nil {
log.Printf("submissions: convert dotm for project %s code %s: %v", projectID, submissionCode, err)
writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": "convert failed",
})
return
}
user, err := dbSvc.users.GetByID(ctx, uid)
if err != nil {
log.Printf("submissions: load user %s: %v", uid, err)
}
lang := "de"
if user != nil && user.Lang != "" {
lang = user.Lang
}
filename := submissionFileName(rule, project, lang)
filename := submissionFileName(resolved.Rule, resolved.Project, resolved.Lang)
// Audit write is best-effort with a background context so the
// download still succeeds if the DB races. Audit failure here only
// affects the system_audit_log feed — never the user's response.
bgCtx, cancelBG := context.WithTimeout(context.Background(), 10*time.Second)
defer cancelBG()
if err := writeSubmissionAuditRow(bgCtx, user, project.ID, submissionCode, rule.Name, filename); err != nil {
if err := writeSubmissionAuditRow(bgCtx, resolved.User, projectID, submissionCode, resolved.Rule.Name, filename); err != nil {
log.Printf("submissions: audit insert failed (project=%s code=%s): %v", projectID, submissionCode, err)
}
@@ -356,41 +346,6 @@ func handleGenerateProjectSubmission(w http.ResponseWriter, r *http.Request) {
}
}
// errRuleNotFound is the sentinel for "no published rule with that
// submission_code" — distinguished from a generic DB error so the
// handler returns 404 instead of 500.
var errRuleNotFound = errors.New("submission rule not found")
// loadPublishedRuleByCode fetches the rule the user requested. Only
// published+active rows resolve; drafts and archived rules never feed
// a real submission.
func loadPublishedRuleByCode(ctx context.Context, submissionCode string) (*models.DeadlineRule, error) {
if submissionCode == "" {
return nil, errRuleNotFound
}
var rule models.DeadlineRule
err := dbSvc.projects.DB().GetContext(ctx, &rule,
`SELECT id, proceeding_type_id, parent_id, submission_code, name, name_en,
description, primary_party, event_type, duration_value, duration_unit,
timing, rule_code, deadline_notes, deadline_notes_en, sequence_order,
alt_duration_value, alt_duration_unit, alt_rule_code, anchor_alt,
concept_id, legal_source, is_spawn, spawn_label, is_active,
created_at, updated_at, lifecycle_state
FROM paliad.deadline_rules
WHERE submission_code = $1
AND lifecycle_state = 'published'
AND is_active = true
ORDER BY sequence_order
LIMIT 1`, submissionCode)
if err != nil {
if strings.Contains(err.Error(), "no rows") {
return nil, errRuleNotFound
}
return nil, err
}
return &rule, nil
}
// submissionFileName produces the user-facing download name per
// design §7: {rule.name}-{project.case_number}-{YYYY-MM-DD}.docx.
// Empty case_number drops the segment entirely (no fallback hash —

View File

@@ -519,6 +519,34 @@ func (s *SubmissionDraftService) Export(ctx context.Context, draft *SubmissionDr
return out, resolved, nil
}
// RenderProjectSubmission renders the given .docx template with a fresh
// variable bag for (user, project, submissionCode). No lawyer overrides
// — the output reflects exactly what SubmissionVarsService resolves
// from project state. Used by the one-click /api/projects/{id}/
// submissions/{code}/generate path which has no saved draft row.
//
// Returns the merged bytes plus the resolved bag (for audit row + file
// naming). Visibility is enforced by SubmissionVarsService.Build via
// ProjectService.GetByID — callers get ErrNotFound on no-access.
// ErrSubmissionRuleNotFound surfaces when no published rule matches the
// requested submission_code.
func (s *SubmissionDraftService) RenderProjectSubmission(ctx context.Context, userID, projectID uuid.UUID, submissionCode string, templateBytes []byte) ([]byte, *SubmissionVarsResult, error) {
pid := projectID
resolved, err := s.vars.Build(ctx, SubmissionVarsContext{
UserID: userID,
ProjectID: &pid,
SubmissionCode: submissionCode,
})
if err != nil {
return nil, nil, err
}
out, err := s.renderer.Render(templateBytes, resolved.Placeholders, DefaultMissingMarker(resolved.Lang))
if err != nil {
return nil, nil, err
}
return out, resolved, nil
}
// decodeVariables turns the raw jsonb bytes into the PlaceholderMap.
// Called by every fetch path so the caller sees a populated Variables.
func (d *SubmissionDraft) decodeVariables() error {

View File

@@ -232,12 +232,15 @@ func buildDocumentXML() string {
// English-locale exercise — lets the lawyer verify the EN long-form
// date and EN proceeding name resolve correctly when the user's
// preference is en.
// preference is en. Also exercises the bare {{today}} alias
// (identical to {{today.iso}}; included so every key the variable
// bag carries appears at least once in this demo template).
heading2(&b, "Locale-aware variants (DEMO)")
plain(&b, "EN long date: {{today.long_en}} · Deadline EN: {{deadline.due_date_long_en}}")
plain(&b, "Project our side (EN): {{project.our_side_en}} · Proceeding (EN): {{project.proceeding.name_en}}")
plain(&b, "Rule name (EN): {{rule.name_en}} · Project our side (DE): {{project.our_side_de}}")
plain(&b, "Proceeding (DE): {{project.proceeding.name_de}} · Rule name (DE): {{rule.name_de}}")
plain(&b, "Today (bare alias): {{today}}")
b.WriteString(`</w:body></w:document>`)
return b.String()