Compare commits

..

9 Commits

Author SHA1 Message Date
mAi
228ae1b263 mAi: #85 - sidebar scroll position persists across nav
Sidebar nav clicks trigger a full page reload, which rebuilds the
sidebar from scratch and snaps .sidebar-nav back to scrollTop=0.
Persist scrollTop to sessionStorage (paliad.sidebar.scroll) on every
scroll and restore on initSidebar(). Re-apply once after
/api/user-views resolves so the async layout shift doesn't leave the
user a few rows off.

sessionStorage scopes the value to the tab: Cmd-click / right-click
"open in new tab" still produces a fresh tab that starts at the top.
2026-05-25 14:03:03 +02:00
mAi
cdd3747c2b Merge: t-paliad-250 — Browse-a-proceeding side+appellant selectors + 'appealable decision' trigger label (m/paliad#81) 2026-05-25 14:00:02 +02:00
mAi
02255c4234 mAi: #81 - verfahrensablauf side+appellant selectors + UPC Appeal trigger label
Concerns A + B + C from m/paliad#81:

A. Browse-a-proceeding (/tools/verfahrensablauf) gains a side selector
   (Kläger/Beklagter/Beide) and an appellant selector. The side selector
   swaps which column labels which user-side; the appellant selector
   collapses party='both' rules into the appellant's column (no mirror)
   so role-swap proceedings (Appeal, etc.) stop showing every row
   twice in the timeline. Both selectors are URL-driven (?side= +
   ?appellant=) and re-render without a backend round-trip.

   The appellant row hides itself for proceedings without an appellant
   axis (first-instance Inf/Rev/Opp) via a small allowlist.

B. UPC Appeal trigger-event caption now reads "Anfechtbare Entscheidung"
   / "Appealable Decision" instead of falling back to the proceeding
   name ("Berufungsverfahren" / "Appeal"). Implemented as an optional
   trigger_event_label_{de,en} column on paliad.proceeding_types (mig
   121); the frontend prefers it over the proceedingName fallback that
   fires when no rule has IsRootEvent=true. No new deadline rules, no
   slug changes (hard rule from the issue).

C. Parameter contract for the column projection is unified in
   bucketDeadlinesIntoColumns(deadlines, {side, appellant}) — a pure
   helper extracted from renderColumnsBody so the routing behaviour
   stays unit-testable without a DOM. Tests cover the default mirror,
   appellant-collapse for both sides, side-swap of column ownership,
   the combined case, and row alignment by dueDate.

Verification

- go build ./...                        clean
- go test ./...                         all green
- bun run build (frontend)              clean
- bun test (frontend/src)               110/110 pass (12 new + 98 prior)
- Migration 121 applied to paliad schema; UPC Appeal proceeding now
  carries the curated trigger label pair.

Out of scope (filed for follow-up): per-rule role tagging so
respondent-side filings (Response to Appeal, Cross-Appeal) land in
the respondent's column when an appellant is selected. The current
issue scope (one-row-per-deadline collapse) is delivered; the
realistic-per-row routing needs a deadline_rules schema bump that
the hard rules of #81 excluded.
2026-05-25 13:57:38 +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
19 changed files with 876 additions and 905 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

@@ -302,9 +302,11 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.view.timeline": "Zeitstrahl",
"deadlines.view.columns": "Spalten",
"deadlines.notes.show": "Hinweise anzeigen",
"deadlines.col.proactive": "Proaktiv",
"deadlines.col.proactive": "Proaktiv (Klägerseite)",
"deadlines.col.proactive.defendant": "Proaktiv (Beklagtenseite)",
"deadlines.col.court": "Gericht",
"deadlines.col.reactive": "Reaktiv",
"deadlines.col.reactive": "Reaktiv (Beklagtenseite)",
"deadlines.col.reactive.claimant": "Reaktiv (Klägerseite)",
"deadlines.col.both": "Beide Parteien",
// Trigger-event mode (PR-2 \u2014 youpc-parity)
"deadlines.mode.procedure": "Verfahrensablauf",
@@ -417,6 +419,14 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.perspective.defendant.title": "Beklagtenseite — versteckt typische Kläger-Schriftsätze",
"deadlines.perspective.appeal_filed_by.label": "Berufung eingelegt durch:",
"deadlines.perspective.predefined_hint": "vorgegeben durch Akte",
"deadlines.side.label": "Seite:",
"deadlines.side.claimant": "Klägerseite",
"deadlines.side.defendant": "Beklagtenseite",
"deadlines.side.both": "Beide",
"deadlines.appellant.label": "Berufung durch:",
"deadlines.appellant.claimant": "Klägerseite",
"deadlines.appellant.defendant": "Beklagtenseite",
"deadlines.appellant.none": "—",
"deadlines.event.composite.label": "Zusammengesetzt:",
"deadlines.event.unit.days.one": "Tag",
"deadlines.event.unit.days.many": "Tage",
@@ -1426,8 +1436,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",
@@ -3242,9 +3258,11 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.view.timeline": "Timeline",
"deadlines.view.columns": "Columns",
"deadlines.notes.show": "Show details",
"deadlines.col.proactive": "Proactive",
"deadlines.col.proactive": "Proactive (Claimant side)",
"deadlines.col.proactive.defendant": "Proactive (Defendant side)",
"deadlines.col.court": "Court",
"deadlines.col.reactive": "Reactive",
"deadlines.col.reactive": "Reactive (Defendant side)",
"deadlines.col.reactive.claimant": "Reactive (Claimant side)",
"deadlines.col.both": "Both parties",
"deadlines.adjusted": "Adjusted",
"deadlines.adjusted.reason": "weekend/holiday",
@@ -3364,6 +3382,14 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.perspective.defendant.title": "Defendant side — hides typical claimant submissions",
"deadlines.perspective.appeal_filed_by.label": "Appeal filed by:",
"deadlines.perspective.predefined_hint": "predefined from project",
"deadlines.side.label": "Side:",
"deadlines.side.claimant": "Claimant",
"deadlines.side.defendant": "Defendant",
"deadlines.side.both": "Both",
"deadlines.appellant.label": "Appeal filed by:",
"deadlines.appellant.claimant": "Claimant",
"deadlines.appellant.defendant": "Defendant",
"deadlines.appellant.none": "—",
"deadlines.event.composite.label": "Composite:",
"deadlines.event.unit.days.one": "day",
"deadlines.event.unit.days.many": "days",
@@ -4347,8 +4373,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

@@ -11,6 +11,13 @@ const WIDTH_KEY = "paliad-sidebar-width";
const SIDEBAR_WIDTH_MIN = 180;
const SIDEBAR_WIDTH_MAX = 480;
const SIDEBAR_WIDTH_DEFAULT = 240;
// Per-tab scroll position of the .sidebar-nav scroll container. Persisted
// on every scroll event, restored on initSidebar() so a full-page nav
// click doesn't bounce the user back to the top of a long sidebar
// (Werkzeuge + projects + user views can easily overflow). sessionStorage
// scopes it to the tab — opening a sidebar link in a new tab (Cmd-click)
// starts that tab fresh at the top, which matches user expectation.
const SCROLL_KEY = "paliad.sidebar.scroll";
// toggleMobileSidebar opens or closes the slide-out drawer. Exposed so the
// BottomNav menu slot can call it without duplicating the open/close
@@ -49,6 +56,23 @@ function applySidebarWidth(px: number): void {
document.documentElement.style.setProperty("--sidebar-width", `${px}px`);
}
// readStoredScroll returns the persisted scrollTop or 0 when missing /
// malformed. Bounds are checked at apply time against the actual
// scrollHeight, so a stale value pointing past the current scroll range
// is harmless (the browser clamps assignments to [0, max]).
function readStoredScroll(): number {
const raw = sessionStorage.getItem(SCROLL_KEY);
if (raw === null) return 0;
const n = parseInt(raw, 10);
if (!Number.isFinite(n) || n < 0) return 0;
return n;
}
function applySidebarScroll(nav: HTMLElement, px: number): void {
if (px <= 0) return;
nav.scrollTop = px;
}
// migrateLegacyPinKey copies the pre-rebrand pin state into the new key on
// first load and removes the stale entry. Drop this fallback once the rename
// grace period is over.
@@ -79,6 +103,7 @@ export function initSidebar() {
const sidebar = document.querySelector<HTMLElement>(".sidebar");
if (!sidebar) return;
initSidebarResize(sidebar);
initSidebarScrollRestore(sidebar);
const pinBtn = sidebar.querySelector<HTMLButtonElement>(".sidebar-pin");
const hamburger = document.querySelector<HTMLButtonElement>(".sidebar-hamburger");
@@ -293,6 +318,29 @@ function initSidebarResize(sidebar: HTMLElement): void {
});
}
// initSidebarScrollRestore wires the .sidebar-nav scroll container to
// sessionStorage so the user's scroll position survives a full-page
// navigation (every sidebar link click is a real reload — see m/paliad#85).
// Restore is synchronous on init so the first paint is already at the
// right offset; the passive scroll listener persists subsequent moves.
// reapplySidebarScroll() exists so callers that mutate sidebar content
// async (initUserViewsGroup appending /api/user-views into the Ansichten
// group) can nudge the scroll back to where it was after the layout shift.
function initSidebarScrollRestore(sidebar: HTMLElement): void {
const nav = sidebar.querySelector<HTMLElement>(".sidebar-nav");
if (!nav) return;
applySidebarScroll(nav, readStoredScroll());
nav.addEventListener("scroll", () => {
sessionStorage.setItem(SCROLL_KEY, String(nav.scrollTop));
}, { passive: true });
}
function reapplySidebarScroll(): void {
const nav = document.querySelector<HTMLElement>(".sidebar .sidebar-nav");
if (!nav) return;
applySidebarScroll(nav, readStoredScroll());
}
// Changelog badge — fetches the count of entries newer than the locally
// stored "last seen" stamp and renders a dot + number on the Neuigkeiten
// link. Skipped on the changelog page itself because changelog.ts stamps
@@ -432,6 +480,11 @@ function initUserViewsGroup(): void {
for (const view of views) {
items.appendChild(renderUserViewItem(view, currentPath));
}
// The synchronous restore in initSidebarScrollRestore() happened
// before these views were appended, so a saved scrollTop that
// pointed below the Ansichten group would now sit on the wrong
// row. Re-apply once the layout has stabilised.
reapplySidebarScroll();
// After rendering, kick off count refresh for views that opted in.
for (const view of views) {
if (view.show_count) {

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

@@ -12,6 +12,7 @@ import { initI18n, t, tDyn, getLang, onLangChange } from "./i18n";
import { initSidebar } from "./sidebar";
import {
type DeadlineResponse,
type Side,
calculateDeadlines,
escHtml,
formatDate,
@@ -24,6 +25,70 @@ import {
let selectedType = "";
let lastResponse: DeadlineResponse | null = null;
// Perspective state (t-paliad-250 / m/paliad#81). URL-driven so the
// view is shareable and survives reload:
// ?side=claimant|defendant → swaps which column owns the user's
// side (proactive vs reactive label).
// Default null = claimant-on-the-left.
// ?appellant=claimant|defendant → collapses party=both rows into the
// appellant's column (no mirror).
// Only meaningful for role-swap
// proceedings (Appeal etc.). Default
// null = legacy mirror behaviour.
let currentSide: Side = null;
let currentAppellant: Side = null;
// Proceedings where one party initiates and "both" rows are role-swap
// (i.e. either party files depending on who acted at the lower
// instance). For these proceedings the appellant selector is meaningful
// — when set, "both" rows collapse to a single row in the appellant's
// column. For first-instance proceedings (Inf, Rev, …) the selector is
// hidden because there's no appellant axis.
//
// Today: every upc.apl.* family member plus dpma.appeal.* and
// de.inf.olg / de.inf.bgh / de.null.bgh (DE Berufung / Revision).
// Conservative — false negatives just hide a control; false positives
// would show an irrelevant control.
const APPELLANT_AXIS_PROCEEDINGS = new Set([
"upc.apl.merits",
"upc.apl.cost",
"upc.apl.order",
"de.inf.olg",
"de.inf.bgh",
"de.null.bgh",
"dpma.appeal.bpatg",
"dpma.appeal.bgh",
"epa.opp.boa",
]);
function hasAppellantAxis(proceedingType: string): boolean {
return APPELLANT_AXIS_PROCEEDINGS.has(proceedingType);
}
function readSideFromURL(): Side {
const raw = new URLSearchParams(window.location.search).get("side");
return raw === "claimant" || raw === "defendant" ? raw : null;
}
function readAppellantFromURL(): Side {
const raw = new URLSearchParams(window.location.search).get("appellant");
return raw === "claimant" || raw === "defendant" ? raw : null;
}
function writeSideToURL(s: Side) {
const url = new URL(window.location.href);
if (s === null) url.searchParams.delete("side");
else url.searchParams.set("side", s);
window.history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash);
}
function writeAppellantToURL(a: Side) {
const url = new URL(window.location.href);
if (a === null) url.searchParams.delete("appellant");
else url.searchParams.set("appellant", a);
window.history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash);
}
// Per-rule anchor overrides set by the click-to-edit affordance on
// timeline / column date cells. Posted as `anchorOverrides` to the
// /api/tools/fristenrechner calc so downstream rules re-anchor off the
@@ -154,20 +219,31 @@ async function doCalc() {
}
// triggerEventLabelFor picks the user-facing "Auslösendes Ereignis"
// label from the calc response. The root rule (isRootEvent=true) is
// the first event in the proceeding — e.g. Klageerhebung for
// upc.inf.cfi, Nichtigkeitsklage for upc.rev.cfi. Falls back to the
// active proceeding name if no root rule fires (shouldn't happen for
// healthy data, but safer than a blank). Fallback respects language —
// proceedingNameEN is consulted on EN before the DE proceedingName
// (m/paliad#58: prior fallback rendered DE on EN for sub-track
// proceedings like upc.ccr.cfi which had no rules → no root).
// label from the calc response. Precedence:
//
// 1. Server-supplied triggerEventLabel from proceeding_types
// (mig 121, m/paliad#81). UPC Appeal sets this to
// "Anfechtbare Entscheidung" / "Appealable Decision" — its rules
// all carry a non-zero duration off the trigger date so none is
// the root, and the proceedingName fallback ("Berufungsverfahren")
// misnamed the input as the proceeding itself.
// 2. Root rule (isRootEvent=true) — the first event in the
// proceeding, e.g. Klageerhebung for upc.inf.cfi,
// Nichtigkeitsklage for upc.rev.cfi.
// 3. Active proceeding name — last-resort fallback. Language-aware
// (m/paliad#58: prior code rendered DE on EN for sub-track
// proceedings like upc.ccr.cfi which had no rules → no root).
function triggerEventLabelFor(data: DeadlineResponse): string {
const lang = getLang();
const curated = lang === "en"
? (data.triggerEventLabelEN || data.triggerEventLabel)
: (data.triggerEventLabel || data.triggerEventLabelEN);
if (curated) return curated;
const root = data.deadlines.find((d) => d.isRootEvent);
if (root) {
return getLang() === "en" ? (root.nameEN || root.name) : (root.name || root.nameEN);
return lang === "en" ? (root.nameEN || root.name) : (root.name || root.nameEN);
}
if (getLang() === "en") {
if (lang === "en") {
return data.proceedingNameEN || data.proceedingName || "";
}
return data.proceedingName || data.proceedingNameEN || "";
@@ -213,7 +289,12 @@ function renderResults(data: DeadlineResponse) {
: "";
const bodyHtml = procedureView === "columns"
? renderColumnsBody(data, { editable: true, showNotes })
? renderColumnsBody(data, {
editable: true,
showNotes,
side: currentSide,
appellant: hasAppellantAxis(selectedType) ? currentAppellant : null,
})
: renderTimelineBody(data, { showParty: true, editable: true, showNotes });
container.innerHTML = headerHtml + noteHtml + bodyHtml;
@@ -276,6 +357,7 @@ function selectProceeding(btn: HTMLButtonElement) {
void populateCourtPicker("court-picker-row", "court-picker", selectedType);
syncFlagRows();
syncAppellantRowVisibility();
setProceedingPickerCollapsed(true, proceedingDisplayName(btn));
@@ -283,6 +365,29 @@ function selectProceeding(btn: HTMLButtonElement) {
scheduleCalc(0);
}
// syncAppellantRowVisibility hides the appellant selector for
// proceedings that have no appellant axis (first-instance Inf, Rev,
// …). Clears the in-memory state and the URL param when hidden so a
// shared link with ?appellant= doesn't leak into an unrelated
// proceeding's render.
function syncAppellantRowVisibility() {
const row = document.getElementById("appellant-row");
if (!row) return;
const visible = hasAppellantAxis(selectedType);
row.style.display = visible ? "" : "none";
if (!visible && currentAppellant !== null) {
currentAppellant = null;
writeAppellantToURL(null);
syncRadioGroup("appellant", "");
}
}
function syncRadioGroup(name: string, value: string) {
document.querySelectorAll<HTMLInputElement>(`input[type=radio][name=${name}]`).forEach((input) => {
input.checked = input.value === value;
});
}
function applyVerfahrensablaufViewBodyClass(view: ProcedureView) {
// Mirrors the events.ts pattern (body.events-view-*). The print
// stylesheet keys `body.verfahrensablauf-view-timeline` to
@@ -321,6 +426,38 @@ function initViewToggle() {
toggle.style.display = "none";
}
// initPerspectiveControls hydrates side+appellant from the URL,
// reflects state into the radio inputs, and wires onchange handlers
// that update state + URL + re-render. Re-render path skips the
// /api/tools/fristenrechner round-trip — perspective is a pure
// projection of the last response, no backend involved.
function initPerspectiveControls() {
currentSide = readSideFromURL();
currentAppellant = readAppellantFromURL();
syncRadioGroup("side", currentSide ?? "");
syncRadioGroup("appellant", currentAppellant ?? "");
document.querySelectorAll<HTMLInputElement>("input[type=radio][name=side]").forEach((input) => {
input.addEventListener("change", () => {
if (!input.checked) return;
const v = input.value;
currentSide = (v === "claimant" || v === "defendant") ? v : null;
writeSideToURL(currentSide);
if (lastResponse) renderResults(lastResponse);
});
});
document.querySelectorAll<HTMLInputElement>("input[type=radio][name=appellant]").forEach((input) => {
input.addEventListener("change", () => {
if (!input.checked) return;
const v = input.value;
currentAppellant = (v === "claimant" || v === "defendant") ? v : null;
writeAppellantToURL(currentAppellant);
if (lastResponse) renderResults(lastResponse);
});
});
}
document.addEventListener("DOMContentLoaded", () => {
initI18n();
initSidebar();
@@ -390,6 +527,7 @@ document.addEventListener("DOMContentLoaded", () => {
}
initViewToggle();
initPerspectiveControls();
onLangChange(() => {
// Active-button name updates with language change (the data-i18n

View File

@@ -1,6 +1,7 @@
import { describe, expect, test } from "bun:test";
import {
type CalculatedDeadline,
bucketDeadlinesIntoColumns,
deadlineCardHtml,
} from "./verfahrensablauf-core";
@@ -65,3 +66,116 @@ describe("deadlineCardHtml — editable=true emits click-to-edit attrs", () => {
expect(html).not.toContain("data-rule-code=");
});
});
// Pure column-routing behaviour pinned by m/paliad#81. Hits
// bucketDeadlinesIntoColumns directly so the assertions stay in
// pure-Node territory (renderColumnsBody goes through escHtml ->
// document.createElement which isn't available in plain bun test).
//
// Scenario fixture mirrors the UPC Appeal "both parties" case m
// pasted into #81: every filing rule carries party='both' so the
// legacy mirror path duplicates every row across proactive +
// reactive. With ?appellant= set, the duplicate must collapse to a
// single row in the appellant's column.
describe("bucketDeadlinesIntoColumns — side+appellant column routing (m/paliad#81)", () => {
const both = (name: string, due: string): CalculatedDeadline => ({
code: name,
name,
nameEN: name,
party: "both",
priority: "mandatory",
ruleRef: "",
dueDate: due,
originalDate: due,
wasAdjusted: false,
isRootEvent: false,
isCourtSet: false,
});
const partySpecific = (party: string, name: string, due: string): CalculatedDeadline => ({
...both(name, due),
party,
});
test("default (no opts) mirrors 'both' rules into proactive AND reactive — legacy behaviour preserved", () => {
const rows = bucketDeadlinesIntoColumns([both("Notice of Appeal", "2026-07-23")]);
expect(rows).toHaveLength(1);
expect(rows[0].proactive.map((d) => d.name)).toEqual(["Notice of Appeal"]);
expect(rows[0].reactive.map((d) => d.name)).toEqual(["Notice of Appeal"]);
expect(rows[0].court).toHaveLength(0);
});
test("appellant=claimant collapses 'both' rules into proactive only — no mirror", () => {
const rows = bucketDeadlinesIntoColumns(
[both("Notice of Appeal", "2026-07-23"), both("Statement of Grounds", "2026-09-23")],
{ appellant: "claimant" },
);
expect(rows.map((r) => r.proactive.map((d) => d.name))).toEqual([
["Notice of Appeal"],
["Statement of Grounds"],
]);
rows.forEach((r) => expect(r.reactive).toHaveLength(0));
});
test("appellant=defendant collapses 'both' rules into reactive only", () => {
const rows = bucketDeadlinesIntoColumns(
[both("Notice of Appeal", "2026-07-23")],
{ appellant: "defendant" },
);
expect(rows[0].proactive).toHaveLength(0);
expect(rows[0].reactive.map((d) => d.name)).toEqual(["Notice of Appeal"]);
});
test("side=defendant swaps which column owns claimant vs defendant rules", () => {
// claimant filing must land in REACTIVE (claimant is the opposing
// side from the defendant user's perspective), defendant filing in
// PROACTIVE. Court rules always go to court.
const rows = bucketDeadlinesIntoColumns(
[
partySpecific("claimant", "Klageschrift", "2026-01-01"),
partySpecific("defendant", "Klageerwiderung", "2026-04-01"),
partySpecific("court", "Urteil", "2026-10-01"),
],
{ side: "defendant" },
);
expect(rows[0].reactive.map((d) => d.name)).toEqual(["Klageschrift"]);
expect(rows[1].proactive.map((d) => d.name)).toEqual(["Klageerwiderung"]);
expect(rows[2].court.map((d) => d.name)).toEqual(["Urteil"]);
});
test("side=defendant + appellant=defendant routes 'both' into PROACTIVE (user's own column)", () => {
// The user is the defendant AND the appellant, so the appellant's
// column == the user's own column == proactive after the swap.
const rows = bucketDeadlinesIntoColumns(
[both("Notice of Appeal", "2026-07-23")],
{ side: "defendant", appellant: "defendant" },
);
expect(rows[0].proactive.map((d) => d.name)).toEqual(["Notice of Appeal"]);
expect(rows[0].reactive).toHaveLength(0);
});
test("rows align across columns by dueDate so same-day events stay on one grid row", () => {
const sameDate = "2026-07-23";
const rows = bucketDeadlinesIntoColumns([
partySpecific("claimant", "A", sameDate),
partySpecific("defendant", "B", sameDate),
partySpecific("court", "C", sameDate),
]);
expect(rows).toHaveLength(1);
expect(rows[0].proactive.map((d) => d.name)).toEqual(["A"]);
expect(rows[0].reactive.map((d) => d.name)).toEqual(["B"]);
expect(rows[0].court.map((d) => d.name)).toEqual(["C"]);
});
test("unscheduled rows (no dueDate) trail dated rows, preserving declaration order", () => {
const rows = bucketDeadlinesIntoColumns([
partySpecific("court", "Oral Hearing", ""),
partySpecific("claimant", "Statement of Claim", "2026-01-01"),
partySpecific("court", "Decision", ""),
]);
expect(rows.map((r) => [r.proactive, r.court, r.reactive].flat().map((d) => d.name))).toEqual([
["Statement of Claim"],
["Oral Hearing"],
["Decision"],
]);
});
});

View File

@@ -110,6 +110,16 @@ export interface DeadlineResponse {
// explains the framing. (m/paliad#58)
contextualNote?: string;
contextualNoteEN?: string;
// triggerEventLabel / triggerEventLabelEN: optional caption for the
// "Auslösendes Ereignis" / "Triggering event" field on
// /tools/verfahrensablauf. Populated from paliad.proceeding_types
// when set (mig 121). The page prefers this over the proceedingName
// fallback that fires when no rule has isRootEvent=true. UPC Appeal
// uses this so the field reads "Anfechtbare Entscheidung" /
// "Appealable Decision" instead of "Berufungsverfahren" / "Appeal".
// (m/paliad#81)
triggerEventLabel?: string;
triggerEventLabelEN?: string;
}
export interface CourtRow {
@@ -412,42 +422,116 @@ export function renderTimelineBody(data: DeadlineResponse, opts: CardOpts = { sh
return html;
}
// Three-column timeline layout: Proactive (claimant) | Court | Reactive
// (defendant). Each grid row shares a dueDate so same-day events line up
// across columns; party=both renders in BOTH the Proactive and Reactive
// cells of the row. Undated rows (Urteil etc.) trail the dated tail, each
// keyed by sequence-order so e.g. Urteil precedes Berufungseinlegung.
export function renderColumnsBody(data: DeadlineResponse, opts: Omit<CardOpts, "showParty"> = {}): string {
type Cell = CalculatedDeadline[];
type Row = { proactive: Cell; court: Cell; reactive: Cell };
// Three-column timeline layout: Proactive | Court | Reactive.
//
// Column assignment per deadline (see m/paliad#81):
//
// - party=claimant → proactive
// - party=defendant → reactive
// - party=court → court
// - party=both → BOTH proactive AND reactive (mirror).
//
// When `opts.appellant` is set (claimant|defendant), "both" rows
// collapse to a single row in the appellant's column. The intent is
// role-swap proceedings (UPC Appeal, Counterclaim, …) where the
// "both" tag really means "either party files, depending on who
// initiated" — once you pick the initiator, the duplicate goes away.
// Hard rule from the issue: "When set, 'both parties' rows collapse
// to one row in the appellant's column." This is a UI projection
// only; the deadline_rules schema is unchanged. A follow-up issue
// can enrich per-rule role tagging so respondent-side filings
// (Response to Appeal, Cross-Appeal) land in the respondent's
// column — out of scope for #81.
//
// `opts.side` controls the column LABELS: side=defendant swaps the
// "Proactive (Klägerseite)" / "Reactive (Beklagtenseite)" headers
// so the user's own side is the proactive (= "your filings") column.
// It does NOT filter deadlines — the user still sees all deadlines
// in the proceeding. Default `side=null` keeps the legacy
// claimant-on-the-left layout. Unscheduled (court-set) rows trail
// the dated tail, each keyed by sequence-order so e.g. Urteil
// precedes Berufungseinlegung.
export type Side = "claimant" | "defendant" | null;
export interface ColumnsBodyOpts {
editable?: boolean;
showNotes?: boolean;
// side: which side the user is on. Drives column-label swap;
// does NOT filter rows. Default null = claimant-on-the-left.
side?: Side;
// appellant: which side initiated the appeal / counterclaim.
// When set, party=both rows go to the appellant's column ONLY
// (no mirror). Default null = mirror "both" into both cells
// (legacy behaviour). Independent of `side`.
appellant?: Side;
}
// ColumnsRow is the per-due-date bucket the renderer consumes. Public
// so unit tests can hit the pure routing logic without going through
// document.createElement (no jsdom in this repo).
export interface ColumnsRow {
key: string;
proactive: CalculatedDeadline[];
court: CalculatedDeadline[];
reactive: CalculatedDeadline[];
}
export interface BucketingOpts {
side?: Side;
appellant?: Side;
}
// bucketDeadlinesIntoColumns is the pure routing primitive that
// renderColumnsBody uses. Extracted as its own export so the per-row
// column placement (including the side-swap + appellant-collapse
// logic from m/paliad#81) is unit-testable without a DOM. The
// returned rows are sorted: dated rows ascending by dueDate, then
// unscheduled rows in declaration order (each keyed by sequence).
export function bucketDeadlinesIntoColumns(
deadlines: CalculatedDeadline[],
opts: BucketingOpts = {},
): ColumnsRow[] {
const userSide: Side = opts.side ?? null;
const claimantColumn: "proactive" | "reactive" = userSide === "defendant" ? "reactive" : "proactive";
const defendantColumn: "proactive" | "reactive" = claimantColumn === "proactive" ? "reactive" : "proactive";
const appellantColumn: "proactive" | "reactive" | null =
opts.appellant === "claimant" ? claimantColumn
: opts.appellant === "defendant" ? defendantColumn
: null;
const UNSCHEDULED_PREFIX = "__unscheduled__";
const rowsMap = new Map<string, Row>();
const ensureRow = (key: string): Row => {
const rowsMap = new Map<string, ColumnsRow>();
const ensureRow = (key: string): ColumnsRow => {
let r = rowsMap.get(key);
if (!r) {
r = { proactive: [], court: [], reactive: [] };
r = { key, proactive: [], court: [], reactive: [] };
rowsMap.set(key, r);
}
return r;
};
data.deadlines.forEach((dl, idx) => {
deadlines.forEach((dl, idx) => {
const key = dl.dueDate || `${UNSCHEDULED_PREFIX}${String(idx).padStart(4, "0")}`;
const row = ensureRow(key);
switch (dl.party) {
case "claimant":
row.proactive.push(dl);
row[claimantColumn].push(dl);
break;
case "defendant":
row.reactive.push(dl);
row[defendantColumn].push(dl);
break;
case "court":
row.court.push(dl);
break;
case "both":
row.proactive.push(dl);
row.reactive.push(dl);
if (appellantColumn !== null) {
// Role-swap collapse: appellant initiated → both → one row
// in appellant's column. Mirror suppressed.
row[appellantColumn].push(dl);
} else {
row.proactive.push(dl);
row.reactive.push(dl);
}
break;
default:
row.court.push(dl);
@@ -462,17 +546,31 @@ export function renderColumnsBody(data: DeadlineResponse, opts: Omit<CardOpts, "
}
datedKeys.sort();
unscheduledKeys.sort();
const keys = [...datedKeys, ...unscheduledKeys];
return [...datedKeys, ...unscheduledKeys].map((k) => rowsMap.get(k)!);
}
export function renderColumnsBody(data: DeadlineResponse, opts: ColumnsBodyOpts = {}): string {
const userSide: Side = opts.side ?? null;
const rows = bucketDeadlinesIntoColumns(data.deadlines, { side: userSide, appellant: opts.appellant });
const appellantColumn: "proactive" | "reactive" | null =
opts.appellant === "claimant" ? (userSide === "defendant" ? "reactive" : "proactive")
: opts.appellant === "defendant" ? (userSide === "defendant" ? "proactive" : "reactive")
: null;
const cardOpts: CardOpts = { showParty: false, editable: opts.editable, showNotes: opts.showNotes };
// Collapsed "both" rows lose their mirror tag — there's no longer
// a sibling row to mirror to, so the "↔ beide Seiten" hint would
// be misleading. Keep it for the legacy mirror path.
const showMirrorTag = appellantColumn === null;
const renderCell = (items: CalculatedDeadline[]): string => {
if (items.length === 0) {
return `<div class="fr-col-cell fr-col-cell--empty"></div>`;
}
const cards = items
.map((dl) => {
const mirrorTag = dl.party === "both"
const mirrorTag = showMirrorTag && dl.party === "both"
? `<div class="fr-col-mirror">↔ ${escHtml(t("deadlines.party.both.label"))}</div>`
: "";
return `<div class="fr-col-item ${dl.isRootEvent ? "fr-col-root" : ""}">
@@ -487,13 +585,22 @@ export function renderColumnsBody(data: DeadlineResponse, opts: Omit<CardOpts, "
const headerCell = (label: string, cls: string) =>
`<div class="fr-col-header ${cls}">${escHtml(label)}</div>`;
let html = '<div class="fr-columns-view">';
html += headerCell(t("deadlines.col.proactive"), "fr-col-proactive");
html += headerCell(t("deadlines.col.court"), "fr-col-court");
html += headerCell(t("deadlines.col.reactive"), "fr-col-reactive");
// Column-label swap when side=defendant: the user's own side stays
// labelled "Proaktiv" (their filings) and the opposing side is
// "Reaktiv". Default keeps the legacy claimant=proactive labels.
const proactiveLabel = userSide === "defendant"
? t("deadlines.col.proactive.defendant")
: t("deadlines.col.proactive");
const reactiveLabel = userSide === "defendant"
? t("deadlines.col.reactive.claimant")
: t("deadlines.col.reactive");
for (const key of keys) {
const row = rowsMap.get(key)!;
let html = '<div class="fr-columns-view">';
html += headerCell(proactiveLabel, "fr-col-proactive");
html += headerCell(t("deadlines.col.court"), "fr-col-court");
html += headerCell(reactiveLabel, "fr-col-reactive");
for (const row of rows) {
html += renderCell(row.proactive);
html += renderCell(row.court);
html += renderCell(row.reactive);

View File

@@ -1112,6 +1112,10 @@ export type I18nKey =
| "deadlines.adjusted.weekend"
| "deadlines.adjusted.weekend.saturday"
| "deadlines.adjusted.weekend.sunday"
| "deadlines.appellant.claimant"
| "deadlines.appellant.defendant"
| "deadlines.appellant.label"
| "deadlines.appellant.none"
| "deadlines.calculate"
| "deadlines.card.calc.add_to_project"
| "deadlines.card.calc.add_to_project.disabled"
@@ -1139,7 +1143,9 @@ export type I18nKey =
| "deadlines.col.due"
| "deadlines.col.event_type"
| "deadlines.col.proactive"
| "deadlines.col.proactive.defendant"
| "deadlines.col.reactive"
| "deadlines.col.reactive.claimant"
| "deadlines.col.rule"
| "deadlines.col.status"
| "deadlines.col.title"
@@ -1366,6 +1372,10 @@ export type I18nKey =
| "deadlines.search.placeholder"
| "deadlines.search.results.count"
| "deadlines.search.results.count_one"
| "deadlines.side.both"
| "deadlines.side.claimant"
| "deadlines.side.defendant"
| "deadlines.side.label"
| "deadlines.source.caldav"
| "deadlines.source.fristenrechner"
| "deadlines.source.imported"
@@ -2188,6 +2198,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 +2292,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

@@ -3548,6 +3548,30 @@ input[type="range"]::-moz-range-thumb {
cursor: pointer;
}
/* Verfahrensablauf — perspective strip (side + appellant selectors,
t-paliad-250 / m/paliad#81). Two rows so the labels stack cleanly on
narrow viewports; each row reuses .fristen-view-toggle for the
chip-radio cluster so the visual language matches the view-toggle
above it. The appellant row hides for proceedings without an
appellant axis (Inf / Rev first-instance). */
.verfahrensablauf-perspective {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-top: 0.75rem;
}
.verfahrensablauf-perspective-row {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.verfahrensablauf-perspective-row .fristen-view-toggle {
margin-bottom: 0;
}
/* Compact note hint — sits in the timeline-meta line when the notes
toggle is off. Native browser tooltip via title= attribute carries
the full text on hover; tabindex=0 + aria-label make it
@@ -7291,6 +7315,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

@@ -210,6 +210,53 @@ export function renderVerfahrensablauf(): string {
Fristen berechnen
</button>
</div>
{/* Perspective strip (t-paliad-250 / m/paliad#81). Side
swaps the column LABELS so the user's own side is
proactive (= "your filings"). Appellant collapses
party=both rows to a single column when set — only
relevant for role-swap proceedings (Appeal etc.);
the row hides itself when the picked proceeding has
no appellant axis (see hasAppellantAxis() in the
client). Both selectors are URL-driven (?side= +
?appellant=) so the perspective survives reload
and is shareable. */}
<div className="verfahrensablauf-perspective" id="verfahrensablauf-perspective">
<div className="verfahrensablauf-perspective-row" id="side-row">
<span className="date-label" data-i18n="deadlines.side.label">Seite:</span>
<div className="fristen-view-toggle" role="radiogroup" aria-label="Side">
<label className="fristen-view-option">
<input type="radio" name="side" value="claimant" />
<span data-i18n="deadlines.side.claimant">Klägerseite</span>
</label>
<label className="fristen-view-option">
<input type="radio" name="side" value="defendant" />
<span data-i18n="deadlines.side.defendant">Beklagtenseite</span>
</label>
<label className="fristen-view-option">
<input type="radio" name="side" value="" checked />
<span data-i18n="deadlines.side.both">Beide</span>
</label>
</div>
</div>
<div className="verfahrensablauf-perspective-row" id="appellant-row" style="display:none">
<span className="date-label" data-i18n="deadlines.appellant.label">Berufung durch:</span>
<div className="fristen-view-toggle" role="radiogroup" aria-label="Appellant">
<label className="fristen-view-option">
<input type="radio" name="appellant" value="claimant" />
<span data-i18n="deadlines.appellant.claimant">Klägerseite</span>
</label>
<label className="fristen-view-option">
<input type="radio" name="appellant" value="defendant" />
<span data-i18n="deadlines.appellant.defendant">Beklagtenseite</span>
</label>
<label className="fristen-view-option">
<input type="radio" name="appellant" value="" checked />
<span data-i18n="deadlines.appellant.none"></span>
</label>
</div>
</div>
</div>
</div>
<div className="wizard-step" id="step-3" style="display:none">

View File

@@ -0,0 +1,7 @@
-- Drop the optional trigger-event label columns added in
-- 121_proceeding_trigger_event_label.up.sql. Any populated rows lose
-- their override; the frontend falls back to proceedingName.
ALTER TABLE paliad.proceeding_types
DROP COLUMN IF EXISTS trigger_event_label_en,
DROP COLUMN IF EXISTS trigger_event_label_de;

View File

@@ -0,0 +1,27 @@
-- t-paliad-250 / m/paliad#81 — Concern B: UPC Appeal trigger-event label.
--
-- The /tools/verfahrensablauf "Auslösendes Ereignis" caption falls back
-- to `paliad.proceeding_types.name` whenever the calculator finds no
-- root rule (duration_value=0 + parent_id=NULL + !is_court_set). For
-- UPC Appeal (upc.apl.merits) all rules carry a non-zero duration off
-- the trigger date, so the caption reads "Berufungsverfahren" /
-- "Appeal" — the proceeding itself — instead of the appealable
-- decision that actually starts the clock.
--
-- Fix: add an optional `trigger_event_label_de` / `trigger_event_label_en`
-- pair on proceeding_types. When set, the calculator surfaces it on the
-- response (TriggerEventLabel{,EN}) and the frontend prefers it over
-- proceedingName. No deadline-rule additions, no slug changes; existing
-- proceeding_type.code stays stable (hard rule from the issue).
ALTER TABLE paliad.proceeding_types
ADD COLUMN IF NOT EXISTS trigger_event_label_de text,
ADD COLUMN IF NOT EXISTS trigger_event_label_en text;
-- UPC Appeal: the trigger date is the date of the appealable first-instance
-- decision (per UPC RoP R.224(1)(a) the 2-month appeal clock runs from
-- service of the decision per R.220.1(a)/(b)).
UPDATE paliad.proceeding_types
SET trigger_event_label_de = 'Anfechtbare Entscheidung',
trigger_event_label_en = 'Appealable Decision'
WHERE code = 'upc.apl.merits';

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

@@ -721,6 +721,14 @@ type ProceedingType struct {
DefaultColor string `db:"default_color" json:"default_color"`
SortOrder int `db:"sort_order" json:"sort_order"`
IsActive bool `db:"is_active" json:"is_active"`
// TriggerEventLabel{DE,EN}: optional caption for /tools/verfahrensablauf
// "Auslösendes Ereignis". When set, overrides the proceedingName fallback
// that fires when no rule has IsRootEvent=true. Populated for UPC Appeal
// (mig 121) so the caption reads "Anfechtbare Entscheidung" /
// "Appealable Decision" instead of "Berufungsverfahren" / "Appeal".
// NULL on most proceedings — they already carry a root rule.
TriggerEventLabelDE *string `db:"trigger_event_label_de" json:"trigger_event_label_de,omitempty"`
TriggerEventLabelEN *string `db:"trigger_event_label_en" json:"trigger_event_label_en,omitempty"`
}
// TriggerEvent is a UPC procedural event that can start one or more deadlines

View File

@@ -115,6 +115,16 @@ type UIResponse struct {
// note explaining the framing.
ContextualNote string `json:"contextualNote,omitempty"`
ContextualNoteEN string `json:"contextualNoteEN,omitempty"`
// TriggerEventLabel / TriggerEventLabelEN: optional caption for the
// /tools/verfahrensablauf "Auslösendes Ereignis" field. Populated
// from paliad.proceeding_types.trigger_event_label_{de,en} (mig 121).
// The frontend prefers this over the proceedingName fallback that
// fires when no rule has IsRootEvent=true — UPC Appeal needed it
// because all its rules carry a non-zero duration off the trigger
// date so no rule is the "anchor". The trigger event for UPC Appeal
// is the appealable first-instance decision (m/paliad#81).
TriggerEventLabel string `json:"triggerEventLabel,omitempty"`
TriggerEventLabelEN string `json:"triggerEventLabelEN,omitempty"`
}
// ErrUnknownProceedingType is returned when the UI sends an unrecognised code.
@@ -237,14 +247,17 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
// Look up proceeding type metadata.
var pt struct {
ID int `db:"id"`
Code string `db:"code"`
Name string `db:"name"`
NameEN string `db:"name_en"`
Jurisdiction *string `db:"jurisdiction"`
ID int `db:"id"`
Code string `db:"code"`
Name string `db:"name"`
NameEN string `db:"name_en"`
Jurisdiction *string `db:"jurisdiction"`
TriggerEventLabelDE *string `db:"trigger_event_label_de"`
TriggerEventLabelEN *string `db:"trigger_event_label_en"`
}
err = s.rules.db.GetContext(ctx, &pt,
`SELECT id, code, name, name_en, jurisdiction
`SELECT id, code, name, name_en, jurisdiction,
trigger_event_label_de, trigger_event_label_en
FROM paliad.proceeding_types
WHERE code = $1 AND is_active = true`, proceedingCode)
if errors.Is(err, sql.ErrNoRows) {
@@ -271,7 +284,8 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
hasSubTrackNote = true
// Re-resolve to the parent proceeding for rule lookup.
err = s.rules.db.GetContext(ctx, &pt,
`SELECT id, code, name, name_en, jurisdiction
`SELECT id, code, name, name_en, jurisdiction,
trigger_event_label_de, trigger_event_label_en
FROM paliad.proceeding_types
WHERE code = $1 AND is_active = true`, route.ParentCode)
if errors.Is(err, sql.ErrNoRows) {
@@ -604,6 +618,17 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
TriggerDate: triggerDateStr,
Deadlines: deadlines,
}
// Sub-track routing keeps the user-picked proceeding's identity,
// so the trigger-event label rides on `pickedProceeding` (e.g.
// upc.ccr.cfi inherits whatever upc.inf.cfi's caption is, not
// upc.ccr.cfi's own — which is fine: the sub-track note already
// explains the framing).
if pickedProceeding.TriggerEventLabelDE != nil {
resp.TriggerEventLabel = *pickedProceeding.TriggerEventLabelDE
}
if pickedProceeding.TriggerEventLabelEN != nil {
resp.TriggerEventLabelEN = *pickedProceeding.TriggerEventLabelEN
}
if hasSubTrackNote {
resp.ContextualNote = subTrackNote.NoteDE
resp.ContextualNoteEN = subTrackNote.NoteEN

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()