Compare commits
15 Commits
mai/cronus
...
mai/brunel
| Author | SHA1 | Date | |
|---|---|---|---|
| 0ac26fe0ee | |||
| 50cd80a4a6 | |||
| 716f6d7ece | |||
| 1bf62c78e3 | |||
| 9a774ba3ad | |||
| 8caaf6a631 | |||
| 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).
|
||||
@@ -366,6 +366,7 @@ function initEdit() {
|
||||
const etEdit = document.getElementById("deadline-event-types-edit");
|
||||
const projectLink = document.getElementById("deadline-project-link") as HTMLAnchorElement;
|
||||
const projectEdit = document.getElementById("deadline-project-edit") as HTMLSelectElement | null;
|
||||
const titleDefaultBtn = document.getElementById("deadline-title-default-btn") as HTMLButtonElement | null;
|
||||
|
||||
function enterEdit() {
|
||||
titleDisplay.style.display = "none";
|
||||
@@ -381,6 +382,7 @@ function initEdit() {
|
||||
projectEdit.style.display = "";
|
||||
projectEdit.value = deadline.project_id;
|
||||
}
|
||||
if (titleDefaultBtn) titleDefaultBtn.style.display = "";
|
||||
saveBtn.style.display = "";
|
||||
editBtn.style.display = "none";
|
||||
titleEdit.focus();
|
||||
@@ -399,12 +401,41 @@ function initEdit() {
|
||||
projectEdit.style.display = "none";
|
||||
projectLink.style.display = "";
|
||||
}
|
||||
if (titleDefaultBtn) titleDefaultBtn.style.display = "none";
|
||||
saveBtn.style.display = "none";
|
||||
editBtn.style.display = "";
|
||||
}
|
||||
|
||||
editBtn.addEventListener("click", enterEdit);
|
||||
|
||||
// t-paliad-251 Part 4 — Standardtitel button.
|
||||
// Recipe (mirror of computeDefaultTitle in deadlines-new.ts):
|
||||
// head = event_type label (if exactly one Typ chip is in edit)
|
||||
// || rule code+name (when deadline carries a rule)
|
||||
// || "Neue Frist" fallback
|
||||
// suffix = " — <project.reference>" when not already in head
|
||||
titleDefaultBtn?.addEventListener("click", () => {
|
||||
if (!deadline) return;
|
||||
let head = "";
|
||||
const ids = eventTypePicker?.getIDs() ?? deadline.event_type_ids ?? [];
|
||||
if (ids.length === 1) {
|
||||
const et = eventTypeByID.get(ids[0]);
|
||||
if (et) head = eventTypeLabel(et);
|
||||
}
|
||||
if (!head && rule) {
|
||||
const code = rule.rule_code || rule.code || "";
|
||||
head = code ? `${code} — ${rule.name}` : rule.name;
|
||||
}
|
||||
if (!head && deadline.rule_code) {
|
||||
head = deadline.rule_code;
|
||||
}
|
||||
if (!head) head = t("deadlines.field.title.default_fallback");
|
||||
const ref = project?.reference?.trim() || "";
|
||||
if (ref && !head.includes(ref)) head = `${head} — ${ref}`;
|
||||
titleEdit.value = head;
|
||||
titleEdit.focus();
|
||||
});
|
||||
|
||||
saveBtn.addEventListener("click", async () => {
|
||||
if (!deadline) return;
|
||||
const newTitle = titleEdit.value.trim();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { initI18n, t, tDyn } from "./i18n";
|
||||
import { initI18n, t, tDyn, getLang } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import {
|
||||
attachEventTypePicker,
|
||||
@@ -24,6 +24,10 @@ interface Project {
|
||||
reference?: string | null;
|
||||
title: string;
|
||||
path: string;
|
||||
// t-paliad-251 — used by Type→Rule autofill to narrow rule candidates
|
||||
// to the project's own proceeding. Optional because not every project
|
||||
// is a case/proceeding (clients + matters carry no proceeding type).
|
||||
proceeding_type_id?: number | null;
|
||||
}
|
||||
|
||||
interface DeadlineRule {
|
||||
@@ -32,15 +36,32 @@ interface DeadlineRule {
|
||||
name: string;
|
||||
name_en: string;
|
||||
rule_code?: string;
|
||||
proceeding_type_id?: number | null;
|
||||
sequence_order?: number;
|
||||
// t-paliad-165 — canonical event_type for this rule's concept,
|
||||
// hydrated server-side from paliad.deadline_concept_event_types.
|
||||
// Drives auto-fill of the Typ chip when the user picks this rule.
|
||||
// Drives auto-fill of the Typ chip when the user picks this rule,
|
||||
// AND is inverted to power Typ→Regel auto-fill (t-paliad-251 Part 2):
|
||||
// given a chosen event_type X, candidate rules are those whose
|
||||
// concept_default_event_type_id === X.
|
||||
concept_default_event_type_id?: string | null;
|
||||
}
|
||||
|
||||
interface ProceedingType {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
name_en?: string;
|
||||
jurisdiction: string;
|
||||
sort_order?: number;
|
||||
}
|
||||
|
||||
// Rules indexed by id so the Regel-change handler can look up the
|
||||
// concept's canonical event_type without re-fetching.
|
||||
let rulesByID = new Map<string, DeadlineRule>();
|
||||
let allRules: DeadlineRule[] = [];
|
||||
let proceedingTypesByID = new Map<number, ProceedingType>();
|
||||
let projectsByID = new Map<string, Project>();
|
||||
|
||||
// Last event_type the rule auto-filled. Tracked so we can tell whether
|
||||
// the picker still reflects the rule's suggestion (replace silently on
|
||||
@@ -48,6 +69,17 @@ let rulesByID = new Map<string, DeadlineRule>();
|
||||
// surface the mismatch warning instead).
|
||||
let lastAutoFilledEventTypeID: string | null = null;
|
||||
|
||||
// t-paliad-251 — symmetric flag for the inverse direction. Tracks the
|
||||
// rule ID we most recently injected as the Auto-derived default for the
|
||||
// chosen event_type, so we can replace it silently when the user picks
|
||||
// a different type but leave manual rule picks alone.
|
||||
let lastAutoFilledRuleID: string | null = null;
|
||||
|
||||
// Current sort mode for the Rule select. Persisted to localStorage so
|
||||
// repeat-form users don't have to re-pick their preferred ordering.
|
||||
type RuleSort = "by_proceeding" | "by_court" | "alpha";
|
||||
const RULE_SORT_KEY = "paliad.deadline.rule.sort";
|
||||
|
||||
let preselectedProjectID = "";
|
||||
|
||||
function esc(s: string): string {
|
||||
@@ -62,6 +94,20 @@ function showError(msg: string) {
|
||||
el.className = "form-msg form-msg-error";
|
||||
}
|
||||
|
||||
function ruleLabel(r: DeadlineRule): string {
|
||||
const lang = getLang();
|
||||
const name = (lang === "en" && r.name_en) ? r.name_en : r.name;
|
||||
const code = r.rule_code || r.code || "";
|
||||
return code ? `${code} — ${name}` : name;
|
||||
}
|
||||
|
||||
function proceedingLabel(pt: ProceedingType | undefined): string {
|
||||
if (!pt) return "";
|
||||
const lang = getLang();
|
||||
const name = (lang === "en" && pt.name_en) ? pt.name_en : pt.name;
|
||||
return `${pt.jurisdiction} — ${name}`;
|
||||
}
|
||||
|
||||
async function loadProjects() {
|
||||
const sel = document.getElementById("deadline-project") as HTMLSelectElement;
|
||||
const hint = document.getElementById("deadline-project-empty-hint")!;
|
||||
@@ -69,6 +115,7 @@ async function loadProjects() {
|
||||
const resp = await fetch("/api/projects");
|
||||
if (!resp.ok) return;
|
||||
const projects: Project[] = await resp.json();
|
||||
projectsByID = new Map(projects.map((p) => [p.id, p]));
|
||||
if (projects.length === 0) {
|
||||
hint.style.display = "";
|
||||
hint.innerHTML = `${esc(t("deadlines.field.akte.empty"))} <a href="/projects/new">${esc(t("deadlines.field.akte.empty.link"))}</a>`;
|
||||
@@ -82,7 +129,7 @@ async function loadProjects() {
|
||||
const ref = p.reference || "";
|
||||
const indent = projectIndent(p.path);
|
||||
options.push(
|
||||
`<option value="${esc(p.id)}"${isSelected}>${indent}${esc(ref)} \u2014 ${esc(p.title)}</option>`,
|
||||
`<option value="${esc(p.id)}"${isSelected}>${indent}${esc(ref)} — ${esc(p.title)}</option>`,
|
||||
);
|
||||
}
|
||||
sel.innerHTML = options.join("");
|
||||
@@ -91,28 +138,186 @@ async function loadProjects() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProceedingTypes() {
|
||||
try {
|
||||
const resp = await fetch("/api/proceeding-types-db");
|
||||
if (!resp.ok) return;
|
||||
const types: ProceedingType[] = await resp.json();
|
||||
proceedingTypesByID = new Map(types.map((pt) => [pt.id, pt]));
|
||||
} catch {
|
||||
/* non-fatal — rule sort falls back to alpha when proceeding-type
|
||||
metadata is missing */
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRules() {
|
||||
// Optional: load rules so user can attach. We pull all rules; small set.
|
||||
const sel = document.getElementById("deadline-rule") as HTMLSelectElement;
|
||||
try {
|
||||
const resp = await fetch("/api/deadline-rules");
|
||||
if (!resp.ok) return;
|
||||
const rules: DeadlineRule[] = await resp.json();
|
||||
rulesByID = new Map(rules.map((r) => [r.id, r]));
|
||||
const opts: string[] = [
|
||||
`<option value="" data-i18n="deadlines.field.rule.none">${esc(t("deadlines.field.rule.none"))}</option>`,
|
||||
];
|
||||
for (const r of rules) {
|
||||
const code = r.rule_code || r.code || "";
|
||||
const label = code ? `${code} \u2014 ${r.name}` : r.name;
|
||||
opts.push(`<option value="${esc(r.id)}">${esc(label)}</option>`);
|
||||
}
|
||||
sel.innerHTML = opts.join("");
|
||||
allRules = (await resp.json()) as DeadlineRule[];
|
||||
rulesByID = new Map(allRules.map((r) => [r.id, r]));
|
||||
renderRuleSelect();
|
||||
} catch {
|
||||
/* non-fatal — rule select stays at "no rule" */
|
||||
}
|
||||
}
|
||||
|
||||
// renderRuleSelect rebuilds the Rule <select> from the current sort
|
||||
// mode + the cached rule set. Called whenever the user changes the sort
|
||||
// dropdown, when the language flips, or after rules + proceeding types
|
||||
// finish loading. The "Keine Regel" sentinel always stays at the top.
|
||||
function renderRuleSelect(): void {
|
||||
const sel = document.getElementById("deadline-rule") as HTMLSelectElement | null;
|
||||
if (!sel) return;
|
||||
const previous = sel.value;
|
||||
|
||||
const sort = readRuleSort();
|
||||
const opts: string[] = [
|
||||
`<option value="" data-i18n="deadlines.field.rule.none">${esc(t("deadlines.field.rule.none"))}</option>`,
|
||||
];
|
||||
|
||||
if (sort === "alpha") {
|
||||
const sorted = [...allRules].sort((a, b) => ruleLabel(a).localeCompare(ruleLabel(b)));
|
||||
for (const r of sorted) {
|
||||
opts.push(`<option value="${esc(r.id)}">${esc(ruleLabel(r))}</option>`);
|
||||
}
|
||||
} else if (sort === "by_court") {
|
||||
// Group by proceeding_type.jurisdiction (UPC / EPA / DPMA / DE /
|
||||
// other). Within each group, sort alpha by rule label so the user
|
||||
// can scan a court's rules in stable order.
|
||||
const byJurisdiction = new Map<string, DeadlineRule[]>();
|
||||
for (const r of allRules) {
|
||||
const pt = r.proceeding_type_id ? proceedingTypesByID.get(r.proceeding_type_id) : undefined;
|
||||
const j = pt?.jurisdiction || t("event_types.browse.jurisdiction.none");
|
||||
const list = byJurisdiction.get(j) ?? [];
|
||||
list.push(r);
|
||||
byJurisdiction.set(j, list);
|
||||
}
|
||||
const order = ["UPC", "EPA", "EPO", "DPMA", "DE"];
|
||||
const keys = [...byJurisdiction.keys()].sort((a, b) => {
|
||||
const ai = order.indexOf(a);
|
||||
const bi = order.indexOf(b);
|
||||
if (ai === -1 && bi === -1) return a.localeCompare(b);
|
||||
if (ai === -1) return 1;
|
||||
if (bi === -1) return -1;
|
||||
return ai - bi;
|
||||
});
|
||||
for (const k of keys) {
|
||||
const list = byJurisdiction.get(k)!.sort((a, b) => ruleLabel(a).localeCompare(ruleLabel(b)));
|
||||
opts.push(`<optgroup label="${esc(k === "EPO" ? "EPA" : k)}">`);
|
||||
for (const r of list) {
|
||||
opts.push(`<option value="${esc(r.id)}">${esc(ruleLabel(r))}</option>`);
|
||||
}
|
||||
opts.push(`</optgroup>`);
|
||||
}
|
||||
} else {
|
||||
// by_proceeding — group by proceeding_type, within each preserve the
|
||||
// canonical sequence_order so the user reads "Klageerwiderung →
|
||||
// Replik → Duplik → Verhandlung" in chronological order.
|
||||
const byProceeding = new Map<number | string, DeadlineRule[]>();
|
||||
const noProceedingKey = "__none__";
|
||||
for (const r of allRules) {
|
||||
const k: number | string = r.proceeding_type_id ?? noProceedingKey;
|
||||
const list = byProceeding.get(k) ?? [];
|
||||
list.push(r);
|
||||
byProceeding.set(k, list);
|
||||
}
|
||||
const keys = [...byProceeding.keys()].sort((a, b) => {
|
||||
if (a === noProceedingKey) return 1;
|
||||
if (b === noProceedingKey) return -1;
|
||||
const pa = proceedingTypesByID.get(a as number);
|
||||
const pb = proceedingTypesByID.get(b as number);
|
||||
const sa = pa?.sort_order ?? 9999;
|
||||
const sb = pb?.sort_order ?? 9999;
|
||||
if (sa !== sb) return sa - sb;
|
||||
return (pa?.code ?? "").localeCompare(pb?.code ?? "");
|
||||
});
|
||||
for (const k of keys) {
|
||||
const list = (byProceeding.get(k)!).slice().sort(
|
||||
(a, b) => (a.sequence_order ?? 0) - (b.sequence_order ?? 0),
|
||||
);
|
||||
const pt = typeof k === "number" ? proceedingTypesByID.get(k) : undefined;
|
||||
const groupLabel = pt ? proceedingLabel(pt) : t("deadlines.field.rule.sort.other_proceeding");
|
||||
opts.push(`<optgroup label="${esc(groupLabel)}">`);
|
||||
for (const r of list) {
|
||||
opts.push(`<option value="${esc(r.id)}">${esc(ruleLabel(r))}</option>`);
|
||||
}
|
||||
opts.push(`</optgroup>`);
|
||||
}
|
||||
}
|
||||
|
||||
sel.innerHTML = opts.join("");
|
||||
// Restore previous selection if it still exists in the new order.
|
||||
if (previous && rulesByID.has(previous)) {
|
||||
sel.value = previous;
|
||||
}
|
||||
}
|
||||
|
||||
function readRuleSort(): RuleSort {
|
||||
try {
|
||||
const raw = localStorage.getItem(RULE_SORT_KEY);
|
||||
if (raw === "by_proceeding" || raw === "by_court" || raw === "alpha") return raw;
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
return "by_court";
|
||||
}
|
||||
|
||||
function writeRuleSort(s: RuleSort): void {
|
||||
try {
|
||||
localStorage.setItem(RULE_SORT_KEY, s);
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
}
|
||||
|
||||
// resolveAutoRuleForType picks the best-match rule for the chosen event
|
||||
// type, scoring by:
|
||||
// 1. project's proceeding_type_id (if known) — exact match wins,
|
||||
// 2. otherwise event_type.jurisdiction matches the rule's
|
||||
// proceeding's jurisdiction (EPA→EPO canonicalised),
|
||||
// 3. otherwise just the first candidate in the canonical ordering.
|
||||
//
|
||||
// Returns null when no rule maps to this event_type. The caller surfaces
|
||||
// this as "no Auto rule available — pick one manually" rather than
|
||||
// silently leaving the dropdown stuck on whatever the user picked before.
|
||||
function resolveAutoRuleForType(eventTypeID: string, projectID: string): DeadlineRule | null {
|
||||
const candidates = allRules.filter((r) => r.concept_default_event_type_id === eventTypeID);
|
||||
if (candidates.length === 0) return null;
|
||||
if (candidates.length === 1) return candidates[0];
|
||||
|
||||
const project = projectID ? projectsByID.get(projectID) : undefined;
|
||||
if (project?.proceeding_type_id) {
|
||||
const exact = candidates.find((r) => r.proceeding_type_id === project.proceeding_type_id);
|
||||
if (exact) return exact;
|
||||
}
|
||||
|
||||
const et = eventTypesByID.get(eventTypeID);
|
||||
if (et?.jurisdiction && et.jurisdiction !== "any") {
|
||||
const want = et.jurisdiction === "EPO" ? "EPA" : et.jurisdiction;
|
||||
const jurMatch = candidates.find((r) => {
|
||||
const pt = r.proceeding_type_id ? proceedingTypesByID.get(r.proceeding_type_id) : undefined;
|
||||
return pt?.jurisdiction === want;
|
||||
});
|
||||
if (jurMatch) return jurMatch;
|
||||
}
|
||||
|
||||
return candidates[0];
|
||||
}
|
||||
|
||||
let preselectedProjectIDLocal = "";
|
||||
|
||||
function initBackLinks() {
|
||||
if (preselectedProjectID) {
|
||||
const back = document.getElementById("deadline-new-back") as HTMLAnchorElement;
|
||||
const cancel = document.getElementById("deadline-new-cancel") as HTMLAnchorElement;
|
||||
back.href = `/projects/${preselectedProjectID}/deadlines`;
|
||||
cancel.href = `/projects/${preselectedProjectID}/deadlines`;
|
||||
}
|
||||
preselectedProjectIDLocal = preselectedProjectID;
|
||||
}
|
||||
|
||||
// t-paliad-165 follow-up — drive the collapsed/expanded view of the Typ
|
||||
// picker. The two modes are mutually exclusive:
|
||||
//
|
||||
@@ -140,8 +345,14 @@ function refreshRuleView(): void {
|
||||
|
||||
const pickerMatchesDefault =
|
||||
expected !== null && picked.length === 1 && picked[0] === expected;
|
||||
// t-paliad-251 — when the rule was auto-derived from a user-picked
|
||||
// type (Typ→Regel direction), the collapsed "vorgegeben durch Regel"
|
||||
// copy reads backwards. Show the picker explicitly + surface the
|
||||
// Auto badge on the Rule field instead.
|
||||
const ruleWasAutoDerivedFromType =
|
||||
lastAutoFilledRuleID !== null && ruleID === lastAutoFilledRuleID;
|
||||
const wantsCollapsed =
|
||||
!expandedOverride && ruleID !== "" && expected !== null && pickerMatchesDefault;
|
||||
!expandedOverride && ruleID !== "" && expected !== null && pickerMatchesDefault && !ruleWasAutoDerivedFromType;
|
||||
|
||||
if (wantsCollapsed) {
|
||||
const et = eventTypesByID.get(expected!);
|
||||
@@ -164,6 +375,58 @@ function refreshRuleView(): void {
|
||||
}
|
||||
}
|
||||
|
||||
// refreshRuleAutoBadgeAndWarning surfaces the Auto badge whenever the
|
||||
// Rule was derived from the Typ (i.e. lastAutoFilledRuleID is currently
|
||||
// selected) AND the warning whenever the user has manually picked a
|
||||
// non-Auto rule that contradicts the Type's derived rule. Both end up
|
||||
// inert when there's no Type chosen.
|
||||
function refreshRuleAutoBadgeAndWarning(): void {
|
||||
const autoEl = document.getElementById("deadline-rule-auto-hint");
|
||||
const autoTextEl = document.getElementById("deadline-rule-auto-hint-text");
|
||||
const warnEl = document.getElementById("deadline-rule-override-warn");
|
||||
if (!autoEl || !autoTextEl || !warnEl) return;
|
||||
|
||||
const ruleSel = document.getElementById("deadline-rule") as HTMLSelectElement | null;
|
||||
if (!ruleSel) return;
|
||||
|
||||
const picked = eventTypePicker?.getIDs() ?? [];
|
||||
if (picked.length !== 1) {
|
||||
autoEl.style.display = "none";
|
||||
warnEl.style.display = "none";
|
||||
return;
|
||||
}
|
||||
const projectID = (document.getElementById("deadline-project") as HTMLSelectElement | null)?.value || "";
|
||||
const derived = resolveAutoRuleForType(picked[0], projectID);
|
||||
const currentRuleID = ruleSel.value || "";
|
||||
|
||||
if (currentRuleID && currentRuleID === lastAutoFilledRuleID) {
|
||||
// The current rule was auto-derived (and the user hasn't touched it).
|
||||
autoEl.style.display = "";
|
||||
autoTextEl.textContent = derived ? ` — ${ruleLabel(derived)}` : "";
|
||||
warnEl.style.display = "none";
|
||||
return;
|
||||
}
|
||||
|
||||
autoEl.style.display = "none";
|
||||
|
||||
// Override warning: derived rule exists AND user has picked a
|
||||
// different non-empty rule. The copy names BOTH so the user knows
|
||||
// exactly what's happening — and which one will be applied.
|
||||
if (derived && currentRuleID && currentRuleID !== derived.id) {
|
||||
const current = rulesByID.get(currentRuleID);
|
||||
if (current) {
|
||||
const tmpl = t("deadlines.field.rule.override_warn");
|
||||
const msg = tmpl
|
||||
.replace("{derived}", ruleLabel(derived))
|
||||
.replace("{selected}", ruleLabel(current));
|
||||
warnEl.textContent = msg;
|
||||
warnEl.style.display = "";
|
||||
return;
|
||||
}
|
||||
}
|
||||
warnEl.style.display = "none";
|
||||
}
|
||||
|
||||
// applyRuleAutoFill replaces the picker silently when it still reflects
|
||||
// the previous rule's suggestion (or is empty); leaves a manually-edited
|
||||
// picker alone. Called whenever the Regel select changes.
|
||||
@@ -200,13 +463,97 @@ function applyRuleAutoFill(): void {
|
||||
refreshRuleView();
|
||||
}
|
||||
|
||||
function initBackLinks() {
|
||||
if (preselectedProjectID) {
|
||||
const back = document.getElementById("deadline-new-back") as HTMLAnchorElement;
|
||||
const cancel = document.getElementById("deadline-new-cancel") as HTMLAnchorElement;
|
||||
back.href = `/projects/${preselectedProjectID}/deadlines`;
|
||||
cancel.href = `/projects/${preselectedProjectID}/deadlines`;
|
||||
// applyTypeAutoFillRule is the inverse direction (t-paliad-251 Part 2):
|
||||
// when the user picks a single Typ chip, derive the canonical Rule and
|
||||
// inject it into the Regel select. Like applyRuleAutoFill, it leaves
|
||||
// manual rule picks alone — only replaces when the current rule is the
|
||||
// previous auto-fill (sticky-replace pattern).
|
||||
function applyTypeAutoFillRule(): void {
|
||||
const ruleSel = document.getElementById("deadline-rule") as HTMLSelectElement | null;
|
||||
if (!ruleSel) return;
|
||||
|
||||
const picked = eventTypePicker?.getIDs() ?? [];
|
||||
const projectID = (document.getElementById("deadline-project") as HTMLSelectElement | null)?.value || "";
|
||||
|
||||
if (picked.length !== 1) {
|
||||
// 0 or 2+ Typ chips → no canonical rule to derive. Clear the
|
||||
// sticky auto-fill so a stale Auto suggestion doesn't linger.
|
||||
if (lastAutoFilledRuleID && ruleSel.value === lastAutoFilledRuleID) {
|
||||
ruleSel.value = "";
|
||||
lastAutoFilledRuleID = null;
|
||||
// Mirror to the Regel→Typ path so its mismatch warning recomputes.
|
||||
applyRuleAutoFill();
|
||||
}
|
||||
refreshRuleAutoBadgeAndWarning();
|
||||
return;
|
||||
}
|
||||
|
||||
const derived = resolveAutoRuleForType(picked[0], projectID);
|
||||
const currentRuleID = ruleSel.value || "";
|
||||
const ruleStillReflectsLastSuggestion =
|
||||
lastAutoFilledRuleID !== null && currentRuleID === lastAutoFilledRuleID;
|
||||
const ruleIsEmpty = currentRuleID === "";
|
||||
|
||||
if (derived) {
|
||||
if (ruleIsEmpty || ruleStillReflectsLastSuggestion) {
|
||||
ruleSel.value = derived.id;
|
||||
lastAutoFilledRuleID = derived.id;
|
||||
// Mirror to the Regel→Typ direction — the new rule's collapsed
|
||||
// view + mismatch state needs to recompute now that we changed
|
||||
// the selection programmatically.
|
||||
applyRuleAutoFill();
|
||||
}
|
||||
} else if (ruleStillReflectsLastSuggestion) {
|
||||
// No derived rule for the new type — drop the stale auto-fill.
|
||||
ruleSel.value = "";
|
||||
lastAutoFilledRuleID = null;
|
||||
applyRuleAutoFill();
|
||||
}
|
||||
refreshRuleAutoBadgeAndWarning();
|
||||
}
|
||||
|
||||
// computeDefaultTitle — t-paliad-251 Part 4. Recipe (documented also in
|
||||
// the commit message so future title templates can mirror it):
|
||||
//
|
||||
// priority order picks the head of the title:
|
||||
// 1. event_type label (when exactly one Typ chip is set)
|
||||
// 2. rule name (when a Rule is set — uses ruleLabel = "code — name")
|
||||
// 3. proceeding type name (when project carries a proceeding_type_id)
|
||||
// 4. fallback: t("deadlines.field.title.default_fallback")
|
||||
//
|
||||
// suffix: " — <project-reference>" when the project has a reference
|
||||
// string and the title doesn't already contain it.
|
||||
//
|
||||
// Returns "" only when even the fallback fails (i18n unavailable) —
|
||||
// callers handle that by leaving the field untouched.
|
||||
function computeDefaultTitle(): string {
|
||||
const projectID = (document.getElementById("deadline-project") as HTMLSelectElement | null)?.value || "";
|
||||
const project = projectID ? projectsByID.get(projectID) : undefined;
|
||||
const ruleID = (document.getElementById("deadline-rule") as HTMLSelectElement | null)?.value || "";
|
||||
const rule = ruleID ? rulesByID.get(ruleID) : undefined;
|
||||
const picked = eventTypePicker?.getIDs() ?? [];
|
||||
|
||||
let head = "";
|
||||
if (picked.length === 1) {
|
||||
const et = eventTypesByID.get(picked[0]);
|
||||
if (et) head = eventTypeLabel(et);
|
||||
}
|
||||
if (!head && rule) {
|
||||
head = ruleLabel(rule);
|
||||
}
|
||||
if (!head && project?.proceeding_type_id) {
|
||||
const pt = proceedingTypesByID.get(project.proceeding_type_id);
|
||||
if (pt) head = proceedingLabel(pt);
|
||||
}
|
||||
if (!head) {
|
||||
head = t("deadlines.field.title.default_fallback");
|
||||
}
|
||||
|
||||
const ref = project?.reference?.trim() || "";
|
||||
if (ref && !head.includes(ref)) {
|
||||
return `${head} — ${ref}`;
|
||||
}
|
||||
return head;
|
||||
}
|
||||
|
||||
async function submitForm(e: Event) {
|
||||
@@ -252,8 +599,8 @@ async function submitForm(e: Event) {
|
||||
return;
|
||||
}
|
||||
const created = await resp.json();
|
||||
if (preselectedProjectID) {
|
||||
window.location.href = `/projects/${preselectedProjectID}/deadlines`;
|
||||
if (preselectedProjectIDLocal) {
|
||||
window.location.href = `/projects/${preselectedProjectIDLocal}/deadlines`;
|
||||
} else {
|
||||
window.location.href = `/deadlines/${created.id}`;
|
||||
}
|
||||
@@ -343,12 +690,33 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
// Default due to today
|
||||
const dueInput = document.getElementById("deadline-due") as HTMLInputElement;
|
||||
if (!dueInput.value) dueInput.value = new Date().toISOString().split("T")[0];
|
||||
await Promise.all([loadProjects(), loadRules(), loadMe()]);
|
||||
|
||||
// Wire the sort dropdown to read its initial value from localStorage and
|
||||
// persist user picks back.
|
||||
const sortSel = document.getElementById("deadline-rule-sort") as HTMLSelectElement | null;
|
||||
if (sortSel) {
|
||||
sortSel.value = readRuleSort();
|
||||
sortSel.addEventListener("change", () => {
|
||||
writeRuleSort(sortSel.value as RuleSort);
|
||||
renderRuleSelect();
|
||||
});
|
||||
}
|
||||
|
||||
await Promise.all([loadProjects(), loadProceedingTypes(), loadRules(), loadMe()]);
|
||||
// After both rules + proceeding types are in, re-render with the
|
||||
// chosen sort so groups carry proper labels.
|
||||
renderRuleSelect();
|
||||
|
||||
const pickerHost = document.getElementById("deadline-event-types");
|
||||
if (pickerHost) {
|
||||
eventTypePicker = attachEventTypePicker(pickerHost, {
|
||||
currentUserAdmin,
|
||||
onChange: () => refreshRuleView(),
|
||||
onChange: () => {
|
||||
// Both directions trigger off picker change: refresh the
|
||||
// Regel→Typ collapsed/expanded state AND the Typ→Regel auto-fill.
|
||||
refreshRuleView();
|
||||
applyTypeAutoFillRule();
|
||||
},
|
||||
});
|
||||
}
|
||||
// t-paliad-165 follow-up — preload event_types so the collapsed
|
||||
@@ -358,13 +726,18 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
.then((types) => {
|
||||
eventTypesByID = new Map(types.map((et) => [et.id, et]));
|
||||
refreshRuleView();
|
||||
refreshRuleAutoBadgeAndWarning();
|
||||
})
|
||||
.catch(() => {/* non-fatal — collapsed view falls back to empty label */});
|
||||
// t-paliad-165 — Regel change auto-fills the Typ chip from the rule's
|
||||
// concept's canonical event_type, when the picker hasn't been
|
||||
// manually edited away from the previous rule's suggestion.
|
||||
// manually edited away from the previous rule's suggestion. ALSO
|
||||
// resets the Typ→Regel auto-fill marker since the user just made a
|
||||
// manual rule pick.
|
||||
document.getElementById("deadline-rule")?.addEventListener("change", () => {
|
||||
lastAutoFilledRuleID = null;
|
||||
applyRuleAutoFill();
|
||||
refreshRuleAutoBadgeAndWarning();
|
||||
});
|
||||
// "Anderen Typ wählen" — sticky expanded mode so the picker stays
|
||||
// visible even when the chip still matches the rule's default.
|
||||
@@ -381,6 +754,20 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
// Wire approval-hint refresh: on first render + on project change.
|
||||
void refreshApprovalHint();
|
||||
document.getElementById("deadline-project")?.addEventListener("change", () => {
|
||||
// Project change can shift which rule the Type maps to (via the
|
||||
// project's proceeding_type_id), so re-run the auto-fill.
|
||||
void refreshApprovalHint();
|
||||
applyTypeAutoFillRule();
|
||||
});
|
||||
|
||||
// t-paliad-251 Part 4 — Standardtitel button replaces the title with
|
||||
// a derived default. No destructive confirmation because the user
|
||||
// invoked it explicitly.
|
||||
document.getElementById("deadline-title-default-btn")?.addEventListener("click", () => {
|
||||
const titleInput = document.getElementById("deadline-title") as HTMLInputElement | null;
|
||||
if (!titleInput) return;
|
||||
const derived = computeDefaultTitle();
|
||||
if (derived) titleInput.value = derived;
|
||||
titleInput.focus();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -686,6 +686,33 @@ export function openBrowseEventTypesModal(
|
||||
return new Promise<string[] | null>((resolve) => {
|
||||
let selected = new Set<string>(opts.initialIDs);
|
||||
let searchQuery = "";
|
||||
// t-paliad-251 — court-type filter chips. `null` = "Alle" (any
|
||||
// jurisdiction). Any non-null value matches event_types.jurisdiction;
|
||||
// "any" is mapped to NULL/missing rows via jurisdictionMatches().
|
||||
let activeJurisdiction: string | null = null;
|
||||
|
||||
// Surface every jurisdiction present in the data — "any" stays bucketed
|
||||
// separately so users still have a "show generic-only" chip. EPA is
|
||||
// canonicalised to EPO in event_types (see mig 074); the chip label
|
||||
// shows EPA to match the legal vocabulary the lawyers use.
|
||||
const jurisdictionsPresent = new Set<string>();
|
||||
for (const et of opts.types) {
|
||||
const j = (et.jurisdiction ?? "").trim();
|
||||
if (j) jurisdictionsPresent.add(j);
|
||||
}
|
||||
const JURISDICTION_ORDER = ["UPC", "EPO", "DPMA", "DE", "any"];
|
||||
const chipJurisdictions = JURISDICTION_ORDER.filter((j) => jurisdictionsPresent.has(j));
|
||||
// Any jurisdiction in the data that isn't in our ordered list lands at
|
||||
// the end so the chip row never silently drops a court flavour.
|
||||
for (const j of jurisdictionsPresent) {
|
||||
if (!chipJurisdictions.includes(j)) chipJurisdictions.push(j);
|
||||
}
|
||||
|
||||
function chipLabel(j: string): string {
|
||||
if (j === "EPO") return "EPA";
|
||||
if (j === "any") return t("event_types.browse.jurisdiction.none");
|
||||
return j;
|
||||
}
|
||||
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "modal-overlay event-type-browse-overlay";
|
||||
@@ -694,6 +721,15 @@ export function openBrowseEventTypesModal(
|
||||
<div class="event-type-browse-header">
|
||||
<h2 id="event-type-browse-title">${esc(t("event_types.browse.title"))}</h2>
|
||||
<input type="text" class="event-type-browse-search" data-role="search" placeholder="${esc(t("event_types.browse.search"))}" autocomplete="off" />
|
||||
<div class="event-type-browse-chips" data-role="chips" role="group" aria-label="${esc(t("event_types.browse.jurisdiction.filter_label"))}">
|
||||
<button type="button" class="event-type-browse-chip event-type-browse-chip--active" data-jurisdiction="" data-role="chip-all">${esc(t("event_types.browse.jurisdiction.all"))}</button>
|
||||
${chipJurisdictions
|
||||
.map(
|
||||
(j) =>
|
||||
`<button type="button" class="event-type-browse-chip" data-jurisdiction="${esc(j)}">${esc(chipLabel(j))}</button>`,
|
||||
)
|
||||
.join("")}
|
||||
</div>
|
||||
</div>
|
||||
<div class="event-type-browse-list" data-role="list" tabindex="-1"></div>
|
||||
<div class="event-type-browse-actions">
|
||||
@@ -711,6 +747,7 @@ export function openBrowseEventTypesModal(
|
||||
const countEl = overlay.querySelector<HTMLElement>("[data-role=count]")!;
|
||||
const cancelBtn = overlay.querySelector<HTMLButtonElement>("[data-role=cancel]")!;
|
||||
const applyBtn = overlay.querySelector<HTMLButtonElement>("[data-role=apply]")!;
|
||||
const chipButtons = overlay.querySelectorAll<HTMLButtonElement>(".event-type-browse-chip");
|
||||
|
||||
const groups = groupByCategory(opts.types);
|
||||
|
||||
@@ -721,6 +758,12 @@ export function openBrowseEventTypesModal(
|
||||
return j;
|
||||
}
|
||||
|
||||
function jurisdictionMatches(et: EventType): boolean {
|
||||
if (activeJurisdiction === null) return true;
|
||||
const j = (et.jurisdiction ?? "").trim();
|
||||
return j === activeJurisdiction;
|
||||
}
|
||||
|
||||
function updateCount() {
|
||||
countEl.textContent = t("event_types.browse.selected_count").replace(
|
||||
"{n}",
|
||||
@@ -731,6 +774,7 @@ export function openBrowseEventTypesModal(
|
||||
function renderList() {
|
||||
const q = searchQuery.trim().toLowerCase();
|
||||
const matches = (et: EventType) => {
|
||||
if (!jurisdictionMatches(et)) return false;
|
||||
if (!q) return true;
|
||||
return (
|
||||
et.label_de.toLowerCase().includes(q) ||
|
||||
@@ -783,6 +827,16 @@ export function openBrowseEventTypesModal(
|
||||
renderList();
|
||||
});
|
||||
|
||||
chipButtons.forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const raw = btn.dataset.jurisdiction ?? "";
|
||||
activeJurisdiction = raw === "" ? null : raw;
|
||||
chipButtons.forEach((b) => b.classList.remove("event-type-browse-chip--active"));
|
||||
btn.classList.add("event-type-browse-chip--active");
|
||||
renderList();
|
||||
});
|
||||
});
|
||||
|
||||
function close(value: string[] | null) {
|
||||
document.removeEventListener("keydown", onKey);
|
||||
overlay.remove();
|
||||
|
||||
@@ -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",
|
||||
@@ -879,6 +889,14 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.field.rule.autofill_inline": " (vorgegeben durch Regel)",
|
||||
"deadlines.field.rule.mismatch": "Hinweis: Typ widerspricht Regel — Sie haben den Typ überschrieben.",
|
||||
"deadlines.field.rule.override": "Anderen Typ wählen",
|
||||
"deadlines.field.rule.auto_badge": "Auto",
|
||||
"deadlines.field.rule.override_warn": "Typ ergibt Regel: {derived}. Gewählte Regel: {selected}. Es wird {selected} angewendet.",
|
||||
"deadlines.field.rule.sort.by_proceeding": "Nach Verfahrensablauf",
|
||||
"deadlines.field.rule.sort.by_court": "Nach Gerichtsart",
|
||||
"deadlines.field.rule.sort.alpha": "Alphabetisch",
|
||||
"deadlines.field.rule.sort.other_proceeding": "Sonstige Regeln",
|
||||
"deadlines.field.title.default_btn": "Standardtitel",
|
||||
"deadlines.field.title.default_fallback": "Neue Frist",
|
||||
"deadlines.field.notes": "Notizen (optional)",
|
||||
"deadlines.field.notes.placeholder": "Hinweise, Verweise, n\u00e4chste Schritte\u2026",
|
||||
"deadlines.error.required": "Akte, Titel und F\u00e4lligkeitsdatum sind Pflichtfelder.",
|
||||
@@ -1426,8 +1444,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",
|
||||
@@ -2431,6 +2455,8 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"event_types.browse.cancel": "Abbrechen",
|
||||
"event_types.browse.selected_count": "{n} ausgewählt",
|
||||
"event_types.browse.jurisdiction.none": "Allgemein",
|
||||
"event_types.browse.jurisdiction.all": "Alle Gerichte",
|
||||
"event_types.browse.jurisdiction.filter_label": "Nach Gerichtsart filtern",
|
||||
"event_types.filter.all": "Alle Typen",
|
||||
"event_types.filter.untyped": "— Ohne Typ —",
|
||||
"event_types.filter.search": "Typ suchen…",
|
||||
@@ -3242,9 +3268,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 +3392,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",
|
||||
@@ -3819,6 +3855,14 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.field.rule.autofill_inline": " (set by rule)",
|
||||
"deadlines.field.rule.mismatch": "Note: type contradicts rule — you have overridden the type.",
|
||||
"deadlines.field.rule.override": "Choose another type",
|
||||
"deadlines.field.rule.auto_badge": "Auto",
|
||||
"deadlines.field.rule.override_warn": "Type derives rule: {derived}. Selected rule: {selected}. {selected} will be applied.",
|
||||
"deadlines.field.rule.sort.by_proceeding": "By proceeding sequence",
|
||||
"deadlines.field.rule.sort.by_court": "By court type",
|
||||
"deadlines.field.rule.sort.alpha": "Alphabetical",
|
||||
"deadlines.field.rule.sort.other_proceeding": "Other rules",
|
||||
"deadlines.field.title.default_btn": "Default title",
|
||||
"deadlines.field.title.default_fallback": "New deadline",
|
||||
"deadlines.field.notes": "Notes (optional)",
|
||||
"deadlines.field.notes.placeholder": "References, hints, next steps\u2026",
|
||||
"deadlines.error.required": "Matter, title and due date are required.",
|
||||
@@ -4347,8 +4391,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",
|
||||
@@ -5343,6 +5393,8 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"event_types.browse.cancel": "Cancel",
|
||||
"event_types.browse.selected_count": "{n} selected",
|
||||
"event_types.browse.jurisdiction.none": "Any",
|
||||
"event_types.browse.jurisdiction.all": "All courts",
|
||||
"event_types.browse.jurisdiction.filter_label": "Filter by court type",
|
||||
"event_types.filter.all": "All types",
|
||||
"event_types.filter.untyped": "— Untyped —",
|
||||
"event_types.filter.search": "Search type…",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -41,6 +41,19 @@ export function renderDeadlinesDetail(): string {
|
||||
<div className="entity-detail-title-col">
|
||||
<h1 id="deadline-title-display" />
|
||||
<input type="text" id="deadline-title-edit" className="entity-title-input" style="display:none" />
|
||||
{/* t-paliad-251 Part 4 — Standardtitel button only
|
||||
visible in edit mode; clicking replaces the
|
||||
title with a default derived from the project
|
||||
and the deadline's event types / rule. */}
|
||||
<button
|
||||
type="button"
|
||||
id="deadline-title-default-btn"
|
||||
className="btn-link-action"
|
||||
style="display:none"
|
||||
data-i18n="deadlines.field.title.default_btn"
|
||||
>
|
||||
Standardtitel
|
||||
</button>
|
||||
<div className="entity-detail-meta">
|
||||
<span id="deadline-due-chip" className="frist-due-chip" />
|
||||
<span id="deadline-status-chip" className="entity-status-chip" />
|
||||
|
||||
@@ -45,7 +45,22 @@ export function renderDeadlinesNew(): string {
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label htmlFor="deadline-title" data-i18n="deadlines.field.title">Titel</label>
|
||||
<div className="form-field-label-row">
|
||||
<label htmlFor="deadline-title" data-i18n="deadlines.field.title">Titel</label>
|
||||
{/* t-paliad-251 Part 4 — derive a Standardtitel from the
|
||||
currently-known context (event type → rule → proceeding
|
||||
type → fallback) with the project reference as suffix.
|
||||
Always replaces the title; no destructive confirmation
|
||||
because the user invoked it explicitly. */}
|
||||
<button
|
||||
type="button"
|
||||
id="deadline-title-default-btn"
|
||||
className="btn-link-action"
|
||||
data-i18n="deadlines.field.title.default_btn"
|
||||
>
|
||||
Standardtitel
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
id="deadline-title"
|
||||
@@ -105,10 +120,44 @@ export function renderDeadlinesNew(): string {
|
||||
picker so the parent/child relationship reads at a
|
||||
glance. Due date is its own row below. */}
|
||||
<div className="form-field">
|
||||
<label htmlFor="deadline-rule" data-i18n="deadlines.field.rule">Regel (optional)</label>
|
||||
<div className="form-field-label-row">
|
||||
<label htmlFor="deadline-rule" data-i18n="deadlines.field.rule">Regel (optional)</label>
|
||||
{/* t-paliad-251 Part 2 — sort options for the Rule
|
||||
select. Defaults to "by_court" so users in the
|
||||
UPC bucket find UPC rules quickly. */}
|
||||
<select id="deadline-rule-sort" className="rule-sort-select" aria-label="Sortierung">
|
||||
<option value="by_proceeding" data-i18n="deadlines.field.rule.sort.by_proceeding">Nach Verfahrensablauf</option>
|
||||
<option value="by_court" data-i18n="deadlines.field.rule.sort.by_court" selected>Nach Gerichtsart</option>
|
||||
<option value="alpha" data-i18n="deadlines.field.rule.sort.alpha">Alphabetisch</option>
|
||||
</select>
|
||||
</div>
|
||||
<select id="deadline-rule">
|
||||
<option value="" data-i18n="deadlines.field.rule.none">Keine Regel</option>
|
||||
</select>
|
||||
{/* t-paliad-251 Part 3 — explicit Auto badge surfaces
|
||||
whenever the Rule was auto-derived from the Typ.
|
||||
Hidden when the user has manually picked a rule. */}
|
||||
<p
|
||||
className="form-hint form-hint--auto"
|
||||
id="deadline-rule-auto-hint"
|
||||
style="display:none"
|
||||
>
|
||||
<span
|
||||
className="form-hint-badge"
|
||||
data-i18n="deadlines.field.rule.auto_badge"
|
||||
>Auto</span>
|
||||
<span id="deadline-rule-auto-hint-text" />
|
||||
</p>
|
||||
{/* t-paliad-251 Part 3 — clearer override warning that
|
||||
names BOTH the type-derived rule and the actually-
|
||||
applied rule. Replaces the older Regel→Typ-only
|
||||
mismatch warning when the contradiction goes the
|
||||
other direction. */}
|
||||
<p
|
||||
className="form-hint form-hint--warning"
|
||||
id="deadline-rule-override-warn"
|
||||
style="display:none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
|
||||
@@ -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"
|
||||
@@ -1227,12 +1233,20 @@ export type I18nKey =
|
||||
| "deadlines.field.notes"
|
||||
| "deadlines.field.notes.placeholder"
|
||||
| "deadlines.field.rule"
|
||||
| "deadlines.field.rule.auto_badge"
|
||||
| "deadlines.field.rule.autofill"
|
||||
| "deadlines.field.rule.autofill_inline"
|
||||
| "deadlines.field.rule.mismatch"
|
||||
| "deadlines.field.rule.none"
|
||||
| "deadlines.field.rule.override"
|
||||
| "deadlines.field.rule.override_warn"
|
||||
| "deadlines.field.rule.sort.alpha"
|
||||
| "deadlines.field.rule.sort.by_court"
|
||||
| "deadlines.field.rule.sort.by_proceeding"
|
||||
| "deadlines.field.rule.sort.other_proceeding"
|
||||
| "deadlines.field.title"
|
||||
| "deadlines.field.title.default_btn"
|
||||
| "deadlines.field.title.default_fallback"
|
||||
| "deadlines.field.title.placeholder"
|
||||
| "deadlines.filter.akte"
|
||||
| "deadlines.filter.akte.all"
|
||||
@@ -1366,6 +1380,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"
|
||||
@@ -1574,6 +1592,8 @@ export type I18nKey =
|
||||
| "event_types.browse.apply"
|
||||
| "event_types.browse.cancel"
|
||||
| "event_types.browse.empty"
|
||||
| "event_types.browse.jurisdiction.all"
|
||||
| "event_types.browse.jurisdiction.filter_label"
|
||||
| "event_types.browse.jurisdiction.none"
|
||||
| "event_types.browse.search"
|
||||
| "event_types.browse.selected_count"
|
||||
@@ -2188,6 +2208,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 +2302,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
|
||||
@@ -6545,12 +6569,18 @@ dialog.modal::backdrop {
|
||||
|
||||
/* Each filter is a label-above-control cell so the caption sits on top of
|
||||
its select / button. The whole filter-row stays a horizontal flex-wrap
|
||||
of these column-cells (t-paliad-117). */
|
||||
of these column-cells (t-paliad-117).
|
||||
|
||||
min-width: 0 + max-width: 100% lets the cell shrink to fit its flex
|
||||
container and prevents a native <select> with long option text from
|
||||
blowing the cell wider than the viewport (t-paliad-255). */
|
||||
.filter-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0.25rem;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
@@ -6564,6 +6594,10 @@ dialog.modal::backdrop {
|
||||
.filter-group .entity-select { width: 100%; }
|
||||
}
|
||||
|
||||
/* max-width: 100% caps the intrinsic width of a native <select> at its
|
||||
parent — without it, browsers size the select to the longest <option>
|
||||
text and a very long project title overflows the viewport on tablet
|
||||
widths above the 480px breakpoint (t-paliad-255). */
|
||||
.entity-select {
|
||||
padding: 0.4rem 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
@@ -6572,6 +6606,8 @@ dialog.modal::backdrop {
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.entity-select:focus {
|
||||
@@ -7291,6 +7327,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;
|
||||
@@ -7506,6 +7556,78 @@ dialog.modal::backdrop {
|
||||
border-left: 2px solid #b88800;
|
||||
}
|
||||
|
||||
/* t-paliad-251 — Auto-derived hint variant. Lime-tint, sibling of the
|
||||
yellow warning variant. Carries a small pill-badge in front (the
|
||||
"Auto" label) followed by the derived rule name. */
|
||||
.form-hint--auto {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
background: var(--color-bg-lime-tint);
|
||||
color: var(--color-text);
|
||||
padding: 0.3rem 0.5rem;
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
border-left: 2px solid var(--color-accent);
|
||||
}
|
||||
.form-hint-badge {
|
||||
display: inline-block;
|
||||
padding: 0.05rem 0.45rem;
|
||||
border-radius: 999px;
|
||||
background: var(--color-accent);
|
||||
color: var(--color-text);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* t-paliad-251 — label row that hosts both the form label and an
|
||||
inline action (Standardtitel button, Rule-sort dropdown). The label
|
||||
keeps growing to push the action to the right edge. */
|
||||
.form-field-label-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.form-field-label-row > label {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Inline action button rendered next to a form label (Standardtitel).
|
||||
Text-link styling so it doesn't compete with the primary CTA. */
|
||||
.btn-link-action {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--color-link, var(--color-text));
|
||||
padding: 0;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.82rem;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
.btn-link-action:hover {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
/* Small dropdown rendered alongside the Rule label to switch the
|
||||
ordering. Tone-down sizing so it doesn't look like a co-equal
|
||||
form field. Specificity-bumped to win over `.form-field select`'s
|
||||
width: 100% baseline. */
|
||||
.form-field select.rule-sort-select,
|
||||
select.rule-sort-select {
|
||||
width: auto;
|
||||
padding: 0.2rem 0.4rem;
|
||||
font-size: 0.82rem;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
/* Inline checkbox label inside the attach-unit form. */
|
||||
.form-checkbox {
|
||||
display: inline-flex;
|
||||
@@ -12503,6 +12625,37 @@ dialog.quick-add-sheet::backdrop {
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
.event-type-browse-search:focus { border-color: var(--color-accent); }
|
||||
/* t-paliad-251 — jurisdiction filter chips inside the browse modal
|
||||
header. Sits below the search input, between the search and the
|
||||
results list. Active chip uses the lime-tint chip palette already
|
||||
established by .event-type-collapsed* (t-paliad-165). */
|
||||
.event-type-browse-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.event-type-browse-chip {
|
||||
padding: 0.2rem 0.7rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 999px;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-muted);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s ease, color 0.12s ease, border-color 0.12s ease;
|
||||
}
|
||||
.event-type-browse-chip:hover {
|
||||
background: var(--color-bg-subtle);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.event-type-browse-chip--active {
|
||||
background: var(--color-bg-lime-tint);
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
.event-type-browse-list {
|
||||
flex: 1 1 auto;
|
||||
overflow-y: auto;
|
||||
|
||||
@@ -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()
|
||||
|
||||
568
scripts/seed-example-projects/main.go
Normal file
568
scripts/seed-example-projects/main.go
Normal file
@@ -0,0 +1,568 @@
|
||||
// Seed Example Projects (t-paliad-256 / m/paliad#87).
|
||||
//
|
||||
// Re-runnable test-data reset:
|
||||
//
|
||||
// 1. Wipes every row in paliad.projects (FK CASCADE handles the
|
||||
// dependent rows: deadlines, appointments, parties, notes,
|
||||
// project_events, project_teams, submission_drafts, approval_*,
|
||||
// project_partner_units, user_pinned_projects, documents,
|
||||
// user_calendar_bindings).
|
||||
//
|
||||
// 2. Inserts a small but realistic example tree (3 clients, 4
|
||||
// litigations, 4 patents, 8 cases — 19 projects total) that
|
||||
// exercises the auto-derived chain code: Client.Litigation.Patent.Case
|
||||
// → e.g. SIEMENS.HUAW.789.INF.CFI.
|
||||
//
|
||||
// 3. Re-reads the projects and prints each row's chain code so the
|
||||
// operator can eyeball the result without bouncing to SQL.
|
||||
//
|
||||
// Reference tables (proceeding_types, deadline_rules, event_types,
|
||||
// gerichte, checklists templates, firms, profiles) are untouched.
|
||||
//
|
||||
// Run:
|
||||
//
|
||||
// DATABASE_URL='postgres://...' go run ./scripts/seed-example-projects
|
||||
//
|
||||
// One transaction wraps both wipe and seed so the DB is never in a
|
||||
// half-wiped state. Re-running drops the previous example tree and
|
||||
// reseeds fresh UUIDs — handy when project-code semantics change.
|
||||
//
|
||||
// Owner: m (matthias.siebels@hoganlovells.com). The script looks the
|
||||
// auth user up by email so it works on any environment where that
|
||||
// account exists; on a brand-new DB it falls back to NULL created_by.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/lib/pq"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
)
|
||||
|
||||
// ownerEmail is the auth.users email the seed assigns as created_by.
|
||||
// Living in code (not a flag) because the example tree is m-owned by
|
||||
// convention; flip if the example data ever needs a service-account
|
||||
// owner.
|
||||
const ownerEmail = "matthias.siebels@hoganlovells.com"
|
||||
|
||||
// Proceeding-type IDs used by the seed. Resolved by code (not pinned
|
||||
// to integer IDs in source) to survive DB renumbering. Loaded once at
|
||||
// startup; missing codes fail fast with a clear message.
|
||||
var proceedingCodes = []string{
|
||||
"upc.inf.cfi",
|
||||
"upc.ccr.cfi",
|
||||
"upc.apl.merits",
|
||||
"de.inf.lg",
|
||||
"epa.opp.opd",
|
||||
"de.null.bpatg",
|
||||
"dpma.opp.dpma",
|
||||
}
|
||||
|
||||
func main() {
|
||||
dsn := flag.String("dsn", os.Getenv("DATABASE_URL"), "Postgres DSN (defaults to $DATABASE_URL)")
|
||||
dryRun := flag.Bool("dry-run", false, "print intended actions, roll back transaction")
|
||||
flag.Parse()
|
||||
|
||||
if *dsn == "" {
|
||||
fmt.Fprintln(os.Stderr, "seed-example-projects: DATABASE_URL not set and -dsn empty")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
db, err := sqlx.Connect("postgres", *dsn)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "connect:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
if err := run(ctx, db, *dryRun); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "seed-example-projects:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(ctx context.Context, db *sqlx.DB, dryRun bool) error {
|
||||
ownerID, err := lookupOwner(ctx, db, ownerEmail)
|
||||
if err != nil {
|
||||
return fmt.Errorf("lookup owner: %w", err)
|
||||
}
|
||||
if ownerID == uuid.Nil {
|
||||
fmt.Printf("note: %s not found in auth.users — created_by will be NULL\n", ownerEmail)
|
||||
} else {
|
||||
fmt.Printf("owner resolved: %s = %s\n", ownerEmail, ownerID)
|
||||
}
|
||||
|
||||
procIDs, err := lookupProceedingTypes(ctx, db, proceedingCodes)
|
||||
if err != nil {
|
||||
return fmt.Errorf("lookup proceeding_types: %w", err)
|
||||
}
|
||||
|
||||
tx, err := db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }() // no-op if Commit ran first
|
||||
|
||||
if err := wipe(ctx, tx); err != nil {
|
||||
return fmt.Errorf("wipe: %w", err)
|
||||
}
|
||||
|
||||
tree, err := seed(ctx, tx, ownerID, procIDs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("seed: %w", err)
|
||||
}
|
||||
|
||||
if dryRun {
|
||||
fmt.Println("\n--- DRY RUN — rolling back ---")
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit: %w", err)
|
||||
}
|
||||
fmt.Println("seed committed.")
|
||||
|
||||
if err := report(ctx, db, tree); err != nil {
|
||||
return fmt.Errorf("report: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func lookupOwner(ctx context.Context, db *sqlx.DB, email string) (uuid.UUID, error) {
|
||||
var id uuid.UUID
|
||||
err := db.GetContext(ctx, &id, `SELECT id FROM auth.users WHERE email = $1`, email)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return uuid.Nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func lookupProceedingTypes(ctx context.Context, db *sqlx.DB, codes []string) (map[string]int, error) {
|
||||
rows, err := db.QueryxContext(ctx,
|
||||
`SELECT id, code FROM paliad.proceeding_types WHERE code = ANY($1)`,
|
||||
pgTextArray(codes))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make(map[string]int, len(codes))
|
||||
for rows.Next() {
|
||||
var id int
|
||||
var code string
|
||||
if err := rows.Scan(&id, &code); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[code] = id
|
||||
}
|
||||
for _, c := range codes {
|
||||
if _, ok := out[c]; !ok {
|
||||
return nil, fmt.Errorf("proceeding_types row missing for code=%q", c)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// pgTextArray is the lib/pq array adapter, repackaged inline so the
|
||||
// script doesn't need a separate util import.
|
||||
func pgTextArray(xs []string) any {
|
||||
type arr = []string
|
||||
return arr(xs)
|
||||
}
|
||||
|
||||
// wipe deletes every paliad.projects row. FK CASCADE handles the
|
||||
// dependent tables (verified live 2026-05-25 against information_schema:
|
||||
// appointments, approval_requests, approval_policies, deadlines,
|
||||
// documents, notes, parties, project_events, project_partner_units,
|
||||
// project_teams, submission_drafts, user_pinned_projects,
|
||||
// user_calendar_bindings, checklist_shares all cascade; projects.
|
||||
// counterclaim_of and checklist_instances SET NULL; policy_audit_log
|
||||
// SET NULL).
|
||||
//
|
||||
// Reference tables (proceeding_types, deadline_rules, event_types,
|
||||
// gerichte, checklists, firms, partner_units, profiles) are not
|
||||
// referenced from this delete.
|
||||
func wipe(ctx context.Context, tx *sqlx.Tx) error {
|
||||
res, err := tx.ExecContext(ctx, `DELETE FROM paliad.projects`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
fmt.Printf("wiped: %d project rows (FK CASCADE handled dependents)\n", n)
|
||||
return nil
|
||||
}
|
||||
|
||||
// seededNode is one row of the seed result, kept so we can print the
|
||||
// chain code after commit without re-querying for IDs.
|
||||
type seededNode struct {
|
||||
id uuid.UUID
|
||||
title string
|
||||
}
|
||||
|
||||
// seed inserts the example tree. Order matters because parent_id FKs
|
||||
// must already exist — clients first, then litigations under them, then
|
||||
// patents, then cases (with the CCR case referencing its sibling
|
||||
// Klage case via counterclaim_of).
|
||||
func seed(ctx context.Context, tx *sqlx.Tx, ownerID uuid.UUID, procIDs map[string]int) ([]seededNode, error) {
|
||||
var nodes []seededNode
|
||||
|
||||
insertProject := func(p projectInsert) (uuid.UUID, error) {
|
||||
id := uuid.New()
|
||||
var createdBy any
|
||||
if ownerID != uuid.Nil {
|
||||
createdBy = ownerID
|
||||
}
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO paliad.projects (
|
||||
id, type, parent_id, title, reference, description, status,
|
||||
created_by, industry, country, client_number, matter_number,
|
||||
patent_number, filing_date, grant_date,
|
||||
court, case_number, proceeding_type_id,
|
||||
our_side, opponent_code, instance_level, counterclaim_of
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, 'active',
|
||||
$7, $8, $9, $10, $11,
|
||||
$12, $13, $14,
|
||||
$15, $16, $17,
|
||||
$18, $19, $20, $21
|
||||
)`,
|
||||
id, p.Type, nullUUID(p.ParentID), p.Title, nullStr(p.Reference), nullStr(p.Description),
|
||||
createdBy, nullStr(p.Industry), nullStr(p.Country), nullStr(p.ClientNumber), nullStr(p.MatterNumber),
|
||||
nullStr(p.PatentNumber), nullDate(p.FilingDate), nullDate(p.GrantDate),
|
||||
nullStr(p.Court), nullStr(p.CaseNumber), nullInt(p.ProceedingTypeID),
|
||||
nullStr(p.OurSide), nullStr(p.OpponentCode), nullStr(p.InstanceLevel), nullUUID(p.CounterclaimOf),
|
||||
)
|
||||
if err != nil {
|
||||
return uuid.Nil, fmt.Errorf("insert %s %q: %w", p.Type, p.Title, err)
|
||||
}
|
||||
nodes = append(nodes, seededNode{id: id, title: p.Title})
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// --- Client 1: Siemens AG ----------------------------------------
|
||||
siemens, err := insertProject(projectInsert{
|
||||
Type: "client", Title: "Siemens AG", Reference: "SIEMENS",
|
||||
Industry: "Telekommunikation / Industrieelektronik", Country: "DE",
|
||||
Description: "Beispiel-Mandant — Telekommunikation & Halbleiter.",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
siemensHuawei, err := insertProject(projectInsert{
|
||||
Type: "litigation", ParentID: siemens,
|
||||
Title: "Siemens ./. Huawei Technologies", OpponentCode: "HUAW",
|
||||
Description: "Patentstreit Mobilfunk-Standardpatent.", OurSide: "claimant",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
siemensHuaweiPatent, err := insertProject(projectInsert{
|
||||
Type: "patent", ParentID: siemensHuawei,
|
||||
Title: "EP3456789 — Funkkommunikationssystem mit Mehrfachantenne",
|
||||
PatentNumber: "EP3456789",
|
||||
FilingDate: "2018-03-12", GrantDate: "2022-11-09",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
upcInfCFI, err := insertProject(projectInsert{
|
||||
Type: "case", ParentID: siemensHuaweiPatent,
|
||||
Title: "UPC CFI München — Klage Siemens ./. Huawei (EP3456789)",
|
||||
Court: "UPC Lokalkammer München",
|
||||
CaseNumber: "UPC_CFI_123/2026",
|
||||
ProceedingTypeID: procIDs["upc.inf.cfi"],
|
||||
OurSide: "claimant",
|
||||
InstanceLevel: "first",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, err = insertProject(projectInsert{
|
||||
Type: "case", ParentID: siemensHuaweiPatent,
|
||||
Title: "UPC CFI München — Widerklage Huawei ./. Siemens (EP3456789)",
|
||||
Court: "UPC Lokalkammer München",
|
||||
CaseNumber: "UPC_CFI_123/2026 (CCR)",
|
||||
ProceedingTypeID: procIDs["upc.ccr.cfi"],
|
||||
OurSide: "defendant", // we're respondent on the CCR
|
||||
InstanceLevel: "first",
|
||||
CounterclaimOf: upcInfCFI,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, err = insertProject(projectInsert{
|
||||
Type: "case", ParentID: siemensHuaweiPatent,
|
||||
Title: "UPC Berufungsgericht — Berufung Huawei (EP3456789)",
|
||||
Court: "UPC Court of Appeal",
|
||||
CaseNumber: "UPC_CoA_45/2027",
|
||||
ProceedingTypeID: procIDs["upc.apl.merits"],
|
||||
OurSide: "respondent",
|
||||
InstanceLevel: "appeal",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
siemensBosch, err := insertProject(projectInsert{
|
||||
Type: "litigation", ParentID: siemens,
|
||||
Title: "Siemens ./. Robert Bosch GmbH", OpponentCode: "BOSCH",
|
||||
Description: "Sensorik / autonomes Fahren.", OurSide: "claimant",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
siemensBoschPatent, err := insertProject(projectInsert{
|
||||
Type: "patent", ParentID: siemensBosch,
|
||||
Title: "EP1111222 — Sensoreinrichtung für autonomes Fahren",
|
||||
PatentNumber: "EP1111222",
|
||||
FilingDate: "2017-06-21", GrantDate: "2021-08-04",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, err = insertProject(projectInsert{
|
||||
Type: "case", ParentID: siemensBoschPatent,
|
||||
Title: "LG München I — Klage Siemens ./. Bosch (EP1111222)",
|
||||
Court: "Landgericht München I",
|
||||
CaseNumber: "7 O 12345/26",
|
||||
ProceedingTypeID: procIDs["de.inf.lg"],
|
||||
OurSide: "claimant",
|
||||
InstanceLevel: "first",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// --- Client 2: Bayer AG ------------------------------------------
|
||||
bayer, err := insertProject(projectInsert{
|
||||
Type: "client", Title: "Bayer AG", Reference: "BAYER",
|
||||
Industry: "Pharma / Life Sciences", Country: "DE",
|
||||
Description: "Beispiel-Mandant — pharmazeutische Wirkstoffe.",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
bayerNova, err := insertProject(projectInsert{
|
||||
Type: "litigation", ParentID: bayer,
|
||||
Title: "Bayer ./. Novartis Pharma", OpponentCode: "NOVA",
|
||||
Description: "Wirkstoffverbindung X — Einspruch + Nichtigkeit.", OurSide: "claimant",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
bayerNovaPatent, err := insertProject(projectInsert{
|
||||
Type: "patent", ParentID: bayerNova,
|
||||
Title: "EP2222333 — Wirkstoffverbindung X",
|
||||
PatentNumber: "EP2222333",
|
||||
FilingDate: "2015-09-30", GrantDate: "2020-04-22",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, err = insertProject(projectInsert{
|
||||
Type: "case", ParentID: bayerNovaPatent,
|
||||
Title: "EPA Einspruch — Novartis ./. EP2222333",
|
||||
Court: "Europäisches Patentamt — Einspruchsabteilung",
|
||||
CaseNumber: "OPP-2026-0042",
|
||||
ProceedingTypeID: procIDs["epa.opp.opd"],
|
||||
OurSide: "respondent", // Bayer is patent owner defending the patent
|
||||
InstanceLevel: "first",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, err = insertProject(projectInsert{
|
||||
Type: "case", ParentID: bayerNovaPatent,
|
||||
Title: "BPatG — Nichtigkeitsklage Novartis ./. EP2222333",
|
||||
Court: "Bundespatentgericht",
|
||||
CaseNumber: "5 Ni 12/26",
|
||||
ProceedingTypeID: procIDs["de.null.bpatg"],
|
||||
OurSide: "respondent",
|
||||
InstanceLevel: "first",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// --- Client 3: Beispiel AG (intentionally sparse) ----------------
|
||||
// Demonstrates the empty-segment skip in BuildProjectCode — the
|
||||
// case row has a proceeding_type set so the tail is present, but
|
||||
// no instance_level / our_side, and the patent's number is national
|
||||
// (DE) so the last-3-digits segment shows DE-style behaviour.
|
||||
beispiel, err := insertProject(projectInsert{
|
||||
Type: "client", Title: "Beispiel AG", Reference: "BEISPL",
|
||||
Industry: "Unspezifiziert", Country: "DE",
|
||||
Description: "Sparse-Beispiel — zeigt, wie fehlende Segmente übersprungen werden.",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
beispielWtb, err := insertProject(projectInsert{
|
||||
Type: "litigation", ParentID: beispiel,
|
||||
Title: "Beispiel ./. Wettbewerber GmbH", OpponentCode: "WTB",
|
||||
Description: "Demo-Litigation ohne große Detailtiefe.",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
beispielWtbPatent, err := insertProject(projectInsert{
|
||||
Type: "patent", ParentID: beispielWtb,
|
||||
Title: "DE10987654 — Demo-Erfindung",
|
||||
PatentNumber: "DE10987654",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, err = insertProject(projectInsert{
|
||||
Type: "case", ParentID: beispielWtbPatent,
|
||||
Title: "DPMA Einspruch — Wettbewerber ./. DE10987654",
|
||||
Court: "Deutsches Patent- und Markenamt",
|
||||
CaseNumber: "DPMA-EIN-987/26",
|
||||
ProceedingTypeID: procIDs["dpma.opp.dpma"],
|
||||
OurSide: "respondent",
|
||||
InstanceLevel: "first",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fmt.Printf("seeded: %d projects\n", len(nodes))
|
||||
return nodes, nil
|
||||
}
|
||||
|
||||
// projectInsert is the typed input for one insertProject call. Pointer
|
||||
// fields are kept as plain strings here and converted via nullStr at
|
||||
// bind time; keeps the call sites readable.
|
||||
type projectInsert struct {
|
||||
Type string
|
||||
ParentID uuid.UUID
|
||||
Title string
|
||||
Reference string
|
||||
Description string
|
||||
Industry string
|
||||
Country string
|
||||
ClientNumber string
|
||||
MatterNumber string
|
||||
PatentNumber string
|
||||
FilingDate string // YYYY-MM-DD
|
||||
GrantDate string
|
||||
Court string
|
||||
CaseNumber string
|
||||
ProceedingTypeID int
|
||||
OurSide string
|
||||
OpponentCode string
|
||||
InstanceLevel string
|
||||
CounterclaimOf uuid.UUID
|
||||
}
|
||||
|
||||
func nullStr(s string) any {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func nullInt(i int) any {
|
||||
if i == 0 {
|
||||
return nil
|
||||
}
|
||||
return i
|
||||
}
|
||||
|
||||
func nullUUID(u uuid.UUID) any {
|
||||
if u == uuid.Nil {
|
||||
return nil
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
func nullDate(s string) any {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
t, err := time.Parse("2006-01-02", s)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// reportRow is one row of the post-seed report — only the fields the
|
||||
// printout needs.
|
||||
type reportRow struct {
|
||||
ID uuid.UUID `db:"id"`
|
||||
Type string `db:"type"`
|
||||
Title string `db:"title"`
|
||||
Path string `db:"path"`
|
||||
}
|
||||
|
||||
// report prints the seeded tree with the auto-derived chain code for
|
||||
// each row. Uses services.BuildProjectCode so the script verifies the
|
||||
// same helper the live app uses (catches drift if the algorithm
|
||||
// changes).
|
||||
func report(ctx context.Context, db *sqlx.DB, _ []seededNode) error {
|
||||
var rows []reportRow
|
||||
err := db.SelectContext(ctx, &rows, `
|
||||
SELECT id, type, title, path
|
||||
FROM paliad.projects
|
||||
ORDER BY path
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println("\nresulting chain codes:")
|
||||
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(tw, "TYPE\tTITLE\tCODE")
|
||||
for _, r := range rows {
|
||||
code, err := services.BuildProjectCode(ctx, db, r.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build code for %s: %w", r.ID, err)
|
||||
}
|
||||
indent := strings.Repeat(" ", pathDepth(r.Path)-1)
|
||||
fmt.Fprintf(tw, "%s\t%s%s\t%s\n", r.Type, indent, r.Title, code)
|
||||
}
|
||||
return tw.Flush()
|
||||
}
|
||||
|
||||
func pathDepth(p string) int {
|
||||
if p == "" {
|
||||
return 1
|
||||
}
|
||||
d := 1
|
||||
for _, c := range p {
|
||||
if c == '.' {
|
||||
d++
|
||||
}
|
||||
}
|
||||
return d
|
||||
}
|
||||
Reference in New Issue
Block a user