Compare commits
9 Commits
mai/cronus
...
mai/hermes
| Author | SHA1 | Date | |
|---|---|---|---|
| 228ae1b263 | |||
| cdd3747c2b | |||
| 02255c4234 | |||
| 206f2917ea | |||
| 5df87f4129 | |||
| 898348a64a | |||
| 1714b788d2 | |||
| db8335253b | |||
| 5589cbb477 |
@@ -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 │ 1’842 │ Download │
|
||||
│ 03:00 UTC │ │ │ │ │ │
|
||||
├──────────────┼────────────┼───────────┼─────────┼────────────┼──────────┤
|
||||
│ 2026-05-24 │ Manuell │ ✓ Fertig │ 138 KB │ 1’793 │ Download │
|
||||
│ 14:22 UTC │ │ │ │ (m@hl…) │ │
|
||||
├──────────────┼────────────┼───────────┼─────────┼────────────┼──────────┤
|
||||
│ 2026-05-24 │ Geplant │ ✓ Fertig │ 137 KB │ 1’791 │ 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).
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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. */}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
@@ -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 —
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user