Compare commits
39 Commits
mai/knuth/
...
mai/cronus
| Author | SHA1 | Date | |
|---|---|---|---|
| 99c9d89daa | |||
| ef21e43375 | |||
| 4cb99fb627 | |||
| 452ccdf127 | |||
| 045accc6d9 | |||
| e6b61b4d2e | |||
| 940df95418 | |||
| 538c2d2da9 | |||
| a9a9adbd2a | |||
| f24a90b722 | |||
| 55bfe439f2 | |||
| 0ac26fe0ee | |||
| 72b64140e9 | |||
| 50cd80a4a6 | |||
| 716f6d7ece | |||
| 1bf62c78e3 | |||
| 9a774ba3ad | |||
| 8caaf6a631 | |||
| 228ae1b263 | |||
| cdd3747c2b | |||
| 02255c4234 | |||
| 206f2917ea | |||
| 5df87f4129 | |||
| 898348a64a | |||
| 1714b788d2 | |||
| db8335253b | |||
| 5589cbb477 | |||
| 0059e3f15b | |||
| a911a2d0ee | |||
| b26f04ffe0 | |||
| 8e195cb497 | |||
| 1f7de99493 | |||
| 0adcc2c826 | |||
| 2c7ac6423f | |||
| 436c1b41bb | |||
| 2c5f85b802 | |||
| d3aade5aac | |||
| 17d2fff661 | |||
| f7374a67cd |
@@ -151,6 +151,14 @@ func main() {
|
||||
|
||||
eventTypeSvc := services.NewEventTypeService(pool, users)
|
||||
deadlineSvc := services.NewDeadlineService(pool, projectSvc, eventTypeSvc)
|
||||
partySvc := services.NewPartyService(pool, projectSvc)
|
||||
// t-paliad-238 — dedicated submission draft editor. The variable
|
||||
// bag service is shared between the renderer (export) and the
|
||||
// preview HTML path. Resurrected from t-paliad-215 Slice 1 backend
|
||||
// (commits 3677c81 + 1765d5e + 8ea3509).
|
||||
submissionVarsSvc := services.NewSubmissionVarsService(pool, projectSvc, partySvc, users)
|
||||
submissionRenderer := services.NewSubmissionRenderer()
|
||||
submissionDraftSvc := services.NewSubmissionDraftService(pool, projectSvc, submissionVarsSvc, submissionRenderer)
|
||||
// t-paliad-225 Slice A — user-authored checklist templates.
|
||||
// Slice B adds checklist_shares grants + admin promotion.
|
||||
checklistCatalogSvc := services.NewChecklistCatalogService(pool)
|
||||
@@ -160,7 +168,8 @@ func main() {
|
||||
Project: projectSvc,
|
||||
Team: teamSvc,
|
||||
PartnerUnit: partnerUnitSvc,
|
||||
Party: services.NewPartyService(pool, projectSvc),
|
||||
Party: partySvc,
|
||||
SubmissionDraft: submissionDraftSvc,
|
||||
Deadline: deadlineSvc,
|
||||
Appointment: appointmentSvc,
|
||||
CalDAV: caldavSvc,
|
||||
@@ -211,6 +220,23 @@ func main() {
|
||||
Export: services.NewExportService(pool, branding.Name),
|
||||
}
|
||||
|
||||
// t-paliad-246 Slice A — Backup Mode runner. Wired only when
|
||||
// PALIAD_EXPORT_DIR is set (LocalDiskStore needs a target
|
||||
// directory). Without it the /admin/backups handlers return 503
|
||||
// in the same shape as Paliadin's gate. The directory is created
|
||||
// (0700) on first use; a malformed path fails fast at boot so
|
||||
// misconfig surfaces before the server starts taking traffic.
|
||||
if exportDir := strings.TrimSpace(os.Getenv("PALIAD_EXPORT_DIR")); exportDir != "" {
|
||||
store, err := services.NewLocalDiskStore(exportDir)
|
||||
if err != nil {
|
||||
log.Fatalf("PALIAD_EXPORT_DIR: %v", err)
|
||||
}
|
||||
svcBundle.Backup = services.NewBackupRunner(pool, svcBundle.Export, store)
|
||||
log.Printf("backup: LocalDiskStore at %s (/admin/backups active)", exportDir)
|
||||
} else {
|
||||
log.Println("PALIAD_EXPORT_DIR not set — /admin/backups will return 503")
|
||||
}
|
||||
|
||||
// t-paliad-219 Slice A3 — stitch DashboardService → ApprovalService
|
||||
// for the inbox-approvals widget. Done post-construction to avoid
|
||||
// a circular constructor dependency (ApprovalService doesn't need
|
||||
|
||||
668
docs/design-submission-page-2026-05-22.md
Normal file
668
docs/design-submission-page-2026-05-22.md
Normal file
@@ -0,0 +1,668 @@
|
||||
# Design — Dedicated Submission/Schriftsätze page (t-paliad-238)
|
||||
|
||||
**Author:** cronus (inventor)
|
||||
**Date:** 2026-05-22
|
||||
**Issue:** m/paliad (mai task t-paliad-238)
|
||||
**Branch:** `mai/cronus/inventor-dedicated`
|
||||
**Status:** DESIGN READY FOR REVIEW
|
||||
**Prior art:** `docs/design-submission-generator-2026-05-19.md` (t-paliad-215). This doc deepens that design rather than replacing it — every section below references the corresponding §x there when the shape is reused.
|
||||
|
||||
---
|
||||
|
||||
## §0 TL;DR
|
||||
|
||||
Today's "Schriftsätze" tab on the project detail page lists each filing-type rule and offers a one-click [Generieren] that streams a clean (format-only) `.docx` of the universal HL Patents Style template. There is no customization — variables, parties, dates, optional passages: none of it is filled in. The lawyer downloads the firm style, opens it in Word, and types everything by hand.
|
||||
|
||||
This design adds a **dedicated submission page** at `/projects/{id}/submissions/{code}/draft` where the lawyer:
|
||||
|
||||
1. Picks (or creates) a named **draft** for one (project, submission_code).
|
||||
2. Sees a sidebar with every `{{placeholder}}` the merge engine knows, pre-filled from the project's data (parties, court, case number, dates, legal_source) — editable inline. Auto-saved.
|
||||
3. Sees a read-only preview pane showing the merged document body as HTML.
|
||||
4. Clicks **Export → .docx** to download a fully-merged Word file (template + project + lawyer overrides), ready to edit.
|
||||
|
||||
Old [Generieren] button stays as the one-click "quick export with empty placeholders" path; the new [Bearbeiten] button next to it deep-links to the draft editor. Drafts persist as `paliad.submission_drafts` rows so the lawyer can come back next week, multiple drafts per submission code, RLS through `paliad.can_see_project`.
|
||||
|
||||
**Reuses** the deleted Slice 1 backend (`SubmissionVarsService` from commit `3677c81`, in-house `SubmissionRenderer` from `8ea3509`, `patent_number_upc` helper from `1765d5e`) wholesale — those files are salvageable from git history and slot back in with one new service (`SubmissionDraftService`) and the new schema. **Reuses** the `internal/handlers/files.go` Gitea proxy pattern for per-submission_code templates in `m/mWorkRepo/templates/{FIRM_NAME}/{code}.docx` (chain: firm → base/code → base/family → skeleton) — same fallback chain m locked in the 2026-05-19 design (§5).
|
||||
|
||||
Three slices: **A** = schema + new page + variables-only export against the universal `.dotm` (one slice; ships the editor end-to-end); **B** = per-`submission_code` `.docx` templates with the fallback-chain registry (template authoring is the bottleneck, not code); **C** = toggleable passages (boilerplate sections the lawyer can include/exclude before export).
|
||||
|
||||
Read-only inventor design. Implementation gate is m's go/no-go on this doc through head.
|
||||
|
||||
---
|
||||
|
||||
## §1 Premises verified live (2026-05-22)
|
||||
|
||||
Anchored against the running paliad codebase + youpc Supabase, not against CLAUDE.md or memory. Every claim that load-bears the design was checked against the live system.
|
||||
|
||||
| Claim | Verification |
|
||||
|---|---|
|
||||
| Today's `/api/projects/{id}/submissions` is **format-only**; no variables. | `internal/handlers/submissions.go:155-245`: handler fetches the universal `hl-patents-style.dotm` from the in-process `fileRegistry` cache, calls `services.ConvertDotmToDocx`, writes one `system_audit_log` row, streams. No project data merged. `frontend/src/client/submissions.ts` confirms the client side: POST → blob → download. The richer engine (`SubmissionVarsService` + `TemplateRegistry` + `SubmissionRenderer`) was reverted to format-only in commit `d86cac0` (t-paliad-230). |
|
||||
| Original Slice 1 backend is preserved in git history and salvageable. | `git show 3677c81:internal/services/submission_vars.go` (484 LoC, 7-namespace placeholder bag), `git show 8ea3509:internal/services/submission_render.go` (in-house run-fragmentation-aware merger, ~315 LoC), `git show 3677c81:internal/services/submission_templates.go` (442 LoC fallback-chain registry), `git show 1765d5e:internal/services/submission_vars.go` (Slice 2 added `{{project.patent_number_upc}}` helper). All four files compile against today's services (`ProjectService`, `PartyService`, `UserService`, `branding.Name`) — no API drift since 2026-05-20. |
|
||||
| `paliad.projects` carries everything the variable bag needs. | `internal/models/project.go:123` — `Title, Reference, CaseNumber, Court, PatentNumber, FilingDate, GrantDate, OurSide, InstanceLevel, ProceedingTypeID, ClientNumber, MatterNumber`. Unchanged since the original design. |
|
||||
| `paliad.parties` carries party data scoped per project. | `internal/models/project.go:567` (`Party{ID, ProjectID, Name, Role, Representative, ContactInfo}`); `PartyService.ListForProject(ctx, userID, projectID)` already exists at `internal/services/party_service.go`. Visibility flows from `ProjectService.GetByID` → `can_see_project`. |
|
||||
| `paliad.deadline_rules` published rows resolve by `submission_code`. | The same query the format-only handler uses (`internal/handlers/submissions.go:255-280`) — `lifecycle_state='published' AND is_active=true ORDER BY sequence_order LIMIT 1`. Today the corpus carries ~254 rules, ~214 published; covers DE-inf-LG, DE-inf-OLG, DE-inf-BGH, UPC-inf-CFI, DE-PatG-DPMA, DE-PatG-BPatG, EPO oppositions. |
|
||||
| Migration tracker is at 106; file list extends to 118 (other-branch work) on the worktree. | `SELECT version FROM paliad.paliad_schema_migrations ORDER BY version DESC LIMIT 1` → 106. `ls internal/db/migrations/` → `…118_paliadin_aichat_conversation`. The next free number for *this* branch's migration is **119** (collisions only if another worktree commits 119 first, in which case the coder picks the next unused). |
|
||||
| `paliad.can_see_project(uuid)` is the canonical RLS predicate. | Mig 055; every other table that gates on project visibility uses it. The new `submission_drafts` table follows the same pattern. |
|
||||
| The Schriftsätze tab already exists on project detail. | `frontend/src/projects-detail.tsx:91` (`data-tab="submissions"`), section `#tab-submissions` at line 629. Empty / no-proceeding / table-of-rules states already wired. The page-level route `GET /projects/{id}/submissions` exists at `internal/handlers/handlers.go:472` and renders the same project detail page with the submissions tab pre-selected (`#tab-submissions` URL fragment + tab activation). **No new top-level route needed**; this design adds a *deeper* route `/projects/{id}/submissions/{code}/draft` and `/draft/{draftID}`. |
|
||||
| `internal/handlers/files.go` carries the Gitea proxy + SHA-cache pattern. | Same template the original Slice 1 design lifted (in `templates/_base/de.inf.lg.erwidg.docx @ SHA 7f97b7f9` per memory). 5-min refresh, in-process cache, single-replica deployment. Reusable wholesale. |
|
||||
| `lukasjarosch/go-docx` is NOT a deal we made. | The original 2026-05-19 design recommended it, but the shipped Slice 1 (commit `8ea3509`) went with an **in-house renderer** because the library refuses to replace sibling `{{a}} ./. {{b}}` placeholders in the same run. The in-house engine handles cross-run fragmentation in ~315 LoC. **This design reuses the in-house engine, no new Go dependency.** Memory entry `ca6de586` corroborates the engine decision verbatim. |
|
||||
| `FIRM_NAME` defaults to "HLC", overridable. | `internal/branding.Name` (read once at process start). Templates land under `templates/HLC/...` for the default; the fallback chain handles per-firm overrides without code change. |
|
||||
| The PoC Paliadin is owner-gated; the submission page is NOT. | `internal/services/paliadin.go:52` — `PaliadinOwnerEmail = "matthias.siebels@hoganlovells.com"`. This is the LLM-shell-out boundary, irrelevant to template-merge. Every paliad user who can see a project can edit its submission drafts. |
|
||||
| The `entity-table` row contract is enforced. | `.claude/CLAUDE.md` → "Whole-card / whole-row click → use a JS row handler". The new draft list (when a submission_code has multiple drafts) follows the same pattern. |
|
||||
|
||||
**Doc-vs-live conflicts found:** none material. `docs/project-status.md` doesn't mention t-paliad-215 or t-paliad-230 yet — that's a documentation lag, not a design risk.
|
||||
|
||||
---
|
||||
|
||||
## §2 m's decisions (2026-05-22)
|
||||
|
||||
The task brief (mai task t-paliad-238 description) carries inventor recommendations (R) for ten open questions. Per project CLAUDE.md inventor → head escalation policy: inventor defaults to (R) unless the pick is materially expensive or risk-bearing, in which case the head escalates to m. The matrix below records the (R) defaults this design adopts; the four genuinely-material picks are escalated to head in §11.
|
||||
|
||||
| # | Question (from task brief) | Default adopted | Source |
|
||||
|---|---|---|---|
|
||||
| Q1 | Page location | **Deep page under project — `/projects/{id}/submissions/{code}/draft` and `…/draft/{draftID}`** | (R) — keeps URL self-describing and shareable with the project. |
|
||||
| Q2 | State persistence | **Server-side draft, `paliad.submission_drafts` keyed on `(project_id, submission_code, user_id, name)` with autosave** | (R) — multiple drafts per code, named; resumable across sessions. |
|
||||
| Q3 | Variable layer | **Resurrect `submission_vars.go` from commit `3677c81` + `1765d5e` (incl. `patent_number_upc`); resolve at export time** | (R) — proven shape, ~30 placeholders, 7 namespaces. |
|
||||
| Q4 | Customization surface (UI) | **Structured sidebar (variable list, editable values) + read-only HTML preview pane** | (R) — sidebar drives the merge; preview reflects the result. |
|
||||
| Q5 | Template source | **(a) per-`submission_code` `.docx` in `m/mWorkRepo/templates/{FIRM_NAME}/...` via Gitea proxy + fallback chain** | (R) — Word is the authoring surface lawyers know; mWorkRepo is the existing vehicle. |
|
||||
| Q6 | Customization options beyond variables | **v1: variables only. Toggleable passages = Slice C** | (R) — citation insertion waits for the sources system. |
|
||||
| Q7 | Migration / data shape | **See §4** | (R) — followed task brief's column list, refined for RLS + cascade + index. |
|
||||
| Q8 | Export endpoint | **`POST /api/projects/{id}/submissions/{code}/drafts/{draftID}/export` → `.docx`** | (R) — reuses `ConvertDotmToDocx` strip-macros path but adds merge step. |
|
||||
| Q9 | Schriftsätze tab integration | **Keep list + existing "Generieren" (one-click format-only); add new "Bearbeiten" button per row that deep-links to `/projects/{id}/submissions/{code}/draft`** | (R) — additive, no churn for users who only want the firm style. |
|
||||
| Q10 | Variable-merge library | **In-house renderer from commit `8ea3509` (no new Go module)** | (R) — the 2026-05-19 design recommended `lukasjarosch/go-docx`, but the shipped Slice 1 reverted to an in-house engine because the library refused sibling placeholders. The in-house engine handles run-fragmentation in ~315 LoC and is already battle-tested against the corpus. |
|
||||
|
||||
Inventor-defaulted (not in the (R) matrix; clear right answer):
|
||||
|
||||
| # | Topic | Default | Reasoning |
|
||||
|---|---|---|---|
|
||||
| D1 | Authorization | `paliad.can_see_project(project_id)` only. No profession floor. | Matches every other write surface on a project. Draft is a Word doc; lawyer's substantive review happens downstream. |
|
||||
| D2 | Missing-placeholder behaviour | `[KEIN WERT: {key}]` / `[NO VALUE: {key}]` in the rendered preview AND in the exported .docx, per `DefaultMissingMarker(lang)` from `8ea3509` | Same call the original design made. Lawyer sees the gap in Word, fixes in paliad, regenerates. Better than 400ing. |
|
||||
| D3 | Editor surface for templates | Gitea-only for v1 (admin edits .docx in Word, commits to mWorkRepo). | Per the original design §5. A paliad-side uploader is a Slice C+ affordance only if Gitea round-trip is friction. |
|
||||
| D4 | Audit trail | One `paliad.system_audit_log` row per export (`event_type='submission.exported'`) + one `paliad.project_events` row (`event_type='submission_exported'`) so the export surfaces in Verlauf / SmartTimeline. **Draft create/update do NOT audit** — autosave noise would dominate the log. | Mirrors the existing `submission.generated` row from `internal/handlers/submissions.go:333-339`. The Verlauf entry is the user-visible footprint; the system_audit_log entry is the admin-visible audit footprint. |
|
||||
| D5 | Preview engine | Server-side merge → render to HTML for preview pane. Same `SubmissionRenderer` walks the .docx, but for the preview it strips `<w:r>` / `<w:p>` to plain HTML paragraphs (no styling beyond paragraph breaks + bold/italic carry-through). The export endpoint produces the real .docx with all formatting preserved. | Cheaper than client-side OOXML parsing; matches the read-only Q4 contract. The preview is a fidelity guide, not a WYSIWYG editor — final formatting comes from Word. |
|
||||
| D6 | Draft autosave cadence | Debounce 500ms after the lawyer stops typing in a variable field; PATCH `…/drafts/{draftID}` with the diff. No optimistic locking — last-write-wins per (project, submission_code, user) draft, and we never multi-user a single draft (one row per `user_id`). | Standard textarea autosave; the data is the lawyer's own draft, not a shared object. |
|
||||
| D7 | Variable contract surfacing | Each placeholder in the sidebar shows: dotted key (e.g. `project.case_number`), human label (DE/EN), current resolved value (from project state), and an editable override field. Override empty → fall back to project state at export. Override filled → carry the lawyer's value into the merge. | Lawyer never has to leave the page to fix a project-level field; AND lawyer can locally override (e.g. "Court is wrong on this draft, but I don't want to edit the project") without polluting project state. |
|
||||
| D8 | Draft naming | First draft per (project, submission_code, user) auto-named "Entwurf 1" (DE) / "Draft 1" (EN). Lawyer can rename inline. Subsequent drafts auto-name "Entwurf 2", etc. The (project_id, submission_code, user_id, name) tuple is the unique constraint — two drafts can't share a name for the same submission of the same project. | Lets the lawyer keep a "submitted version" and a "scratch" version side-by-side. |
|
||||
|
||||
---
|
||||
|
||||
## §3 Architecture overview
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ Project detail (existing) — /projects/{id} with #tab-submissions │
|
||||
│ ┌───────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ Schriftsätze tab │ │
|
||||
│ │ Klageerwiderung [Bearbeiten ↗] [Generieren ↓] │ │
|
||||
│ │ Schriftsatz der Klägerin (SoC) [Bearbeiten ↗] [Generieren ↓] │ │
|
||||
│ │ Replik [Bearbeiten ↗] [Generieren ↓] │ │
|
||||
│ └────────┬──────────────────────────────────────────┬─────────────────────┘ │
|
||||
│ │ "Bearbeiten" deep-link │ "Generieren" = │
|
||||
│ │ │ existing one-click │
|
||||
│ ▼ │ format-only export │
|
||||
└───────────┼───────────────────────────────────────────┼──────────────────────┘
|
||||
│ │
|
||||
▼ │
|
||||
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||
│ NEW Submission draft page — /projects/{id}/submissions/{code}/draft │
|
||||
│ (lands on most-recent draft for this user, or creates "Entwurf 1") │
|
||||
│ │
|
||||
│ ┌──── Sidebar (sticky left) ────────┐ ┌──── Preview pane (right) ───────┐ │
|
||||
│ │ Schriftsatz: Klageerwiderung │ │ [HTML-rendered merge of │ │
|
||||
│ │ Entwurf 1 ▼ [+ Neuer Entwurf] │ │ template + variables + │ │
|
||||
│ │ ───────────────────────────────── │ │ overrides] │ │
|
||||
│ │ project.case_number │ │ │ │
|
||||
│ │ 2 O 123/25 [override?] │ │ Klage gegen die │ │
|
||||
│ │ parties.claimant.name │ │ Beklagte BMW AG, vertreten │ │
|
||||
│ │ BMW AG [override?] │ │ durch … │ │
|
||||
│ │ deadline.due_date │ │ │ │
|
||||
│ │ 2026-06-12 [override?] │ │ Sehr geehrte Damen und Herren, │ │
|
||||
│ │ ... │ │ │ │
|
||||
│ │ │ │ [KEIN WERT: project.our_side] │ │
|
||||
│ │ [✎ Bearbeiten] inline │ │ │ │
|
||||
│ └───────────────────────────────────┘ └──────────────────────────────────┘ │
|
||||
│ │
|
||||
│ [⬇ Als .docx exportieren] │
|
||||
└──────────────────────────────────┬───────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
POST /api/projects/{id}/submissions/{code}/drafts/{draftID}/export
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||
│ handlers/submission_drafts.go (NEW) │
|
||||
│ 1. Auth: ProjectService.GetByID → can_see_project │
|
||||
│ 2. Load draft row (RLS via project visibility) │
|
||||
│ 3. SubmissionVarsService.Build (project + parties + rule + next-Frist) │
|
||||
│ 4. Apply draft.overrides on top of bag │
|
||||
│ 5. TemplateRegistry.Resolve(code) — fallback chain → bytes + SHA │
|
||||
│ (Slice A: skips registry, fetches the universal .dotm directly) │
|
||||
│ 6. SubmissionRenderer.Render(bytes, bag, missingMarker) → .docx bytes │
|
||||
│ 7. Audit: system_audit_log + project_events │
|
||||
│ 8. Stream .docx with Content-Disposition: attachment │
|
||||
└──────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**No backend changes to today's Schriftsätze tab** — its list endpoint + one-click generate stay exactly as they are. The new page is additive.
|
||||
|
||||
---
|
||||
|
||||
## §4 Schema (`paliad.submission_drafts`)
|
||||
|
||||
Migration `119_submission_drafts.up.sql` (next free number on this branch; coder bumps if 119 is taken at write time).
|
||||
|
||||
```sql
|
||||
CREATE TABLE paliad.submission_drafts (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
project_id uuid NOT NULL REFERENCES paliad.projects(id) ON DELETE CASCADE,
|
||||
submission_code text NOT NULL,
|
||||
user_id uuid NOT NULL REFERENCES paliad.users(id) ON DELETE CASCADE,
|
||||
name text NOT NULL, -- "Entwurf 1", lawyer-renameable
|
||||
|
||||
overrides jsonb NOT NULL DEFAULT '{}'::jsonb, -- { "project.case_number": "2 O 999/25", ... }
|
||||
-- empty value = "don't override, use bag"
|
||||
-- present key = "use this verbatim"
|
||||
|
||||
last_exported_at timestamptz, -- NULL until first export
|
||||
last_exported_sha text, -- template SHA at last export (audit aid)
|
||||
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
CONSTRAINT submission_drafts_unique_per_user
|
||||
UNIQUE (project_id, submission_code, user_id, name)
|
||||
);
|
||||
|
||||
CREATE INDEX submission_drafts_project_user_idx
|
||||
ON paliad.submission_drafts (project_id, user_id, submission_code, updated_at DESC);
|
||||
|
||||
ALTER TABLE paliad.submission_drafts ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY submission_drafts_visible
|
||||
ON paliad.submission_drafts
|
||||
FOR ALL
|
||||
USING (paliad.can_see_project(project_id));
|
||||
|
||||
-- updated_at trigger pattern (same shape as paliad.notizen, etc.).
|
||||
CREATE TRIGGER submission_drafts_updated_at
|
||||
BEFORE UPDATE ON paliad.submission_drafts
|
||||
FOR EACH ROW EXECUTE FUNCTION paliad.tg_set_updated_at();
|
||||
```
|
||||
|
||||
**No changes to `paliad.deadline_rules`.** The task brief floats a `template_body_de/_en` Markdown column as alternative (b) — rejected per Q5 default. Templates stay in Gitea.
|
||||
|
||||
**No changes to `paliad.documents`.** The original Slice 1 design wrote an `audit-only` row (`file_path NULL`) per generation; this design **does not** — `system_audit_log` + `project_events` carry the audit trail, and `paliad.documents` is reserved for actually-uploaded documents (a Phase 2 affordance per §13.5 of the 2026-05-19 design). If m wants the `documents` row for symmetry with future "I uploaded my edited version" UX, the coder can land it in a follow-up migration; it's not load-bearing for this design.
|
||||
|
||||
### 4.1 RLS read-vs-write
|
||||
|
||||
`can_see_project` is the only gate — the policy applies to FOR ALL operations. Anyone who can see a project can create / read / update / export drafts under that project, for their own user_id. Inter-user draft visibility (paralegal sees associate's drafts) is **NOT** a requirement in v1 — the unique constraint includes `user_id` and we don't expose a "drafts by other users on this project" endpoint. Multi-user collaboration on a single draft is out of scope.
|
||||
|
||||
### 4.2 Down migration
|
||||
|
||||
```sql
|
||||
DROP TABLE IF EXISTS paliad.submission_drafts;
|
||||
```
|
||||
|
||||
No data loss concern at design time — feature ships without legacy drafts.
|
||||
|
||||
---
|
||||
|
||||
## §5 Service layer
|
||||
|
||||
### 5.1 Resurrect from git (no new code)
|
||||
|
||||
```
|
||||
internal/services/submission_vars.go RESURRECT from 3677c81 + Slice 2 patch from 1765d5e (patent_number_upc)
|
||||
internal/services/submission_render.go REPLACE the format-only convert with the in-house renderer from 8ea3509.
|
||||
KEEP the convert helper (ConvertDotmToDocx) — Slice A still needs it to
|
||||
strip macros from the universal .dotm before the merge step runs.
|
||||
internal/services/submission_templates.go RESURRECT from 3677c81 — Gitea-backed TemplateRegistry with fallback chain.
|
||||
NOT wired in Slice A (universal .dotm only); wired in Slice B.
|
||||
```
|
||||
|
||||
The three files were ~926 LoC + 350 LoC + 35 LoC patch when shipped. They compile against today's services (`ProjectService`, `PartyService`, `UserService`, `branding.Name`); zero API drift since their deletion. The resurrection is a copy-paste from `git show`, plus a one-line wiring in `cmd/server/main.go` + `internal/handlers/handlers.go`.
|
||||
|
||||
### 5.2 New service — `SubmissionDraftService`
|
||||
|
||||
```go
|
||||
// internal/services/submission_draft_service.go (NEW, ~300 LoC)
|
||||
|
||||
type SubmissionDraftService struct {
|
||||
db *sqlx.DB
|
||||
projects *ProjectService
|
||||
}
|
||||
|
||||
type SubmissionDraft struct {
|
||||
ID uuid.UUID
|
||||
ProjectID uuid.UUID
|
||||
SubmissionCode string
|
||||
UserID uuid.UUID
|
||||
Name string
|
||||
Overrides PlaceholderMap // jsonb → map[string]string
|
||||
LastExportedAt *time.Time
|
||||
LastExportedSHA *string
|
||||
CreatedAt, UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// List returns every draft for (project, submission_code, user) ordered by updated_at DESC.
|
||||
// Visibility flows through projects.GetByID.
|
||||
func (s *SubmissionDraftService) List(ctx context.Context, userID, projectID uuid.UUID, submissionCode string) ([]SubmissionDraft, error)
|
||||
|
||||
// Get returns a single draft by ID, gated on project visibility.
|
||||
func (s *SubmissionDraftService) Get(ctx context.Context, userID, draftID uuid.UUID) (*SubmissionDraft, error)
|
||||
|
||||
// EnsureLatest returns the user's most-recently-updated draft for (project, submission_code).
|
||||
// Creates "Entwurf 1" / "Draft 1" if none exists. Idempotent on repeat calls.
|
||||
func (s *SubmissionDraftService) EnsureLatest(ctx context.Context, userID, projectID uuid.UUID, submissionCode, lang string) (*SubmissionDraft, error)
|
||||
|
||||
// Create makes a new draft with an auto-incremented "Entwurf N" name (lawyer can rename via Update).
|
||||
func (s *SubmissionDraftService) Create(ctx context.Context, userID, projectID uuid.UUID, submissionCode, lang string) (*SubmissionDraft, error)
|
||||
|
||||
// Update patches the draft. Permitted fields: name, overrides. last_exported_* is set by the export handler.
|
||||
func (s *SubmissionDraftService) Update(ctx context.Context, userID, draftID uuid.UUID, patch DraftPatch) (*SubmissionDraft, error)
|
||||
|
||||
// Delete archives the draft. ON DELETE CASCADE from project takes care of project-archival fallout.
|
||||
func (s *SubmissionDraftService) Delete(ctx context.Context, userID, draftID uuid.UUID) error
|
||||
|
||||
// MarkExported updates last_exported_at + last_exported_sha after a successful export.
|
||||
func (s *SubmissionDraftService) MarkExported(ctx context.Context, draftID uuid.UUID, sha string) error
|
||||
```
|
||||
|
||||
`DraftPatch` is a `struct { Name *string; Overrides *PlaceholderMap }` — nil pointer = "no change", non-nil = "set to this". `Overrides` is replace-semantics (lawyer's sidebar sends the full map); the service does not merge.
|
||||
|
||||
### 5.3 Wiring
|
||||
|
||||
```go
|
||||
// cmd/server/main.go (additions, no replacements)
|
||||
draftSvc := services.NewSubmissionDraftService(db, projectSvc)
|
||||
varsSvc := services.NewSubmissionVarsService(db, projectSvc, partySvc, userSvc)
|
||||
// Slice B only:
|
||||
tplRegistry := services.NewTemplateRegistry(os.Getenv("GITEA_TOKEN"), branding.Name)
|
||||
```
|
||||
|
||||
No new env var. `GITEA_TOKEN` is already documented in CLAUDE.md and used by `internal/handlers/files.go`.
|
||||
|
||||
---
|
||||
|
||||
## §6 UI surface
|
||||
|
||||
### 6.1 Page layout
|
||||
|
||||
`/projects/{id}/submissions/{code}/draft` lands on the user's latest draft for that (project, code). `/projects/{id}/submissions/{code}/draft/{draftID}` opens a specific draft (e.g. "Entwurf 2"). Both routes call the same renderer + client bundle; the difference is which draft `EnsureLatest` vs `Get` returns.
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||
│ ← Zurück zum Projekt: BMW AG ./. Bosch GmbH │
|
||||
│ Schriftsatz: Klageerwiderung (DE.ZPO.276.1) • Entwurf 1 │
|
||||
│ [⬇ Als .docx exportieren] │
|
||||
├────────── Sidebar (sticky) ────────────────┬─────── Preview ────────────────┤
|
||||
│ Entwurf 1 ▼ │ [HTML-rendered merge] │
|
||||
│ • Entwurf 1 (zuletzt 23 Mai 2026) │ │
|
||||
│ • Entwurf 2 (zuletzt 20 Mai 2026) │ An das Landgericht München I │
|
||||
│ • [+ Neuer Entwurf] │ Pacellistr. 5 │
|
||||
│ │ 80333 München │
|
||||
│ ───────────────────────────────────────── │ │
|
||||
│ Variablen │ In der Sache │
|
||||
│ firm.name HLC │ │
|
||||
│ project.case_number 2 O 123/25 [✎] │ BMW AG, vertreten durch … │
|
||||
│ project.court LG München I [✎] │ — Klägerin — │
|
||||
│ parties.claimant.name BMW AG [✎] │ │
|
||||
│ parties.defendant.name Bosch GmbH [✎] │ gegen │
|
||||
│ parties.defendant.representative │ │
|
||||
│ Dr. Maria Schmidt [✎] │ Bosch GmbH … │
|
||||
│ deadline.due_date 2026-06-12 [✎] │ — Beklagte — │
|
||||
│ rule.legal_source_pretty │ │
|
||||
│ § 276 Abs. 1 ZPO ✓ │ Sehr geehrte Damen und Herren,│
|
||||
│ … │ │
|
||||
│ │ [KEIN WERT: project.our_side] │
|
||||
│ ───────────────────────────────────────── │ │
|
||||
│ [Entwurf umbenennen] [Entwurf löschen] │ │
|
||||
└────────────────────────────────────────────┴────────────────────────────────┘
|
||||
```
|
||||
|
||||
Sidebar grouping (top-to-bottom, locale-aware labels):
|
||||
|
||||
1. **Schriftsatz** (rule.* — read-only metadata: name, legal_source_pretty, primary_party)
|
||||
2. **Mandanten & Parteien** (parties.*)
|
||||
3. **Verfahren** (project.* — case_number, court, patent_number, patent_number_upc, our_side, …)
|
||||
4. **Frist** (deadline.* — due_date, computed_from)
|
||||
5. **Kanzlei & Datum** (firm.*, user.*, today.*)
|
||||
|
||||
Each placeholder row shows: human label (DE/EN), resolved value, edit icon. Click [✎] expands an inline text input pre-filled with the current value. Blur or Enter → debounced autosave (500ms). Empty override → revert to bag value.
|
||||
|
||||
### 6.2 Preview pane
|
||||
|
||||
Read-only HTML. The same `SubmissionRenderer.Render(...)` call that produces the .docx for export ALSO produces a sidecar HTML preview (the in-house renderer walks `<w:p>` / `<w:r>` runs and emits `<p>` / inline `<strong>` / `<em>` based on `<w:b>` / `<w:i>` flags). Preview re-renders on every autosave round-trip (cheap: server-side merge, ~10ms for a 5-page brief). Loading state: ghost-skeleton paragraphs during the round-trip.
|
||||
|
||||
This is the SLIGHTLY non-trivial coder piece: the in-house renderer today emits .docx; the coder adds a parallel `RenderHTML` path that walks the same tree but emits HTML. Same regex, same run-merge logic, different writer. Coder estimates ~120 LoC on top of the resurrected `submission_render.go`.
|
||||
|
||||
### 6.3 Routing + handlers
|
||||
|
||||
```
|
||||
GET /projects/{id}/submissions/{code}/draft → page (lands on latest, creates if none)
|
||||
GET /projects/{id}/submissions/{code}/draft/{draftID} → page (specific draft)
|
||||
GET /api/projects/{id}/submissions/{code}/drafts → list drafts for current user
|
||||
POST /api/projects/{id}/submissions/{code}/drafts → create new draft → returns row + redirect target
|
||||
GET /api/projects/{id}/submissions/{code}/drafts/{draftID} → single draft + resolved bag + HTML preview
|
||||
PATCH /api/projects/{id}/submissions/{code}/drafts/{draftID} → update name / overrides; returns new preview
|
||||
DELETE /api/projects/{id}/submissions/{code}/drafts/{draftID} → delete
|
||||
POST /api/projects/{id}/submissions/{code}/drafts/{draftID}/export
|
||||
→ .docx download (application/vnd.openxmlformats-officedocument.wordprocessingml.document)
|
||||
```
|
||||
|
||||
Page-route handler is `handleSubmissionDraftPage` in `internal/handlers/submission_drafts.go` — calls `handleProjectsDetailPage` shape (returns HTML for an SSR layout) with a deep-route flag, OR ships an entirely new TSX page module. Inventor default: **new TSX page** at `frontend/src/submission-draft.tsx` rendering its own layout. Lighter than retrofitting the existing project-detail page with conditional panels, and the URL semantics demand it (`#tab-submissions` is the tab; `/draft/{id}` is a distinct page).
|
||||
|
||||
### 6.4 Schriftsätze tab — additive change
|
||||
|
||||
```diff
|
||||
- // Each row: [Generieren ↓]
|
||||
+ // Each row: [Bearbeiten ↗] [Generieren ↓]
|
||||
```
|
||||
|
||||
`[Bearbeiten ↗]` → `window.location.href = "/projects/{id}/submissions/{code}/draft"`. `[Generieren ↓]` stays as today (one-click format-only export of the universal .dotm). For users who want zero-config "give me a clean firm style template", `[Generieren]` is the path; for users who want a merged draft, `[Bearbeiten]` is the path.
|
||||
|
||||
Per the `.entity-table` row contract in CLAUDE.md, the row itself becomes clickable (navigates to `/draft`), with the `Generieren` button stopping propagation. The `entity-table--readonly` modifier is removed.
|
||||
|
||||
---
|
||||
|
||||
## §7 Variable contract (v1 placeholder set)
|
||||
|
||||
Reproduced from the resurrected `submission_vars.go` (commits `3677c81` + `1765d5e`). The sidebar's "Variablen" section enumerates this list in the exact same order as `addProjectVars` / `addPartyVars` / etc., grouped per §6.1.
|
||||
|
||||
```
|
||||
firm.name — branding.Name (HLC or FIRM_NAME override)
|
||||
firm.signature_block — empty in v1 (Phase 2 affordance)
|
||||
|
||||
today — 2026-05-22 (ISO, Europe/Berlin)
|
||||
today.iso — ISO short
|
||||
today.long_de — "22. Mai 2026"
|
||||
today.long_en — "22 May 2026"
|
||||
|
||||
user.display_name — paliad.users.display_name
|
||||
user.email — paliad.users.email
|
||||
user.office — paliad.users.office
|
||||
|
||||
project.title — paliad.projects.title
|
||||
project.reference — paliad.projects.reference
|
||||
project.case_number — paliad.projects.case_number
|
||||
project.court — paliad.projects.court
|
||||
project.patent_number — DE/inline form "EP 1 234 567 B1"
|
||||
project.patent_number_upc — UPC parenthesised form "EP 1 234 567 (B1)" (Slice 2 helper, 1765d5e)
|
||||
project.filing_date — ISO date
|
||||
project.grant_date — ISO date
|
||||
project.our_side — claimant | defendant
|
||||
project.our_side_de — "Klägerin" | "Beklagte"
|
||||
project.our_side_en — "Claimant" | "Defendant"
|
||||
project.instance_level — lg | olg | bgh | cfi | …
|
||||
project.client_number — paliad.projects.client_number
|
||||
project.matter_number — paliad.projects.matter_number
|
||||
project.proceeding.code — e.g. "de.inf.lg"
|
||||
project.proceeding.name — locale-aware (DE: Verletzungsklage am Landgericht)
|
||||
project.proceeding.name_de — explicit DE
|
||||
project.proceeding.name_en — explicit EN
|
||||
|
||||
parties.claimant.name — first paliad.parties row with role='claimant'
|
||||
parties.claimant.representative
|
||||
parties.defendant.name — first row with role='defendant'
|
||||
parties.defendant.representative
|
||||
parties.other.name — first non-claimant/defendant row
|
||||
parties.other.representative
|
||||
|
||||
rule.submission_code — "de.inf.lg.erwidg"
|
||||
rule.name — locale-aware ("Klageerwiderung" / "Statement of Defence")
|
||||
rule.name_de
|
||||
rule.name_en
|
||||
rule.legal_source — "DE.ZPO.276.1"
|
||||
rule.legal_source_pretty — "§ 276 Abs. 1 ZPO" / "Section 276(1) ZPO"
|
||||
rule.primary_party — claimant | defendant | court | both
|
||||
rule.event_type — filing | hearing | decision
|
||||
|
||||
deadline.due_date — ISO of next pending deadline for this rule on this project
|
||||
deadline.due_date_long_de — "12. Juni 2026"
|
||||
deadline.due_date_long_en — "12 June 2026"
|
||||
deadline.original_due_date — ISO if extended
|
||||
deadline.computed_from — anchor description (e.g. "Klagezustellung am 14.05.2026 + 6 Wochen")
|
||||
deadline.title — deadline.title
|
||||
deadline.source — "rule" | "manual" | …
|
||||
```
|
||||
|
||||
Variable bag construction is `SubmissionVarsService.Build(ctx, in)` — exactly the function in `3677c81`'s `submission_vars.go`, no changes.
|
||||
|
||||
### 7.1 Override semantics
|
||||
|
||||
The lawyer's `overrides` map (jsonb in `submission_drafts`) shadows the bag at export time:
|
||||
|
||||
```go
|
||||
bag := varsSvc.Build(ctx, ...).Placeholders // ~30 keys, resolved from project state
|
||||
for k, v := range draft.Overrides { // lawyer's edits
|
||||
if v == "" {
|
||||
delete(bag, k) // empty override means "force missing marker"
|
||||
} else {
|
||||
bag[k] = v // non-empty override replaces
|
||||
}
|
||||
}
|
||||
docx := renderer.Render(templateBytes, bag, missingMarker(lang))
|
||||
```
|
||||
|
||||
Edge case: lawyer types empty string into a field that was resolved from the project. Decision: empty override forces the `[KEIN WERT: …]` marker. Lawyer's intent ("blank this out, I'll fill manually in Word") is honoured rather than silently falling back to project state. Sidebar UX: empty override field is annotated "→ [KEIN WERT: …]" so the lawyer sees the consequence before exporting.
|
||||
|
||||
---
|
||||
|
||||
## §8 Template authoring (mWorkRepo layout, naming, fallback chain)
|
||||
|
||||
### 8.1 Slice A — universal .dotm only
|
||||
|
||||
Slice A merges the variable bag into the same universal HL Patents Style .dotm that today's format-only convert ships. **The .dotm body must carry `{{placeholder}}` tokens** — currently it doesn't (it's a firm style template, not a per-submission template). m has two ways to seed Slice A:
|
||||
|
||||
- **8.1.a** — author one universal template (`m/mWorkRepo/templates/_base/_universal.docx` or similar) with `{{firm.name}}`, `{{rule.name}}`, `{{project.case_number}}`, `{{parties.claimant.name}}`, etc. The merge engine fills these and outputs a draft that's still a generic letter shape but pre-populated.
|
||||
- **8.1.b** — author one Klageerwiderung-shaped template (`m/mWorkRepo/templates/HLC/de.inf.lg.erwidg.docx`) and route Slice A's export to that path for `submission_code='de.inf.lg.erwidg'`, with a hard-coded fall-back to `_universal.docx` for any other code. This is essentially Slice A + B's first template — wins both rounds.
|
||||
|
||||
Inventor recommendation: **8.1.b**. Strictly more useful, identical engine code, identical mWorkRepo round-trip. The Slice A → Slice B transition is then "add more templates", not "rewire the resolver".
|
||||
|
||||
### 8.2 Slice B — fallback-chain registry
|
||||
|
||||
Layout reproduced from the 2026-05-19 design §5.1:
|
||||
|
||||
```
|
||||
m/mWorkRepo (existing repo, already proxied)
|
||||
└── templates/
|
||||
├── HLC/ # FIRM_NAME-keyed override dir
|
||||
│ ├── de.inf.lg.erwidg.docx # Slice A target (per 8.1.b above)
|
||||
│ ├── de.inf.lg.klage.docx # Slice B addition
|
||||
│ ├── upc.inf.cfi.soc.docx # Slice B addition
|
||||
│ └── upc.inf.cfi.sod.docx # Slice B addition
|
||||
├── _base/ # Cross-firm baseline
|
||||
│ ├── de.inf.lg.erwidg.docx # base equivalent (Slice C+)
|
||||
│ ├── de.inf.lg.docx # proceeding-family fallback
|
||||
│ ├── upc.inf.cfi.docx
|
||||
│ ├── _skeleton.docx # ultra-generic fallback
|
||||
│ └── _universal.docx # the v1 Slice A "any code" template
|
||||
└── README.md # placeholder reference for template authors
|
||||
```
|
||||
|
||||
Naming: `{submission_code}.docx`. Family fallback uses the first three dot-segments (`de.inf.lg` from `de.inf.lg.erwidg`). Skeleton is the ultra-generic fallback (letterhead + party block + court address + signature stub).
|
||||
|
||||
### 8.3 Lookup algorithm
|
||||
|
||||
```go
|
||||
// services/submission_templates.go (resurrected from 3677c81)
|
||||
func (r *TemplateRegistry) candidates(submissionCode string) []string {
|
||||
family := familyOf(submissionCode)
|
||||
out := []string{
|
||||
fmt.Sprintf("templates/%s/%s.docx", r.firmName, submissionCode),
|
||||
fmt.Sprintf("templates/_base/%s.docx", submissionCode),
|
||||
}
|
||||
if family != "" && family != submissionCode {
|
||||
out = append(out, fmt.Sprintf("templates/_base/%s.docx", family))
|
||||
}
|
||||
out = append(out, "templates/_base/_skeleton.docx")
|
||||
return out
|
||||
}
|
||||
```
|
||||
|
||||
Gitea proxy: same `internal/handlers/files.go` shape. 5-min SHA refresh, in-process cache, `GITEA_TOKEN` for auth. The original `submission_templates.go` already implements this end-to-end; the coder re-applies it from `git show 3677c81`.
|
||||
|
||||
### 8.4 No template at all — Slice A vs Slice B
|
||||
|
||||
Slice A: the universal template always resolves; `ErrNoTemplate` is impossible.
|
||||
|
||||
Slice B: if every candidate in the fallback chain 404s, the handler returns 503 + `"Vorlagen-Repository nicht erreichbar"` in the UI (same handling as the original Slice 1 design §5.4). Since the chain ends at `_skeleton.docx`, this only fires when the mWorkRepo itself is misconfigured.
|
||||
|
||||
### 8.5 Template authoring task lands outside this design
|
||||
|
||||
Inventor flags but does not assign: HLC must author the per-submission_code `.docx` templates. Slice A's `_universal.docx` is one document. Slice B adds Klageerwiderung, Klageerhebung, SoC, SoD, … iteratively. **Template authoring runs in parallel with engine code**; the coder ships the engine, m + HLC ships the templates. The two converge before the slice closes.
|
||||
|
||||
This is the m-escalated piece (see §11): without per-submission templates, Slice B is engine-only.
|
||||
|
||||
---
|
||||
|
||||
## §9 Slice plan
|
||||
|
||||
### Slice A — schema + new page + variables-only export against universal .docx
|
||||
|
||||
Ships the editor end-to-end with one template.
|
||||
|
||||
| Deliverable | Files |
|
||||
|---|---|
|
||||
| Migration 119 — `submission_drafts` table + RLS + trigger | `internal/db/migrations/119_submission_drafts.{up,down}.sql` |
|
||||
| `SubmissionVarsService` resurrected | `internal/services/submission_vars.go` (from `3677c81` + Slice 2 patch `1765d5e`) |
|
||||
| `SubmissionRenderer` resurrected with new `RenderHTML` | `internal/services/submission_render.go` (from `8ea3509`); adds `RenderHTML(...) string` for preview |
|
||||
| `SubmissionDraftService` | `internal/services/submission_draft_service.go` (NEW) |
|
||||
| Handlers (page + 7 API endpoints) | `internal/handlers/submission_drafts.go` (NEW) |
|
||||
| Wiring | `cmd/server/main.go`, `internal/handlers/handlers.go` |
|
||||
| Page TSX | `frontend/src/submission-draft.tsx` (NEW) |
|
||||
| Client bundle | `frontend/src/client/submission-draft.ts` (NEW) |
|
||||
| Schriftsätze tab update | `frontend/src/projects-detail.tsx` (rows get [Bearbeiten]), `frontend/src/client/submissions.ts` (handler) |
|
||||
| i18n | new keys under `projects.detail.submissions.draft.*` and `submissions.draft.*` (page-level) |
|
||||
| One template at `m/mWorkRepo/templates/_base/_universal.docx` (8.1.b → also `templates/HLC/de.inf.lg.erwidg.docx`) | mWorkRepo, separate PR by m |
|
||||
| Tests | `internal/services/submission_render_test.go` (resurrected + RenderHTML), `internal/services/submission_vars_test.go` (round-trip), handler smoke |
|
||||
|
||||
Acceptance:
|
||||
|
||||
1. Opening `/projects/{id}/submissions/de.inf.lg.erwidg/draft` lands on the user's latest draft (or creates "Entwurf 1").
|
||||
2. Sidebar renders ~30 placeholders, pre-filled from project state.
|
||||
3. Editing a sidebar value autosaves within 500ms and updates the preview pane.
|
||||
4. Multiple drafts per (project, code, user) supported; switcher in sidebar.
|
||||
5. Clicking "Als .docx exportieren" downloads a merged `.docx` (universal template + project + lawyer overrides).
|
||||
6. `system_audit_log` row appears on export (`event_type='submission.exported'`).
|
||||
7. `project_events` row appears on export and surfaces in Verlauf.
|
||||
8. RLS: caller without `can_see_project` gets 404 on the page and 404 on every draft API.
|
||||
9. Schriftsätze tab on project detail shows [Bearbeiten] alongside [Generieren].
|
||||
10. `go build ./... && go vet ./... && go test ./... && bun run build` clean.
|
||||
|
||||
### Slice B — per-submission_code templates + fallback chain
|
||||
|
||||
Engine is unchanged from Slice A; this slice wires `TemplateRegistry` into the export endpoint and lights up per-code templates.
|
||||
|
||||
| Deliverable | Files |
|
||||
|---|---|
|
||||
| `TemplateRegistry` resurrected | `internal/services/submission_templates.go` (from `3677c81`) |
|
||||
| Handler swaps Slice A's `fetchHLPatentsStyleBytes` for `templateRegistry.Resolve(code)` | `internal/handlers/submission_drafts.go` |
|
||||
| `has_template` boolean per row in Schriftsätze tab list (today: unconditionally true; under Slice B: depends on registry probe) | `internal/handlers/submissions.go` |
|
||||
| Templates authored in mWorkRepo: at least Klageerwiderung + Klageerhebung + SoC + SoD | mWorkRepo PR by m |
|
||||
| Tests for fallback chain | `internal/services/submission_templates_test.go` (resurrect from history if it existed; otherwise new) |
|
||||
|
||||
Acceptance:
|
||||
|
||||
1. Pushing `m/mWorkRepo/templates/HLC/upc.inf.cfi.soc.docx` makes the SoC draft page resolve that template within 5 min (or instantly via `POST /api/files/refresh`).
|
||||
2. `has_template=false` rows in the Schriftsätze tab show [Keine Vorlage] instead of [Bearbeiten]/[Generieren]. Existing list ordering preserved.
|
||||
3. `last_exported_sha` on `submission_drafts` records which SHA the lawyer exported against.
|
||||
4. Misconfigured repo (every fallback 404s) → 503 with clear error.
|
||||
|
||||
### Slice C — toggleable passages
|
||||
|
||||
Lawyer can include/exclude boilerplate sections before export.
|
||||
|
||||
| Deliverable | Notes |
|
||||
|---|---|
|
||||
| `passages` jsonb column on `submission_drafts` | `migration 120` (or whatever's free at land time): `passages jsonb NOT NULL DEFAULT '{}'::jsonb` — `{"intro": true, "patent_validity_attack": false, "non_infringement": true}`. |
|
||||
| Template syntax for passage blocks | `{{#passage intro}}…{{/passage}}` — start/end markers, merger drops the block when the corresponding `passages.{key}` is false. The in-house renderer's run-fragmentation handling extends to the new tokens cleanly. |
|
||||
| Sidebar UI | "Passagen" group above "Variablen", per-passage toggle (on by default), help text per passage. |
|
||||
| Template author API | `templates/README.md` documents the passage syntax + a worked example. |
|
||||
|
||||
Acceptance: turning off `non_infringement` in the sidebar of a Klageerwiderung draft removes the corresponding section from the exported .docx; preview reflects immediately.
|
||||
|
||||
Slices D+ (not detailed here): citation insertion from the sources system (waits for that surface), per-firm template overrides (registry already supports this), `/admin/submission-templates` variable contract sidebar.
|
||||
|
||||
---
|
||||
|
||||
## §10 Out of scope
|
||||
|
||||
- AI-drafted prose (the 2026-05-19 design §11 sketch; still deferred).
|
||||
- PDF export. v1 ships `.docx` only; the lawyer's Word does the PDF step.
|
||||
- Multi-user collaboration on a single draft. Each draft is owner-scoped (`user_id`).
|
||||
- Real-time co-editing. Last-write-wins per draft; no operational transforms.
|
||||
- An in-paliad WYSIWYG editor for `.docx` content. Preview is read-only; final edits happen in Word.
|
||||
- A paliad-side template uploader. Gitea stays as the editor for templates until lawyers complain about the round-trip.
|
||||
- Translation of templates DE↔EN. Templates are mono-locale; the variable bag is bilingual.
|
||||
- Citation insertion from the sources system. Waits for the sources surface m parked.
|
||||
- Frist-detail "Exportieren" button. The submission page is reachable only from the project's Schriftsätze tab in v1; a Frist-level deep-link is a Slice D+ affordance.
|
||||
- Validation of the rendered draft against any legal rule. The engine produces text; the lawyer's substantive review is downstream.
|
||||
- Sending the draft to court / e-filing. The lawyer downloads and handles transmission outside paliad.
|
||||
|
||||
---
|
||||
|
||||
## §11 Material picks escalated to head
|
||||
|
||||
Per project CLAUDE.md inventor → head policy, the four picks below carry enough cost or risk to deserve head's read. Head ratifies (or escalates to m) before the coder shift starts.
|
||||
|
||||
### Q-E1 — Template authoring effort
|
||||
|
||||
Slice A needs at least one custom-authored template (`_universal.docx` or `de.inf.lg.erwidg.docx`) carrying `{{placeholder}}` tokens. Slice B needs four more (Klageerhebung, SoC, SoD, Erwiderung). The engine ships independently of template content, but the feature is unfinished without lawyer-authored templates.
|
||||
|
||||
**Inventor pick:** ship Slice A with **one** lawyer-authored template (8.1.b: `templates/HLC/de.inf.lg.erwidg.docx`) + the universal fallback. m + HLC owns the authoring; the coder owns the engine. Slices A and template-1 land together.
|
||||
|
||||
**Material because:** without a template, the feature looks broken in user testing. Head decides: does m commit to authoring or reviewing the first template before Slice A merges, or does Slice A merge engine-only and we accept the "format-only export with placeholders" intermediate state for a week?
|
||||
|
||||
### Q-E2 — `paliad.documents` row on export
|
||||
|
||||
The original Slice 1 design wrote an audit-only `paliad.documents` row (`file_path NULL`, `doc_type='generated_submission'`) per generation, on the theory that "Documents" would become the canonical listing UI. This design defers that.
|
||||
|
||||
**Inventor pick:** **no** `paliad.documents` write. `system_audit_log` + `project_events` carry the audit trail. The `documents` table is reserved for actually-uploaded documents (Phase 2 of the broader docs roadmap).
|
||||
|
||||
**Material because:** if head agrees, we skip a column repurpose (`ai_extracted` jsonb being used for generation provenance — the 2026-05-19 design noted this was ugly). If head disagrees, the coder lands the row inside Slice A.
|
||||
|
||||
### Q-E3 — Preview render — server or client?
|
||||
|
||||
Server-side: `RenderHTML(...)` on the in-house renderer, round-trip per autosave. Cheaper to build, costs ~10ms server-side per keystroke (debounced 500ms).
|
||||
|
||||
Client-side: ship the merged document body as JSON of paragraph runs, render in TS. Faster preview, harder to build (parallel render path in TS), and **diverges** the preview from the export shape (export still goes server-side).
|
||||
|
||||
**Inventor pick:** **server-side**. Single source of truth for the merge logic. The 500ms debounce already absorbs the round-trip; a 10ms server merge plus 50ms HTTP RTT is sub-perceptible.
|
||||
|
||||
**Material because:** if head wants the client-side preview for fully-offline draft editing, the coder needs a TS port of `substituteInDocumentXML`. Bigger build, but no round-trip latency on every keystroke.
|
||||
|
||||
### Q-E4 — Inter-user draft visibility
|
||||
|
||||
Today's design: each user sees only their own drafts. If two associates on the same project both draft a Klageerwiderung, they don't see each other's drafts (each has their own row).
|
||||
|
||||
**Inventor pick:** **owner-scoped (status quo of this design)**. The unique constraint includes `user_id`; the `List` endpoint filters by current user.
|
||||
|
||||
**Material because:** if head wants project-team visibility ("paralegal sees associate's draft for review"), the unique constraint shifts to `(project_id, submission_code, name)` (drop `user_id`), the RLS already covers the read path (`can_see_project`), and `submission_drafts` becomes a project-team resource. **This is a Phase-shape change** — the lawyer model differs. Inventor flags it because the change is cheap to make now (one column + one constraint) and expensive to make later (drafts already accumulate per-user). Head's call.
|
||||
|
||||
---
|
||||
|
||||
## §12 Implementation notes
|
||||
|
||||
For the coder, not for head.
|
||||
|
||||
- **Resurrection is `git show`, not "re-write".** The four file revisions (`3677c81:internal/services/submission_vars.go`, `1765d5e:internal/services/submission_vars.go` for the Slice 2 patch, `8ea3509:internal/services/submission_render.go`, `3677c81:internal/services/submission_templates.go`) can be applied via `git checkout 3677c81 -- internal/services/submission_vars.go` etc. The coder should verify each compiles against today's `cmd/server/main.go` wiring before applying.
|
||||
- **Renderer's `RenderHTML` is new.** The .docx walker today emits OOXML bytes; the HTML emitter walks the same tree and emits `<p>` / `<strong>` / `<em>` / `<br>`. ~120 LoC on top of the resurrected file. Same regex (`placeholderRegex`), same run-merge logic, different writer.
|
||||
- **Sidebar variable schema needs a label table.** The variable contract from §7 is keyed by dotted paths; the sidebar UI needs DE/EN labels per key. Coder adds `services/submission_var_labels.go` with a `map[string]struct{LabelDE, LabelEN, HelpDE, HelpEN}` for the ~30 keys. (Mirrors `internal/services/email_template_variables.go` shape — same lawyer-facing pattern paliad already ships at `/admin/email-templates`.)
|
||||
- **Autosave race.** The lawyer types fast → multiple PATCHes in flight. Coder uses a request-ID-debouncing pattern on the client (cancel in-flight PATCH when a new one starts) and last-write-wins on the server. No version column on the draft row in v1.
|
||||
- **Empty-override semantics in the jsonb.** `overrides = {"project.case_number": ""}` means "force missing marker". `overrides = {}` (key absent) means "fall back to bag". The service code distinguishes — careful with `omitempty`.
|
||||
- **i18n key audit.** Add `projects.detail.submissions.action.edit`, `submissions.draft.title`, `submissions.draft.export`, `submissions.draft.sidebar.{firm,project,parties,deadline,user}.group`, `submissions.draft.rename`, `submissions.draft.delete`, `submissions.draft.new`, etc. Roughly 35 new keys in DE + EN.
|
||||
- **`entity-table` row contract.** Schriftsätze tab today carries `entity-table--readonly`. Slice A removes that modifier and adds a row-click handler that navigates to `/projects/{id}/submissions/{code}/draft`, skipping clicks on the inner [Generieren] button. Matches the pattern in `frontend/src/client/checklists.ts`, `client/projects-detail.ts`, `client/deadlines.ts`.
|
||||
- **Migration 119 may collide.** Other worktrees (paliadin aichat, mig 118) may land 119 before this branch merges. Coder verifies at land time; bump to the next free number if needed.
|
||||
|
||||
---
|
||||
|
||||
## §13 Acceptance gate
|
||||
|
||||
Per inventor SKILL.md + project CLAUDE.md: this design needs head's go/no-go before any coder is hired. After head ratifies (with or without escalating §11 to m):
|
||||
|
||||
- The head decides whether to hire the same worker as `/mai-coder` with this design as the brief, or a fresh coder.
|
||||
- A coder shift takes this doc as the spec, ships Slice A, opens a PR (no self-merge).
|
||||
- Slices B and C are SEPARATE tasks — not auto-spawned.
|
||||
|
||||
Inventor parks here.
|
||||
@@ -18,6 +18,9 @@ import { renderProjects } from "./src/projects";
|
||||
import { renderProjectsNew } from "./src/projects-new";
|
||||
import { renderProjectsDetail } from "./src/projects-detail";
|
||||
import { renderProjectsChart } from "./src/projects-chart";
|
||||
import { renderSubmissionDraft } from "./src/submission-draft";
|
||||
import { renderSubmissionsIndex } from "./src/submissions-index";
|
||||
import { renderSubmissionsNew } from "./src/submissions-new";
|
||||
import { renderEvents } from "./src/events";
|
||||
import { renderDeadlinesNew } from "./src/deadlines-new";
|
||||
import { renderDeadlinesDetail } from "./src/deadlines-detail";
|
||||
@@ -46,6 +49,7 @@ import { renderAdminRulesEdit } from "./src/admin-rules-edit";
|
||||
import { renderAdminRulesExport } from "./src/admin-rules-export";
|
||||
import { renderPaliadin } from "./src/paliadin";
|
||||
import { renderAdminPaliadin } from "./src/admin-paliadin";
|
||||
import { renderAdminBackups } from "./src/admin-backups";
|
||||
import { renderNotFound } from "./src/notfound";
|
||||
|
||||
const DIST = join(import.meta.dir, "dist");
|
||||
@@ -252,6 +256,9 @@ async function build() {
|
||||
join(import.meta.dir, "src/client/projects-new.ts"),
|
||||
join(import.meta.dir, "src/client/projects-detail.ts"),
|
||||
join(import.meta.dir, "src/client/projects-chart.ts"),
|
||||
join(import.meta.dir, "src/client/submission-draft.ts"),
|
||||
join(import.meta.dir, "src/client/submissions-index.ts"),
|
||||
join(import.meta.dir, "src/client/submissions-new.ts"),
|
||||
join(import.meta.dir, "src/client/events.ts"),
|
||||
join(import.meta.dir, "src/client/deadlines-new.ts"),
|
||||
join(import.meta.dir, "src/client/deadlines-detail.ts"),
|
||||
@@ -285,6 +292,7 @@ async function build() {
|
||||
// skip the re-fetch.
|
||||
join(import.meta.dir, "src/client/paliadin-widget.ts"),
|
||||
join(import.meta.dir, "src/client/admin-paliadin.ts"),
|
||||
join(import.meta.dir, "src/client/admin-backups.ts"),
|
||||
join(import.meta.dir, "src/client/notfound.ts"),
|
||||
],
|
||||
outdir: join(DIST, "assets"),
|
||||
@@ -376,6 +384,9 @@ async function build() {
|
||||
await Bun.write(join(DIST, "projects-new.html"), renderProjectsNew());
|
||||
await Bun.write(join(DIST, "projects-detail.html"), renderProjectsDetail());
|
||||
await Bun.write(join(DIST, "projects-chart.html"), renderProjectsChart());
|
||||
await Bun.write(join(DIST, "submission-draft.html"), renderSubmissionDraft());
|
||||
await Bun.write(join(DIST, "submissions-index.html"), renderSubmissionsIndex());
|
||||
await Bun.write(join(DIST, "submissions-new.html"), renderSubmissionsNew());
|
||||
// t-paliad-115 — shared EventsPage at the canonical /events URL.
|
||||
// One HTML output; defaultType="all" baked in. Sidebar Fristen /
|
||||
// Termine entries point at /events?type=… and events.ts re-highlights
|
||||
@@ -408,6 +419,7 @@ async function build() {
|
||||
await Bun.write(join(DIST, "admin-rules-export.html"), renderAdminRulesExport());
|
||||
await Bun.write(join(DIST, "paliadin.html"), renderPaliadin());
|
||||
await Bun.write(join(DIST, "admin-paliadin.html"), renderAdminPaliadin());
|
||||
await Bun.write(join(DIST, "admin-backups.html"), renderAdminBackups());
|
||||
await Bun.write(join(DIST, "notfound.html"), renderNotFound());
|
||||
|
||||
// Append ?v=<buildVersion> to every /assets/*.js and /assets/*.css URL in
|
||||
|
||||
96
frontend/src/admin-backups.tsx
Normal file
96
frontend/src/admin-backups.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
// Backup Mode admin page (t-paliad-246 / m/paliad#77 Slice A).
|
||||
//
|
||||
// global_admin only — gated by adminGate(...) in handlers.go. Shows the
|
||||
// chronological list of backup runs (one row per kind in
|
||||
// {scheduled, on_demand}) plus a button to kick off an on-demand backup.
|
||||
// Catalog rows + the "run now" action are fetched client-side via
|
||||
// /api/admin/backups.
|
||||
export function renderAdminBackups(): string {
|
||||
return "<!DOCTYPE html>" + (
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#BFF355" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="admin.backups.title">Backups — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/admin/backups" />
|
||||
<BottomNav currentPath="/admin/backups" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
<div className="container">
|
||||
<div className="tool-header">
|
||||
<div>
|
||||
<h1 data-i18n="admin.backups.heading">Backups</h1>
|
||||
<p className="tool-subtitle" data-i18n="admin.backups.subtitle">
|
||||
Vollständige Snapshots aller Daten — manuell oder zeitgesteuert.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
className="btn-primary"
|
||||
id="admin-backups-run-btn"
|
||||
type="button"
|
||||
data-i18n="admin.backups.run_now"
|
||||
>
|
||||
Backup jetzt erstellen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="admin-backups-feedback" className="form-msg" style="display:none" />
|
||||
|
||||
<div className="entity-table-wrap">
|
||||
<table className="entity-table entity-table--readonly">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="admin.backups.col.started">Erstellt</th>
|
||||
<th data-i18n="admin.backups.col.kind">Auslöser</th>
|
||||
<th data-i18n="admin.backups.col.status">Status</th>
|
||||
<th data-i18n="admin.backups.col.requested_by">Angefordert von</th>
|
||||
<th data-i18n="admin.backups.col.size">Größe</th>
|
||||
<th data-i18n="admin.backups.col.rows">Zeilen</th>
|
||||
<th data-i18n="admin.backups.col.actions">Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="admin-backups-tbody">
|
||||
<tr>
|
||||
<td colspan={7} data-i18n="admin.backups.loading">Lade …</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="entity-empty" id="admin-backups-empty" style="display:none">
|
||||
<p data-i18n="admin.backups.empty">Noch keine Backups vorhanden.</p>
|
||||
</div>
|
||||
|
||||
<p className="tool-footer-note" id="admin-backups-footer">
|
||||
<span data-i18n="admin.backups.footer.note">
|
||||
Geplante Backups werden in einer späteren Slice aktiviert. Manuelle Backups stehen jetzt zur Verfügung.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/admin-backups.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
192
frontend/src/client/admin-backups.ts
Normal file
192
frontend/src/client/admin-backups.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { initI18n, t } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
|
||||
// Backup Mode admin client (t-paliad-246 / m/paliad#77 Slice A).
|
||||
//
|
||||
// Reads /api/admin/backups (chronological list) and wires the
|
||||
// "Backup jetzt erstellen" button to POST /api/admin/backups/run.
|
||||
// Synchronous: the server holds the connection for the duration of
|
||||
// the backup (sub-second at firm-scale today), then returns the new
|
||||
// catalog row inline. No polling needed at v1's data shape; if the
|
||||
// run takes > 5 minutes the handler returns 500 and the UI surfaces
|
||||
// the error.
|
||||
|
||||
interface BackupRow {
|
||||
id: string;
|
||||
kind: "scheduled" | "on_demand";
|
||||
status: "running" | "done" | "failed";
|
||||
requested_by?: string;
|
||||
requested_by_email: string;
|
||||
audit_id?: string;
|
||||
storage_uri?: string;
|
||||
size_bytes?: number;
|
||||
row_counts?: unknown; // jsonb passes through as raw bytes; we don't read it
|
||||
sheet_count?: number;
|
||||
warnings?: unknown;
|
||||
error?: string;
|
||||
started_at: string;
|
||||
finished_at?: string;
|
||||
deleted_at?: string;
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
|
||||
await refreshList();
|
||||
wireRunButton();
|
||||
});
|
||||
|
||||
function wireRunButton(): void {
|
||||
const btn = document.getElementById("admin-backups-run-btn") as HTMLButtonElement | null;
|
||||
if (!btn) return;
|
||||
btn.addEventListener("click", async () => {
|
||||
btn.disabled = true;
|
||||
const originalText = btn.textContent;
|
||||
btn.textContent = t("admin.backups.running") || "Läuft …";
|
||||
clearFeedback();
|
||||
try {
|
||||
const r = await fetch("/api/admin/backups/run", {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
});
|
||||
if (!r.ok) {
|
||||
const body = await r.json().catch(() => ({ error: "request failed" }));
|
||||
showFeedback("error", body.error || `HTTP ${r.status}`);
|
||||
return;
|
||||
}
|
||||
// The created row is in the response; refresh the list to land it.
|
||||
await refreshList();
|
||||
showFeedback("success", t("admin.backups.success") || "Backup erfolgreich erstellt.");
|
||||
} catch (e) {
|
||||
showFeedback("error", (e as Error).message || "network error");
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = originalText;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function refreshList(): Promise<void> {
|
||||
const rows = await fetchJSON<BackupRow[]>("/api/admin/backups?limit=200");
|
||||
const tbody = document.getElementById("admin-backups-tbody") as HTMLTableSectionElement | null;
|
||||
const empty = document.getElementById("admin-backups-empty") as HTMLElement | null;
|
||||
if (!tbody) return;
|
||||
if (!rows || rows.length === 0) {
|
||||
tbody.innerHTML = "";
|
||||
if (empty) empty.style.display = "";
|
||||
return;
|
||||
}
|
||||
if (empty) empty.style.display = "none";
|
||||
tbody.innerHTML = rows.map(renderRow).join("");
|
||||
}
|
||||
|
||||
function renderRow(b: BackupRow): string {
|
||||
const started = formatTimestamp(b.started_at);
|
||||
const kind =
|
||||
b.kind === "scheduled"
|
||||
? t("admin.backups.kind.scheduled") || "Geplant"
|
||||
: t("admin.backups.kind.on_demand") || "Manuell";
|
||||
const status = renderStatus(b);
|
||||
const requestedBy =
|
||||
b.kind === "scheduled" ? "—" : escapeHTML(b.requested_by_email);
|
||||
const size = b.size_bytes != null ? formatBytes(b.size_bytes) : "—";
|
||||
const rows = b.sheet_count != null ? String(b.sheet_count) : "—";
|
||||
const action = renderAction(b);
|
||||
return `<tr>
|
||||
<td>${started}</td>
|
||||
<td>${kind}</td>
|
||||
<td>${status}</td>
|
||||
<td>${requestedBy}</td>
|
||||
<td>${size}</td>
|
||||
<td>${rows}</td>
|
||||
<td>${action}</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
function renderStatus(b: BackupRow): string {
|
||||
switch (b.status) {
|
||||
case "done":
|
||||
return `<span class="status-done">${escapeHTML(t("admin.backups.status.done") || "✓ Fertig")}</span>`;
|
||||
case "running":
|
||||
return `<span class="status-running">${escapeHTML(t("admin.backups.status.running") || "Läuft …")}</span>`;
|
||||
case "failed":
|
||||
const label = t("admin.backups.status.failed") || "✗ Fehlgeschlagen";
|
||||
const tip = b.error ? ` title="${escapeAttr(b.error)}"` : "";
|
||||
return `<span class="status-failed"${tip}>${escapeHTML(label)}</span>`;
|
||||
default:
|
||||
return escapeHTML(b.status);
|
||||
}
|
||||
}
|
||||
|
||||
function renderAction(b: BackupRow): string {
|
||||
if (b.status !== "done" || !b.storage_uri || b.deleted_at) {
|
||||
return "—";
|
||||
}
|
||||
const label = t("admin.backups.download") || "Download";
|
||||
return `<a class="btn-link" href="/api/admin/backups/${encodeURIComponent(b.id)}/file">${escapeHTML(label)}</a>`;
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
async function fetchJSON<T>(url: string): Promise<T | null> {
|
||||
try {
|
||||
const r = await fetch(url, { credentials: "same-origin" });
|
||||
if (!r.ok) return null;
|
||||
return (await r.json()) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function formatTimestamp(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return escapeHTML(iso);
|
||||
const yyyy = d.getUTCFullYear();
|
||||
const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
|
||||
const dd = String(d.getUTCDate()).padStart(2, "0");
|
||||
const hh = String(d.getUTCHours()).padStart(2, "0");
|
||||
const mi = String(d.getUTCMinutes()).padStart(2, "0");
|
||||
return `${yyyy}-${mm}-${dd} ${hh}:${mi} UTC`;
|
||||
}
|
||||
|
||||
function formatBytes(n: number): string {
|
||||
if (n < 1024) return `${n} B`;
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
||||
if (n < 1024 * 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(n / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
function escapeHTML(s: string): string {
|
||||
return s.replace(/[&<>"']/g, (c) => {
|
||||
switch (c) {
|
||||
case "&": return "&";
|
||||
case "<": return "<";
|
||||
case ">": return ">";
|
||||
case '"': return """;
|
||||
case "'": return "'";
|
||||
default: return c;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function escapeAttr(s: string): string {
|
||||
return escapeHTML(s);
|
||||
}
|
||||
|
||||
function showFeedback(kind: "success" | "error", text: string): void {
|
||||
const el = document.getElementById("admin-backups-feedback") as HTMLElement | null;
|
||||
if (!el) return;
|
||||
el.textContent = text;
|
||||
el.classList.remove("form-msg-success", "form-msg-error");
|
||||
el.classList.add(kind === "success" ? "form-msg-success" : "form-msg-error");
|
||||
el.style.display = "";
|
||||
}
|
||||
|
||||
function clearFeedback(): void {
|
||||
const el = document.getElementById("admin-backups-feedback") as HTMLElement | null;
|
||||
if (!el) return;
|
||||
el.style.display = "none";
|
||||
el.textContent = "";
|
||||
el.classList.remove("form-msg-success", "form-msg-error");
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { initI18n, t, tDyn, getLang } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import { initNotes } from "./notes";
|
||||
import { projectIndent } from "./project-indent";
|
||||
import { openWithdrawWarningModal } from "./components/withdraw-warning-modal";
|
||||
|
||||
interface Appointment {
|
||||
id: string;
|
||||
@@ -25,6 +26,9 @@ interface PendingApprovalRequest {
|
||||
requested_at: string;
|
||||
required_role: string;
|
||||
requester_name?: string;
|
||||
// t-paliad-252 — used by the withdraw warning modal to pick the right
|
||||
// copy (CREATE warns about deletion; UPDATE/COMPLETE about revert).
|
||||
lifecycle_event?: string;
|
||||
}
|
||||
|
||||
interface Me {
|
||||
@@ -43,6 +47,10 @@ let project: Project | null = null;
|
||||
let allProjects: Project[] = [];
|
||||
let pendingRequest: PendingApprovalRequest | null = null;
|
||||
let me: Me | null = null;
|
||||
// t-paliad-252 — see deadlines-detail.ts. Routes Save to the new
|
||||
// /api/approval-requests/{id}/edit-entity endpoint when the user picked
|
||||
// "Termin bearbeiten" in the withdraw warning modal.
|
||||
let pendingEditMode = false;
|
||||
|
||||
function parseAppointmentID(): string | null {
|
||||
const parts = window.location.pathname.split("/").filter(Boolean);
|
||||
@@ -207,10 +215,14 @@ function renderHeader() {
|
||||
}
|
||||
|
||||
// Freeze the edit form + delete button while a request is in flight.
|
||||
// t-paliad-252 — when the user picked "Termin bearbeiten" in the
|
||||
// withdraw modal, pendingEditMode unfreezes the form so Save can route
|
||||
// to /edit-entity (which keeps the request pending + merges payload).
|
||||
const form = document.getElementById("appointment-edit-form") as HTMLFormElement | null;
|
||||
if (form) {
|
||||
const freeze = isPending && !pendingEditMode;
|
||||
form.querySelectorAll<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement | HTMLButtonElement>("input, select, textarea, button[type=submit]")
|
||||
.forEach((el) => { el.disabled = isPending; });
|
||||
.forEach((el) => { el.disabled = freeze; });
|
||||
}
|
||||
const deleteBtn = document.getElementById("appointment-delete-btn") as HTMLButtonElement | null;
|
||||
if (deleteBtn) deleteBtn.disabled = isPending;
|
||||
@@ -263,6 +275,39 @@ async function saveEdit(ev: Event) {
|
||||
|
||||
submitBtn.disabled = true;
|
||||
try {
|
||||
// t-paliad-252 — pending-edit mode routes through /edit-entity which
|
||||
// keeps the request pending + merges fields into payload. clear_project
|
||||
// and project_id are NOT in the counter-allowlist (yet) — the requester
|
||||
// can't move projects on a pending request from this surface.
|
||||
if (pendingEditMode && pendingRequest) {
|
||||
const editFields = { ...payload };
|
||||
delete editFields.clear_project;
|
||||
const resp = await fetch(
|
||||
`/api/approval-requests/${pendingRequest.id}/edit-entity`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ fields: editFields }),
|
||||
},
|
||||
);
|
||||
if (resp.ok) {
|
||||
const fresh = await fetch(`/api/appointments/${appointment.id}`);
|
||||
if (fresh.ok) appointment = await fresh.json();
|
||||
await loadPendingRequest();
|
||||
// Exit pending-edit mode so the form re-freezes (still pending).
|
||||
pendingEditMode = false;
|
||||
renderHeader();
|
||||
fillEditForm();
|
||||
msg.textContent = t("appointments.detail.saved");
|
||||
msg.className = "form-msg form-msg-ok";
|
||||
} else {
|
||||
const data = await resp.json().catch(() => ({}) as { error?: string; message?: string });
|
||||
msg.textContent = data.message || data.error || t("appointments.error.generic");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const resp = await fetch(`/api/appointments/${appointment.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -312,12 +357,37 @@ async function deleteAppointment() {
|
||||
}
|
||||
}
|
||||
|
||||
// t-paliad-252 — withdraw warning modal replaces the old confirm().
|
||||
// Returns:
|
||||
// "edit" → unfreeze the edit form (pending-edit mode); Save will
|
||||
// route through /api/approval-requests/{id}/edit-entity
|
||||
// "withdraw" → destructive: the existing /revoke endpoint
|
||||
// null → user cancelled
|
||||
async function withdrawAppointmentRequest() {
|
||||
if (!appointment || !pendingRequest) return;
|
||||
if (!confirm(t("approvals.withdraw.confirm") || "Anfrage wirklich zurückziehen?")) return;
|
||||
const btn = document.getElementById("appointment-withdraw-btn") as HTMLButtonElement | null;
|
||||
if (btn) btn.disabled = true;
|
||||
try {
|
||||
const action = await openWithdrawWarningModal({
|
||||
entityType: "appointment",
|
||||
lifecycleEvent: pendingRequest.lifecycle_event ?? "create",
|
||||
});
|
||||
if (action === null) {
|
||||
if (btn) btn.disabled = false;
|
||||
return;
|
||||
}
|
||||
if (action === "edit") {
|
||||
pendingEditMode = true;
|
||||
if (btn) btn.disabled = false;
|
||||
// renderHeader re-evaluates the freeze and unfreezes the form now
|
||||
// that pendingEditMode is set. Focus the first editable field so the
|
||||
// user can type immediately.
|
||||
renderHeader();
|
||||
const titleEl = document.getElementById("appointment-title-edit") as HTMLInputElement | null;
|
||||
titleEl?.focus();
|
||||
return;
|
||||
}
|
||||
// action === "withdraw" → destructive path.
|
||||
const resp = await fetch(`/api/approval-requests/${pendingRequest.id}/revoke`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -328,9 +398,12 @@ async function withdrawAppointmentRequest() {
|
||||
if (fresh.ok) {
|
||||
appointment = await fresh.json();
|
||||
await loadPendingRequest();
|
||||
renderHeader();
|
||||
fillEditForm();
|
||||
} else {
|
||||
// CREATE lifecycle: entity gone → back to the list.
|
||||
window.location.href = "/events?type=appointment";
|
||||
}
|
||||
renderHeader();
|
||||
fillEditForm();
|
||||
} else {
|
||||
const data = await resp.json().catch(() => ({}) as { message?: string; error?: string });
|
||||
const msg = document.getElementById("appointment-edit-msg")!;
|
||||
|
||||
149
frontend/src/client/components/withdraw-warning-modal.ts
Normal file
149
frontend/src/client/components/withdraw-warning-modal.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
// t-paliad-252 / m/paliad#83 — withdraw warning modal.
|
||||
//
|
||||
// Before t-paliad-252 the deadline + appointment detail pages did a
|
||||
// confirm() dialog before POSTing to /api/approval-requests/{id}/revoke.
|
||||
// For pending CREATE lifecycles that endpoint silently DELETES the
|
||||
// underlying entity row — m's "withdrawing the approval deletes the event"
|
||||
// surprise.
|
||||
//
|
||||
// This modal replaces the confirm() with three explicit paths:
|
||||
//
|
||||
// 1. Cancel — does nothing
|
||||
// 2. Termin bearbeiten (primary) — opens the edit form; saving routes
|
||||
// through POST /approval-requests/{id}/
|
||||
// edit-entity which keeps the request
|
||||
// pending and merges the new fields
|
||||
// into approval_request.payload
|
||||
// 3. Endgültig zurückziehen + — destructive; current /revoke
|
||||
// löschen behaviour (delete for CREATE, revert
|
||||
// for UPDATE/COMPLETE, cancel for
|
||||
// DELETE-lifecycle requests)
|
||||
//
|
||||
// Built on the unified openModal() primitive (t-paliad-217 Slice A) so the
|
||||
// three-button row sits cleanly inside the body — the primitive only
|
||||
// supports one secondary action, but we paint the destructive button as a
|
||||
// separate row above the footer.
|
||||
|
||||
import { t } from "../i18n";
|
||||
import { openModal } from "./modal";
|
||||
|
||||
export type WithdrawAction = "edit" | "withdraw";
|
||||
|
||||
export interface WithdrawWarningArgs {
|
||||
// entityType drives the copy ("event" vs "appointment" labels).
|
||||
entityType: "deadline" | "appointment";
|
||||
// lifecycleEvent of the pending request; copy adapts (CREATE warns about
|
||||
// deletion; UPDATE/COMPLETE warn about revert; DELETE warns about
|
||||
// cancelling the deletion request).
|
||||
lifecycleEvent: "create" | "update" | "complete" | "delete" | string;
|
||||
}
|
||||
|
||||
// openWithdrawWarningModal resolves with the chosen action, or null if the
|
||||
// user dismissed via Cancel / Esc / backdrop / browser back-button.
|
||||
export async function openWithdrawWarningModal(
|
||||
args: WithdrawWarningArgs,
|
||||
): Promise<WithdrawAction | null> {
|
||||
const body = document.createElement("div");
|
||||
body.className = "withdraw-warning-body";
|
||||
|
||||
// Lead paragraph + sub-paragraph adapt to lifecycle so the user always
|
||||
// knows what the destructive button will actually do. The /revoke
|
||||
// backend behaviour:
|
||||
// - create → DELETE the entity (the "surprise" m flagged)
|
||||
// - update → revert to pre_image
|
||||
// - complete → revert to pre-complete state
|
||||
// - delete → cancel the delete request (entity stays alive)
|
||||
const intro = document.createElement("p");
|
||||
intro.className = "withdraw-warning-intro";
|
||||
intro.textContent = leadCopyFor(args);
|
||||
body.appendChild(intro);
|
||||
|
||||
const sub = document.createElement("p");
|
||||
sub.className = "withdraw-warning-sub muted";
|
||||
sub.textContent = subCopyFor(args);
|
||||
body.appendChild(sub);
|
||||
|
||||
// The destructive button lives inside the body — the openModal primitive
|
||||
// only exposes one secondary button slot, and we want the safe "Edit"
|
||||
// path to be the primary CTA. Painting it in red here, separated from
|
||||
// the footer, signals "this is the dangerous option" without competing
|
||||
// visually with the primary CTA.
|
||||
const destructiveRow = document.createElement("div");
|
||||
destructiveRow.className = "withdraw-warning-destructive-row";
|
||||
const destructiveBtn = document.createElement("button");
|
||||
destructiveBtn.type = "button";
|
||||
destructiveBtn.className = "btn btn-danger withdraw-warning-destructive-btn";
|
||||
destructiveBtn.textContent = t("approvals.withdraw.destructive.label");
|
||||
destructiveRow.appendChild(destructiveBtn);
|
||||
body.appendChild(destructiveRow);
|
||||
|
||||
return new Promise<WithdrawAction | null>((resolve) => {
|
||||
let chosen: WithdrawAction | null = null;
|
||||
|
||||
// The destructive button has to close the modal and return "withdraw".
|
||||
// We need access to the modal's internal close() — fortunately openModal
|
||||
// exposes it via the primary handler's first arg. We pass through the
|
||||
// outer resolve and let the primary handler (Edit) own the close-fn
|
||||
// route. For the destructive button we resolve the outer promise
|
||||
// directly and then synthesise an ESC keypress so the modal dismisses
|
||||
// — or, simpler, set chosen and use the secondary "Cancel" path that
|
||||
// the modal already supports. (openModal's onClose fires on every
|
||||
// dismiss path including the primary handler resolution.)
|
||||
destructiveBtn.addEventListener("click", () => {
|
||||
chosen = "withdraw";
|
||||
// The unified openModal primitive (modal.ts) wires its dismiss path
|
||||
// through the native <dialog>'s `cancel` event. Dispatching it on
|
||||
// the parent <dialog> runs the same finish() → onClose → resolve
|
||||
// sequence as ESC / backdrop. We then map the resolved `null` back
|
||||
// to "withdraw" via the captured `chosen` in onClose below.
|
||||
const dialogEl = body.closest("dialog");
|
||||
dialogEl?.dispatchEvent(new Event("cancel"));
|
||||
});
|
||||
|
||||
void openModal<WithdrawAction>({
|
||||
title: t("approvals.withdraw.modal.title"),
|
||||
body,
|
||||
size: "md",
|
||||
classNames: "withdraw-warning-modal",
|
||||
primary: {
|
||||
label: t("approvals.withdraw.primary.label"),
|
||||
handler: (close) => {
|
||||
chosen = "edit";
|
||||
close("edit");
|
||||
},
|
||||
},
|
||||
secondary: { label: t("approvals.withdraw.cancel") },
|
||||
onClose: () => {
|
||||
// Resolves whatever was chosen via the destructive button OR the
|
||||
// primary handler. ESC / backdrop / secondary clear `chosen` to
|
||||
// null which is the right "cancel" semantics.
|
||||
resolve(chosen);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function leadCopyFor(args: WithdrawWarningArgs): string {
|
||||
switch (args.lifecycleEvent) {
|
||||
case "create":
|
||||
return args.entityType === "appointment"
|
||||
? t("approvals.withdraw.lead.create.appointment")
|
||||
: t("approvals.withdraw.lead.create.deadline");
|
||||
case "delete":
|
||||
return t("approvals.withdraw.lead.delete");
|
||||
default:
|
||||
// update / complete / unknown → revert semantics
|
||||
return t("approvals.withdraw.lead.update");
|
||||
}
|
||||
}
|
||||
|
||||
function subCopyFor(args: WithdrawWarningArgs): string {
|
||||
switch (args.lifecycleEvent) {
|
||||
case "create":
|
||||
return t("approvals.withdraw.sub.create");
|
||||
case "delete":
|
||||
return t("approvals.withdraw.sub.delete");
|
||||
default:
|
||||
return t("approvals.withdraw.sub.update");
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
type EventType,
|
||||
type PickerHandle,
|
||||
} from "./event-types";
|
||||
import { openWithdrawWarningModal } from "./components/withdraw-warning-modal";
|
||||
import { formatRuleLabel, formatRuleLabelHTML, formatCustomRuleLabelHTML } from "./rule-label";
|
||||
|
||||
interface Deadline {
|
||||
id: string;
|
||||
@@ -20,6 +22,9 @@ interface Deadline {
|
||||
source: string;
|
||||
rule_id?: string;
|
||||
rule_code?: string;
|
||||
// t-paliad-258 — lawyer's free-text rule label when the deadline was
|
||||
// saved in Custom mode. Mutually exclusive with rule_id.
|
||||
custom_rule_text?: string;
|
||||
notes?: string;
|
||||
created_at: string;
|
||||
completed_at?: string;
|
||||
@@ -38,6 +43,9 @@ interface PendingApprovalRequest {
|
||||
requested_at: string;
|
||||
required_role: string;
|
||||
requester_name?: string;
|
||||
// t-paliad-252 — used by the withdraw warning modal to pick the right
|
||||
// copy (CREATE warns about deletion; UPDATE/COMPLETE about revert).
|
||||
lifecycle_event?: string;
|
||||
}
|
||||
|
||||
let eventTypePicker: PickerHandle | null = null;
|
||||
@@ -54,7 +62,21 @@ interface DeadlineRule {
|
||||
id: string;
|
||||
code?: string;
|
||||
name: string;
|
||||
name_en?: string;
|
||||
rule_code?: string;
|
||||
legal_source?: string | null;
|
||||
// t-paliad-258 — canonical event_type for Auto-mode rule resolution
|
||||
// when the user flips to Auto on the edit form.
|
||||
concept_default_event_type_id?: string | null;
|
||||
proceeding_type_id?: number | null;
|
||||
}
|
||||
|
||||
interface ProceedingType {
|
||||
id: number;
|
||||
jurisdiction: string;
|
||||
name: string;
|
||||
name_en?: string;
|
||||
sort_order?: number;
|
||||
}
|
||||
|
||||
interface Me {
|
||||
@@ -70,6 +92,30 @@ let me: Me | null = null;
|
||||
let allProjects: Project[] = [];
|
||||
let pendingRequest: PendingApprovalRequest | null = null;
|
||||
|
||||
// t-paliad-258 — Auto/Custom rule editor state. Mirrors the create form.
|
||||
// On enterEdit we initialise the mode from the persisted deadline:
|
||||
// rule_id set → "auto"
|
||||
// custom_rule_text set, no rule_id → "custom"
|
||||
// neither set → "auto" (so the Type-driven
|
||||
// resolver fills in immediately).
|
||||
type RuleMode = "auto" | "custom";
|
||||
let ruleMode: RuleMode = "auto";
|
||||
let allRules: DeadlineRule[] = [];
|
||||
let rulesByID = new Map<string, DeadlineRule>();
|
||||
let proceedingTypesByID = new Map<number, ProceedingType>();
|
||||
// t-paliad-252 — when the user chose "Edit event" in the withdraw warning
|
||||
// modal, the entity is still in approval_status='pending'. Save must POST
|
||||
// to /api/approval-requests/{id}/edit-entity (which keeps the request
|
||||
// pending + merges the new fields into payload) instead of the regular
|
||||
// PATCH /api/deadlines/{id} (which 409s during pending). Cleared on exit
|
||||
// from edit mode + after a successful save.
|
||||
let pendingEditMode = false;
|
||||
|
||||
// pendingEnterEdit — late-bound by initEdit() so the withdraw warning
|
||||
// modal handler (initWithdraw) can route into pending-edit mode without
|
||||
// duplicating the edit-mode toggle logic.
|
||||
let pendingEnterEdit: (() => void) | null = null;
|
||||
|
||||
function parseDeadlineID(): string | null {
|
||||
const parts = window.location.pathname.split("/").filter(Boolean);
|
||||
if (parts[0] !== "deadlines" || !parts[1]) return null;
|
||||
@@ -165,17 +211,66 @@ function populateProjectPicker() {
|
||||
sel.value = deadline.project_id;
|
||||
}
|
||||
|
||||
async function loadRule(ruleID: string) {
|
||||
async function loadAllRules() {
|
||||
try {
|
||||
const resp = await fetch(`/api/deadline-rules`);
|
||||
if (!resp.ok) return;
|
||||
const all: DeadlineRule[] = await resp.json();
|
||||
rule = all.find((r) => r.id === ruleID) || null;
|
||||
allRules = (await resp.json()) as DeadlineRule[];
|
||||
rulesByID = new Map(allRules.map((r) => [r.id, r]));
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
}
|
||||
|
||||
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 */
|
||||
}
|
||||
}
|
||||
|
||||
function lookupRule(ruleID: string): DeadlineRule | null {
|
||||
return rulesByID.get(ruleID) || null;
|
||||
}
|
||||
|
||||
// resolveAutoRuleForType mirrors the create-form resolver: pick the
|
||||
// canonical rule for the chosen event_type, prioritising the project's
|
||||
// proceeding then jurisdiction match.
|
||||
function resolveAutoRuleForType(eventTypeID: 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 projID = deadline?.project_id;
|
||||
const proj = projID ? allProjects.find((p) => p.id === projID) as (Project & { proceeding_type_id?: number | null }) | undefined : undefined;
|
||||
if (proj && proj.proceeding_type_id) {
|
||||
const exact = candidates.find((r) => r.proceeding_type_id === proj.proceeding_type_id);
|
||||
if (exact) return exact;
|
||||
}
|
||||
|
||||
const et = eventTypeByID.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];
|
||||
}
|
||||
|
||||
function currentAutoRule(): DeadlineRule | null {
|
||||
const picked = eventTypePicker?.getIDs() ?? [];
|
||||
if (picked.length !== 1) return null;
|
||||
return resolveAutoRuleForType(picked[0]);
|
||||
}
|
||||
|
||||
async function loadMe() {
|
||||
try {
|
||||
const resp = await fetch("/api/me");
|
||||
@@ -227,9 +322,15 @@ function render() {
|
||||
}
|
||||
|
||||
const ruleEl = document.getElementById("deadline-rule-display")!;
|
||||
// t-paliad-258 — display priority:
|
||||
// 1. catalog rule (canonical Name · Citation pattern)
|
||||
// 2. custom_rule_text + Custom badge
|
||||
// 3. legacy rule_code-only (Fristenrechner saves)
|
||||
// 4. "—"
|
||||
if (rule) {
|
||||
const code = rule.rule_code || rule.code || "";
|
||||
ruleEl.textContent = code ? `${code} — ${rule.name}` : rule.name;
|
||||
ruleEl.innerHTML = formatRuleLabelHTML(rule, esc);
|
||||
} else if (deadline.custom_rule_text && deadline.custom_rule_text.trim()) {
|
||||
ruleEl.innerHTML = formatCustomRuleLabelHTML(deadline.custom_rule_text, esc);
|
||||
} else if (deadline.rule_code) {
|
||||
// Fristenrechner-saved deadlines carry rule_code directly without
|
||||
// a rule_id (no rule UUID round-trips through the public API).
|
||||
@@ -353,6 +454,48 @@ function render() {
|
||||
}
|
||||
}
|
||||
|
||||
function refreshRuleAutoDisplay(): void {
|
||||
const panel = document.getElementById("deadline-rule-auto-display");
|
||||
const text = document.getElementById("deadline-rule-auto-text");
|
||||
if (!panel || !text) return;
|
||||
if (ruleMode !== "auto") {
|
||||
panel.style.display = "none";
|
||||
return;
|
||||
}
|
||||
panel.style.display = "";
|
||||
const r = currentAutoRule();
|
||||
if (r) {
|
||||
text.textContent = formatRuleLabel(r);
|
||||
text.classList.remove("rule-auto-text--empty");
|
||||
return;
|
||||
}
|
||||
const picked = eventTypePicker?.getIDs() ?? [];
|
||||
const fallback = picked.length === 1
|
||||
? (t("deadlines.field.rule.auto_no_match") || "Keine Regel zur gewählten Verfahrenshandlung")
|
||||
: (t("deadlines.field.rule.auto_pick_type") || "Wählen Sie zuerst eine Verfahrenshandlung");
|
||||
text.textContent = fallback;
|
||||
text.classList.add("rule-auto-text--empty");
|
||||
}
|
||||
|
||||
function applyRuleModeUI(): void {
|
||||
const toggleBtn = document.getElementById("deadline-rule-mode-toggle") as HTMLButtonElement | null;
|
||||
const autoPanel = document.getElementById("deadline-rule-auto-display");
|
||||
const customInput = document.getElementById("deadline-rule-custom-input") as HTMLInputElement | null;
|
||||
if (!toggleBtn || !autoPanel || !customInput) return;
|
||||
if (ruleMode === "auto") {
|
||||
autoPanel.style.display = "";
|
||||
customInput.style.display = "none";
|
||||
toggleBtn.textContent = t("deadlines.field.rule.mode.toggle_to_custom") || "Eigene Regel eingeben";
|
||||
toggleBtn.setAttribute("data-i18n", "deadlines.field.rule.mode.toggle_to_custom");
|
||||
} else {
|
||||
autoPanel.style.display = "none";
|
||||
customInput.style.display = "";
|
||||
toggleBtn.textContent = t("deadlines.field.rule.mode.toggle_to_auto") || "Zurück zu Auto";
|
||||
toggleBtn.setAttribute("data-i18n", "deadlines.field.rule.mode.toggle_to_auto");
|
||||
}
|
||||
refreshRuleAutoDisplay();
|
||||
}
|
||||
|
||||
function initEdit() {
|
||||
const titleDisplay = document.getElementById("deadline-title-display")!;
|
||||
const titleEdit = document.getElementById("deadline-title-edit") as HTMLInputElement;
|
||||
@@ -366,6 +509,11 @@ 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;
|
||||
const ruleDisplay = document.getElementById("deadline-rule-display");
|
||||
const ruleEdit = document.getElementById("deadline-rule-edit");
|
||||
const ruleCustomInput = document.getElementById("deadline-rule-custom-input") as HTMLInputElement | null;
|
||||
const ruleToggleBtn = document.getElementById("deadline-rule-mode-toggle") as HTMLButtonElement | null;
|
||||
|
||||
function enterEdit() {
|
||||
titleDisplay.style.display = "none";
|
||||
@@ -381,6 +529,20 @@ function initEdit() {
|
||||
projectEdit.style.display = "";
|
||||
projectEdit.value = deadline.project_id;
|
||||
}
|
||||
if (titleDefaultBtn) titleDefaultBtn.style.display = "";
|
||||
// t-paliad-258 — show the Auto/Custom rule editor + initialise mode
|
||||
// from the persisted deadline. Display element stays visible so the
|
||||
// user keeps "before / after" context while editing.
|
||||
if (ruleEdit) ruleEdit.style.display = "";
|
||||
if (ruleDisplay) ruleDisplay.style.display = "none";
|
||||
if (deadline?.custom_rule_text && !deadline.rule_id) {
|
||||
ruleMode = "custom";
|
||||
if (ruleCustomInput) ruleCustomInput.value = deadline.custom_rule_text;
|
||||
} else {
|
||||
ruleMode = "auto";
|
||||
if (ruleCustomInput) ruleCustomInput.value = "";
|
||||
}
|
||||
applyRuleModeUI();
|
||||
saveBtn.style.display = "";
|
||||
editBtn.style.display = "none";
|
||||
titleEdit.focus();
|
||||
@@ -399,12 +561,71 @@ function initEdit() {
|
||||
projectEdit.style.display = "none";
|
||||
projectLink.style.display = "";
|
||||
}
|
||||
if (titleDefaultBtn) titleDefaultBtn.style.display = "none";
|
||||
if (ruleEdit) ruleEdit.style.display = "none";
|
||||
if (ruleDisplay) ruleDisplay.style.display = "";
|
||||
saveBtn.style.display = "none";
|
||||
editBtn.style.display = "";
|
||||
pendingEditMode = false;
|
||||
}
|
||||
|
||||
// Rule mode toggle (Auto ↔ Custom). The Auto resolver re-runs every
|
||||
// time the Type picker changes, so just-toggling-to-Auto immediately
|
||||
// surfaces a fresh resolution.
|
||||
ruleToggleBtn?.addEventListener("click", () => {
|
||||
ruleMode = ruleMode === "auto" ? "custom" : "auto";
|
||||
applyRuleModeUI();
|
||||
if (ruleMode === "custom") ruleCustomInput?.focus();
|
||||
});
|
||||
|
||||
// t-paliad-252 — expose enterEdit so the withdraw warning modal can
|
||||
// route into pending-edit mode without re-running the edit-button
|
||||
// visibility gate (which hides the button during pending).
|
||||
pendingEnterEdit = () => {
|
||||
pendingEditMode = true;
|
||||
enterEdit();
|
||||
};
|
||||
|
||||
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 in edit)
|
||||
// || Auto-resolved rule's canonical label (Name · Citation)
|
||||
// || saved rule's canonical label
|
||||
// || custom_rule_text (when in Custom mode + non-empty)
|
||||
// || rule_code-only legacy fallback
|
||||
// || "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) {
|
||||
const r = ruleMode === "auto" ? (currentAutoRule() ?? rule) : null;
|
||||
if (r) head = formatRuleLabel(r);
|
||||
}
|
||||
if (!head && ruleMode === "custom") {
|
||||
const txt = ruleCustomInput?.value.trim() || "";
|
||||
if (txt) head = txt;
|
||||
}
|
||||
if (!head && rule) {
|
||||
head = formatRuleLabel(rule);
|
||||
}
|
||||
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();
|
||||
@@ -424,6 +645,48 @@ function initEdit() {
|
||||
if (projectEdit && projectEdit.value && projectEdit.value !== deadline.project_id) {
|
||||
payload.project_id = projectEdit.value;
|
||||
}
|
||||
// t-paliad-258 — rule_set discriminator tells the service this
|
||||
// PATCH carries an Auto/Custom rule change. Both columns are
|
||||
// mutually exclusive at the persistence boundary.
|
||||
payload.rule_set = true;
|
||||
if (ruleMode === "auto") {
|
||||
const r = currentAutoRule();
|
||||
payload.rule_id = r ? r.id : null;
|
||||
payload.custom_rule_text = null;
|
||||
} else {
|
||||
const txt = ruleCustomInput?.value.trim() || "";
|
||||
payload.rule_id = null;
|
||||
payload.custom_rule_text = txt || null;
|
||||
}
|
||||
|
||||
// t-paliad-252 — pending-edit mode routes through the new endpoint
|
||||
// that updates the entity + merges payload into the still-pending
|
||||
// approval_request. Outside pending-edit mode the regular PATCH
|
||||
// path remains the authoritative one (with its existing 409-on-
|
||||
// pending guard).
|
||||
if (pendingEditMode && pendingRequest) {
|
||||
const resp = await fetch(
|
||||
`/api/approval-requests/${pendingRequest.id}/edit-entity`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ fields: payload }),
|
||||
},
|
||||
);
|
||||
if (resp.ok) {
|
||||
const fresh = await fetch(`/api/deadlines/${deadline.id}`);
|
||||
if (fresh.ok) deadline = await fresh.json();
|
||||
await loadPendingRequest();
|
||||
render();
|
||||
} else {
|
||||
const body = await resp.json().catch(() => null);
|
||||
const msg = (body && (body.message || body.error))
|
||||
|| (t("approvals.withdraw.error") || "Fehler");
|
||||
window.alert(msg);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const resp = await fetch(`/api/deadlines/${deadline.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -501,19 +764,39 @@ function initReopen() {
|
||||
});
|
||||
}
|
||||
|
||||
// initWithdraw — t-paliad-160 §C+E. Reuses the existing
|
||||
// /api/approval-requests/{id}/revoke endpoint (no new server route
|
||||
// needed). After the revoke lands, the entity goes back to
|
||||
// approval_status='approved' and the page reloads to refresh the
|
||||
// in-memory state cleanly.
|
||||
// initWithdraw — t-paliad-160 §C+E + t-paliad-252.
|
||||
//
|
||||
// Click flow: open the withdraw warning modal (replaces the old
|
||||
// confirm()). The modal returns one of:
|
||||
//
|
||||
// "edit" — open the edit form in pending-edit mode; Save calls
|
||||
// /api/approval-requests/{id}/edit-entity which keeps the
|
||||
// request pending + merges the new fields into payload
|
||||
// "withdraw" — destructive: call the existing /revoke endpoint
|
||||
// (DELETE entity for CREATE, revert for UPDATE/COMPLETE,
|
||||
// cancel-delete for DELETE lifecycle)
|
||||
// null — user cancelled; nothing happens
|
||||
function initWithdraw() {
|
||||
const btn = document.getElementById("deadline-withdraw-btn") as HTMLButtonElement | null;
|
||||
if (!btn) return;
|
||||
btn.addEventListener("click", async () => {
|
||||
if (!deadline || !pendingRequest) return;
|
||||
if (!window.confirm(t("approvals.withdraw.confirm") || "Anfrage wirklich zurückziehen?")) return;
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const action = await openWithdrawWarningModal({
|
||||
entityType: "deadline",
|
||||
lifecycleEvent: pendingRequest.lifecycle_event ?? "create",
|
||||
});
|
||||
if (action === null) {
|
||||
btn.disabled = false;
|
||||
return;
|
||||
}
|
||||
if (action === "edit") {
|
||||
btn.disabled = false;
|
||||
pendingEnterEdit?.();
|
||||
return;
|
||||
}
|
||||
// action === "withdraw" → existing destructive path.
|
||||
const resp = await fetch(`/api/approval-requests/${pendingRequest.id}/revoke`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -521,14 +804,16 @@ function initWithdraw() {
|
||||
});
|
||||
if (resp.ok) {
|
||||
// Re-fetch the entity so approval_status flips back to 'approved'
|
||||
// and the badge / buttons rerender accordingly.
|
||||
// and the badge / buttons rerender accordingly. For CREATE
|
||||
// lifecycle the entity is gone, so the 404 surfaces as a reload.
|
||||
const r = await fetch(`/api/deadlines/${deadline.id}`);
|
||||
if (r.ok) {
|
||||
deadline = await r.json();
|
||||
await loadPendingRequest();
|
||||
render();
|
||||
} else {
|
||||
window.location.reload();
|
||||
// CREATE lifecycle deleted the entity — bounce to the list.
|
||||
window.location.href = "/events?type=deadline";
|
||||
}
|
||||
} else {
|
||||
btn.disabled = false;
|
||||
@@ -592,8 +877,14 @@ async function main() {
|
||||
notfound.style.display = "block";
|
||||
return;
|
||||
}
|
||||
await Promise.all([loadProject(deadline.project_id), loadAllProjects(), loadPendingRequest()]);
|
||||
if (deadline.rule_id) await loadRule(deadline.rule_id);
|
||||
await Promise.all([
|
||||
loadProject(deadline.project_id),
|
||||
loadAllProjects(),
|
||||
loadPendingRequest(),
|
||||
loadAllRules(),
|
||||
loadProceedingTypes(),
|
||||
]);
|
||||
if (deadline.rule_id) rule = lookupRule(deadline.rule_id);
|
||||
|
||||
// Load event types in parallel; render once ready (the picker re-renders
|
||||
// chips off the cached map, and the display element re-renders on the
|
||||
@@ -614,6 +905,11 @@ async function main() {
|
||||
eventTypePicker = attachEventTypePicker(pickerHost, {
|
||||
initialIDs: deadline.event_type_ids ?? [],
|
||||
currentUserAdmin: me?.global_role === "global_admin",
|
||||
onChange: () => {
|
||||
// Type change shifts the Auto-resolved rule. Refresh the
|
||||
// read-only display panel (no-op outside edit mode / Custom).
|
||||
refreshRuleAutoDisplay();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { initI18n, t, tDyn } from "./i18n";
|
||||
import { initI18n, t, tDyn, getLang } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import {
|
||||
attachEventTypePicker,
|
||||
@@ -8,22 +8,21 @@ import {
|
||||
type PickerHandle,
|
||||
} from "./event-types";
|
||||
import { projectIndent } from "./project-indent";
|
||||
import { formatRuleLabel } from "./rule-label";
|
||||
|
||||
let eventTypePicker: PickerHandle | null = null;
|
||||
let currentUserAdmin = false;
|
||||
let eventTypesByID = new Map<string, EventType>();
|
||||
// expandedOverride flips to true when the user clicks "Anderen Typ
|
||||
// wählen" on the collapsed inline summary. Sticky for the rest of the
|
||||
// form session — cleared only when the user reverts the rule to "Keine
|
||||
// Regel". When true, the picker stays visible regardless of whether
|
||||
// the chip matches the rule's canonical default.
|
||||
let expandedOverride = false;
|
||||
|
||||
interface Project {
|
||||
id: string;
|
||||
reference?: string | null;
|
||||
title: string;
|
||||
path: string;
|
||||
// Used by the Type→Rule resolver to narrow rule candidates to the
|
||||
// project's own proceeding when one applies. Optional because clients
|
||||
// and matter-level projects don't carry a proceeding type.
|
||||
proceeding_type_id?: number | null;
|
||||
}
|
||||
|
||||
interface DeadlineRule {
|
||||
@@ -32,23 +31,37 @@ interface DeadlineRule {
|
||||
name: string;
|
||||
name_en: string;
|
||||
rule_code?: string;
|
||||
// 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.
|
||||
legal_source?: string | null;
|
||||
proceeding_type_id?: number | null;
|
||||
sequence_order?: number;
|
||||
// t-paliad-165 — canonical event_type for the rule's concept. The
|
||||
// catalog is indexed by it so we can resolve Type → canonical Rule.
|
||||
concept_default_event_type_id?: string | null;
|
||||
}
|
||||
|
||||
// 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>();
|
||||
interface ProceedingType {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
name_en?: string;
|
||||
jurisdiction: string;
|
||||
sort_order?: number;
|
||||
}
|
||||
|
||||
// Last event_type the rule auto-filled. Tracked so we can tell whether
|
||||
// the picker still reflects the rule's suggestion (replace silently on
|
||||
// new rule pick) or whether the user has manually edited (leave alone,
|
||||
// surface the mismatch warning instead).
|
||||
let lastAutoFilledEventTypeID: string | null = null;
|
||||
// Rule mode (t-paliad-258 / m/paliad#89). The form has two states:
|
||||
// auto — rule_id resolved from the chosen event_type, rendered
|
||||
// read-only as "Auto: Name · Citation".
|
||||
// custom — free-text input; submits as custom_rule_text on the API.
|
||||
type RuleMode = "auto" | "custom";
|
||||
let ruleMode: RuleMode = "auto";
|
||||
|
||||
let rulesByID = new Map<string, DeadlineRule>();
|
||||
let allRules: DeadlineRule[] = [];
|
||||
let proceedingTypesByID = new Map<number, ProceedingType>();
|
||||
let projectsByID = new Map<string, Project>();
|
||||
|
||||
let preselectedProjectID = "";
|
||||
let preselectedProjectIDLocal = "";
|
||||
|
||||
function esc(s: string): string {
|
||||
const d = document.createElement("div");
|
||||
@@ -62,6 +75,13 @@ function showError(msg: string) {
|
||||
el.className = "form-msg form-msg-error";
|
||||
}
|
||||
|
||||
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 +89,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 +103,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,122 +112,166 @@ 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 */
|
||||
}
|
||||
}
|
||||
|
||||
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]));
|
||||
} catch {
|
||||
/* non-fatal — rule select stays at "no rule" */
|
||||
/* non-fatal — rule display falls back to "—" */
|
||||
}
|
||||
}
|
||||
|
||||
// t-paliad-165 follow-up — drive the collapsed/expanded view of the Typ
|
||||
// picker. The two modes are mutually exclusive:
|
||||
// resolveAutoRuleForType picks the best-match catalog 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 the first candidate in canonical sequence_order.
|
||||
//
|
||||
// collapsed: rule selected + canonical event_type known + picker
|
||||
// contains exactly [default] + user hasn't clicked "Anderen Typ
|
||||
// wählen". Hides the chip cluster, surfaces a single inline
|
||||
// summary "Klageerwiderung (vorgegeben durch Regel)" + an
|
||||
// override link.
|
||||
//
|
||||
// expanded: every other case — no rule, no default for the rule,
|
||||
// picker has been edited, or expandedOverride is sticky after the
|
||||
// user clicked the override link. Picker visible; mismatch warning
|
||||
// surfaces yellow when the rule expected a different event_type.
|
||||
function refreshRuleView(): void {
|
||||
const collapsed = document.getElementById("deadline-event-type-collapsed");
|
||||
const collapsedLabel = document.getElementById("deadline-event-type-collapsed-label");
|
||||
const pickerHost = document.getElementById("deadline-event-types");
|
||||
const warn = document.getElementById("deadline-event-type-rule-mismatch");
|
||||
if (!collapsed || !collapsedLabel || !pickerHost || !warn) return;
|
||||
// Returns null when no rule maps. Callers render that as "no Auto rule
|
||||
// available" so the user can flip to Custom or pick a different Type.
|
||||
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 ruleID = (document.getElementById("deadline-rule") as HTMLSelectElement | null)?.value || "";
|
||||
const rule = ruleID ? rulesByID.get(ruleID) : undefined;
|
||||
const expected = rule?.concept_default_event_type_id ?? null;
|
||||
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];
|
||||
}
|
||||
|
||||
// currentAutoRule returns the catalog rule the Auto mode would resolve
|
||||
// to for the current form state, or null when no Type is picked or no
|
||||
// rule maps. Centralised so the Auto display, submitForm, and the
|
||||
// Standardtitel button all agree on the same resolution.
|
||||
function currentAutoRule(): DeadlineRule | null {
|
||||
const picked = eventTypePicker?.getIDs() ?? [];
|
||||
if (picked.length !== 1) return null;
|
||||
const projectID = (document.getElementById("deadline-project") as HTMLSelectElement | null)?.value || "";
|
||||
return resolveAutoRuleForType(picked[0], projectID);
|
||||
}
|
||||
|
||||
const pickerMatchesDefault =
|
||||
expected !== null && picked.length === 1 && picked[0] === expected;
|
||||
const wantsCollapsed =
|
||||
!expandedOverride && ruleID !== "" && expected !== null && pickerMatchesDefault;
|
||||
|
||||
if (wantsCollapsed) {
|
||||
const et = eventTypesByID.get(expected!);
|
||||
collapsedLabel.textContent = et ? eventTypeLabel(et) : "";
|
||||
collapsed.style.display = "";
|
||||
pickerHost.style.display = "none";
|
||||
warn.style.display = "none";
|
||||
// refreshRuleAutoDisplay updates the read-only Auto display panel to
|
||||
// reflect the rule that would be saved in Auto mode. Hides itself when
|
||||
// the user is in Custom mode (the input takes its place).
|
||||
function refreshRuleAutoDisplay(): void {
|
||||
const panel = document.getElementById("deadline-rule-auto-display");
|
||||
const text = document.getElementById("deadline-rule-auto-text");
|
||||
if (!panel || !text) return;
|
||||
if (ruleMode !== "auto") {
|
||||
panel.style.display = "none";
|
||||
return;
|
||||
}
|
||||
panel.style.display = "";
|
||||
const rule = currentAutoRule();
|
||||
if (rule) {
|
||||
text.textContent = formatRuleLabel(rule);
|
||||
text.classList.remove("rule-auto-text--empty");
|
||||
return;
|
||||
}
|
||||
const picked = eventTypePicker?.getIDs() ?? [];
|
||||
const fallback = picked.length === 1
|
||||
? (t("deadlines.field.rule.auto_no_match") || "Keine Regel zur gewählten Verfahrenshandlung")
|
||||
: (t("deadlines.field.rule.auto_pick_type") || "Wählen Sie zuerst eine Verfahrenshandlung");
|
||||
text.textContent = fallback;
|
||||
text.classList.add("rule-auto-text--empty");
|
||||
}
|
||||
|
||||
collapsed.style.display = "none";
|
||||
pickerHost.style.display = "";
|
||||
// Mismatch warning: rule expected an event_type AND the picker
|
||||
// doesn't contain it. (When the picker is empty + no override, no
|
||||
// warning — user is free to leave it blank.)
|
||||
if (expected && picked.length > 0 && !picked.includes(expected)) {
|
||||
warn.style.display = "";
|
||||
function applyRuleModeUI(): void {
|
||||
const toggleBtn = document.getElementById("deadline-rule-mode-toggle") as HTMLButtonElement | null;
|
||||
const autoPanel = document.getElementById("deadline-rule-auto-display");
|
||||
const customInput = document.getElementById("deadline-rule-custom-input") as HTMLInputElement | null;
|
||||
if (!toggleBtn || !autoPanel || !customInput) return;
|
||||
if (ruleMode === "auto") {
|
||||
autoPanel.style.display = "";
|
||||
customInput.style.display = "none";
|
||||
toggleBtn.textContent = t("deadlines.field.rule.mode.toggle_to_custom") || "Eigene Regel eingeben";
|
||||
toggleBtn.setAttribute("data-i18n", "deadlines.field.rule.mode.toggle_to_custom");
|
||||
} else {
|
||||
warn.style.display = "none";
|
||||
autoPanel.style.display = "none";
|
||||
customInput.style.display = "";
|
||||
toggleBtn.textContent = t("deadlines.field.rule.mode.toggle_to_auto") || "Zurück zu Auto";
|
||||
toggleBtn.setAttribute("data-i18n", "deadlines.field.rule.mode.toggle_to_auto");
|
||||
}
|
||||
refreshRuleAutoDisplay();
|
||||
}
|
||||
|
||||
function setRuleMode(mode: RuleMode): void {
|
||||
ruleMode = mode;
|
||||
applyRuleModeUI();
|
||||
if (mode === "custom") {
|
||||
const input = document.getElementById("deadline-rule-custom-input") as HTMLInputElement | null;
|
||||
input?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
function applyRuleAutoFill(): void {
|
||||
if (!eventTypePicker) return;
|
||||
const ruleID = (document.getElementById("deadline-rule") as HTMLSelectElement | null)?.value || "";
|
||||
const rule = ruleID ? rulesByID.get(ruleID) : undefined;
|
||||
const expected = rule?.concept_default_event_type_id ?? null;
|
||||
const current = eventTypePicker.getIDs();
|
||||
// computeDefaultTitle — t-paliad-251 Part 4. Priority order picks the head:
|
||||
// 1. event_type label (when exactly one Typ chip is set)
|
||||
// 2. canonical rule name (when Auto resolves to a rule)
|
||||
// 3. custom rule text (when in Custom mode)
|
||||
// 4. proceeding type name (when project carries one)
|
||||
// 5. fallback i18n key
|
||||
// Suffix: " — <project-reference>" when not already in head.
|
||||
function computeDefaultTitle(): string {
|
||||
const projectID = (document.getElementById("deadline-project") as HTMLSelectElement | null)?.value || "";
|
||||
const project = projectID ? projectsByID.get(projectID) : undefined;
|
||||
const picked = eventTypePicker?.getIDs() ?? [];
|
||||
|
||||
// Reset the override on transition to "Keine Regel" — fresh form
|
||||
// session. Otherwise expandedOverride stays sticky.
|
||||
if (ruleID === "") {
|
||||
expandedOverride = false;
|
||||
let head = "";
|
||||
if (picked.length === 1) {
|
||||
const et = eventTypesByID.get(picked[0]);
|
||||
if (et) head = eventTypeLabel(et);
|
||||
}
|
||||
|
||||
const pickerStillReflectsLastSuggestion =
|
||||
lastAutoFilledEventTypeID !== null &&
|
||||
current.length === 1 &&
|
||||
current[0] === lastAutoFilledEventTypeID;
|
||||
const pickerIsEmpty = current.length === 0;
|
||||
|
||||
if (expected) {
|
||||
if (pickerIsEmpty || pickerStillReflectsLastSuggestion) {
|
||||
eventTypePicker.setIDs([expected]);
|
||||
lastAutoFilledEventTypeID = expected;
|
||||
if (!head) {
|
||||
if (ruleMode === "auto") {
|
||||
const rule = currentAutoRule();
|
||||
if (rule) head = formatRuleLabel(rule);
|
||||
} else {
|
||||
const customInput = document.getElementById("deadline-rule-custom-input") as HTMLInputElement | null;
|
||||
const txt = customInput?.value.trim() || "";
|
||||
if (txt) head = txt;
|
||||
}
|
||||
} else if (pickerStillReflectsLastSuggestion) {
|
||||
// New rule has no canonical event_type — clear the stale auto-fill
|
||||
// so the picker doesn't carry a chip from the old rule.
|
||||
eventTypePicker.setIDs([]);
|
||||
lastAutoFilledEventTypeID = null;
|
||||
}
|
||||
refreshRuleView();
|
||||
}
|
||||
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");
|
||||
}
|
||||
|
||||
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`;
|
||||
const ref = project?.reference?.trim() || "";
|
||||
if (ref && !head.includes(ref)) {
|
||||
return `${head} — ${ref}`;
|
||||
}
|
||||
return head;
|
||||
}
|
||||
|
||||
async function submitForm(e: Event) {
|
||||
@@ -217,7 +282,6 @@ async function submitForm(e: Event) {
|
||||
const projectID = (document.getElementById("deadline-project") as HTMLSelectElement).value;
|
||||
const title = (document.getElementById("deadline-title") as HTMLInputElement).value.trim();
|
||||
const due = (document.getElementById("deadline-due") as HTMLInputElement).value;
|
||||
const ruleID = (document.getElementById("deadline-rule") as HTMLSelectElement).value;
|
||||
const notes = (document.getElementById("deadline-notes") as HTMLTextAreaElement).value.trim();
|
||||
|
||||
if (!projectID || !title || !due) {
|
||||
@@ -234,7 +298,15 @@ async function submitForm(e: Event) {
|
||||
due_date: due,
|
||||
source: "manual",
|
||||
};
|
||||
if (ruleID) payload.rule_id = ruleID;
|
||||
// Rule field: Auto resolves to rule_id, Custom sends the free text.
|
||||
if (ruleMode === "auto") {
|
||||
const rule = currentAutoRule();
|
||||
if (rule) payload.rule_id = rule.id;
|
||||
} else {
|
||||
const customInput = document.getElementById("deadline-rule-custom-input") as HTMLInputElement | null;
|
||||
const txt = customInput?.value.trim() || "";
|
||||
if (txt) payload.custom_rule_text = txt;
|
||||
}
|
||||
if (notes) payload.notes = notes;
|
||||
const eventTypeIDs = eventTypePicker?.getIDs() ?? [];
|
||||
if (eventTypeIDs.length > 0) payload.event_type_ids = eventTypeIDs;
|
||||
@@ -252,8 +324,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}`;
|
||||
}
|
||||
@@ -275,6 +347,16 @@ function detectPreselect() {
|
||||
if (fromQuery) preselectedProjectID = fromQuery;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
async function loadMe() {
|
||||
try {
|
||||
const resp = await fetch("/api/me");
|
||||
@@ -288,8 +370,6 @@ async function loadMe() {
|
||||
|
||||
// t-paliad-154 — fetch the effective approval policy for (project,
|
||||
// deadline, create) and reveal the form-time hint when it applies.
|
||||
// Hidden when no policy applies. Re-runs on project change so the hint
|
||||
// updates if the user picks a different project mid-form.
|
||||
async function refreshApprovalHint(): Promise<void> {
|
||||
const hint = document.getElementById("deadline-approval-hint");
|
||||
const text = document.getElementById("deadline-approval-hint-text");
|
||||
@@ -308,7 +388,6 @@ async function refreshApprovalHint(): Promise<void> {
|
||||
hint.style.display = "none";
|
||||
return;
|
||||
}
|
||||
// t-paliad-160 split-grammar (with M1 legacy fallback).
|
||||
const eff = await resp.json() as {
|
||||
requires_approval?: boolean;
|
||||
min_role?: string | null;
|
||||
@@ -343,44 +422,51 @@ 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()]);
|
||||
|
||||
await Promise.all([loadProjects(), loadProceedingTypes(), loadRules(), loadMe()]);
|
||||
|
||||
const pickerHost = document.getElementById("deadline-event-types");
|
||||
if (pickerHost) {
|
||||
eventTypePicker = attachEventTypePicker(pickerHost, {
|
||||
currentUserAdmin,
|
||||
onChange: () => refreshRuleView(),
|
||||
onChange: () => {
|
||||
// Type change shifts which Auto rule resolves; re-render the
|
||||
// read-only Auto display panel.
|
||||
refreshRuleAutoDisplay();
|
||||
},
|
||||
});
|
||||
}
|
||||
// t-paliad-165 follow-up — preload event_types so the collapsed
|
||||
// summary can render the type's label inline without an extra round
|
||||
// trip when the user picks a Regel.
|
||||
|
||||
// Preload event_types for the Auto display + Standardtitel resolver.
|
||||
fetchEventTypes()
|
||||
.then((types) => {
|
||||
eventTypesByID = new Map(types.map((et) => [et.id, et]));
|
||||
refreshRuleView();
|
||||
refreshRuleAutoDisplay();
|
||||
})
|
||||
.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.
|
||||
document.getElementById("deadline-rule")?.addEventListener("change", () => {
|
||||
applyRuleAutoFill();
|
||||
.catch(() => {/* non-fatal */});
|
||||
|
||||
// Rule mode toggle.
|
||||
document.getElementById("deadline-rule-mode-toggle")?.addEventListener("click", () => {
|
||||
setRuleMode(ruleMode === "auto" ? "custom" : "auto");
|
||||
});
|
||||
// "Anderen Typ wählen" — sticky expanded mode so the picker stays
|
||||
// visible even when the chip still matches the rule's default.
|
||||
document.getElementById("deadline-event-type-override-btn")?.addEventListener("click", () => {
|
||||
expandedOverride = true;
|
||||
refreshRuleView();
|
||||
// Move focus into the picker's search box so the user can type
|
||||
// immediately without an extra click.
|
||||
const search = document.querySelector<HTMLInputElement>(
|
||||
"#deadline-event-types .event-type-search",
|
||||
);
|
||||
search?.focus();
|
||||
});
|
||||
// Wire approval-hint refresh: on first render + on project change.
|
||||
|
||||
applyRuleModeUI();
|
||||
|
||||
// Approval-hint refresh: on first render + on project change.
|
||||
void refreshApprovalHint();
|
||||
document.getElementById("deadline-project")?.addEventListener("change", () => {
|
||||
void refreshApprovalHint();
|
||||
// Project change can shift which Auto rule resolves (via the
|
||||
// project's proceeding_type_id).
|
||||
refreshRuleAutoDisplay();
|
||||
});
|
||||
|
||||
// t-paliad-251 Part 4 — Standardtitel button.
|
||||
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();
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from "./event-types";
|
||||
import { projectIndent } from "./project-indent";
|
||||
import { mountCalendar, type CalendarHandle, type CalendarItem } from "./calendar/mount-calendar";
|
||||
import { formatRuleLabelHTML, formatCustomRuleLabelHTML } from "./rule-label";
|
||||
|
||||
// Two-eyes glyph 👀 inside .approval-pill--icon. m's 2026-05-08 follow-
|
||||
// up: "two eyes instead of the one." Emoji rather than SVG keeps the
|
||||
@@ -66,6 +67,9 @@ interface EventListItem {
|
||||
rule_code?: string;
|
||||
rule_name?: string;
|
||||
rule_name_en?: string;
|
||||
// t-paliad-258 — free-text rule label when the deadline was created
|
||||
// via the Custom rule path. Mutually exclusive with rule_id.
|
||||
custom_rule_text?: string;
|
||||
event_type_ids?: string[];
|
||||
|
||||
// appointment-only
|
||||
@@ -264,13 +268,26 @@ function urgencyClass(item: EventListItem): string {
|
||||
|
||||
function ruleDisplay(item: EventListItem): string {
|
||||
if (item.type !== "deadline") return "";
|
||||
// Prefer the saved citation (RoP.023, R.151) over the rule name —
|
||||
// REGEL is meant for the legal reference, not the rule's display
|
||||
// name (which is the title column's job).
|
||||
if (item.rule_code && item.rule_code.trim()) return esc(item.rule_code);
|
||||
const lang = getLang();
|
||||
const localized = lang === "en" ? item.rule_name_en : item.rule_name;
|
||||
if (localized && localized.trim()) return esc(localized);
|
||||
// t-paliad-258 addendum — canonical display contract: Name primary,
|
||||
// Citation muted secondary ("Notice of Appeal · UPC.RoP.220.1").
|
||||
// Custom rules render the lawyer's free text + a "Custom" badge.
|
||||
// Legacy rule-code-only saves (Fristenrechner, no rule_id) still
|
||||
// show the bare citation as last-resort fallback.
|
||||
const hasName = (item.rule_name && item.rule_name.trim()) ||
|
||||
(item.rule_name_en && item.rule_name_en.trim());
|
||||
if (hasName || (item.rule_code && item.rule_code.trim())) {
|
||||
return formatRuleLabelHTML(
|
||||
{
|
||||
name: item.rule_name || "",
|
||||
name_en: item.rule_name_en,
|
||||
rule_code: item.rule_code,
|
||||
},
|
||||
esc,
|
||||
);
|
||||
}
|
||||
if (item.custom_rule_text && item.custom_rule_text.trim()) {
|
||||
return formatCustomRuleLabelHTML(item.custom_rule_text, esc);
|
||||
}
|
||||
return "—";
|
||||
}
|
||||
|
||||
|
||||
@@ -429,8 +429,13 @@ function renderProcedureResults(data: DeadlineResponse) {
|
||||
<span class="timeline-trigger-date">${t("deadlines.trigger.label")}: ${formatDate(data.triggerDate)}</span>
|
||||
</div>`;
|
||||
|
||||
// Pass the chip-strip perspective through as `side` so the column
|
||||
// bucketer keeps the user's own party on the left (Unsere Seite) —
|
||||
// t-paliad-257: the old Proaktiv/Reaktiv labels lied when the user
|
||||
// was on the defendant side, the new labels demand we route the
|
||||
// user's party into the `ours` column.
|
||||
const bodyHtml = procedureView === "columns"
|
||||
? renderColumnsBody(data, { editable: true, showNotes })
|
||||
? renderColumnsBody(data, { editable: true, showNotes, side: currentPerspective })
|
||||
: renderTimelineBody(data, { showParty: true, editable: true, showNotes });
|
||||
|
||||
container.innerHTML = headerHtml + bodyHtml;
|
||||
|
||||
@@ -27,6 +27,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"nav.glossar": "Glossar",
|
||||
"nav.gebuehrentabellen": "Geb\u00fchrentabellen",
|
||||
"nav.checklisten": "Checklisten",
|
||||
"nav.submissions": "Schriftsätze",
|
||||
"nav.gerichte": "Gerichte",
|
||||
"nav.logout": "Abmelden",
|
||||
"nav.akten": "Akten",
|
||||
@@ -301,9 +302,9 @@ 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.ours": "Unsere Seite",
|
||||
"deadlines.col.court": "Gericht",
|
||||
"deadlines.col.reactive": "Reaktiv",
|
||||
"deadlines.col.opponent": "Gegnerseite",
|
||||
"deadlines.col.both": "Beide Parteien",
|
||||
// Trigger-event mode (PR-2 \u2014 youpc-parity)
|
||||
"deadlines.mode.procedure": "Verfahrensablauf",
|
||||
@@ -416,6 +417,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",
|
||||
@@ -873,11 +882,15 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.field.title.placeholder": "z.\u202fB. Klageerwiderung einreichen",
|
||||
"deadlines.field.due": "F\u00e4lligkeitsdatum",
|
||||
"deadlines.field.rule": "Regel (optional)",
|
||||
"deadlines.field.rule.none": "Keine Regel",
|
||||
"deadlines.field.rule.autofill": "Typ vorgegeben durch Regel — entfernen, um zu überschreiben.",
|
||||
"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.auto_no_match": "Keine Regel zur gewählten Verfahrenshandlung",
|
||||
"deadlines.field.rule.auto_pick_type": "Wählen Sie zuerst eine Verfahrenshandlung",
|
||||
"deadlines.field.rule.custom_badge": "Eigen",
|
||||
"deadlines.field.rule.custom_placeholder": "z.B. interner Review-Termin, Mandantengespräch",
|
||||
"deadlines.field.rule.mode.toggle_to_auto": "Zurück zu Auto",
|
||||
"deadlines.field.rule.mode.toggle_to_custom": "Eigene Regel eingeben",
|
||||
"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.",
|
||||
@@ -1425,10 +1438,16 @@ 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.submissions.empty": "Für dieses Verfahren sind keine Schriftsätze hinterlegt.",
|
||||
"projects.detail.submissions.empty.no_proceeding": "Für dieses Projekt ist noch kein Verfahrenstyp gesetzt. Bitte im Projekt bearbeiten.",
|
||||
"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",
|
||||
"projects.detail.submissions.col.name": "Schriftsatz",
|
||||
"projects.detail.submissions.col.party": "Partei",
|
||||
@@ -1436,7 +1455,50 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"projects.detail.submissions.col.action": "",
|
||||
"projects.detail.submissions.action.generate": "Generieren",
|
||||
"projects.detail.submissions.action.no_template": "Keine Vorlage",
|
||||
"projects.detail.submissions.action.edit": "Bearbeiten",
|
||||
"projects.detail.submissions.hint": "Schriftsätze werden direkt aus dem Projekt heraus als .docx generiert. Anpassen, drucken, einreichen.",
|
||||
// t-paliad-238 — dedicated draft editor page.
|
||||
"submissions.draft.title": "Schriftsatz bearbeiten — Paliad",
|
||||
"submissions.draft.back": "← Zurück zum Projekt",
|
||||
"submissions.draft.loading": "Lädt…",
|
||||
"submissions.draft.notfound": "Schriftsatz nicht gefunden oder keine Berechtigung.",
|
||||
"submissions.draft.action.export": "Als .docx exportieren",
|
||||
"submissions.draft.action.new": "+ Neuer Entwurf",
|
||||
"submissions.draft.action.delete": "Löschen",
|
||||
"submissions.draft.switcher.label": "Entwurf",
|
||||
"submissions.draft.name.placeholder": "Name dieses Entwurfs",
|
||||
"submissions.draft.preview.title": "Vorschau",
|
||||
"submissions.draft.preview.hint": "Read-only Vorschau — finale Bearbeitung in Word.",
|
||||
// t-paliad-240 — global Schriftsätze drafts index page.
|
||||
"submissions.index.title": "Schriftsätze — Paliad",
|
||||
"submissions.index.heading": "Schriftsätze",
|
||||
"submissions.index.subtitle": "Ihre Schriftsatz-Entwürfe über alle sichtbaren Projekte.",
|
||||
"submissions.index.loading": "Lädt…",
|
||||
"submissions.index.empty": "Noch keine Entwürfe. Beginnen Sie mit einem neuen Entwurf — mit oder ohne Projekt.",
|
||||
"submissions.index.empty.cta": "+ Neuer Entwurf",
|
||||
"submissions.index.error": "Schriftsätze konnten nicht geladen werden.",
|
||||
"submissions.index.col.project": "Projekt",
|
||||
"submissions.index.col.submission": "Schriftsatz",
|
||||
"submissions.index.col.draft": "Entwurf",
|
||||
"submissions.index.col.updated": "Zuletzt geändert",
|
||||
"submissions.index.action.new": "+ Neuer Entwurf",
|
||||
// t-paliad-243 — global Schriftsatz picker (/submissions/new).
|
||||
"submissions.new.title": "Neuer Schriftsatz — Paliad",
|
||||
"submissions.new.back": "← Zurück zur Übersicht",
|
||||
"submissions.new.heading": "Neuer Schriftsatz",
|
||||
"submissions.new.subtitle": "Wählen Sie eine Vorlage. Optional verknüpfen Sie den Entwurf mit einem Projekt — sonst füllen Sie alle Variablen manuell.",
|
||||
"submissions.new.search.placeholder": "Suche nach Schriftsatz, Code oder Norm…",
|
||||
"submissions.new.loading": "Lädt…",
|
||||
"submissions.new.error": "Katalog konnte nicht geladen werden.",
|
||||
"submissions.new.col.name": "Schriftsatz",
|
||||
"submissions.new.col.party": "Partei",
|
||||
"submissions.new.col.source": "Rechtsgrundlage",
|
||||
"submissions.new.col.actions": "Entwurf starten",
|
||||
"submissions.new.empty.filtered": "Keine passenden Schriftsätze. Filter zurücksetzen.",
|
||||
"submissions.new.picker.title": "Projekt wählen",
|
||||
"submissions.new.picker.placeholder": "Projekt suchen (Titel oder Aktenzeichen)…",
|
||||
"submissions.new.picker.loading": "Lädt Projekte…",
|
||||
"submissions.new.picker.empty": "Keine sichtbaren Projekte.",
|
||||
"projects.detail.verlauf.empty": "Noch keine Ereignisse aufgezeichnet.",
|
||||
"projects.detail.verlauf.loadMore": "Mehr laden",
|
||||
// SmartTimeline (t-paliad-171, Slice 1).
|
||||
@@ -2288,6 +2350,31 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
// Admin audit log (t-paliad-071)
|
||||
"nav.admin.audit": "Audit-Log",
|
||||
"nav.admin.partner_units": "Partner Units",
|
||||
|
||||
// Admin Backup Mode (t-paliad-246 / m/paliad#77)
|
||||
"nav.admin.backups": "Backups",
|
||||
"admin.backups.title": "Backups — Paliad",
|
||||
"admin.backups.heading": "Backups",
|
||||
"admin.backups.subtitle": "Vollständige Snapshots aller Daten — manuell oder zeitgesteuert.",
|
||||
"admin.backups.run_now": "Backup jetzt erstellen",
|
||||
"admin.backups.running": "Läuft …",
|
||||
"admin.backups.success": "Backup erfolgreich erstellt.",
|
||||
"admin.backups.empty": "Noch keine Backups vorhanden.",
|
||||
"admin.backups.loading": "Lade …",
|
||||
"admin.backups.col.started": "Erstellt",
|
||||
"admin.backups.col.kind": "Auslöser",
|
||||
"admin.backups.col.status": "Status",
|
||||
"admin.backups.col.requested_by": "Angefordert von",
|
||||
"admin.backups.col.size": "Größe",
|
||||
"admin.backups.col.rows": "Sheets",
|
||||
"admin.backups.col.actions": "Aktion",
|
||||
"admin.backups.kind.scheduled": "Geplant",
|
||||
"admin.backups.kind.on_demand": "Manuell",
|
||||
"admin.backups.status.running": "Läuft …",
|
||||
"admin.backups.status.done": "✓ Fertig",
|
||||
"admin.backups.status.failed": "✗ Fehlgeschlagen",
|
||||
"admin.backups.download": "Download",
|
||||
"admin.backups.footer.note": "Geplante Backups werden in einer späteren Slice aktiviert. Manuelle Backups stehen jetzt zur Verfügung.",
|
||||
"admin.audit.title": "Audit-Log — Paliad",
|
||||
"admin.audit.heading": "Audit-Log",
|
||||
"admin.audit.subtitle": "Globale Zeitleiste über Projekt-, CalDAV-, Reminder- und Partner-Unit-Ereignisse.",
|
||||
@@ -2387,6 +2474,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…",
|
||||
@@ -2532,6 +2621,17 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"approvals.withdraw.cta": "Genehmigungsanfrage zurückziehen",
|
||||
"approvals.withdraw.confirm": "Genehmigungsanfrage wirklich zurückziehen?",
|
||||
"approvals.withdraw.error": "Fehler beim Zurückziehen",
|
||||
"approvals.withdraw.cancel": "Abbrechen",
|
||||
"approvals.withdraw.modal.title": "Genehmigungsanfrage zurückziehen?",
|
||||
"approvals.withdraw.primary.label": "Termin bearbeiten",
|
||||
"approvals.withdraw.destructive.label": "Endgültig zurückziehen und löschen",
|
||||
"approvals.withdraw.lead.create.deadline": "Wenn Sie die Anfrage zurückziehen, wird die Frist gelöscht.",
|
||||
"approvals.withdraw.lead.create.appointment": "Wenn Sie die Anfrage zurückziehen, wird der Termin gelöscht.",
|
||||
"approvals.withdraw.lead.update": "Wenn Sie die Anfrage zurückziehen, werden die vorgeschlagenen Änderungen verworfen — der Eintrag kehrt in den Zustand vor Ihrer Bearbeitung zurück.",
|
||||
"approvals.withdraw.lead.delete": "Wenn Sie die Löschanfrage zurückziehen, bleibt der Eintrag bestehen.",
|
||||
"approvals.withdraw.sub.create": "Alternativ können Sie den Eintrag stattdessen bearbeiten. Die Anfrage bleibt offen und der Genehmiger sieht Ihre neuen Werte.",
|
||||
"approvals.withdraw.sub.update": "Alternativ können Sie Ihre Änderungen bearbeiten und neu absenden. Die Anfrage bleibt offen.",
|
||||
"approvals.withdraw.sub.delete": "Sind Sie sicher, dass Sie die Löschanfrage zurückziehen möchten?",
|
||||
"approvals.pending_create.label": "Erstellung wartet auf Genehmigung",
|
||||
"approvals.pending_update.label": "Änderung wartet auf Genehmigung",
|
||||
"approvals.pending_complete.label": "Erledigung wartet auf Genehmigung",
|
||||
@@ -2926,6 +3026,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"nav.glossar": "Glossary",
|
||||
"nav.gebuehrentabellen": "Fee Schedules",
|
||||
"nav.checklisten": "Checklists",
|
||||
"nav.submissions": "Submissions",
|
||||
"nav.gerichte": "Courts",
|
||||
"nav.logout": "Sign Out",
|
||||
"nav.akten": "Matters",
|
||||
@@ -3197,9 +3298,9 @@ 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.ours": "Client Side",
|
||||
"deadlines.col.court": "Court",
|
||||
"deadlines.col.reactive": "Reactive",
|
||||
"deadlines.col.opponent": "Opponent Side",
|
||||
"deadlines.col.both": "Both parties",
|
||||
"deadlines.adjusted": "Adjusted",
|
||||
"deadlines.adjusted.reason": "weekend/holiday",
|
||||
@@ -3319,6 +3420,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",
|
||||
@@ -3769,11 +3878,15 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.field.title.placeholder": "e.g. File statement of defence",
|
||||
"deadlines.field.due": "Due date",
|
||||
"deadlines.field.rule": "Rule (optional)",
|
||||
"deadlines.field.rule.none": "No rule",
|
||||
"deadlines.field.rule.autofill": "Type set by rule — remove to override.",
|
||||
"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.auto_no_match": "No rule maps to the chosen Type",
|
||||
"deadlines.field.rule.auto_pick_type": "Pick a Type first",
|
||||
"deadlines.field.rule.custom_badge": "Custom",
|
||||
"deadlines.field.rule.custom_placeholder": "e.g. internal review meeting, client call",
|
||||
"deadlines.field.rule.mode.toggle_to_auto": "Back to Auto",
|
||||
"deadlines.field.rule.mode.toggle_to_custom": "Enter custom rule",
|
||||
"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.",
|
||||
@@ -4302,10 +4415,16 @@ 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.submissions.empty": "No submissions are configured for this proceeding.",
|
||||
"projects.detail.submissions.empty.no_proceeding": "No proceeding type is set for this project yet. Edit the project to choose one.",
|
||||
"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",
|
||||
"projects.detail.submissions.col.name": "Submission",
|
||||
"projects.detail.submissions.col.party": "Party",
|
||||
@@ -4313,7 +4432,49 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"projects.detail.submissions.col.action": "",
|
||||
"projects.detail.submissions.action.generate": "Generate",
|
||||
"projects.detail.submissions.action.no_template": "No template",
|
||||
"projects.detail.submissions.action.edit": "Edit",
|
||||
"projects.detail.submissions.hint": "Submissions are generated as .docx directly from the project. Edit, print, file.",
|
||||
// t-paliad-238 — dedicated draft editor page.
|
||||
"submissions.draft.title": "Edit submission — Paliad",
|
||||
"submissions.draft.back": "← Back to project",
|
||||
"submissions.draft.loading": "Loading…",
|
||||
"submissions.draft.notfound": "Submission not found or insufficient access.",
|
||||
"submissions.draft.action.export": "Export as .docx",
|
||||
"submissions.draft.action.new": "+ New draft",
|
||||
"submissions.draft.action.delete": "Delete",
|
||||
"submissions.draft.switcher.label": "Draft",
|
||||
"submissions.draft.name.placeholder": "Name of this draft",
|
||||
"submissions.draft.preview.title": "Preview",
|
||||
"submissions.draft.preview.hint": "Read-only preview — final formatting in Word.",
|
||||
// t-paliad-240 — global submissions drafts index page.
|
||||
"submissions.index.title": "Submissions — Paliad",
|
||||
"submissions.index.heading": "Submissions",
|
||||
"submissions.index.subtitle": "Your submission drafts across every visible project.",
|
||||
"submissions.index.loading": "Loading…",
|
||||
"submissions.index.empty": "No drafts yet. Start a new draft — with or without a project.",
|
||||
"submissions.index.empty.cta": "+ New draft",
|
||||
"submissions.index.error": "Could not load submissions.",
|
||||
"submissions.index.col.project": "Project",
|
||||
"submissions.index.col.submission": "Submission",
|
||||
"submissions.index.col.draft": "Draft",
|
||||
"submissions.index.col.updated": "Last updated",
|
||||
"submissions.index.action.new": "+ New draft",
|
||||
"submissions.new.title": "New submission — Paliad",
|
||||
"submissions.new.back": "← Back to drafts",
|
||||
"submissions.new.heading": "New submission",
|
||||
"submissions.new.subtitle": "Pick a template. Optionally bind it to a project — otherwise all variables are filled manually.",
|
||||
"submissions.new.search.placeholder": "Search by name, code or statute…",
|
||||
"submissions.new.loading": "Loading…",
|
||||
"submissions.new.error": "Could not load catalog.",
|
||||
"submissions.new.col.name": "Submission",
|
||||
"submissions.new.col.party": "Party",
|
||||
"submissions.new.col.source": "Legal source",
|
||||
"submissions.new.col.actions": "Start draft",
|
||||
"submissions.new.empty.filtered": "No submissions match the filters. Reset them to see the full catalog.",
|
||||
"submissions.new.picker.title": "Pick a project",
|
||||
"submissions.new.picker.placeholder": "Search project (title or reference)…",
|
||||
"submissions.new.picker.loading": "Loading projects…",
|
||||
"submissions.new.picker.empty": "No visible projects.",
|
||||
"projects.detail.verlauf.empty": "No events recorded yet.",
|
||||
"projects.detail.verlauf.loadMore": "Load more",
|
||||
"projects.detail.smarttimeline.empty": "No events captured yet.",
|
||||
@@ -5157,6 +5318,31 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
// Admin audit log (t-paliad-071)
|
||||
"nav.admin.audit": "Audit Log",
|
||||
"nav.admin.partner_units": "Partner Units",
|
||||
|
||||
// Admin Backup Mode (t-paliad-246 / m/paliad#77)
|
||||
"nav.admin.backups": "Backups",
|
||||
"admin.backups.title": "Backups — Paliad",
|
||||
"admin.backups.heading": "Backups",
|
||||
"admin.backups.subtitle": "Full snapshots of all data — manual or scheduled.",
|
||||
"admin.backups.run_now": "Run backup now",
|
||||
"admin.backups.running": "Running …",
|
||||
"admin.backups.success": "Backup created successfully.",
|
||||
"admin.backups.empty": "No backups yet.",
|
||||
"admin.backups.loading": "Loading …",
|
||||
"admin.backups.col.started": "Started",
|
||||
"admin.backups.col.kind": "Trigger",
|
||||
"admin.backups.col.status": "Status",
|
||||
"admin.backups.col.requested_by": "Requested by",
|
||||
"admin.backups.col.size": "Size",
|
||||
"admin.backups.col.rows": "Sheets",
|
||||
"admin.backups.col.actions": "Action",
|
||||
"admin.backups.kind.scheduled": "Scheduled",
|
||||
"admin.backups.kind.on_demand": "Manual",
|
||||
"admin.backups.status.running": "Running …",
|
||||
"admin.backups.status.done": "✓ Done",
|
||||
"admin.backups.status.failed": "✗ Failed",
|
||||
"admin.backups.download": "Download",
|
||||
"admin.backups.footer.note": "Scheduled backups land in a later slice. Manual backups are available now.",
|
||||
"admin.audit.title": "Audit Log — Paliad",
|
||||
"admin.audit.heading": "Audit Log",
|
||||
"admin.audit.subtitle": "Global timeline across project, CalDAV, reminder and partner-unit events.",
|
||||
@@ -5256,6 +5442,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…",
|
||||
@@ -5401,6 +5589,17 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"approvals.withdraw.cta": "Withdraw approval request",
|
||||
"approvals.withdraw.confirm": "Withdraw the approval request?",
|
||||
"approvals.withdraw.error": "Failed to withdraw",
|
||||
"approvals.withdraw.cancel": "Cancel",
|
||||
"approvals.withdraw.modal.title": "Withdraw approval request?",
|
||||
"approvals.withdraw.primary.label": "Edit event",
|
||||
"approvals.withdraw.destructive.label": "Withdraw permanently and delete",
|
||||
"approvals.withdraw.lead.create.deadline": "Withdrawing this request will delete the deadline.",
|
||||
"approvals.withdraw.lead.create.appointment": "Withdrawing this request will delete the appointment.",
|
||||
"approvals.withdraw.lead.update": "Withdrawing this request will discard your proposed changes — the entry will revert to its state before your edit.",
|
||||
"approvals.withdraw.lead.delete": "Withdrawing the delete request will keep the entry alive.",
|
||||
"approvals.withdraw.sub.create": "Alternatively, you can edit the entry instead. The request stays open and the approver will see your new values.",
|
||||
"approvals.withdraw.sub.update": "Alternatively, you can edit your changes and resubmit. The request stays open.",
|
||||
"approvals.withdraw.sub.delete": "Are you sure you want to withdraw the delete request?",
|
||||
"approvals.pending_create.label": "Awaits approval (creation)",
|
||||
"approvals.pending_update.label": "Awaits approval (change)",
|
||||
"approvals.pending_complete.label": "Awaits approval (completion)",
|
||||
|
||||
@@ -16,6 +16,7 @@ import type { FilterSpec, RenderSpec } from "./views/types";
|
||||
import { renderSmartTimeline, type TimelineEvent as SmartTimelineEvent, type LaneInfo as SmartTimelineLane } from "./views/shape-timeline";
|
||||
import { loadAndRenderSubmissions } from "./submissions";
|
||||
import { buildMailtoHref, type BroadcastRecipient } from "./broadcast";
|
||||
import { formatRuleLabelHTML, formatCustomRuleLabelHTML } from "./rule-label";
|
||||
|
||||
interface Project {
|
||||
id: string;
|
||||
@@ -142,6 +143,11 @@ interface Deadline {
|
||||
status: string;
|
||||
rule_id?: string;
|
||||
rule_code?: string;
|
||||
rule_name?: string;
|
||||
rule_name_en?: string;
|
||||
// t-paliad-258 — free-text rule label when the deadline was saved in
|
||||
// Custom mode. Mutually exclusive with rule_id.
|
||||
custom_rule_text?: string;
|
||||
// Populated by the union endpoint (/api/events) which is what the project
|
||||
// detail page calls — used for attribution when the row lives on a
|
||||
// descendant project (t-paliad-139).
|
||||
@@ -175,7 +181,8 @@ type TabId =
|
||||
| "appointments"
|
||||
| "notes"
|
||||
| "checklists"
|
||||
| "submissions";
|
||||
| "submissions"
|
||||
| "settings";
|
||||
|
||||
const VALID_TABS: TabId[] = [
|
||||
"history",
|
||||
@@ -187,6 +194,7 @@ const VALID_TABS: TabId[] = [
|
||||
"notes",
|
||||
"checklists",
|
||||
"submissions",
|
||||
"settings",
|
||||
];
|
||||
|
||||
// Legacy German tab slugs that may appear in bookmarked URLs after the
|
||||
@@ -803,6 +811,9 @@ interface UnionEvent {
|
||||
status?: string;
|
||||
rule_id?: string;
|
||||
rule_code?: string;
|
||||
rule_name?: string;
|
||||
rule_name_en?: string;
|
||||
custom_rule_text?: string;
|
||||
start_at?: string;
|
||||
end_at?: string;
|
||||
location?: string;
|
||||
@@ -830,6 +841,9 @@ async function loadDeadlines(id: string) {
|
||||
status: it.status ?? "pending",
|
||||
rule_id: it.rule_id,
|
||||
rule_code: it.rule_code,
|
||||
rule_name: it.rule_name,
|
||||
rule_name_en: it.rule_name_en,
|
||||
custom_rule_text: it.custom_rule_text,
|
||||
project_title: it.project_title,
|
||||
}));
|
||||
} else {
|
||||
@@ -999,6 +1013,27 @@ function fmtDateOnly(iso: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
// formatDeadlineRuleCell renders the REGEL column for the project
|
||||
// detail Fristen table using the canonical t-paliad-258 contract:
|
||||
// 1. catalog rule (rule_name / rule_name_en + rule_code) → "Name · Code"
|
||||
// 2. custom_rule_text → text + "Custom" badge
|
||||
// 3. legacy rule_code-only saves → bare citation
|
||||
// 4. otherwise "—"
|
||||
function formatDeadlineRuleCell(f: Deadline): string {
|
||||
const hasName = (f.rule_name && f.rule_name.trim()) ||
|
||||
(f.rule_name_en && f.rule_name_en.trim());
|
||||
if (hasName || (f.rule_code && f.rule_code.trim())) {
|
||||
return formatRuleLabelHTML(
|
||||
{ name: f.rule_name || "", name_en: f.rule_name_en, rule_code: f.rule_code },
|
||||
esc,
|
||||
);
|
||||
}
|
||||
if (f.custom_rule_text && f.custom_rule_text.trim()) {
|
||||
return formatCustomRuleLabelHTML(f.custom_rule_text, esc);
|
||||
}
|
||||
return "—";
|
||||
}
|
||||
|
||||
function urgencyClass(due: string, status: string): string {
|
||||
if (status === "completed") return "frist-urgency-done";
|
||||
const today = new Date();
|
||||
@@ -1037,7 +1072,7 @@ function renderDeadlines() {
|
||||
</td>
|
||||
<td class="frist-col-due ${urgency}"><span class="frist-due-dot"></span>${fmtDateOnly(f.due_date)}</td>
|
||||
<td class="frist-col-title ${titleClass}">${esc(f.title)}${attributionChip(f.project_id, f.project_title)}</td>
|
||||
<td class="frist-col-rule">${f.rule_code ? esc(f.rule_code) : "—"}</td>
|
||||
<td class="frist-col-rule">${formatDeadlineRuleCell(f)}</td>
|
||||
<td><span class="entity-status-chip entity-status-${esc(f.status)}">${esc(statusLabel)}</span></td>
|
||||
</tr>`;
|
||||
})
|
||||
@@ -1185,13 +1220,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 +2083,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 +3040,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 +3065,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;
|
||||
|
||||
87
frontend/src/client/rule-label.ts
Normal file
87
frontend/src/client/rule-label.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
// rule-label — canonical display contract for deadline rules.
|
||||
//
|
||||
// t-paliad-258 / m/paliad#89 addendum. Previously each surface (deadline
|
||||
// form, list rows, detail header, Schriftsätze tab, browse-a-proceeding)
|
||||
// invented its own pattern: sometimes citation-only, sometimes name-only,
|
||||
// sometimes "code — name". m flagged this on the first submissions in a
|
||||
// proceeding sequence where the inconsistency was most visible.
|
||||
//
|
||||
// Canonical pattern: **Name primary, Citation muted secondary**.
|
||||
// Text: "Notice of Appeal · UPC.RoP.220.1"
|
||||
// HTML: <span class="rule-label-name">Notice of Appeal</span>
|
||||
// <span class="rule-label-sep"> · </span>
|
||||
// <span class="rule-label-cite">UPC.RoP.220.1</span>
|
||||
//
|
||||
// Custom rules (t-paliad-258 — free-text label entered by the lawyer):
|
||||
// formatCustomRuleLabel produces "<text>" with a "Custom" badge slot
|
||||
// so list/detail surfaces can render both shapes uniformly.
|
||||
|
||||
import { getLang, t } from "./i18n";
|
||||
|
||||
export interface RuleLike {
|
||||
name: string;
|
||||
name_en?: string | null;
|
||||
// The catalog carries multiple citation fields depending on which
|
||||
// surface populated it. Order of preference: legal_source > rule_code
|
||||
// > code. All three are accepted so callers don't have to normalise.
|
||||
rule_code?: string | null;
|
||||
code?: string | null;
|
||||
legal_source?: string | null;
|
||||
}
|
||||
|
||||
// formatRuleLabel returns the canonical plain-text label.
|
||||
// Falls back gracefully when either side is missing.
|
||||
export function formatRuleLabel(r: RuleLike): string {
|
||||
const lang = getLang();
|
||||
const name = (lang === "en" && r.name_en) ? r.name_en : r.name;
|
||||
const cite = ruleCitation(r);
|
||||
if (name && cite) return `${name} · ${cite}`;
|
||||
return name || cite || "";
|
||||
}
|
||||
|
||||
// formatRuleLabelHTML returns the canonical HTML form with muted-citation
|
||||
// styling. The caller passes the HTML-escape helper so we don't pull a
|
||||
// dependency on a specific esc() module — every surface already has one.
|
||||
export function formatRuleLabelHTML(r: RuleLike, esc: (s: string) => string): string {
|
||||
const lang = getLang();
|
||||
const name = (lang === "en" && r.name_en) ? r.name_en : r.name;
|
||||
const cite = ruleCitation(r);
|
||||
if (name && cite) {
|
||||
return (
|
||||
`<span class="rule-label-name">${esc(name)}</span>` +
|
||||
`<span class="rule-label-sep"> · </span>` +
|
||||
`<span class="rule-label-cite">${esc(cite)}</span>`
|
||||
);
|
||||
}
|
||||
return esc(name || cite || "");
|
||||
}
|
||||
|
||||
// ruleCitation returns the best-available citation string for a rule.
|
||||
// Exported so callers that need the bare code (e.g. CalDAV exports,
|
||||
// inline data attributes) can pull it without going through the label
|
||||
// formatter.
|
||||
export function ruleCitation(r: RuleLike): string {
|
||||
return r.legal_source || r.rule_code || r.code || "";
|
||||
}
|
||||
|
||||
// formatCustomRuleLabelHTML — render a free-text custom rule label with
|
||||
// a "Custom" badge slot. Used by surfaces that may display either a
|
||||
// catalog rule (formatRuleLabelHTML) or a custom one. Returns "" when
|
||||
// the text is empty so callers can fall through to "—".
|
||||
export function formatCustomRuleLabelHTML(text: string | null | undefined, esc: (s: string) => string): string {
|
||||
const trimmed = (text ?? "").trim();
|
||||
if (!trimmed) return "";
|
||||
const badge = t("deadlines.field.rule.custom_badge") || "Custom";
|
||||
return (
|
||||
`<span class="rule-label-name">${esc(trimmed)}</span>` +
|
||||
`<span class="rule-label-badge rule-label-badge--custom">${esc(badge)}</span>`
|
||||
);
|
||||
}
|
||||
|
||||
// formatCustomRuleLabel — plain-text equivalent of the above.
|
||||
export function formatCustomRuleLabel(text: string | null | undefined): string {
|
||||
const trimmed = (text ?? "").trim();
|
||||
if (!trimmed) return "";
|
||||
const badge = t("deadlines.field.rule.custom_badge") || "Custom";
|
||||
return `${trimmed} · ${badge}`;
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
955
frontend/src/client/submission-draft.ts
Normal file
955
frontend/src/client/submission-draft.ts
Normal file
@@ -0,0 +1,955 @@
|
||||
import { initI18n, t } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
|
||||
// t-paliad-238 Slice A — client bundle for the dedicated
|
||||
// Submissions/Schriftsätze editor at
|
||||
// /projects/{id}/submissions/{code}/draft
|
||||
// /projects/{id}/submissions/{code}/draft/{draft_id}
|
||||
//
|
||||
// Reads (project_id, submission_code, optional draft_id) from the URL,
|
||||
// loads the editor payload (draft row + resolved bag + merged bag +
|
||||
// HTML preview), and wires the sidebar / preview / autosave / export.
|
||||
//
|
||||
// Autosave is debounced 500ms after the lawyer stops typing in any
|
||||
// variable input. Each PATCH returns a fresh editor payload, so the
|
||||
// preview pane stays in lockstep with the variable overrides.
|
||||
|
||||
interface SubmissionDraftJSON {
|
||||
id: string;
|
||||
project_id: string | null;
|
||||
submission_code: string;
|
||||
user_id: string;
|
||||
name: string;
|
||||
variables: Record<string, string>;
|
||||
last_exported_at?: string | null;
|
||||
last_exported_sha?: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface SubmissionRuleSummary {
|
||||
name: string;
|
||||
name_en: string;
|
||||
submission_code: string;
|
||||
primary_party?: string;
|
||||
event_type?: string;
|
||||
legal_source?: string;
|
||||
legal_source_pretty?: string;
|
||||
}
|
||||
|
||||
interface SubmissionDraftView {
|
||||
draft: SubmissionDraftJSON;
|
||||
rule?: SubmissionRuleSummary;
|
||||
resolved_bag: Record<string, string>;
|
||||
merged_bag: Record<string, string>;
|
||||
preview_html: string;
|
||||
lang: string;
|
||||
has_template: boolean;
|
||||
template_missing?: boolean;
|
||||
}
|
||||
|
||||
interface SubmissionDraftListResponse {
|
||||
project_id: string;
|
||||
submission_code: string;
|
||||
drafts: SubmissionDraftJSON[];
|
||||
}
|
||||
|
||||
interface ParsedPath {
|
||||
// Project-scoped path: /projects/{id}/submissions/{code}/draft[/{draft_id}].
|
||||
// Global path: /submissions/draft/{draft_id} — projectID + submissionCode are derived
|
||||
// from the loaded draft row after fetch.
|
||||
projectID: string | null;
|
||||
submissionCode: string | null;
|
||||
draftID?: string;
|
||||
// mode tracks the URL shape we were entered from. Affects redirect
|
||||
// semantics when we create a new draft or navigate away.
|
||||
mode: "project" | "global";
|
||||
}
|
||||
|
||||
const PROJECT_PATH_RE = /^\/projects\/([0-9a-fA-F-]{36})\/submissions\/([^/]+)\/draft(?:\/([0-9a-fA-F-]{36}))?\/?$/;
|
||||
const GLOBAL_PATH_RE = /^\/submissions\/draft\/([0-9a-fA-F-]{36})\/?$/;
|
||||
|
||||
function parsePath(): ParsedPath | null {
|
||||
let m = PROJECT_PATH_RE.exec(window.location.pathname);
|
||||
if (m) {
|
||||
return {
|
||||
projectID: m[1],
|
||||
submissionCode: decodeURIComponent(m[2]),
|
||||
draftID: m[3],
|
||||
mode: "project",
|
||||
};
|
||||
}
|
||||
m = GLOBAL_PATH_RE.exec(window.location.pathname);
|
||||
if (m) {
|
||||
return {
|
||||
projectID: null,
|
||||
submissionCode: null,
|
||||
draftID: m[1],
|
||||
mode: "global",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isEN(): boolean {
|
||||
return document.documentElement.lang === "en";
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Variable contract — DE/EN labels per dotted-path placeholder.
|
||||
// Mirrors the same shape the email-template variables sidebar uses;
|
||||
// keeps the lawyer's mental model anchored on the same vocabulary.
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface VariableLabel {
|
||||
de: string;
|
||||
en: string;
|
||||
}
|
||||
|
||||
interface VariableGroup {
|
||||
id: string;
|
||||
label: VariableLabel;
|
||||
keys: string[];
|
||||
}
|
||||
|
||||
const VARIABLE_LABELS: Record<string, VariableLabel> = {
|
||||
"firm.name": { de: "Kanzlei", en: "Firm" },
|
||||
"firm.signature_block": { de: "Signatur-Block", en: "Signature block" },
|
||||
"today": { de: "Heute", en: "Today" },
|
||||
"today.iso": { de: "Heute (ISO)", en: "Today (ISO)" },
|
||||
"today.long_de": { de: "Heute (DE lang)", en: "Today (DE long)" },
|
||||
"today.long_en": { de: "Heute (EN lang)", en: "Today (EN long)" },
|
||||
"user.display_name": { de: "Bearbeiter", en: "Author" },
|
||||
"user.email": { de: "E-Mail", en: "Email" },
|
||||
"user.office": { de: "Büro", en: "Office" },
|
||||
"project.title": { de: "Projekttitel", en: "Project title" },
|
||||
"project.reference": { de: "Aktenzeichen (intern)", en: "Internal reference" },
|
||||
"project.case_number": { de: "Aktenzeichen (Gericht)", en: "Court case number" },
|
||||
"project.court": { de: "Gericht", en: "Court" },
|
||||
"project.patent_number": { de: "Patentnummer", en: "Patent number" },
|
||||
"project.patent_number_upc": { de: "Patentnummer (UPC-Format)", en: "Patent number (UPC format)" },
|
||||
"project.filing_date": { de: "Anmeldedatum", en: "Filing date" },
|
||||
"project.grant_date": { de: "Erteilungsdatum", en: "Grant date" },
|
||||
"project.our_side": { de: "Unsere Seite", en: "Our side" },
|
||||
"project.our_side_de": { de: "Unsere Seite (DE)", en: "Our side (DE)" },
|
||||
"project.our_side_en": { de: "Unsere Seite (EN)", en: "Our side (EN)" },
|
||||
"project.instance_level": { de: "Instanz", en: "Instance" },
|
||||
"project.client_number": { de: "Mandantennummer", en: "Client number" },
|
||||
"project.matter_number": { de: "Matter-Nummer", en: "Matter number" },
|
||||
"project.proceeding.code": { de: "Verfahrenstyp (Code)", en: "Proceeding type (code)" },
|
||||
"project.proceeding.name": { de: "Verfahrenstyp", en: "Proceeding type" },
|
||||
"project.proceeding.name_de": { de: "Verfahrenstyp (DE)", en: "Proceeding type (DE)" },
|
||||
"project.proceeding.name_en": { de: "Verfahrenstyp (EN)", en: "Proceeding type (EN)" },
|
||||
"parties.claimant.name": { de: "Klägerin", en: "Claimant" },
|
||||
"parties.claimant.representative": { de: "Klägerin-Vertreter", en: "Claimant representative" },
|
||||
"parties.defendant.name": { de: "Beklagte", en: "Defendant" },
|
||||
"parties.defendant.representative":{ de: "Beklagten-Vertreter", en: "Defendant representative" },
|
||||
"parties.other.name": { de: "Weitere Partei", en: "Other party" },
|
||||
"parties.other.representative": { de: "Weitere-Partei-Vertreter", en: "Other party representative" },
|
||||
"rule.submission_code": { de: "Schriftsatz-Code", en: "Submission code" },
|
||||
"rule.name": { de: "Schriftsatz", en: "Submission" },
|
||||
"rule.name_de": { de: "Schriftsatz (DE)", en: "Submission (DE)" },
|
||||
"rule.name_en": { de: "Schriftsatz (EN)", en: "Submission (EN)" },
|
||||
"rule.legal_source": { de: "Rechtsgrundlage (Code)", en: "Legal source (code)" },
|
||||
"rule.legal_source_pretty": { de: "Rechtsgrundlage", en: "Legal source" },
|
||||
"rule.primary_party": { de: "Partei (typisch)", en: "Primary party" },
|
||||
"rule.event_type": { de: "Schriftsatz-Typ", en: "Event type" },
|
||||
"deadline.due_date": { de: "Frist (ISO)", en: "Deadline (ISO)" },
|
||||
"deadline.due_date_long_de": { de: "Frist (DE lang)", en: "Deadline (DE long)" },
|
||||
"deadline.due_date_long_en": { de: "Frist (EN lang)", en: "Deadline (EN long)" },
|
||||
"deadline.original_due_date": { de: "Ursprüngliche Frist", en: "Original deadline" },
|
||||
"deadline.computed_from": { de: "Frist berechnet aus", en: "Deadline computed from" },
|
||||
"deadline.title": { de: "Frist-Titel", en: "Deadline title" },
|
||||
"deadline.source": { de: "Frist-Quelle", en: "Deadline source" },
|
||||
};
|
||||
|
||||
const VARIABLE_GROUPS: VariableGroup[] = [
|
||||
{
|
||||
id: "rule",
|
||||
label: { de: "Schriftsatz", en: "Submission" },
|
||||
keys: [
|
||||
"rule.name",
|
||||
"rule.legal_source_pretty",
|
||||
"rule.primary_party",
|
||||
"rule.event_type",
|
||||
"rule.submission_code",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "parties",
|
||||
label: { de: "Mandanten & Parteien", en: "Clients & parties" },
|
||||
keys: [
|
||||
"parties.claimant.name",
|
||||
"parties.claimant.representative",
|
||||
"parties.defendant.name",
|
||||
"parties.defendant.representative",
|
||||
"parties.other.name",
|
||||
"parties.other.representative",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "project",
|
||||
label: { de: "Verfahren", en: "Proceeding" },
|
||||
keys: [
|
||||
"project.title",
|
||||
"project.case_number",
|
||||
"project.court",
|
||||
"project.patent_number",
|
||||
"project.patent_number_upc",
|
||||
"project.filing_date",
|
||||
"project.grant_date",
|
||||
"project.our_side",
|
||||
"project.proceeding.name",
|
||||
"project.client_number",
|
||||
"project.matter_number",
|
||||
"project.reference",
|
||||
"project.instance_level",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "deadline",
|
||||
label: { de: "Frist", en: "Deadline" },
|
||||
keys: [
|
||||
"deadline.due_date",
|
||||
"deadline.due_date_long_de",
|
||||
"deadline.due_date_long_en",
|
||||
"deadline.computed_from",
|
||||
"deadline.title",
|
||||
"deadline.original_due_date",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "firm",
|
||||
label: { de: "Kanzlei & Datum", en: "Firm & date" },
|
||||
keys: [
|
||||
"firm.name",
|
||||
"user.display_name",
|
||||
"user.email",
|
||||
"user.office",
|
||||
"today.long_de",
|
||||
"today.long_en",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function labelFor(key: string): string {
|
||||
const entry = VARIABLE_LABELS[key];
|
||||
if (!entry) return key;
|
||||
return isEN() ? entry.en : entry.de;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Module state
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface State {
|
||||
parsed: ParsedPath;
|
||||
view: SubmissionDraftView | null;
|
||||
drafts: SubmissionDraftJSON[];
|
||||
saveTimer: number | null;
|
||||
pendingOverrides: Record<string, string> | null;
|
||||
inFlight: AbortController | null;
|
||||
}
|
||||
|
||||
const state: State = {
|
||||
parsed: null as unknown as ParsedPath,
|
||||
view: null,
|
||||
drafts: [],
|
||||
saveTimer: null,
|
||||
pendingOverrides: null,
|
||||
inFlight: null,
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Boot
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function boot(): Promise<void> {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
|
||||
const parsed = parsePath();
|
||||
if (!parsed) {
|
||||
showNotFound();
|
||||
return;
|
||||
}
|
||||
state.parsed = parsed;
|
||||
|
||||
try {
|
||||
if (parsed.mode === "global") {
|
||||
// Global path: we have a draft_id, fetch by id alone. Drafts
|
||||
// list (the sidebar switcher) is scoped to the same project +
|
||||
// submission_code AFTER we've loaded the draft.
|
||||
if (!parsed.draftID) {
|
||||
showNotFound();
|
||||
return;
|
||||
}
|
||||
const view = await fetchGlobalView(parsed.draftID);
|
||||
state.view = view;
|
||||
// Backfill parsed.* from the loaded draft so the sidebar
|
||||
// switcher can list peers; project-less drafts get no peer list
|
||||
// beyond themselves (no useful (project, code) cross-section).
|
||||
state.parsed = {
|
||||
...parsed,
|
||||
projectID: view.draft.project_id,
|
||||
submissionCode: view.draft.submission_code,
|
||||
};
|
||||
if (view.draft.project_id) {
|
||||
try {
|
||||
const list = await fetchDrafts(state.parsed);
|
||||
state.drafts = list.drafts;
|
||||
} catch { state.drafts = [view.draft]; }
|
||||
} else {
|
||||
state.drafts = [view.draft];
|
||||
}
|
||||
paint();
|
||||
return;
|
||||
}
|
||||
|
||||
// Project-scoped path: same logic as before.
|
||||
if (!parsed.projectID || !parsed.submissionCode) {
|
||||
showNotFound();
|
||||
return;
|
||||
}
|
||||
const list = await fetchDrafts(parsed);
|
||||
state.drafts = list.drafts;
|
||||
let draft: SubmissionDraftJSON | null = null;
|
||||
if (parsed.draftID) {
|
||||
draft = state.drafts.find((d) => d.id === parsed.draftID) ?? null;
|
||||
if (!draft) {
|
||||
showNotFound();
|
||||
return;
|
||||
}
|
||||
} else if (state.drafts.length > 0) {
|
||||
draft = state.drafts[0];
|
||||
// Redirect to the canonical /draft/{id} URL so refresh + share
|
||||
// both land on the same draft.
|
||||
const url = `/projects/${parsed.projectID}/submissions/${encodeURIComponent(parsed.submissionCode)}/draft/${draft.id}`;
|
||||
window.history.replaceState({}, "", url);
|
||||
state.parsed = { ...parsed, draftID: draft.id };
|
||||
} else {
|
||||
draft = await createProjectDraft(parsed);
|
||||
state.drafts = [draft];
|
||||
const url = `/projects/${parsed.projectID}/submissions/${encodeURIComponent(parsed.submissionCode)}/draft/${draft.id}`;
|
||||
window.history.replaceState({}, "", url);
|
||||
state.parsed = { ...parsed, draftID: draft.id };
|
||||
}
|
||||
const view = await fetchView(state.parsed.projectID!, state.parsed.submissionCode!, draft.id);
|
||||
state.view = view;
|
||||
paint();
|
||||
} catch (err) {
|
||||
console.error("submission-draft boot:", err);
|
||||
showError(isEN() ? "Failed to load draft." : "Entwurf konnte nicht geladen werden.");
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// API
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function fetchDrafts(p: ParsedPath): Promise<SubmissionDraftListResponse> {
|
||||
if (!p.projectID || !p.submissionCode) throw new Error("no project context");
|
||||
const url = `/api/projects/${p.projectID}/submissions/${encodeURIComponent(p.submissionCode)}/drafts`;
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) throw new Error(`drafts list ${resp.status}`);
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
async function createProjectDraft(p: ParsedPath): Promise<SubmissionDraftJSON> {
|
||||
if (!p.projectID || !p.submissionCode) throw new Error("no project context");
|
||||
const url = `/api/projects/${p.projectID}/submissions/${encodeURIComponent(p.submissionCode)}/drafts`;
|
||||
const resp = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" } });
|
||||
if (!resp.ok) throw new Error(`create draft ${resp.status}`);
|
||||
const view = (await resp.json()) as SubmissionDraftView;
|
||||
return view.draft;
|
||||
}
|
||||
|
||||
async function fetchView(projectID: string, code: string, draftID: string): Promise<SubmissionDraftView> {
|
||||
const url = `/api/projects/${projectID}/submissions/${encodeURIComponent(code)}/drafts/${draftID}`;
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) throw new Error(`get draft ${resp.status}`);
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
async function fetchGlobalView(draftID: string): Promise<SubmissionDraftView> {
|
||||
const resp = await fetch(`/api/submission-drafts/${draftID}`);
|
||||
if (!resp.ok) throw new Error(`get draft ${resp.status}`);
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
async function patchDraft(payload: { name?: string; variables?: Record<string, string>; project_id?: string | null }): Promise<SubmissionDraftView> {
|
||||
const p = state.parsed;
|
||||
if (!p.draftID) throw new Error("no draft id");
|
||||
if (state.inFlight) {
|
||||
state.inFlight.abort();
|
||||
state.inFlight = null;
|
||||
}
|
||||
const ctl = new AbortController();
|
||||
state.inFlight = ctl;
|
||||
// The global PATCH endpoint accepts both project-scoped and
|
||||
// project-less drafts — route everything through it so attach (set
|
||||
// project_id) works from both URL shapes.
|
||||
const url = `/api/submission-drafts/${p.draftID}`;
|
||||
try {
|
||||
const resp = await fetch(url, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
signal: ctl.signal,
|
||||
});
|
||||
if (!resp.ok) throw new Error(`patch draft ${resp.status}`);
|
||||
return resp.json();
|
||||
} finally {
|
||||
if (state.inFlight === ctl) state.inFlight = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteDraft(): Promise<void> {
|
||||
const p = state.parsed;
|
||||
if (!p.draftID) return;
|
||||
const resp = await fetch(`/api/submission-drafts/${p.draftID}`, { method: "DELETE" });
|
||||
if (!resp.ok && resp.status !== 204) throw new Error(`delete draft ${resp.status}`);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Render
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
function paint(): void {
|
||||
if (!state.view) return;
|
||||
hide("submission-draft-loading");
|
||||
hide("submission-draft-notfound");
|
||||
hide("submission-draft-error");
|
||||
show("submission-draft-body");
|
||||
|
||||
paintHeader();
|
||||
paintBackLink();
|
||||
paintNoProjectBanner();
|
||||
paintSwitcher();
|
||||
paintNameRow();
|
||||
paintVariables();
|
||||
paintPreview();
|
||||
}
|
||||
|
||||
function paintHeader(): void {
|
||||
const view = state.view!;
|
||||
const title = document.getElementById("submission-draft-title");
|
||||
if (title) {
|
||||
const ruleName = view.rule?.name ?? view.draft.submission_code;
|
||||
title.textContent = ruleName;
|
||||
}
|
||||
const subtitle = document.getElementById("submission-draft-subtitle");
|
||||
if (subtitle) {
|
||||
const code = view.draft.submission_code;
|
||||
const source = view.rule?.legal_source_pretty ?? view.rule?.legal_source ?? "";
|
||||
const parts: string[] = [code];
|
||||
if (source) parts.push(source);
|
||||
subtitle.textContent = parts.join(" · ");
|
||||
}
|
||||
}
|
||||
|
||||
function paintBackLink(): void {
|
||||
const back = document.getElementById("submission-draft-back-link") as HTMLAnchorElement | null;
|
||||
if (!back || !state.view) return;
|
||||
if (state.view.draft.project_id) {
|
||||
back.href = `/projects/${state.view.draft.project_id}/submissions`;
|
||||
back.textContent = isEN() ? "← Back to project" : "← Zurück zum Projekt";
|
||||
} else {
|
||||
back.href = "/submissions";
|
||||
back.textContent = isEN() ? "← Back to drafts" : "← Zurück zur Übersicht";
|
||||
}
|
||||
}
|
||||
|
||||
// paintNoProjectBanner adds (or removes) the "Kein Projekt zugeordnet"
|
||||
// banner above the editor body. The banner offers a "Projekt zuweisen"
|
||||
// button that opens an inline project picker — same modal pattern the
|
||||
// /submissions/new page uses. Removed once the draft has a project_id.
|
||||
function paintNoProjectBanner(): void {
|
||||
const body = document.getElementById("submission-draft-body");
|
||||
if (!body || !state.view) return;
|
||||
let banner = document.getElementById("submission-draft-noproject-banner");
|
||||
|
||||
if (state.view.draft.project_id) {
|
||||
if (banner) banner.remove();
|
||||
return;
|
||||
}
|
||||
|
||||
const msg = isEN()
|
||||
? "No project assigned — all variables are filled manually."
|
||||
: "Kein Projekt zugeordnet — alle Variablen werden manuell befüllt.";
|
||||
const cta = isEN() ? "Assign project…" : "Projekt zuweisen…";
|
||||
const html = `<p class="submission-draft-noproject-banner-msg">${escapeHtml(msg)}</p>
|
||||
<button type="button" id="submission-draft-noproject-assign"
|
||||
class="btn-secondary btn-small">${escapeHtml(cta)}</button>`;
|
||||
|
||||
if (banner) {
|
||||
banner.innerHTML = html;
|
||||
} else {
|
||||
banner = document.createElement("aside");
|
||||
banner.id = "submission-draft-noproject-banner";
|
||||
banner.className = "submission-draft-noproject-banner";
|
||||
banner.innerHTML = html;
|
||||
// Insert before the header.
|
||||
const header = body.querySelector(".submission-draft-header");
|
||||
if (header && header.parentElement) {
|
||||
header.parentElement.insertBefore(banner, header);
|
||||
} else {
|
||||
body.prepend(banner);
|
||||
}
|
||||
}
|
||||
|
||||
const btn = document.getElementById("submission-draft-noproject-assign") as HTMLButtonElement | null;
|
||||
if (btn) btn.onclick = () => openProjectAssignPicker();
|
||||
}
|
||||
|
||||
function paintSwitcher(): void {
|
||||
const sel = document.getElementById("submission-draft-pick") as HTMLSelectElement | null;
|
||||
if (!sel || !state.view) return;
|
||||
sel.innerHTML = state.drafts
|
||||
.map((d) => `<option value="${escapeHtml(d.id)}"${d.id === state.view!.draft.id ? " selected" : ""}>${escapeHtml(d.name)}</option>`)
|
||||
.join("");
|
||||
sel.onchange = () => {
|
||||
const id = sel.value;
|
||||
if (!id || !state.view) return;
|
||||
if (id === state.view.draft.id) return;
|
||||
const p = state.parsed;
|
||||
const url = `/projects/${p.projectID}/submissions/${encodeURIComponent(p.submissionCode)}/draft/${id}`;
|
||||
window.location.href = url;
|
||||
};
|
||||
}
|
||||
|
||||
function paintNameRow(): void {
|
||||
const input = document.getElementById("submission-draft-name") as HTMLInputElement | null;
|
||||
const del = document.getElementById("submission-draft-delete-btn") as HTMLButtonElement | null;
|
||||
if (input && state.view) {
|
||||
input.value = state.view.draft.name;
|
||||
input.onchange = () => {
|
||||
const newName = input.value.trim();
|
||||
if (!newName || newName === state.view!.draft.name) return;
|
||||
void renameDraft(newName);
|
||||
};
|
||||
}
|
||||
if (del) del.onclick = () => onDelete();
|
||||
|
||||
const newBtn = document.getElementById("submission-draft-new-btn") as HTMLButtonElement | null;
|
||||
if (newBtn) newBtn.onclick = () => onCreateNew();
|
||||
|
||||
const exportBtn = document.getElementById("submission-draft-export-btn") as HTMLButtonElement | null;
|
||||
if (exportBtn) exportBtn.onclick = () => onExport(exportBtn);
|
||||
}
|
||||
|
||||
function paintVariables(): void {
|
||||
const host = document.getElementById("submission-draft-variables");
|
||||
if (!host || !state.view) return;
|
||||
const overrides = state.view.draft.variables ?? {};
|
||||
const resolved = state.view.resolved_bag ?? {};
|
||||
const merged = state.view.merged_bag ?? {};
|
||||
|
||||
let html = "";
|
||||
for (const group of VARIABLE_GROUPS) {
|
||||
const groupLabel = isEN() ? group.label.en : group.label.de;
|
||||
html += `<section class="submission-draft-var-group" data-group="${group.id}">`;
|
||||
html += `<h3 class="submission-draft-var-group-title">${escapeHtml(groupLabel)}</h3>`;
|
||||
for (const key of group.keys) {
|
||||
const label = labelFor(key);
|
||||
const override = overrides[key];
|
||||
const resolvedVal = resolved[key] ?? "";
|
||||
const mergedVal = merged[key] ?? "";
|
||||
const overridden = Object.prototype.hasOwnProperty.call(overrides, key);
|
||||
html += `<label class="submission-draft-var-row" data-key="${escapeHtml(key)}">`;
|
||||
html += `<span class="submission-draft-var-label">${escapeHtml(label)}</span>`;
|
||||
html += `<input type="text" class="submission-draft-var-input entity-form-input"`;
|
||||
html += ` data-var="${escapeHtml(key)}"`;
|
||||
html += ` value="${escapeHtml(overridden ? override : resolvedVal)}"`;
|
||||
html += ` data-resolved="${escapeHtml(resolvedVal)}" />`;
|
||||
const hintParts: string[] = [];
|
||||
hintParts.push(`<code>{{${escapeHtml(key)}}}</code>`);
|
||||
if (overridden) {
|
||||
if (override === "") {
|
||||
hintParts.push(`<span class="submission-draft-var-marker">${escapeHtml(isEN() ? "→ [NO VALUE: " + key + "]" : "→ [KEIN WERT: " + key + "]")}</span>`);
|
||||
} else if (override !== resolvedVal) {
|
||||
const original = resolvedVal === "" ? (isEN() ? "(empty)" : "(leer)") : resolvedVal;
|
||||
hintParts.push(`<span class="submission-draft-var-was">${escapeHtml((isEN() ? "Project: " : "Projekt: ") + original)}</span>`);
|
||||
}
|
||||
}
|
||||
html += `<span class="submission-draft-var-hint">${hintParts.join(" · ")}</span>`;
|
||||
if (overridden && override !== resolvedVal) {
|
||||
html += `<button type="button" class="submission-draft-var-reset btn-small btn-link" data-reset-key="${escapeHtml(key)}">${escapeHtml(isEN() ? "Reset" : "Zurücksetzen")}</button>`;
|
||||
}
|
||||
html += `</label>`;
|
||||
// Visual hint: marker text appears in preview when override is "".
|
||||
void mergedVal;
|
||||
}
|
||||
html += `</section>`;
|
||||
}
|
||||
host.innerHTML = html;
|
||||
|
||||
host.querySelectorAll<HTMLInputElement>(".submission-draft-var-input").forEach((inp) => {
|
||||
inp.addEventListener("input", () => onVarChange(inp));
|
||||
});
|
||||
host.querySelectorAll<HTMLButtonElement>(".submission-draft-var-reset").forEach((btn) => {
|
||||
btn.addEventListener("click", () => onVarReset(btn.dataset.resetKey ?? ""));
|
||||
});
|
||||
}
|
||||
|
||||
function paintPreview(): void {
|
||||
const host = document.getElementById("submission-draft-preview");
|
||||
if (!host || !state.view) return;
|
||||
host.innerHTML = state.view.preview_html ?? "";
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Event handlers
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
function onVarChange(input: HTMLInputElement): void {
|
||||
const key = input.dataset.var;
|
||||
if (!key || !state.view) return;
|
||||
// Stage the override on the draft view so paintPreview reflects.
|
||||
const overrides = { ...state.view.draft.variables };
|
||||
overrides[key] = input.value;
|
||||
state.view.draft.variables = overrides;
|
||||
state.pendingOverrides = overrides;
|
||||
setSaveStatus(isEN() ? "Saving…" : "Speichert…");
|
||||
if (state.saveTimer) window.clearTimeout(state.saveTimer);
|
||||
state.saveTimer = window.setTimeout(() => {
|
||||
void flushAutosave();
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function onVarReset(key: string): void {
|
||||
if (!state.view) return;
|
||||
const overrides = { ...state.view.draft.variables };
|
||||
delete overrides[key];
|
||||
state.view.draft.variables = overrides;
|
||||
state.pendingOverrides = overrides;
|
||||
setSaveStatus(isEN() ? "Saving…" : "Speichert…");
|
||||
if (state.saveTimer) window.clearTimeout(state.saveTimer);
|
||||
state.saveTimer = window.setTimeout(() => {
|
||||
void flushAutosave();
|
||||
}, 500);
|
||||
}
|
||||
|
||||
async function flushAutosave(): Promise<void> {
|
||||
if (!state.pendingOverrides) return;
|
||||
const payload = { variables: state.pendingOverrides };
|
||||
state.pendingOverrides = null;
|
||||
try {
|
||||
const view = await patchDraft(payload);
|
||||
state.view = view;
|
||||
paintVariables();
|
||||
paintPreview();
|
||||
setSaveStatus(isEN() ? "Saved" : "Gespeichert");
|
||||
} catch (err) {
|
||||
if ((err as Error).name === "AbortError") return;
|
||||
console.error("submission-draft autosave:", err);
|
||||
setSaveStatus(isEN() ? "Save failed" : "Speichern fehlgeschlagen", true);
|
||||
}
|
||||
}
|
||||
|
||||
async function renameDraft(newName: string): Promise<void> {
|
||||
setSaveStatus(isEN() ? "Saving…" : "Speichert…");
|
||||
try {
|
||||
const view = await patchDraft({ name: newName });
|
||||
state.view = view;
|
||||
// Refresh the draft list cache.
|
||||
const idx = state.drafts.findIndex((d) => d.id === view.draft.id);
|
||||
if (idx >= 0) state.drafts[idx].name = view.draft.name;
|
||||
paintSwitcher();
|
||||
setSaveStatus(isEN() ? "Renamed" : "Umbenannt");
|
||||
} catch (err) {
|
||||
if ((err as Error).name === "AbortError") return;
|
||||
console.error("submission-draft rename:", err);
|
||||
const msg = (err as Error).message?.includes("409")
|
||||
? (isEN() ? "Name already in use" : "Name bereits vergeben")
|
||||
: (isEN() ? "Rename failed" : "Umbenennen fehlgeschlagen");
|
||||
setSaveStatus(msg, true);
|
||||
}
|
||||
}
|
||||
|
||||
async function onCreateNew(): Promise<void> {
|
||||
const p = state.parsed;
|
||||
// From a project-less draft, "Neuer Entwurf" can't auto-pick a
|
||||
// (project, code) cross-section — kick the user out to the global
|
||||
// picker instead.
|
||||
if (!p.projectID || !p.submissionCode) {
|
||||
window.location.href = "/submissions/new";
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const fresh = await createProjectDraft(p);
|
||||
const url = `/projects/${p.projectID}/submissions/${encodeURIComponent(p.submissionCode)}/draft/${fresh.id}`;
|
||||
window.location.href = url;
|
||||
} catch (err) {
|
||||
console.error("submission-draft new:", err);
|
||||
setSaveStatus(isEN() ? "Create failed" : "Anlegen fehlgeschlagen", true);
|
||||
}
|
||||
}
|
||||
|
||||
async function onDelete(): Promise<void> {
|
||||
if (!state.view) return;
|
||||
const msg = isEN()
|
||||
? `Delete draft "${state.view.draft.name}"? This cannot be undone.`
|
||||
: `Entwurf "${state.view.draft.name}" löschen? Das kann nicht rückgängig gemacht werden.`;
|
||||
if (!window.confirm(msg)) return;
|
||||
try {
|
||||
await deleteDraft();
|
||||
const p = state.parsed;
|
||||
const url = p.projectID && p.submissionCode
|
||||
? `/projects/${p.projectID}/submissions/${encodeURIComponent(p.submissionCode)}/draft`
|
||||
: "/submissions";
|
||||
window.location.href = url;
|
||||
} catch (err) {
|
||||
console.error("submission-draft delete:", err);
|
||||
setSaveStatus(isEN() ? "Delete failed" : "Löschen fehlgeschlagen", true);
|
||||
}
|
||||
}
|
||||
|
||||
async function onExport(btn: HTMLButtonElement): Promise<void> {
|
||||
if (!state.view) return;
|
||||
const p = state.parsed;
|
||||
if (!p.draftID) return;
|
||||
const originalLabel = btn.textContent ?? "";
|
||||
btn.disabled = true;
|
||||
btn.textContent = isEN() ? "Exporting…" : "Exportiert…";
|
||||
try {
|
||||
// Use the global export endpoint for both project-scoped and
|
||||
// project-less drafts; the handler routes audit + project_events
|
||||
// writes based on the draft row's project_id.
|
||||
const url = `/api/submission-drafts/${p.draftID}/export`;
|
||||
const resp = await fetch(url, { method: "POST" });
|
||||
if (!resp.ok) {
|
||||
let detail = "";
|
||||
try {
|
||||
const data = (await resp.json()) as { error?: string };
|
||||
detail = data.error ?? "";
|
||||
} catch { /* fallthrough */ }
|
||||
alert((isEN() ? "Export failed." : "Export fehlgeschlagen.") + (detail ? `\n\n${detail}` : ""));
|
||||
return;
|
||||
}
|
||||
const blob = await resp.blob();
|
||||
const filename = parseFilename(resp.headers.get("Content-Disposition") ?? "") ?? `${state.view.draft.submission_code}.docx`;
|
||||
triggerDownload(blob, filename);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = originalLabel;
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Project assign picker (project-less → project-scoped)
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface PickerProjectRow {
|
||||
id: string;
|
||||
title: string;
|
||||
reference?: string | null;
|
||||
}
|
||||
|
||||
let assignPickerProjects: PickerProjectRow[] = [];
|
||||
let assignPickerLoaded = false;
|
||||
|
||||
function openProjectAssignPicker(): void {
|
||||
ensureAssignPickerDOM();
|
||||
const modal = document.getElementById("submission-draft-assign-modal");
|
||||
if (modal) modal.style.display = "";
|
||||
if (!assignPickerLoaded) {
|
||||
void loadAssignPickerProjects();
|
||||
} else {
|
||||
renderAssignPickerList();
|
||||
}
|
||||
const searchInput = document.getElementById("submission-draft-assign-search") as HTMLInputElement | null;
|
||||
if (searchInput) {
|
||||
searchInput.value = "";
|
||||
setTimeout(() => searchInput.focus(), 50);
|
||||
}
|
||||
}
|
||||
|
||||
function closeProjectAssignPicker(): void {
|
||||
const modal = document.getElementById("submission-draft-assign-modal");
|
||||
if (modal) modal.style.display = "none";
|
||||
}
|
||||
|
||||
function ensureAssignPickerDOM(): void {
|
||||
if (document.getElementById("submission-draft-assign-modal")) return;
|
||||
const titleTxt = isEN() ? "Assign project" : "Projekt zuweisen";
|
||||
const placeholder = isEN()
|
||||
? "Search project (title or reference)…"
|
||||
: "Projekt suchen (Titel oder Aktenzeichen)…";
|
||||
const loadingTxt = isEN() ? "Loading projects…" : "Lädt Projekte…";
|
||||
const emptyTxt = isEN() ? "No visible projects." : "Keine sichtbaren Projekte.";
|
||||
|
||||
const modal = document.createElement("div");
|
||||
modal.id = "submission-draft-assign-modal";
|
||||
modal.className = "modal-overlay";
|
||||
modal.setAttribute("role", "dialog");
|
||||
modal.setAttribute("aria-modal", "true");
|
||||
modal.style.display = "none";
|
||||
modal.innerHTML = `
|
||||
<div class="modal-card">
|
||||
<header class="modal-header">
|
||||
<h2>${escapeHtml(titleTxt)}</h2>
|
||||
<button type="button" id="submission-draft-assign-close" class="modal-close" aria-label="Close">×</button>
|
||||
</header>
|
||||
<div class="modal-body">
|
||||
<input type="search" id="submission-draft-assign-search" class="entity-form-input" placeholder="${escapeHtml(placeholder)}" />
|
||||
<ul id="submission-draft-assign-list" class="submissions-new-project-list"></ul>
|
||||
<p id="submission-draft-assign-loading" class="entity-events-empty" style="display:none">${escapeHtml(loadingTxt)}</p>
|
||||
<p id="submission-draft-assign-empty" class="entity-empty" style="display:none">${escapeHtml(emptyTxt)}</p>
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.appendChild(modal);
|
||||
|
||||
modal.addEventListener("click", (e) => {
|
||||
if (e.target === modal) closeProjectAssignPicker();
|
||||
});
|
||||
const closeBtn = document.getElementById("submission-draft-assign-close");
|
||||
if (closeBtn) closeBtn.addEventListener("click", () => closeProjectAssignPicker());
|
||||
const searchInput = document.getElementById("submission-draft-assign-search") as HTMLInputElement | null;
|
||||
if (searchInput) searchInput.addEventListener("input", () => renderAssignPickerList());
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && modal.style.display !== "none") closeProjectAssignPicker();
|
||||
});
|
||||
}
|
||||
|
||||
async function loadAssignPickerProjects(): Promise<void> {
|
||||
const loadingEl = document.getElementById("submission-draft-assign-loading");
|
||||
if (loadingEl) loadingEl.style.display = "";
|
||||
try {
|
||||
const resp = await fetch("/api/projects?status=active");
|
||||
if (!resp.ok) throw new Error(`projects list ${resp.status}`);
|
||||
const rows = (await resp.json()) as PickerProjectRow[];
|
||||
assignPickerProjects = rows ?? [];
|
||||
assignPickerLoaded = true;
|
||||
} catch (err) {
|
||||
console.error("submission-draft assignPicker:", err);
|
||||
assignPickerProjects = [];
|
||||
} finally {
|
||||
if (loadingEl) loadingEl.style.display = "none";
|
||||
}
|
||||
renderAssignPickerList();
|
||||
}
|
||||
|
||||
function renderAssignPickerList(): void {
|
||||
const list = document.getElementById("submission-draft-assign-list");
|
||||
const empty = document.getElementById("submission-draft-assign-empty");
|
||||
if (!list || !empty) return;
|
||||
|
||||
const searchInput = document.getElementById("submission-draft-assign-search") as HTMLInputElement | null;
|
||||
const term = (searchInput?.value ?? "").trim().toLowerCase();
|
||||
|
||||
const matches = assignPickerProjects.filter((p) => {
|
||||
if (term === "") return true;
|
||||
const hay = [p.title, p.reference ?? ""].join(" ").toLowerCase();
|
||||
return hay.includes(term);
|
||||
}).slice(0, 50);
|
||||
|
||||
if (matches.length === 0) {
|
||||
list.innerHTML = "";
|
||||
empty.style.display = "";
|
||||
return;
|
||||
}
|
||||
empty.style.display = "none";
|
||||
|
||||
list.innerHTML = matches.map((p) => {
|
||||
const ref = p.reference ? `<span class="entity-ref">${escapeHtml(p.reference)}</span> ` : "";
|
||||
return `<li class="submissions-new-project-item" data-id="${escapeHtml(p.id)}">${ref}<span class="submissions-new-project-title">${escapeHtml(p.title)}</span></li>`;
|
||||
}).join("");
|
||||
|
||||
list.querySelectorAll<HTMLLIElement>(".submissions-new-project-item").forEach((li) => {
|
||||
li.addEventListener("click", () => {
|
||||
const pid = li.dataset.id;
|
||||
if (pid) void onAssignProject(pid);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function onAssignProject(projectID: string): Promise<void> {
|
||||
closeProjectAssignPicker();
|
||||
setSaveStatus(isEN() ? "Assigning…" : "Wird zugewiesen…");
|
||||
try {
|
||||
const view = await patchDraft({ project_id: projectID });
|
||||
state.view = view;
|
||||
setSaveStatus(isEN() ? "Project assigned" : "Projekt zugewiesen");
|
||||
// Redirect to the project-scoped URL so the editor's URL matches the
|
||||
// attached project and the project-scoped draft list (sidebar
|
||||
// switcher) loads on refresh.
|
||||
const code = view.draft.submission_code;
|
||||
window.location.href = `/projects/${projectID}/submissions/${encodeURIComponent(code)}/draft/${view.draft.id}`;
|
||||
} catch (err) {
|
||||
console.error("submission-draft assign:", err);
|
||||
setSaveStatus(isEN() ? "Assign failed" : "Zuweisung fehlgeschlagen", true);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
function show(id: string): void {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.style.display = "";
|
||||
}
|
||||
|
||||
function hide(id: string): void {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.style.display = "none";
|
||||
}
|
||||
|
||||
function showNotFound(): void {
|
||||
hide("submission-draft-loading");
|
||||
hide("submission-draft-body");
|
||||
show("submission-draft-notfound");
|
||||
}
|
||||
|
||||
function showError(msg: string): void {
|
||||
hide("submission-draft-loading");
|
||||
hide("submission-draft-body");
|
||||
const el = document.getElementById("submission-draft-error");
|
||||
if (el) {
|
||||
el.textContent = msg;
|
||||
el.style.display = "";
|
||||
}
|
||||
}
|
||||
|
||||
function setSaveStatus(msg: string, errorState: boolean = false): void {
|
||||
const el = document.getElementById("submission-draft-savestatus");
|
||||
if (!el) return;
|
||||
el.textContent = msg;
|
||||
el.classList.toggle("submission-draft-savestatus--error", errorState);
|
||||
}
|
||||
|
||||
function parseFilename(header: string): string | null {
|
||||
const m = /filename\s*=\s*"?([^";]+)"?/i.exec(header);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
function triggerDownload(blob: Blob, filename: string): void {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
setTimeout(() => URL.revokeObjectURL(url), 0);
|
||||
}
|
||||
|
||||
// Keep t() referenced so the bundler doesn't tree-shake it; future
|
||||
// affordances will use the per-page i18n keys.
|
||||
void t;
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", () => { void boot(); });
|
||||
} else {
|
||||
void boot();
|
||||
}
|
||||
130
frontend/src/client/submissions-index.ts
Normal file
130
frontend/src/client/submissions-index.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { initI18n, onLangChange, t, getLang } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
|
||||
// t-paliad-240 — global Schriftsätze drafts index. Loads
|
||||
// /api/user/submission-drafts and renders one entity-table row per
|
||||
// draft. Row click → editor at /projects/{project_id}/submissions/
|
||||
// {submission_code}/draft/{draft_id}. Per project CLAUDE.md row-click
|
||||
// contract: a table whose rows look clickable must navigate on click;
|
||||
// inner links / buttons keep their own affordance.
|
||||
|
||||
interface DraftRow {
|
||||
id: string;
|
||||
project_id: string | null;
|
||||
project_title: string | null;
|
||||
project_reference?: string | null;
|
||||
submission_code: string;
|
||||
name: string;
|
||||
last_exported_at?: string | null;
|
||||
updated_at: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
let drafts: DraftRow[] = [];
|
||||
|
||||
function esc(s: string): string {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function fmtDate(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return "";
|
||||
const isEN = getLang() === "en";
|
||||
return d.toLocaleDateString(isEN ? "en-GB" : "de-DE", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
const loading = document.getElementById("submissions-index-loading")!;
|
||||
const empty = document.getElementById("submissions-index-empty")!;
|
||||
const error = document.getElementById("submissions-index-error")!;
|
||||
const wrap = document.getElementById("submissions-index-tablewrap")!;
|
||||
|
||||
try {
|
||||
const resp = await fetch("/api/user/submission-drafts");
|
||||
if (!resp.ok) {
|
||||
loading.style.display = "none";
|
||||
error.style.display = "";
|
||||
return;
|
||||
}
|
||||
const data = await resp.json();
|
||||
drafts = (data.drafts ?? []) as DraftRow[];
|
||||
} catch {
|
||||
loading.style.display = "none";
|
||||
error.style.display = "";
|
||||
return;
|
||||
}
|
||||
|
||||
loading.style.display = "none";
|
||||
|
||||
if (drafts.length === 0) {
|
||||
empty.style.display = "";
|
||||
wrap.style.display = "none";
|
||||
return;
|
||||
}
|
||||
empty.style.display = "none";
|
||||
wrap.style.display = "";
|
||||
render();
|
||||
}
|
||||
|
||||
function render(): void {
|
||||
const body = document.getElementById("submissions-index-body")!;
|
||||
|
||||
const isEN = getLang() === "en";
|
||||
const noProjectLabel = isEN ? "(no project)" : "(kein Projekt)";
|
||||
|
||||
body.innerHTML = drafts.map((d) => {
|
||||
const projectCell = (() => {
|
||||
if (!d.project_id) {
|
||||
return `<span class="submissions-index-no-project">${esc(noProjectLabel)}</span>`;
|
||||
}
|
||||
const title = esc(d.project_title ?? "");
|
||||
if (d.project_reference) {
|
||||
return `<a href="/projects/${esc(d.project_id)}" class="checklist-instance-project"><span class="entity-ref">${esc(d.project_reference)}</span> ${title}</a>`;
|
||||
}
|
||||
return `<a href="/projects/${esc(d.project_id)}" class="checklist-instance-project">${title}</a>`;
|
||||
})();
|
||||
|
||||
const href = d.project_id
|
||||
? `/projects/${esc(d.project_id)}/submissions/${esc(d.submission_code)}/draft/${esc(d.id)}`
|
||||
: `/submissions/draft/${esc(d.id)}`;
|
||||
|
||||
return `<tr class="submissions-index-row" data-href="${esc(href)}">
|
||||
<td>${projectCell}</td>
|
||||
<td>${esc(d.submission_code)}</td>
|
||||
<td><a href="${esc(href)}" class="submissions-index-draft-name">${esc(d.name)}</a></td>
|
||||
<td>${esc(fmtDate(d.updated_at))}</td>
|
||||
</tr>`;
|
||||
}).join("");
|
||||
|
||||
body.querySelectorAll<HTMLTableRowElement>(".submissions-index-row").forEach((row) => {
|
||||
const href = row.dataset.href!;
|
||||
row.addEventListener("click", (e) => {
|
||||
// Inner <a> elements (project link, draft name) handle their own
|
||||
// navigation — let the browser dispatch them.
|
||||
if ((e.target as HTMLElement).closest("a, button")) return;
|
||||
window.location.href = href;
|
||||
});
|
||||
});
|
||||
|
||||
// Keep tsc happy for the imported `t` (used only via data-i18n on
|
||||
// static markup — keep the import so future dynamic strings can hook
|
||||
// in without re-importing).
|
||||
void t;
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
onLangChange(() => {
|
||||
if (drafts.length > 0) render();
|
||||
});
|
||||
void load();
|
||||
});
|
||||
368
frontend/src/client/submissions-new.ts
Normal file
368
frontend/src/client/submissions-new.ts
Normal file
@@ -0,0 +1,368 @@
|
||||
import { initI18n, getLang } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
|
||||
// t-paliad-243 — client for /submissions/new. Fetches the
|
||||
// cross-proceeding submission catalog, groups it by proceeding, filters
|
||||
// by text + chip, and offers two start paths per row: with project
|
||||
// (modal picker) or without (project-less draft → /submissions/draft/{id}).
|
||||
|
||||
interface CatalogEntry {
|
||||
submission_code: string;
|
||||
name: string;
|
||||
name_en: string;
|
||||
event_type?: string;
|
||||
primary_party?: string;
|
||||
legal_source?: string;
|
||||
has_template: boolean;
|
||||
proceeding_code: string;
|
||||
proceeding_name: string;
|
||||
proceeding_name_en: string;
|
||||
}
|
||||
|
||||
interface CatalogResponse {
|
||||
entries: CatalogEntry[];
|
||||
}
|
||||
|
||||
interface ProjectRow {
|
||||
id: string;
|
||||
title: string;
|
||||
reference?: string | null;
|
||||
}
|
||||
|
||||
interface State {
|
||||
entries: CatalogEntry[];
|
||||
activeProceeding: string | null; // null = all
|
||||
searchTerm: string;
|
||||
pickerForCode: string | null;
|
||||
}
|
||||
|
||||
const state: State = {
|
||||
entries: [],
|
||||
activeProceeding: null,
|
||||
searchTerm: "",
|
||||
pickerForCode: null,
|
||||
};
|
||||
|
||||
function isEN(): boolean {
|
||||
return getLang() === "en";
|
||||
}
|
||||
|
||||
function esc(s: string): string {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function partyLabel(role: string | undefined): string {
|
||||
switch ((role ?? "").toLowerCase()) {
|
||||
case "claimant": return isEN() ? "Claimant" : "Klägerin";
|
||||
case "defendant": return isEN() ? "Defendant" : "Beklagte";
|
||||
case "both": return isEN() ? "Both" : "Beide";
|
||||
case "court": return isEN() ? "Court" : "Gericht";
|
||||
default: return "";
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCatalog(): Promise<void> {
|
||||
const loading = document.getElementById("submissions-new-loading")!;
|
||||
const error = document.getElementById("submissions-new-error")!;
|
||||
const wrap = document.getElementById("submissions-new-tablewrap")!;
|
||||
|
||||
try {
|
||||
const resp = await fetch("/api/submissions/catalog");
|
||||
if (!resp.ok) {
|
||||
loading.style.display = "none";
|
||||
error.style.display = "";
|
||||
return;
|
||||
}
|
||||
const data = (await resp.json()) as CatalogResponse;
|
||||
state.entries = data.entries ?? [];
|
||||
} catch {
|
||||
loading.style.display = "none";
|
||||
error.style.display = "";
|
||||
return;
|
||||
}
|
||||
|
||||
loading.style.display = "none";
|
||||
wrap.style.display = "";
|
||||
renderChips();
|
||||
renderTable();
|
||||
}
|
||||
|
||||
function renderChips(): void {
|
||||
const host = document.getElementById("submissions-new-proceeding-chips");
|
||||
if (!host) return;
|
||||
const seen = new Map<string, string>();
|
||||
for (const e of state.entries) {
|
||||
if (!seen.has(e.proceeding_code)) {
|
||||
seen.set(e.proceeding_code, isEN() && e.proceeding_name_en ? e.proceeding_name_en : e.proceeding_name);
|
||||
}
|
||||
}
|
||||
const chips: string[] = [];
|
||||
const allLabel = isEN() ? "All" : "Alle";
|
||||
const allActive = state.activeProceeding === null;
|
||||
chips.push(`<button type="button" class="submissions-new-chip${allActive ? " submissions-new-chip--active" : ""}" data-code="">${esc(allLabel)}</button>`);
|
||||
for (const [code, name] of seen) {
|
||||
const active = state.activeProceeding === code;
|
||||
chips.push(`<button type="button" class="submissions-new-chip${active ? " submissions-new-chip--active" : ""}" data-code="${esc(code)}">${esc(name)} <span class="submissions-new-chip-code">${esc(code)}</span></button>`);
|
||||
}
|
||||
host.innerHTML = chips.join("");
|
||||
host.querySelectorAll<HTMLButtonElement>(".submissions-new-chip").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const code = btn.dataset.code ?? "";
|
||||
state.activeProceeding = code === "" ? null : code;
|
||||
renderChips();
|
||||
renderTable();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function filtered(): CatalogEntry[] {
|
||||
const term = state.searchTerm.trim().toLowerCase();
|
||||
return state.entries.filter((e) => {
|
||||
if (state.activeProceeding !== null && e.proceeding_code !== state.activeProceeding) {
|
||||
return false;
|
||||
}
|
||||
if (term === "") return true;
|
||||
const name = isEN() && e.name_en ? e.name_en : e.name;
|
||||
const hay = [
|
||||
name,
|
||||
e.submission_code,
|
||||
e.legal_source ?? "",
|
||||
e.proceeding_code,
|
||||
e.proceeding_name,
|
||||
e.proceeding_name_en,
|
||||
].join(" ").toLowerCase();
|
||||
return hay.includes(term);
|
||||
});
|
||||
}
|
||||
|
||||
function renderTable(): void {
|
||||
const body = document.getElementById("submissions-new-body");
|
||||
const empty = document.getElementById("submissions-new-empty");
|
||||
const wrap = document.getElementById("submissions-new-tablewrap");
|
||||
if (!body || !empty || !wrap) return;
|
||||
|
||||
const rows = filtered();
|
||||
if (rows.length === 0) {
|
||||
wrap.style.display = "none";
|
||||
empty.style.display = "";
|
||||
return;
|
||||
}
|
||||
wrap.style.display = "";
|
||||
empty.style.display = "none";
|
||||
|
||||
// Group by proceeding.
|
||||
const groups = new Map<string, { name: string; entries: CatalogEntry[] }>();
|
||||
for (const e of rows) {
|
||||
const gname = isEN() && e.proceeding_name_en ? e.proceeding_name_en : e.proceeding_name;
|
||||
const bucket = groups.get(e.proceeding_code);
|
||||
if (bucket) {
|
||||
bucket.entries.push(e);
|
||||
} else {
|
||||
groups.set(e.proceeding_code, { name: gname, entries: [e] });
|
||||
}
|
||||
}
|
||||
|
||||
const colspan = 4;
|
||||
const html: string[] = [];
|
||||
for (const [code, group] of groups) {
|
||||
html.push(`<tr class="entity-table-group-header"><th colspan="${colspan}" scope="colgroup"><span class="entity-table-group-header__name">${esc(group.name)}</span> <span class="entity-table-group-header__code">${esc(code)}</span></th></tr>`);
|
||||
for (const entry of group.entries) {
|
||||
html.push(renderRow(entry));
|
||||
}
|
||||
}
|
||||
body.innerHTML = html.join("");
|
||||
|
||||
body.querySelectorAll<HTMLButtonElement>(".submissions-new-start-no-project").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const code = btn.dataset.code;
|
||||
if (code) void startDraft(code, null);
|
||||
});
|
||||
});
|
||||
body.querySelectorAll<HTMLButtonElement>(".submissions-new-start-with-project").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const code = btn.dataset.code;
|
||||
if (code) openProjectPicker(code);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderRow(entry: CatalogEntry): string {
|
||||
const name = isEN() && entry.name_en ? entry.name_en : entry.name;
|
||||
const source = entry.legal_source ?? "";
|
||||
const templateBadge = entry.has_template
|
||||
? ""
|
||||
: ` <span class="submission-template-badge" title="${esc(isEN() ? "Uses the universal style template" : "Verwendet die universelle Stilvorlage")}">${esc(isEN() ? "universal" : "universell")}</span>`;
|
||||
const withProject = isEN() ? "Mit Projekt…" : "Mit Projekt…";
|
||||
const noProject = isEN() ? "Ohne Projekt" : "Ohne Projekt";
|
||||
|
||||
return `<tr class="submission-row">
|
||||
<td>
|
||||
<span class="submission-name">${esc(name)}</span>
|
||||
<span class="submission-code">${esc(entry.submission_code)}</span>${templateBadge}
|
||||
</td>
|
||||
<td>${esc(partyLabel(entry.primary_party))}</td>
|
||||
<td>${esc(source)}</td>
|
||||
<td class="submission-action-cell">
|
||||
<button type="button" class="btn-secondary btn-small submissions-new-start-with-project" data-code="${esc(entry.submission_code)}">${esc(withProject)}</button>
|
||||
<button type="button" class="btn-primary btn-cta-lime btn-small submissions-new-start-no-project" data-code="${esc(entry.submission_code)}">${esc(noProject)}</button>
|
||||
</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
async function startDraft(submissionCode: string, projectID: string | null): Promise<void> {
|
||||
try {
|
||||
const resp = await fetch("/api/submission-drafts", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ submission_code: submissionCode, project_id: projectID }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
let detail = "";
|
||||
try {
|
||||
const data = (await resp.json()) as { error?: string };
|
||||
detail = data.error ?? "";
|
||||
} catch { /* ignore */ }
|
||||
alert((isEN() ? "Failed to create draft." : "Entwurf konnte nicht angelegt werden.") + (detail ? `\n\n${detail}` : ""));
|
||||
return;
|
||||
}
|
||||
const view = await resp.json() as { draft: { id: string; project_id: string | null; submission_code: string } };
|
||||
const id = view.draft.id;
|
||||
const pid = view.draft.project_id;
|
||||
const code = view.draft.submission_code;
|
||||
if (pid) {
|
||||
window.location.href = `/projects/${pid}/submissions/${encodeURIComponent(code)}/draft/${id}`;
|
||||
} else {
|
||||
window.location.href = `/submissions/draft/${id}`;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("submissions-new createDraft:", err);
|
||||
alert(isEN() ? "Failed to create draft." : "Entwurf konnte nicht angelegt werden.");
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Project picker modal
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
let pickerProjects: ProjectRow[] = [];
|
||||
let pickerLoaded = false;
|
||||
|
||||
function openProjectPicker(submissionCode: string): void {
|
||||
state.pickerForCode = submissionCode;
|
||||
const modal = document.getElementById("submissions-new-project-modal");
|
||||
if (modal) modal.style.display = "";
|
||||
if (!pickerLoaded) {
|
||||
void loadPickerProjects();
|
||||
} else {
|
||||
renderPickerList();
|
||||
}
|
||||
const searchInput = document.getElementById("submissions-new-project-search") as HTMLInputElement | null;
|
||||
if (searchInput) {
|
||||
searchInput.value = "";
|
||||
setTimeout(() => searchInput.focus(), 50);
|
||||
}
|
||||
}
|
||||
|
||||
function closeProjectPicker(): void {
|
||||
state.pickerForCode = null;
|
||||
const modal = document.getElementById("submissions-new-project-modal");
|
||||
if (modal) modal.style.display = "none";
|
||||
}
|
||||
|
||||
async function loadPickerProjects(): Promise<void> {
|
||||
const loadingEl = document.getElementById("submissions-new-project-loading");
|
||||
if (loadingEl) loadingEl.style.display = "";
|
||||
try {
|
||||
const resp = await fetch("/api/projects?status=active");
|
||||
if (!resp.ok) throw new Error(`projects list ${resp.status}`);
|
||||
const rows = (await resp.json()) as ProjectRow[];
|
||||
pickerProjects = rows ?? [];
|
||||
pickerLoaded = true;
|
||||
} catch (err) {
|
||||
console.error("submissions-new loadPickerProjects:", err);
|
||||
pickerProjects = [];
|
||||
} finally {
|
||||
if (loadingEl) loadingEl.style.display = "none";
|
||||
}
|
||||
renderPickerList();
|
||||
}
|
||||
|
||||
function renderPickerList(): void {
|
||||
const list = document.getElementById("submissions-new-project-list");
|
||||
const empty = document.getElementById("submissions-new-project-empty");
|
||||
if (!list || !empty) return;
|
||||
|
||||
const searchInput = document.getElementById("submissions-new-project-search") as HTMLInputElement | null;
|
||||
const term = (searchInput?.value ?? "").trim().toLowerCase();
|
||||
|
||||
const matches = pickerProjects.filter((p) => {
|
||||
if (term === "") return true;
|
||||
const hay = [p.title, p.reference ?? ""].join(" ").toLowerCase();
|
||||
return hay.includes(term);
|
||||
}).slice(0, 50);
|
||||
|
||||
if (matches.length === 0) {
|
||||
list.innerHTML = "";
|
||||
empty.style.display = "";
|
||||
return;
|
||||
}
|
||||
empty.style.display = "none";
|
||||
|
||||
list.innerHTML = matches.map((p) => {
|
||||
const ref = p.reference ? `<span class="entity-ref">${esc(p.reference)}</span> ` : "";
|
||||
return `<li class="submissions-new-project-item" data-id="${esc(p.id)}">${ref}<span class="submissions-new-project-title">${esc(p.title)}</span></li>`;
|
||||
}).join("");
|
||||
|
||||
list.querySelectorAll<HTMLLIElement>(".submissions-new-project-item").forEach((li) => {
|
||||
li.addEventListener("click", () => {
|
||||
const pid = li.dataset.id;
|
||||
const code = state.pickerForCode;
|
||||
if (pid && code) {
|
||||
closeProjectPicker();
|
||||
void startDraft(code, pid);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Boot
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
function wireToolbar(): void {
|
||||
const search = document.getElementById("submissions-new-search") as HTMLInputElement | null;
|
||||
if (search) {
|
||||
search.addEventListener("input", () => {
|
||||
state.searchTerm = search.value;
|
||||
renderTable();
|
||||
});
|
||||
}
|
||||
|
||||
const closeBtn = document.getElementById("submissions-new-project-modal-close");
|
||||
if (closeBtn) closeBtn.addEventListener("click", () => closeProjectPicker());
|
||||
|
||||
const modal = document.getElementById("submissions-new-project-modal");
|
||||
if (modal) {
|
||||
modal.addEventListener("click", (e) => {
|
||||
if (e.target === modal) closeProjectPicker();
|
||||
});
|
||||
}
|
||||
|
||||
const pickerSearch = document.getElementById("submissions-new-project-search") as HTMLInputElement | null;
|
||||
if (pickerSearch) {
|
||||
pickerSearch.addEventListener("input", () => renderPickerList());
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && state.pickerForCode) closeProjectPicker();
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
wireToolbar();
|
||||
void loadCatalog();
|
||||
});
|
||||
@@ -1,10 +1,13 @@
|
||||
// Submissions panel — fetches the project's submission catalog and
|
||||
// renders one row per filing-type rule, with a [Generieren] action
|
||||
// when a .docx template resolves server-side.
|
||||
// Submissions panel — fetches the full submission catalog across every
|
||||
// proceeding and renders it grouped by proceeding, with the project's
|
||||
// own proceeding pinned at the top.
|
||||
//
|
||||
// t-paliad-215 Slice 1. Loaded lazily by the projects-detail tab
|
||||
// switcher so projects without the Schriftsätze tab open don't pay
|
||||
// for the per-row template-availability probes.
|
||||
// t-paliad-215 Slice 1 introduced the per-project list. t-paliad-242
|
||||
// broadened it to the catalog: from any project a lawyer can pick a
|
||||
// Statement of Defence under UPC.INF.CFI, a Klageerwiderung under
|
||||
// DE.INF.LG, an Opposition under EPO, etc. — the editor (t-paliad-238)
|
||||
// handles missing variables gracefully via the [KEIN WERT: …] marker,
|
||||
// so cross-proceeding picks still render cleanly.
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
@@ -23,11 +26,15 @@ interface SubmissionEntry {
|
||||
primary_party?: string;
|
||||
legal_source?: string;
|
||||
has_template: boolean;
|
||||
proceeding_code: string;
|
||||
proceeding_name: string;
|
||||
proceeding_name_en: string;
|
||||
}
|
||||
|
||||
interface SubmissionListResponse {
|
||||
project_id: string;
|
||||
proceeding_type_id?: number;
|
||||
project_proceeding_code?: string;
|
||||
entries: SubmissionEntry[];
|
||||
}
|
||||
|
||||
@@ -74,13 +81,13 @@ function render(data: SubmissionListResponse): void {
|
||||
const body = document.getElementById("project-submissions-body");
|
||||
if (!empty || !noProc || !wrap || !body) return;
|
||||
|
||||
if (data.proceeding_type_id == null || data.proceeding_type_id === 0) {
|
||||
noProc.style.display = "";
|
||||
empty.style.display = "none";
|
||||
wrap.style.display = "none";
|
||||
return;
|
||||
}
|
||||
noProc.style.display = "none";
|
||||
// t-paliad-242: the catalog is shown to every project regardless of
|
||||
// whether a proceeding is bound — the no-proceeding hint stays as a
|
||||
// soft nudge above the table, but no longer hides the catalog.
|
||||
noProc.style.display = data.proceeding_type_id == null || data.proceeding_type_id === 0
|
||||
? ""
|
||||
: "none";
|
||||
|
||||
if (data.entries.length === 0) {
|
||||
empty.style.display = "";
|
||||
wrap.style.display = "none";
|
||||
@@ -90,29 +97,56 @@ function render(data: SubmissionListResponse): void {
|
||||
wrap.style.display = "";
|
||||
|
||||
const isEN = document.documentElement.lang === "en";
|
||||
body.innerHTML = data.entries.map((entry) => {
|
||||
const name = isEN && entry.name_en ? entry.name_en : entry.name;
|
||||
const party = formatParty(entry.primary_party, isEN);
|
||||
const source = entry.legal_source ?? "";
|
||||
const action = entry.has_template
|
||||
? `<button type="button" class="btn-primary btn-cta-lime btn-small submission-generate-btn"
|
||||
data-code="${escapeHtml(entry.submission_code)}"
|
||||
data-project="${escapeHtml(data.project_id)}"
|
||||
data-i18n="projects.detail.submissions.action.generate">${isEN ? "Generate" : "Generieren"}</button>`
|
||||
: `<span class="submission-no-template" data-i18n="projects.detail.submissions.action.no_template">${isEN ? "No template" : "Keine Vorlage"}</span>`;
|
||||
return `<tr class="submission-row">
|
||||
<td>
|
||||
<span class="submission-name">${escapeHtml(name)}</span>
|
||||
<span class="submission-code">${escapeHtml(entry.submission_code)}</span>
|
||||
</td>
|
||||
<td>${escapeHtml(party)}</td>
|
||||
<td>${escapeHtml(source)}</td>
|
||||
<td class="submission-action-cell">${action}</td>
|
||||
</tr>`;
|
||||
}).join("");
|
||||
|
||||
// Wire button clicks. One click handler per render to avoid stale
|
||||
// closures from the previous render's data.
|
||||
// Group entries by proceeding_code. Build a stable group order:
|
||||
// project's own proceeding first (when present), then alphabetical
|
||||
// by proceeding_code for the rest.
|
||||
const groups = new Map<string, { name: string; entries: SubmissionEntry[] }>();
|
||||
for (const entry of data.entries) {
|
||||
const key = entry.proceeding_code || "";
|
||||
const groupName = isEN && entry.proceeding_name_en
|
||||
? entry.proceeding_name_en
|
||||
: entry.proceeding_name;
|
||||
const bucket = groups.get(key);
|
||||
if (bucket) {
|
||||
bucket.entries.push(entry);
|
||||
} else {
|
||||
groups.set(key, { name: groupName, entries: [entry] });
|
||||
}
|
||||
}
|
||||
|
||||
const ownCode = data.project_proceeding_code ?? "";
|
||||
const orderedCodes: string[] = [];
|
||||
if (ownCode && groups.has(ownCode)) orderedCodes.push(ownCode);
|
||||
for (const code of Array.from(groups.keys()).sort()) {
|
||||
if (code !== ownCode) orderedCodes.push(code);
|
||||
}
|
||||
|
||||
const ownSuffix = isEN ? " (this project)" : " (dieses Projekt)";
|
||||
const colspan = 4;
|
||||
|
||||
const html: string[] = [];
|
||||
for (const code of orderedCodes) {
|
||||
const group = groups.get(code);
|
||||
if (!group) continue;
|
||||
const isOwn = code === ownCode;
|
||||
const label = group.name + (isOwn ? ownSuffix : "");
|
||||
const headerClass = isOwn
|
||||
? "entity-table-group-header entity-table-group-header--own"
|
||||
: "entity-table-group-header";
|
||||
html.push(`<tr class="${headerClass}">`
|
||||
+ `<th colspan="${colspan}" scope="colgroup">`
|
||||
+ `<span class="entity-table-group-header__name">${escapeHtml(label)}</span>`
|
||||
+ ` <span class="entity-table-group-header__code">${escapeHtml(code)}</span>`
|
||||
+ `</th></tr>`);
|
||||
for (const entry of group.entries) {
|
||||
html.push(renderRow(entry, data.project_id, isEN));
|
||||
}
|
||||
}
|
||||
body.innerHTML = html.join("");
|
||||
|
||||
// Wire button clicks. One handler per render to avoid stale closures
|
||||
// from the previous render's data.
|
||||
body.querySelectorAll<HTMLButtonElement>(".submission-generate-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
@@ -122,6 +156,33 @@ function render(data: SubmissionListResponse): void {
|
||||
});
|
||||
}
|
||||
|
||||
function renderRow(entry: SubmissionEntry, projectID: string, isEN: boolean): string {
|
||||
const name = isEN && entry.name_en ? entry.name_en : entry.name;
|
||||
const party = formatParty(entry.primary_party, isEN);
|
||||
const source = entry.legal_source ?? "";
|
||||
const draftHref = `/projects/${encodeURIComponent(projectID)}/submissions/${encodeURIComponent(entry.submission_code)}/draft`;
|
||||
const templateBadge = entry.has_template
|
||||
? ""
|
||||
: ` <span class="submission-template-badge" title="${isEN ? "Uses the universal style template" : "Verwendet die universelle Stilvorlage"}">${isEN ? "universal" : "universell"}</span>`;
|
||||
const editBtn = `<a href="${escapeHtml(draftHref)}" class="btn-primary btn-cta-lime btn-small submission-edit-btn"
|
||||
data-code="${escapeHtml(entry.submission_code)}"
|
||||
data-i18n="projects.detail.submissions.action.edit">${isEN ? "Edit" : "Bearbeiten"}</a>`;
|
||||
const generateBtn = `<button type="button" class="btn-secondary btn-small submission-generate-btn"
|
||||
data-code="${escapeHtml(entry.submission_code)}"
|
||||
data-project="${escapeHtml(projectID)}"
|
||||
data-i18n="projects.detail.submissions.action.generate">${isEN ? "Generate" : "Generieren"}</button>`;
|
||||
const action = `${editBtn} ${generateBtn}`;
|
||||
return `<tr class="submission-row">
|
||||
<td>
|
||||
<span class="submission-name">${escapeHtml(name)}</span>
|
||||
<span class="submission-code">${escapeHtml(entry.submission_code)}</span>${templateBadge}
|
||||
</td>
|
||||
<td>${escapeHtml(party)}</td>
|
||||
<td>${escapeHtml(source)}</td>
|
||||
<td class="submission-action-cell">${action}</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
function renderError(): void {
|
||||
const empty = document.getElementById("project-submissions-empty");
|
||||
const noProc = document.getElementById("project-submissions-no-proceeding");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -147,8 +147,22 @@ function formatColumn(row: ViewRow, col: string): string {
|
||||
const s = (row.detail.status as string | undefined) ?? "";
|
||||
return s ? t(("deadlines.status." + s) as I18nKey) : "—";
|
||||
}
|
||||
case "rule":
|
||||
return (row.detail.rule_code as string | undefined) ?? "—";
|
||||
case "rule": {
|
||||
// t-paliad-258 — canonical "Name · Citation" pattern; fall back
|
||||
// to custom_rule_text + " · Custom" for Custom-mode deadlines.
|
||||
const lang = getLang();
|
||||
const nameKey = lang === "en" ? "rule_name_en" : "rule_name";
|
||||
const name = (row.detail[nameKey] as string | undefined)
|
||||
|| (row.detail.rule_name as string | undefined)
|
||||
|| "";
|
||||
const cite = (row.detail.rule_code as string | undefined) ?? "";
|
||||
if (name && cite) return `${name} · ${cite}`;
|
||||
if (name) return name;
|
||||
if (cite) return cite;
|
||||
const custom = (row.detail.custom_rule_text as string | undefined) ?? "";
|
||||
if (custom.trim()) return `${custom} · Custom`;
|
||||
return "—";
|
||||
}
|
||||
case "event_type":
|
||||
return (row.detail.event_type as string | undefined) ?? "—";
|
||||
case "location":
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
type CalculatedDeadline,
|
||||
bucketDeadlinesIntoColumns,
|
||||
deadlineCardHtml,
|
||||
} from "./verfahrensablauf-core";
|
||||
|
||||
@@ -65,3 +66,141 @@ describe("deadlineCardHtml — editable=true emits click-to-edit attrs", () => {
|
||||
expect(html).not.toContain("data-rule-code=");
|
||||
});
|
||||
});
|
||||
|
||||
// Pure column-routing behaviour. Originally pinned by m/paliad#81
|
||||
// (side + appellant axes), re-framed by m/paliad#88: the column
|
||||
// axis is now "Unsere Seite vs Gegnerseite" ("WE always on the
|
||||
// left") instead of the misleading Proaktiv/Reaktiv pair.
|
||||
// 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 both columns.
|
||||
// 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, #88)", () => {
|
||||
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 ours AND opponent — legacy behaviour preserved", () => {
|
||||
const rows = bucketDeadlinesIntoColumns([both("Notice of Appeal", "2026-07-23")]);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].ours.map((d) => d.name)).toEqual(["Notice of Appeal"]);
|
||||
expect(rows[0].opponent.map((d) => d.name)).toEqual(["Notice of Appeal"]);
|
||||
expect(rows[0].court).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("default (no side) places claimant on the left (ours) — 'we are claimant' fallback", () => {
|
||||
const rows = bucketDeadlinesIntoColumns([
|
||||
partySpecific("claimant", "Klageschrift", "2026-01-01"),
|
||||
partySpecific("defendant", "Klageerwiderung", "2026-04-01"),
|
||||
]);
|
||||
expect(rows[0].ours.map((d) => d.name)).toEqual(["Klageschrift"]);
|
||||
expect(rows[1].opponent.map((d) => d.name)).toEqual(["Klageerwiderung"]);
|
||||
});
|
||||
|
||||
test("appellant=claimant collapses 'both' rules into ours when side=claimant (or default)", () => {
|
||||
const rows = bucketDeadlinesIntoColumns(
|
||||
[both("Notice of Appeal", "2026-07-23"), both("Statement of Grounds", "2026-09-23")],
|
||||
{ appellant: "claimant" },
|
||||
);
|
||||
expect(rows.map((r) => r.ours.map((d) => d.name))).toEqual([
|
||||
["Notice of Appeal"],
|
||||
["Statement of Grounds"],
|
||||
]);
|
||||
rows.forEach((r) => expect(r.opponent).toHaveLength(0));
|
||||
});
|
||||
|
||||
test("appellant=defendant collapses 'both' rules into opponent when side=null/claimant", () => {
|
||||
const rows = bucketDeadlinesIntoColumns(
|
||||
[both("Notice of Appeal", "2026-07-23")],
|
||||
{ appellant: "defendant" },
|
||||
);
|
||||
expect(rows[0].ours).toHaveLength(0);
|
||||
expect(rows[0].opponent.map((d) => d.name)).toEqual(["Notice of Appeal"]);
|
||||
});
|
||||
|
||||
test("side=defendant flips which party owns 'ours' vs 'opponent' — WE always on the left", () => {
|
||||
// User is on the defendant side: defendant filings land in 'ours'
|
||||
// (left), claimant filings land in 'opponent' (right). Court rules
|
||||
// stay in court regardless of side.
|
||||
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].opponent.map((d) => d.name)).toEqual(["Klageschrift"]);
|
||||
expect(rows[1].ours.map((d) => d.name)).toEqual(["Klageerwiderung"]);
|
||||
expect(rows[2].court.map((d) => d.name)).toEqual(["Urteil"]);
|
||||
});
|
||||
|
||||
test("side=defendant + appellant=defendant routes 'both' into 'ours' (user's own column)", () => {
|
||||
// The user is the defendant AND the appellant, so the appellant's
|
||||
// column == the user's own column == ours after the swap.
|
||||
const rows = bucketDeadlinesIntoColumns(
|
||||
[both("Notice of Appeal", "2026-07-23")],
|
||||
{ side: "defendant", appellant: "defendant" },
|
||||
);
|
||||
expect(rows[0].ours.map((d) => d.name)).toEqual(["Notice of Appeal"]);
|
||||
expect(rows[0].opponent).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("side=defendant + appellant=claimant routes 'both' into opponent (claimant ≠ us)", () => {
|
||||
// Side flip + appellant axis combined: the claimant is the appellant
|
||||
// but NOT us, so the collapsed 'both' row lands in the opponent
|
||||
// column (right). This is the UPC Appeal "they appealed, we
|
||||
// respond" scenario.
|
||||
const rows = bucketDeadlinesIntoColumns(
|
||||
[both("Notice of Appeal", "2026-07-23")],
|
||||
{ side: "defendant", appellant: "claimant" },
|
||||
);
|
||||
expect(rows[0].opponent.map((d) => d.name)).toEqual(["Notice of Appeal"]);
|
||||
expect(rows[0].ours).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].ours.map((d) => d.name)).toEqual(["A"]);
|
||||
expect(rows[0].opponent.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.ours, r.court, r.opponent].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,124 @@ 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: Unsere Seite | Gericht | Gegnerseite.
|
||||
//
|
||||
// The columns are user-perspective ("WE are always on the left", per
|
||||
// t-paliad-257 / m/paliad#88). The old Proaktiv/Reaktiv axis lied:
|
||||
// Klägerseite is sometimes proactive (filing the claim) and sometimes
|
||||
// reactive (responding to a counterclaim), so the static "Proaktiv =
|
||||
// Klägerseite" label-pair was wrong half the time. The new axis is
|
||||
// "ours vs opponent" — the side toggle picks who WE are in this
|
||||
// proceeding (Klägerseite vs Beklagtenseite, i.e. patentee vs alleged
|
||||
// infringer / Einsprechender vs Patentinhaber, etc.), and rule
|
||||
// placement re-resolves around that pick.
|
||||
//
|
||||
// Column assignment per deadline (default opts.side === null keeps
|
||||
// the legacy claimant-on-the-left layout — i.e. "we are claimant"):
|
||||
//
|
||||
// - party=claimant → ours when side ∈ {null,"claimant"}, else opponent
|
||||
// - party=defendant → opponent when side ∈ {null,"claimant"}, else ours
|
||||
// - party=court → court (independent of side)
|
||||
// - party=both → BOTH ours AND opponent (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 "both"
|
||||
// really means "either party files, depending on who initiated".
|
||||
// Appellant axis is independent of `side`: in an Appeal CoA, the
|
||||
// appellant selector pins which party appealed; the side toggle
|
||||
// still picks which of those is us.
|
||||
export type Side = "claimant" | "defendant" | null;
|
||||
|
||||
// Internal column-position alias. "ours" is always rendered in the
|
||||
// left grid column ("Unsere Seite"); "opponent" is always the right
|
||||
// column ("Gegnerseite"). Field names mirror the labels so the
|
||||
// bucketing primitive reads as a direct mapping.
|
||||
type ColumnPosition = "ours" | "opponent";
|
||||
|
||||
export interface ColumnsBodyOpts {
|
||||
editable?: boolean;
|
||||
showNotes?: boolean;
|
||||
// side: which side the user is on. Drives column placement;
|
||||
// does NOT filter rows. Default null = claimant-on-the-left
|
||||
// (i.e. "ours = claimant", legacy default).
|
||||
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;
|
||||
ours: CalculatedDeadline[];
|
||||
court: CalculatedDeadline[];
|
||||
opponent: 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 and the user-perspective re-frame from
|
||||
// m/paliad#88) 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;
|
||||
// Default (side=null) treats the user as claimant — keeps the
|
||||
// legacy claimant-on-the-left layout when no perspective is picked.
|
||||
const claimantColumn: ColumnPosition = userSide === "defendant" ? "opponent" : "ours";
|
||||
const defendantColumn: ColumnPosition = claimantColumn === "ours" ? "opponent" : "ours";
|
||||
const appellantColumn: ColumnPosition | 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, ours: [], court: [], opponent: [] };
|
||||
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.ours.push(dl);
|
||||
row.opponent.push(dl);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
row.court.push(dl);
|
||||
@@ -462,17 +554,28 @@ 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 appellantPinned = opts.appellant === "claimant" || opts.appellant === "defendant";
|
||||
|
||||
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 = !appellantPinned;
|
||||
|
||||
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,16 +590,19 @@ export function renderColumnsBody(data: DeadlineResponse, opts: Omit<CardOpts, "
|
||||
const headerCell = (label: string, cls: string) =>
|
||||
`<div class="fr-col-header ${cls}">${escHtml(label)}</div>`;
|
||||
|
||||
// Static labels — "Unsere Seite" is always the left column, regardless
|
||||
// of which physical party (claimant vs defendant) occupies it. The
|
||||
// bucketing primitive already routes the user's side into the `ours`
|
||||
// bucket, so the header truth-fully describes the column contents.
|
||||
let html = '<div class="fr-columns-view">';
|
||||
html += headerCell(t("deadlines.col.proactive"), "fr-col-proactive");
|
||||
html += headerCell(t("deadlines.col.ours"), "fr-col-ours");
|
||||
html += headerCell(t("deadlines.col.court"), "fr-col-court");
|
||||
html += headerCell(t("deadlines.col.reactive"), "fr-col-reactive");
|
||||
html += headerCell(t("deadlines.col.opponent"), "fr-col-opponent");
|
||||
|
||||
for (const key of keys) {
|
||||
const row = rowsMap.get(key)!;
|
||||
html += renderCell(row.proactive);
|
||||
for (const row of rows) {
|
||||
html += renderCell(row.ours);
|
||||
html += renderCell(row.court);
|
||||
html += renderCell(row.reactive);
|
||||
html += renderCell(row.opponent);
|
||||
}
|
||||
html += "</div>";
|
||||
return html;
|
||||
|
||||
@@ -13,6 +13,10 @@ const ICON_BOOK = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" st
|
||||
// at a glance.
|
||||
const ICON_BOOK_OPEN = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 4h7a3 3 0 0 1 3 3v13a2 2 0 0 0-2-2H2z"/><path d="M22 4h-7a3 3 0 0 0-3 3v13a2 2 0 0 1 2-2h8z"/></svg>';
|
||||
const ICON_TABLE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/><line x1="9" y1="3" x2="9" y2="21"/></svg>';
|
||||
// Document-with-lines icon for /submissions (t-paliad-240) — distinct
|
||||
// from ICON_BOOK / ICON_BOOK_OPEN / ICON_NEWSPAPER so the Schriftsätze
|
||||
// affordance reads as "a draft document" at a glance.
|
||||
const ICON_FILE_TEXT = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="16" y2="17"/><line x1="8" y1="9" x2="10" y2="9"/></svg>';
|
||||
const ICON_CHECK = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>';
|
||||
const ICON_GLOBE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10A15.3 15.3 0 0 1 12 2z"/></svg>';
|
||||
const ICON_BUILDING = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21h18"/><path d="M5 21V5a2 2 0 0 1 2-2h7a2 2 0 0 1 2 2v16"/><path d="M16 9h3a2 2 0 0 1 2 2v10"/><path d="M9 7h2"/><path d="M9 11h2"/><path d="M9 15h2"/></svg>';
|
||||
@@ -175,6 +179,7 @@ export function Sidebar({ currentPath, authenticated = true }: SidebarProps): st
|
||||
{group("nav.group.werkzeuge", "Werkzeuge",
|
||||
navItem("/tools/fristenrechner", ICON_CLOCK, "nav.fristenrechner", "Fristenrechner", currentPath) +
|
||||
navItem("/tools/verfahrensablauf", ICON_BOOK_OPEN, "nav.verfahrensablauf", "Verfahrensablauf", currentPath) +
|
||||
navItem("/submissions", ICON_FILE_TEXT, "nav.submissions", "Schriftsätze", currentPath) +
|
||||
navItem("/tools/kostenrechner", ICON_CALC, "nav.kostenrechner", "Kostenrechner", currentPath) +
|
||||
navItem("/tools/gebuehrentabellen", ICON_TABLE, "nav.gebuehrentabellen", "Gebührentabellen", currentPath) +
|
||||
navItem("/checklists", ICON_CHECK, "nav.checklisten", "Checklisten", currentPath) +
|
||||
@@ -202,6 +207,7 @@ export function Sidebar({ currentPath, authenticated = true }: SidebarProps): st
|
||||
{navItem("/admin/rules", ICON_BOOK, "nav.admin.rules", "Regeln verwalten", currentPath)}
|
||||
{navItem("/admin/rules/export", ICON_DOWNLOAD, "nav.admin.rules_export", "Regel-Migrations", currentPath)}
|
||||
{navItem("/admin/audit-log", ICON_AUDIT_LOG, "nav.admin.audit", "Audit-Log", currentPath)}
|
||||
{navItem("/admin/backups", ICON_DOWNLOAD, "nav.admin.backups", "Backups", currentPath)}
|
||||
{/* Paliadin Monitor — owner-only sub-entry; revealed by sidebar.ts together with the /paliadin link. */}
|
||||
<a href="/admin/paliadin" id="sidebar-admin-paliadin-link"
|
||||
className={`sidebar-item${currentPath === "/admin/paliadin" ? " active" : ""}`}
|
||||
|
||||
@@ -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" />
|
||||
@@ -95,7 +108,36 @@ export function renderDeadlinesDetail(): string {
|
||||
</dd>
|
||||
|
||||
<dt data-i18n="deadlines.detail.rule">Regel</dt>
|
||||
<dd id="deadline-rule-display">—</dd>
|
||||
<dd>
|
||||
<span id="deadline-rule-display">—</span>
|
||||
{/* t-paliad-258 — Auto / Custom rule editor.
|
||||
Mirrors /deadlines/new: read-only Auto display
|
||||
(resolved from Type) or free-text Custom input,
|
||||
with a toggle link. Hidden outside edit mode. */}
|
||||
<div className="rule-edit-block" id="deadline-rule-edit" style="display:none">
|
||||
<button
|
||||
type="button"
|
||||
id="deadline-rule-mode-toggle"
|
||||
className="btn-link-action"
|
||||
data-i18n="deadlines.field.rule.mode.toggle_to_custom"
|
||||
>
|
||||
Eigene Regel eingeben
|
||||
</button>
|
||||
<div className="rule-mode-auto" id="deadline-rule-auto-display">
|
||||
<span className="form-hint-badge" data-i18n="deadlines.field.rule.auto_badge">Auto</span>
|
||||
<span id="deadline-rule-auto-text" className="rule-auto-text">—</span>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
id="deadline-rule-custom-input"
|
||||
className="rule-mode-custom"
|
||||
style="display:none"
|
||||
placeholder="z.B. interner Review-Termin"
|
||||
data-i18n-placeholder="deadlines.field.rule.custom_placeholder"
|
||||
maxLength={200}
|
||||
/>
|
||||
</div>
|
||||
</dd>
|
||||
|
||||
<dt data-i18n="deadlines.detail.source">Quelle</dt>
|
||||
<dd id="deadline-source-display" />
|
||||
|
||||
@@ -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"
|
||||
@@ -57,58 +72,42 @@ export function renderDeadlinesNew(): string {
|
||||
|
||||
<div className="form-field" id="deadline-event-type-field">
|
||||
<label data-i18n="deadlines.field.event_type">Typ (optional)</label>
|
||||
{/* t-paliad-165 follow-up — collapsed view: when a Regel
|
||||
is selected and a default event_type is known, the
|
||||
Typ chip is hidden and the type is rendered inline
|
||||
as a single read-only summary with an "Anderen Typ
|
||||
wählen" link that re-expands the picker. */}
|
||||
<div
|
||||
className="event-type-collapsed"
|
||||
id="deadline-event-type-collapsed"
|
||||
style="display:none"
|
||||
>
|
||||
<span
|
||||
className="event-type-collapsed-label"
|
||||
id="deadline-event-type-collapsed-label"
|
||||
/>
|
||||
<span
|
||||
className="event-type-collapsed-source"
|
||||
data-i18n="deadlines.field.rule.autofill_inline"
|
||||
>
|
||||
(vorgegeben durch Regel)
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="event-type-collapsed-override"
|
||||
id="deadline-event-type-override-btn"
|
||||
data-i18n="deadlines.field.rule.override"
|
||||
>
|
||||
Anderen Typ wählen
|
||||
</button>
|
||||
</div>
|
||||
<div id="deadline-event-types" className="event-type-picker-host" />
|
||||
{/* Soft warning when the user is in expanded mode AND
|
||||
has picked an event_type that doesn't include the
|
||||
rule's canonical default. Reuses the existing
|
||||
yellow form-hint--warning style; never blocking. */}
|
||||
<p
|
||||
className="form-hint form-hint--warning"
|
||||
id="deadline-event-type-rule-mismatch"
|
||||
style="display:none"
|
||||
data-i18n="deadlines.field.rule.mismatch"
|
||||
>
|
||||
Hinweis: Typ widerspricht Regel — Sie haben den Typ überschrieben.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* m/paliad#56 — Regel sits directly beneath the Typ
|
||||
picker so the parent/child relationship reads at a
|
||||
glance. Due date is its own row below. */}
|
||||
{/* t-paliad-258 / m/paliad#89 — binary Rule field.
|
||||
Auto (default): rule_id derived from the chosen
|
||||
Type, displayed read-only with a canonical
|
||||
"Name · Citation" label. Custom: free-text input,
|
||||
no catalog FK. Toggle switches modes. */}
|
||||
<div className="form-field">
|
||||
<label htmlFor="deadline-rule" data-i18n="deadlines.field.rule">Regel (optional)</label>
|
||||
<select id="deadline-rule">
|
||||
<option value="" data-i18n="deadlines.field.rule.none">Keine Regel</option>
|
||||
</select>
|
||||
<div className="form-field-label-row">
|
||||
<label data-i18n="deadlines.field.rule">Regel</label>
|
||||
<button
|
||||
type="button"
|
||||
id="deadline-rule-mode-toggle"
|
||||
className="btn-link-action"
|
||||
data-i18n="deadlines.field.rule.mode.toggle_to_custom"
|
||||
>
|
||||
Eigene Regel eingeben
|
||||
</button>
|
||||
</div>
|
||||
<div className="rule-mode-auto" id="deadline-rule-auto-display">
|
||||
<span
|
||||
className="form-hint-badge"
|
||||
data-i18n="deadlines.field.rule.auto_badge"
|
||||
>Auto</span>
|
||||
<span id="deadline-rule-auto-text" className="rule-auto-text">—</span>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
id="deadline-rule-custom-input"
|
||||
className="rule-mode-custom"
|
||||
style="display:none"
|
||||
placeholder="z.B. interner Review-Termin"
|
||||
data-i18n-placeholder="deadlines.field.rule.custom_placeholder"
|
||||
maxLength={200}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
|
||||
@@ -90,6 +90,28 @@ export type I18nKey =
|
||||
| "admin.audit.source.reminder_log"
|
||||
| "admin.audit.subtitle"
|
||||
| "admin.audit.title"
|
||||
| "admin.backups.col.actions"
|
||||
| "admin.backups.col.kind"
|
||||
| "admin.backups.col.requested_by"
|
||||
| "admin.backups.col.rows"
|
||||
| "admin.backups.col.size"
|
||||
| "admin.backups.col.started"
|
||||
| "admin.backups.col.status"
|
||||
| "admin.backups.download"
|
||||
| "admin.backups.empty"
|
||||
| "admin.backups.footer.note"
|
||||
| "admin.backups.heading"
|
||||
| "admin.backups.kind.on_demand"
|
||||
| "admin.backups.kind.scheduled"
|
||||
| "admin.backups.loading"
|
||||
| "admin.backups.run_now"
|
||||
| "admin.backups.running"
|
||||
| "admin.backups.status.done"
|
||||
| "admin.backups.status.failed"
|
||||
| "admin.backups.status.running"
|
||||
| "admin.backups.subtitle"
|
||||
| "admin.backups.success"
|
||||
| "admin.backups.title"
|
||||
| "admin.broadcasts.col.count"
|
||||
| "admin.broadcasts.col.sender"
|
||||
| "admin.broadcasts.col.sent_at"
|
||||
@@ -682,9 +704,20 @@ export type I18nKey =
|
||||
| "approvals.tab.mine"
|
||||
| "approvals.tab.pending_mine"
|
||||
| "approvals.title"
|
||||
| "approvals.withdraw.cancel"
|
||||
| "approvals.withdraw.confirm"
|
||||
| "approvals.withdraw.cta"
|
||||
| "approvals.withdraw.destructive.label"
|
||||
| "approvals.withdraw.error"
|
||||
| "approvals.withdraw.lead.create.appointment"
|
||||
| "approvals.withdraw.lead.create.deadline"
|
||||
| "approvals.withdraw.lead.delete"
|
||||
| "approvals.withdraw.lead.update"
|
||||
| "approvals.withdraw.modal.title"
|
||||
| "approvals.withdraw.primary.label"
|
||||
| "approvals.withdraw.sub.create"
|
||||
| "approvals.withdraw.sub.delete"
|
||||
| "approvals.withdraw.sub.update"
|
||||
| "bottomnav.add"
|
||||
| "bottomnav.add.appointment"
|
||||
| "bottomnav.add.appointment.sub"
|
||||
@@ -1112,6 +1145,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"
|
||||
@@ -1138,8 +1175,8 @@ export type I18nKey =
|
||||
| "deadlines.col.court"
|
||||
| "deadlines.col.due"
|
||||
| "deadlines.col.event_type"
|
||||
| "deadlines.col.proactive"
|
||||
| "deadlines.col.reactive"
|
||||
| "deadlines.col.opponent"
|
||||
| "deadlines.col.ours"
|
||||
| "deadlines.col.rule"
|
||||
| "deadlines.col.status"
|
||||
| "deadlines.col.title"
|
||||
@@ -1227,12 +1264,16 @@ export type I18nKey =
|
||||
| "deadlines.field.notes"
|
||||
| "deadlines.field.notes.placeholder"
|
||||
| "deadlines.field.rule"
|
||||
| "deadlines.field.rule.autofill"
|
||||
| "deadlines.field.rule.autofill_inline"
|
||||
| "deadlines.field.rule.mismatch"
|
||||
| "deadlines.field.rule.none"
|
||||
| "deadlines.field.rule.override"
|
||||
| "deadlines.field.rule.auto_badge"
|
||||
| "deadlines.field.rule.auto_no_match"
|
||||
| "deadlines.field.rule.auto_pick_type"
|
||||
| "deadlines.field.rule.custom_badge"
|
||||
| "deadlines.field.rule.custom_placeholder"
|
||||
| "deadlines.field.rule.mode.toggle_to_auto"
|
||||
| "deadlines.field.rule.mode.toggle_to_custom"
|
||||
| "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 +1407,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 +1619,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"
|
||||
@@ -1869,6 +1916,7 @@ export type I18nKey =
|
||||
| "login.title"
|
||||
| "modal.close.label"
|
||||
| "nav.admin.audit"
|
||||
| "nav.admin.backups"
|
||||
| "nav.admin.bereich"
|
||||
| "nav.admin.event_types"
|
||||
| "nav.admin.paliadin"
|
||||
@@ -1904,6 +1952,7 @@ export type I18nKey =
|
||||
| "nav.paliadin"
|
||||
| "nav.projekte"
|
||||
| "nav.soon.tooltip"
|
||||
| "nav.submissions"
|
||||
| "nav.team"
|
||||
| "nav.termine"
|
||||
| "nav.user_views.new"
|
||||
@@ -2187,6 +2236,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"
|
||||
@@ -2260,6 +2314,7 @@ export type I18nKey =
|
||||
| "projects.detail.smarttimeline.track.only.counterclaim"
|
||||
| "projects.detail.smarttimeline.track.only.parent"
|
||||
| "projects.detail.smarttimeline.track.only.parent_context"
|
||||
| "projects.detail.submissions.action.edit"
|
||||
| "projects.detail.submissions.action.generate"
|
||||
| "projects.detail.submissions.action.no_template"
|
||||
| "projects.detail.submissions.col.action"
|
||||
@@ -2275,6 +2330,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"
|
||||
@@ -2495,6 +2551,45 @@ export type I18nKey =
|
||||
| "search.no_results"
|
||||
| "search.placeholder"
|
||||
| "sidebar.resize.title"
|
||||
| "submissions.draft.action.delete"
|
||||
| "submissions.draft.action.export"
|
||||
| "submissions.draft.action.new"
|
||||
| "submissions.draft.back"
|
||||
| "submissions.draft.loading"
|
||||
| "submissions.draft.name.placeholder"
|
||||
| "submissions.draft.notfound"
|
||||
| "submissions.draft.preview.hint"
|
||||
| "submissions.draft.preview.title"
|
||||
| "submissions.draft.switcher.label"
|
||||
| "submissions.draft.title"
|
||||
| "submissions.index.action.new"
|
||||
| "submissions.index.col.draft"
|
||||
| "submissions.index.col.project"
|
||||
| "submissions.index.col.submission"
|
||||
| "submissions.index.col.updated"
|
||||
| "submissions.index.empty"
|
||||
| "submissions.index.empty.cta"
|
||||
| "submissions.index.error"
|
||||
| "submissions.index.heading"
|
||||
| "submissions.index.loading"
|
||||
| "submissions.index.subtitle"
|
||||
| "submissions.index.title"
|
||||
| "submissions.new.back"
|
||||
| "submissions.new.col.actions"
|
||||
| "submissions.new.col.name"
|
||||
| "submissions.new.col.party"
|
||||
| "submissions.new.col.source"
|
||||
| "submissions.new.empty.filtered"
|
||||
| "submissions.new.error"
|
||||
| "submissions.new.heading"
|
||||
| "submissions.new.loading"
|
||||
| "submissions.new.picker.empty"
|
||||
| "submissions.new.picker.loading"
|
||||
| "submissions.new.picker.placeholder"
|
||||
| "submissions.new.picker.title"
|
||||
| "submissions.new.search.placeholder"
|
||||
| "submissions.new.subtitle"
|
||||
| "submissions.new.title"
|
||||
| "team.broadcast.body"
|
||||
| "team.broadcast.body_placeholder"
|
||||
| "team.broadcast.button"
|
||||
|
||||
@@ -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.
|
||||
@@ -625,17 +614,18 @@ export function renderProjectsDetail(): string {
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Submissions (Schriftsätze) — t-paliad-215 Slice 1.
|
||||
Lists the project's filing-type rules with a per-row
|
||||
[Generieren] button when a .docx template resolves
|
||||
in the registry's fallback chain (firm → base/code →
|
||||
base/family → skeleton). Empty for projects with no
|
||||
proceeding bound; otherwise enumerates every active
|
||||
filing rule for the proceeding. */}
|
||||
{/* Submissions (Schriftsätze) — t-paliad-242 broadened
|
||||
the original t-paliad-215 list to the full
|
||||
cross-proceeding catalog. The table shows every
|
||||
active filing rule across every proceeding, grouped
|
||||
by proceeding; the project's own proceeding is
|
||||
pinned to the top. The no-proceeding hint stays as
|
||||
a soft nudge above the catalog (the table renders
|
||||
regardless). */}
|
||||
<section className="entity-tab-panel" id="tab-submissions" style="display:none">
|
||||
<div id="project-submissions-no-proceeding" className="entity-events-empty" style="display:none">
|
||||
<p data-i18n="projects.detail.submissions.empty.no_proceeding">
|
||||
Für dieses Projekt ist noch kein Verfahrenstyp gesetzt. Bitte im Projekt bearbeiten.
|
||||
Für dieses Projekt ist noch kein Verfahrenstyp gesetzt — der Katalog unten zeigt trotzdem alle Vorlagen.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
@@ -646,7 +636,7 @@ export function renderProjectsDetail(): string {
|
||||
</button>
|
||||
</div>
|
||||
<p id="project-submissions-empty" className="entity-events-empty" style="display:none" data-i18n="projects.detail.submissions.empty">
|
||||
Für dieses Verfahren sind keine Schriftsätze hinterlegt.
|
||||
Es sind aktuell keine Schriftsatzvorlagen hinterlegt.
|
||||
</p>
|
||||
<div className="entity-table-wrap" id="project-submissions-tablewrap" style="display:none">
|
||||
<table className="entity-table entity-table--readonly">
|
||||
@@ -665,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
|
||||
@@ -3605,7 +3629,7 @@ input[type="range"]::-moz-range-thumb {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.fr-col-header.fr-col-proactive {
|
||||
.fr-col-header.fr-col-ours {
|
||||
background: var(--status-blue-bg);
|
||||
color: var(--status-blue-fg);
|
||||
}
|
||||
@@ -3615,7 +3639,7 @@ input[type="range"]::-moz-range-thumb {
|
||||
color: var(--status-blue-soft-fg);
|
||||
}
|
||||
|
||||
.fr-col-header.fr-col-reactive {
|
||||
.fr-col-header.fr-col-opponent {
|
||||
background: var(--status-amber-bg);
|
||||
color: var(--status-amber-fg);
|
||||
}
|
||||
@@ -5586,10 +5610,385 @@ dialog.modal::backdrop {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* t-paliad-242 — grouped Schriftsätze catalog. The Schriftsätze tab
|
||||
shows filing rules from every proceeding the platform knows about,
|
||||
grouped by proceeding (DE LG vs UPC CFI vs EPO Opposition etc.).
|
||||
.entity-table-group-header is a <tr> spanning all columns that
|
||||
labels each block; the --own modifier picks out the project's own
|
||||
proceeding with a lime border so the lawyer sees their primary
|
||||
context at a glance. */
|
||||
.entity-table-group-header th {
|
||||
padding: 0.65rem 1rem;
|
||||
text-align: left;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-text-muted);
|
||||
background: var(--color-bg-subtle);
|
||||
border-top: 1px solid var(--color-border);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.entity-table tbody tr.entity-table-group-header,
|
||||
.entity-table--readonly tbody tr.entity-table-group-header {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.entity-table tbody tr.entity-table-group-header:hover,
|
||||
.entity-table--readonly tbody tr.entity-table-group-header:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.entity-table-group-header--own th {
|
||||
background: var(--color-bg-lime-tint);
|
||||
color: var(--color-text);
|
||||
border-left: 3px solid var(--color-accent-fg);
|
||||
}
|
||||
|
||||
.entity-table-group-header__name {
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
font-size: 0.95rem;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.entity-table-group-header__code {
|
||||
font-family: var(--font-mono, monospace);
|
||||
color: var(--color-text-muted);
|
||||
margin-left: 0.5rem;
|
||||
font-size: 0.78rem;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.submission-template-badge {
|
||||
display: inline-block;
|
||||
margin-left: 0.4rem;
|
||||
padding: 0.05rem 0.4rem;
|
||||
border-radius: 999px;
|
||||
background: var(--color-bg-subtle);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.02em;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.submissions-hint {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
/* t-paliad-238 — dedicated draft editor page. */
|
||||
.submission-draft-page {
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.submission-draft-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.submission-draft-header-text h1 {
|
||||
margin: 0 0 0.25rem 0;
|
||||
}
|
||||
|
||||
.submission-draft-header-actions {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.submission-draft-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(280px, 360px) 1fr;
|
||||
gap: 1.5rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.submission-draft-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.submission-draft-sidebar {
|
||||
position: sticky;
|
||||
top: 1rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
background: var(--color-surface, #fff);
|
||||
max-height: calc(100vh - 2rem);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* t-paliad-260 — at single-column widths, drop the sticky/max-height
|
||||
constraints on the variable editor so it reflows above the preview
|
||||
and scrolls away naturally instead of overlaying the preview pane
|
||||
(sticky + calc(100vh - 2rem) keep the form pinned at the top of the
|
||||
viewport while the user scrolls down to read the preview). Must come
|
||||
after the unscoped .submission-draft-sidebar block to win source
|
||||
order at equal specificity. */
|
||||
@media (max-width: 900px) {
|
||||
.submission-draft-sidebar {
|
||||
position: static;
|
||||
max-height: none;
|
||||
overflow-y: visible;
|
||||
}
|
||||
}
|
||||
|
||||
.submission-draft-switcher {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.submission-draft-switcher select {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.submission-draft-name-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.submission-draft-name-row input {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.submission-draft-savestatus {
|
||||
font-size: 0.85em;
|
||||
color: var(--color-text-muted);
|
||||
min-height: 1.2em;
|
||||
margin: 0.25rem 0 0.75rem 0;
|
||||
}
|
||||
|
||||
.submission-draft-savestatus--error {
|
||||
color: var(--color-danger, #c00);
|
||||
}
|
||||
|
||||
.submission-draft-variables {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.submission-draft-var-group {
|
||||
border-top: 1px solid var(--color-border);
|
||||
padding-top: 0.75rem;
|
||||
}
|
||||
|
||||
.submission-draft-var-group-title {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 0.95em;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.submission-draft-var-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
.submission-draft-var-label {
|
||||
font-size: 0.9em;
|
||||
color: var(--color-text);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.submission-draft-var-input {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.submission-draft-var-hint {
|
||||
font-size: 0.8em;
|
||||
color: var(--color-text-muted);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.submission-draft-var-hint code {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
.submission-draft-var-marker {
|
||||
color: var(--color-warning, #b27800);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.submission-draft-var-was {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.submission-draft-var-reset {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.submission-draft-preview-wrap {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
background: var(--color-surface, #fff);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.submission-draft-preview-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: var(--color-surface-alt, #fafafa);
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.submission-draft-preview-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.submission-draft-preview-hint {
|
||||
font-size: 0.85em;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.submission-draft-preview {
|
||||
padding: 1.5rem 2rem;
|
||||
line-height: 1.6;
|
||||
font-family: var(--font-serif, Georgia, serif);
|
||||
min-height: 60vh;
|
||||
}
|
||||
|
||||
.submission-draft-preview p {
|
||||
margin: 0 0 0.75rem 0;
|
||||
}
|
||||
|
||||
.submission-draft-preview .preview-error {
|
||||
color: var(--color-danger, #c00);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.submission-edit-btn {
|
||||
margin-right: 0.4rem;
|
||||
}
|
||||
|
||||
/* t-paliad-243 — global Schriftsätze picker (/submissions/new) +
|
||||
project-less editor banner + assign-project modal styling. */
|
||||
|
||||
.submissions-index-headline {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.submissions-index-no-project {
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.submissions-new-toolbar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
margin: 0 0 1.5rem;
|
||||
}
|
||||
|
||||
.submissions-new-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.submissions-new-chip {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 999px;
|
||||
padding: 0.35rem 0.85rem;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.submissions-new-chip:hover {
|
||||
background: var(--color-surface-alt, #f4f4f4);
|
||||
}
|
||||
|
||||
.submissions-new-chip--active {
|
||||
background: var(--color-accent, #c6f41c);
|
||||
border-color: var(--color-accent, #c6f41c);
|
||||
color: #000;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.submissions-new-chip-code {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.78rem;
|
||||
opacity: 0.7;
|
||||
margin-left: 0.35rem;
|
||||
}
|
||||
|
||||
.submissions-new-project-list {
|
||||
list-style: none;
|
||||
margin: 0.75rem 0 0;
|
||||
padding: 0;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.submissions-new-project-item {
|
||||
padding: 0.6rem 0.85rem;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.submissions-new-project-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.submissions-new-project-item:hover {
|
||||
background: var(--color-surface-alt, #f4f4f4);
|
||||
}
|
||||
|
||||
.submissions-new-project-title {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.submission-draft-noproject-banner {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
padding: 0.75rem 1rem;
|
||||
margin: 0 0 1.25rem;
|
||||
background: var(--color-surface-alt, #f7f7f0);
|
||||
border: 1px solid var(--color-border);
|
||||
border-left: 4px solid var(--color-accent, #c6f41c);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.submission-draft-noproject-banner-msg {
|
||||
margin: 0;
|
||||
color: var(--color-text);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.checklist-instance-actions {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
@@ -6185,12 +6584,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 {
|
||||
@@ -6204,6 +6609,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;
|
||||
@@ -6212,6 +6621,8 @@ dialog.modal::backdrop {
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.entity-select:focus {
|
||||
@@ -6931,6 +7342,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;
|
||||
@@ -7146,6 +7571,126 @@ 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);
|
||||
}
|
||||
|
||||
/* t-paliad-258 — Auto/Custom Rule editor (m/paliad#89).
|
||||
Replaces the t-paliad-251 catalog dropdown + sort selector with a
|
||||
binary toggle:
|
||||
.rule-mode-auto — read-only display, lime-tint pill + label.
|
||||
.rule-mode-custom — free-text input, full-width.
|
||||
Toggle button reuses .btn-link-action for the inline link styling. */
|
||||
.rule-mode-auto {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
padding: 0.35rem 0.55rem;
|
||||
background: var(--color-bg-lime-tint);
|
||||
border-left: 2px solid var(--color-accent);
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
min-height: 2rem;
|
||||
}
|
||||
.rule-auto-text {
|
||||
color: var(--color-text);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.rule-auto-text--empty {
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
font-style: italic;
|
||||
}
|
||||
.form-field input.rule-mode-custom,
|
||||
input.rule-mode-custom {
|
||||
width: 100%;
|
||||
padding: 0.45rem 0.6rem;
|
||||
font-size: 0.95rem;
|
||||
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);
|
||||
}
|
||||
|
||||
/* t-paliad-258 addendum — canonical rule label display:
|
||||
Name primary, Citation muted secondary ("Name · Citation").
|
||||
Custom rules use a "Custom" pill instead of a citation. */
|
||||
.rule-label-name {
|
||||
color: var(--color-text);
|
||||
}
|
||||
.rule-label-sep,
|
||||
.rule-label-cite {
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.rule-label-cite {
|
||||
margin-left: 0.15rem;
|
||||
}
|
||||
.rule-label-badge {
|
||||
display: inline-block;
|
||||
margin-left: 0.4rem;
|
||||
padding: 0.02rem 0.4rem;
|
||||
border-radius: 999px;
|
||||
background: var(--color-bg-lime-tint);
|
||||
color: var(--color-text);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.03em;
|
||||
text-transform: uppercase;
|
||||
border: 1px solid var(--color-accent);
|
||||
}
|
||||
|
||||
/* Inline checkbox label inside the attach-unit form. */
|
||||
.form-checkbox {
|
||||
display: inline-flex;
|
||||
@@ -7253,6 +7798,42 @@ dialog.modal::backdrop {
|
||||
background: #b91c1c;
|
||||
}
|
||||
|
||||
/* t-paliad-252 — withdraw warning modal body. The destructive button sits
|
||||
inside the body (above the footer's Cancel + Edit primary) so the safe
|
||||
"Edit event" path stays visually primary. The intro paragraph leads,
|
||||
the muted sub-line explains consequences, then the red row makes the
|
||||
destructive option discoverable without competing with the CTA. */
|
||||
.withdraw-warning-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.withdraw-warning-intro {
|
||||
margin: 0;
|
||||
color: var(--color-text);
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.withdraw-warning-sub {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.withdraw-warning-destructive-row {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 0.5rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px dashed var(--color-border);
|
||||
}
|
||||
.withdraw-warning-destructive-btn {
|
||||
/* Inherits .btn .btn-danger, but bump the font size down a touch so
|
||||
the body button doesn't crowd the footer's primary CTA. */
|
||||
font-size: 0.82rem;
|
||||
padding: 0.4rem 1rem;
|
||||
}
|
||||
|
||||
.entity-soon {
|
||||
text-align: center;
|
||||
padding: 3rem 1.5rem;
|
||||
@@ -11713,42 +12294,10 @@ dialog.quick-add-sheet::backdrop {
|
||||
t-paliad-088 — Event Types: picker, multi-select filter, add modal
|
||||
============================================================================ */
|
||||
|
||||
/* t-paliad-165 follow-up — collapsed read-only view used on
|
||||
/deadlines/new when a Regel is selected and a default event_type is
|
||||
known. Replaces the picker with a single inline label + an
|
||||
"Anderen Typ wählen" override link. */
|
||||
.event-type-collapsed {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 0.4rem;
|
||||
padding: 0.35rem 0.55rem;
|
||||
background: var(--color-bg-lime-tint);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.3;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.event-type-collapsed-label {
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
.event-type-collapsed-source {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.event-type-collapsed-override {
|
||||
margin-left: auto;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
color: var(--color-link, #1d4ed8);
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.event-type-collapsed-override:hover { color: var(--color-link-hover, #1e40af); }
|
||||
/* (t-paliad-258 — the .event-type-collapsed* "vorgegeben durch Regel"
|
||||
collapsed view from t-paliad-165 was retired with the catalog
|
||||
dropdown. The Auto/Custom rule editor took its place; styles for
|
||||
that live under .rule-mode-auto / .rule-mode-custom above.) */
|
||||
|
||||
/* Picker host — chip cluster + search + suggest dropdown */
|
||||
.event-type-picker {
|
||||
@@ -12143,6 +12692,36 @@ 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. */
|
||||
.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;
|
||||
|
||||
143
frontend/src/submission-draft.tsx
Normal file
143
frontend/src/submission-draft.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
// t-paliad-238 Slice A — dedicated Submissions/Schriftsätze editor page.
|
||||
//
|
||||
// Lawyer picks (or creates) a draft for one (project, submission_code),
|
||||
// edits placeholder variables in a sticky sidebar, sees a read-only
|
||||
// HTML preview of the merged document, and exports the result as
|
||||
// .docx. Drafts persist server-side per paliad.submission_drafts.
|
||||
//
|
||||
// Pure shell: client/submission-draft.ts hydrates draft list + variable
|
||||
// form + preview pane after page load. The same dist/submission-draft.html
|
||||
// serves every (project_id, submission_code, [draft_id]) URL.
|
||||
//
|
||||
// Design ref: docs/design-submission-page-2026-05-22.md §6.
|
||||
|
||||
export function renderSubmissionDraft(): string {
|
||||
return "<!DOCTYPE html>" + (
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#BFF355" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="submissions.draft.title">Schriftsatz bearbeiten — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar page-submission-draft">
|
||||
<Sidebar currentPath="/projects" />
|
||||
<BottomNav currentPath="/projects" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page submission-draft-page">
|
||||
<div className="container">
|
||||
<a
|
||||
id="submission-draft-back-link"
|
||||
href="/projects"
|
||||
className="back-link"
|
||||
data-i18n="submissions.draft.back">
|
||||
← Zurück zum Projekt
|
||||
</a>
|
||||
|
||||
<div id="submission-draft-loading" className="entity-loading">
|
||||
<p data-i18n="submissions.draft.loading">Lädt…</p>
|
||||
</div>
|
||||
|
||||
<div id="submission-draft-notfound" className="entity-empty" style="display:none">
|
||||
<p data-i18n="submissions.draft.notfound">
|
||||
Schriftsatz nicht gefunden oder keine Berechtigung.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div id="submission-draft-error" className="entity-empty" style="display:none" />
|
||||
|
||||
<div id="submission-draft-body" style="display:none">
|
||||
<header className="submission-draft-header">
|
||||
<div className="submission-draft-header-text">
|
||||
<h1 id="submission-draft-title" />
|
||||
<p id="submission-draft-subtitle" className="tool-subtitle" />
|
||||
</div>
|
||||
<div className="submission-draft-header-actions">
|
||||
<button
|
||||
id="submission-draft-export-btn"
|
||||
type="button"
|
||||
className="btn-primary btn-cta-lime"
|
||||
data-i18n="submissions.draft.action.export">
|
||||
Als .docx exportieren
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="submission-draft-grid">
|
||||
{/* Sidebar — draft switcher + variable groups. */}
|
||||
<aside className="submission-draft-sidebar" id="submission-draft-sidebar">
|
||||
<div className="submission-draft-switcher">
|
||||
<label htmlFor="submission-draft-pick" data-i18n="submissions.draft.switcher.label">
|
||||
Entwurf
|
||||
</label>
|
||||
<select id="submission-draft-pick" />
|
||||
<button
|
||||
type="button"
|
||||
id="submission-draft-new-btn"
|
||||
className="btn-small btn-secondary"
|
||||
data-i18n="submissions.draft.action.new">
|
||||
+ Neuer Entwurf
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="submission-draft-name-row">
|
||||
<input
|
||||
type="text"
|
||||
id="submission-draft-name"
|
||||
className="entity-form-input"
|
||||
data-i18n-placeholder="submissions.draft.name.placeholder"
|
||||
placeholder="Name dieses Entwurfs"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
id="submission-draft-delete-btn"
|
||||
className="btn-small btn-link-danger"
|
||||
data-i18n="submissions.draft.action.delete">
|
||||
Löschen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="submission-draft-savestatus" id="submission-draft-savestatus" />
|
||||
|
||||
<div className="submission-draft-variables" id="submission-draft-variables" />
|
||||
</aside>
|
||||
|
||||
{/* Preview pane — read-only HTML render of the merged
|
||||
document body. Re-renders on autosave round-trip. */}
|
||||
<section className="submission-draft-preview-wrap">
|
||||
<header className="submission-draft-preview-header">
|
||||
<h2 data-i18n="submissions.draft.preview.title">Vorschau</h2>
|
||||
<span
|
||||
className="submission-draft-preview-hint"
|
||||
data-i18n="submissions.draft.preview.hint">
|
||||
Read-only Vorschau — finale Bearbeitung in Word.
|
||||
</span>
|
||||
</header>
|
||||
<div className="submission-draft-preview" id="submission-draft-preview" />
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
|
||||
<script src="/assets/submission-draft.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
84
frontend/src/submissions-index.tsx
Normal file
84
frontend/src/submissions-index.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
// t-paliad-240 — global Schriftsätze drafts index. Top-level sidebar
|
||||
// entry that lists every draft the caller owns across visible projects.
|
||||
// Per-project editor stays at /projects/{id}/submissions/{code}/draft —
|
||||
// this page only adds a discovery surface and click-through to it.
|
||||
|
||||
export function renderSubmissionsIndex(): string {
|
||||
return "<!DOCTYPE html>" + (
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#BFF355" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="submissions.index.title">Schriftsätze — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/submissions" />
|
||||
<BottomNav currentPath="/submissions" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
<div className="container">
|
||||
<div className="tool-header">
|
||||
<div className="submissions-index-headline">
|
||||
<div>
|
||||
<h1 data-i18n="submissions.index.heading">Schriftsätze</h1>
|
||||
<p className="tool-subtitle" data-i18n="submissions.index.subtitle">
|
||||
Ihre Schriftsatz-Entwürfe über alle sichtbaren Projekte.
|
||||
</p>
|
||||
</div>
|
||||
<a href="/submissions/new" className="btn-primary btn-cta-lime"
|
||||
data-i18n="submissions.index.action.new">+ Neuer Entwurf</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="entity-events-empty" id="submissions-index-loading"
|
||||
data-i18n="submissions.index.loading">Lädt…</p>
|
||||
|
||||
<div className="entity-empty" id="submissions-index-empty" style="display:none">
|
||||
<p data-i18n="submissions.index.empty">
|
||||
Noch keine Entwürfe. Beginnen Sie mit einem neuen Entwurf — mit oder ohne Projekt.
|
||||
</p>
|
||||
<a href="/submissions/new" className="btn-primary btn-cta-lime"
|
||||
data-i18n="submissions.index.empty.cta">+ Neuer Entwurf</a>
|
||||
</div>
|
||||
|
||||
<div className="entity-empty" id="submissions-index-error" style="display:none">
|
||||
<p data-i18n="submissions.index.error">Schriftsätze konnten nicht geladen werden.</p>
|
||||
</div>
|
||||
|
||||
<div className="entity-table-wrap" id="submissions-index-tablewrap" style="display:none">
|
||||
<table className="entity-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="submissions.index.col.project">Projekt</th>
|
||||
<th data-i18n="submissions.index.col.submission">Schriftsatz</th>
|
||||
<th data-i18n="submissions.index.col.draft">Entwurf</th>
|
||||
<th data-i18n="submissions.index.col.updated">Zuletzt geändert</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="submissions-index-body" />
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/submissions-index.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
118
frontend/src/submissions-new.tsx
Normal file
118
frontend/src/submissions-new.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
// t-paliad-243 — global Schriftsatz picker. Lists the full
|
||||
// cross-proceeding submission catalog (grouped by proceeding,
|
||||
// filterable) and lets the lawyer start a draft with or without
|
||||
// binding a project. Picking "Ohne Projekt" jumps straight to
|
||||
// /submissions/draft/{id}; picking "Mit Projekt verknüpfen" opens an
|
||||
// autocomplete project picker, then redirects to the project-scoped
|
||||
// editor.
|
||||
|
||||
export function renderSubmissionsNew(): string {
|
||||
return "<!DOCTYPE html>" + (
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#BFF355" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="submissions.new.title">Neuer Schriftsatz — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/submissions" />
|
||||
<BottomNav currentPath="/submissions" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page submissions-new-page">
|
||||
<div className="container">
|
||||
<a href="/submissions" className="back-link"
|
||||
data-i18n="submissions.new.back">← Zurück zur Übersicht</a>
|
||||
|
||||
<div className="tool-header">
|
||||
<h1 data-i18n="submissions.new.heading">Neuer Schriftsatz</h1>
|
||||
<p className="tool-subtitle" data-i18n="submissions.new.subtitle">
|
||||
Wählen Sie eine Vorlage. Optional verknüpfen Sie den
|
||||
Entwurf mit einem Projekt — sonst füllen Sie alle
|
||||
Variablen manuell.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="submissions-new-toolbar">
|
||||
<input
|
||||
type="search"
|
||||
id="submissions-new-search"
|
||||
className="entity-form-input"
|
||||
data-i18n-placeholder="submissions.new.search.placeholder"
|
||||
placeholder="Suche nach Schriftsatz, Code oder Norm…" />
|
||||
<div id="submissions-new-proceeding-chips" className="submissions-new-chips" />
|
||||
</div>
|
||||
|
||||
<p className="entity-events-empty" id="submissions-new-loading"
|
||||
data-i18n="submissions.new.loading">Lädt…</p>
|
||||
|
||||
<div className="entity-empty" id="submissions-new-error" style="display:none">
|
||||
<p data-i18n="submissions.new.error">Katalog konnte nicht geladen werden.</p>
|
||||
</div>
|
||||
|
||||
<div className="entity-table-wrap" id="submissions-new-tablewrap" style="display:none">
|
||||
<table className="entity-table entity-table--readonly">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="submissions.new.col.name">Schriftsatz</th>
|
||||
<th data-i18n="submissions.new.col.party">Partei</th>
|
||||
<th data-i18n="submissions.new.col.source">Rechtsgrundlage</th>
|
||||
<th data-i18n="submissions.new.col.actions">Entwurf starten</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="submissions-new-body" />
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p className="entity-empty" id="submissions-new-empty" style="display:none">
|
||||
<span data-i18n="submissions.new.empty.filtered">
|
||||
Keine passenden Schriftsätze. Filter zurücksetzen.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Project picker modal — opened by "Mit Projekt verknüpfen". */}
|
||||
<div id="submissions-new-project-modal" className="modal-overlay" style="display:none" role="dialog" aria-modal="true">
|
||||
<div className="modal-card">
|
||||
<header className="modal-header">
|
||||
<h2 data-i18n="submissions.new.picker.title">Projekt wählen</h2>
|
||||
<button type="button" id="submissions-new-project-modal-close"
|
||||
className="modal-close" aria-label="Close">×</button>
|
||||
</header>
|
||||
<div className="modal-body">
|
||||
<input
|
||||
type="search"
|
||||
id="submissions-new-project-search"
|
||||
className="entity-form-input"
|
||||
data-i18n-placeholder="submissions.new.picker.placeholder"
|
||||
placeholder="Projekt suchen (Titel oder Aktenzeichen)…" />
|
||||
<ul id="submissions-new-project-list" className="submissions-new-project-list" />
|
||||
<p id="submissions-new-project-loading" className="entity-events-empty" style="display:none"
|
||||
data-i18n="submissions.new.picker.loading">Lädt Projekte…</p>
|
||||
<p id="submissions-new-project-empty" className="entity-empty" style="display:none"
|
||||
data-i18n="submissions.new.picker.empty">Keine sichtbaren Projekte.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/submissions-new.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
7
internal/db/migrations/119_submission_drafts.down.sql
Normal file
7
internal/db/migrations/119_submission_drafts.down.sql
Normal file
@@ -0,0 +1,7 @@
|
||||
-- t-paliad-238: revert submission_drafts table.
|
||||
--
|
||||
-- The shared paliad.tg_set_updated_at trigger function is intentionally
|
||||
-- left in place — it may be in use by other tables. DROP TABLE cascades
|
||||
-- to the trigger that references it on this table only.
|
||||
|
||||
DROP TABLE IF EXISTS paliad.submission_drafts;
|
||||
88
internal/db/migrations/119_submission_drafts.up.sql
Normal file
88
internal/db/migrations/119_submission_drafts.up.sql
Normal file
@@ -0,0 +1,88 @@
|
||||
-- t-paliad-238: dedicated Submissions/Schriftsätze page.
|
||||
--
|
||||
-- paliad.submission_drafts holds the lawyer's per-(project, submission_code)
|
||||
-- draft state for the new editor at /projects/{id}/submissions/{code}/draft.
|
||||
-- Each row is one named draft owned by one user; multiple drafts per
|
||||
-- (project, submission_code, user_id) are supported via the `name` field
|
||||
-- (auto-generated "Entwurf 1", "Entwurf 2", … and lawyer-renameable).
|
||||
--
|
||||
-- `variables` carries the lawyer's overrides for the placeholder map
|
||||
-- assembled at export time by SubmissionVarsService — empty string forces
|
||||
-- the [KEIN WERT: …] marker; absent key falls back to the resolved bag.
|
||||
--
|
||||
-- `last_exported_at` / `last_exported_sha` record provenance of the most
|
||||
-- recent .docx export (template SHA pinned for audit). Audit rows live
|
||||
-- in paliad.system_audit_log + paliad.project_events; this table is the
|
||||
-- lawyer's working state, not the audit log.
|
||||
--
|
||||
-- Visibility: per project CLAUDE.md, every project-scoped resource gates
|
||||
-- on paliad.can_see_project. UPDATE / DELETE additionally require the
|
||||
-- draft's user_id to match auth.uid() so two co-team-members don't stomp
|
||||
-- on each other's drafts (head-confirmed Q-E4 owner-scoped pick).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS paliad.submission_drafts (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
project_id uuid NOT NULL REFERENCES paliad.projects(id) ON DELETE CASCADE,
|
||||
submission_code text NOT NULL,
|
||||
user_id uuid NOT NULL REFERENCES paliad.users(id) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
|
||||
variables jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
|
||||
last_exported_at timestamptz,
|
||||
last_exported_sha text,
|
||||
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
CONSTRAINT submission_drafts_unique_per_user
|
||||
UNIQUE (project_id, submission_code, user_id, name)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS submission_drafts_project_user_idx
|
||||
ON paliad.submission_drafts (project_id, user_id, submission_code, updated_at DESC);
|
||||
|
||||
ALTER TABLE paliad.submission_drafts ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS submission_drafts_select ON paliad.submission_drafts;
|
||||
CREATE POLICY submission_drafts_select
|
||||
ON paliad.submission_drafts FOR SELECT TO authenticated
|
||||
USING (paliad.can_see_project(project_id));
|
||||
|
||||
DROP POLICY IF EXISTS submission_drafts_insert ON paliad.submission_drafts;
|
||||
CREATE POLICY submission_drafts_insert
|
||||
ON paliad.submission_drafts FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
user_id = auth.uid()
|
||||
AND paliad.can_see_project(project_id)
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS submission_drafts_update ON paliad.submission_drafts;
|
||||
CREATE POLICY submission_drafts_update
|
||||
ON paliad.submission_drafts FOR UPDATE TO authenticated
|
||||
USING (user_id = auth.uid() AND paliad.can_see_project(project_id))
|
||||
WITH CHECK (user_id = auth.uid() AND paliad.can_see_project(project_id));
|
||||
|
||||
DROP POLICY IF EXISTS submission_drafts_delete ON paliad.submission_drafts;
|
||||
CREATE POLICY submission_drafts_delete
|
||||
ON paliad.submission_drafts FOR DELETE TO authenticated
|
||||
USING (user_id = auth.uid() AND paliad.can_see_project(project_id));
|
||||
|
||||
-- updated_at maintenance: trigger function lives once per schema; reuse if
|
||||
-- it already exists, otherwise create the standard shape.
|
||||
CREATE OR REPLACE FUNCTION paliad.tg_set_updated_at()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = now();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS submission_drafts_set_updated_at ON paliad.submission_drafts;
|
||||
CREATE TRIGGER submission_drafts_set_updated_at
|
||||
BEFORE UPDATE ON paliad.submission_drafts
|
||||
FOR EACH ROW EXECUTE FUNCTION paliad.tg_set_updated_at();
|
||||
|
||||
COMMENT ON TABLE paliad.submission_drafts IS
|
||||
't-paliad-238: per-(project, submission_code, user) named drafts for the dedicated Submissions/Schriftsätze page. Each row holds the lawyer-edited variable overrides for the .docx export. Audit rows live in paliad.system_audit_log + paliad.project_events.';
|
||||
@@ -0,0 +1,46 @@
|
||||
-- t-paliad-243 revert: restore NOT NULL on project_id.
|
||||
--
|
||||
-- The revert refuses to run if any project-less draft exists — those
|
||||
-- rows would silently fail the NOT NULL re-imposition and corrupt the
|
||||
-- migration runner's state. The safe revert path is to surface the
|
||||
-- conflict to the operator who can decide whether to attach the rows
|
||||
-- to a project or delete them before retrying the down.
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM paliad.submission_drafts WHERE project_id IS NULL
|
||||
) THEN
|
||||
RAISE EXCEPTION
|
||||
'cannot re-impose NOT NULL on paliad.submission_drafts.project_id: '
|
||||
'project-less drafts exist. Attach them to a project or delete '
|
||||
'them, then re-run the down migration.';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
ALTER TABLE paliad.submission_drafts
|
||||
ALTER COLUMN project_id SET NOT NULL;
|
||||
|
||||
DROP POLICY IF EXISTS submission_drafts_select ON paliad.submission_drafts;
|
||||
CREATE POLICY submission_drafts_select
|
||||
ON paliad.submission_drafts FOR SELECT TO authenticated
|
||||
USING (paliad.can_see_project(project_id));
|
||||
|
||||
DROP POLICY IF EXISTS submission_drafts_insert ON paliad.submission_drafts;
|
||||
CREATE POLICY submission_drafts_insert
|
||||
ON paliad.submission_drafts FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
user_id = auth.uid()
|
||||
AND paliad.can_see_project(project_id)
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS submission_drafts_update ON paliad.submission_drafts;
|
||||
CREATE POLICY submission_drafts_update
|
||||
ON paliad.submission_drafts FOR UPDATE TO authenticated
|
||||
USING (user_id = auth.uid() AND paliad.can_see_project(project_id))
|
||||
WITH CHECK (user_id = auth.uid() AND paliad.can_see_project(project_id));
|
||||
|
||||
DROP POLICY IF EXISTS submission_drafts_delete ON paliad.submission_drafts;
|
||||
CREATE POLICY submission_drafts_delete
|
||||
ON paliad.submission_drafts FOR DELETE TO authenticated
|
||||
USING (user_id = auth.uid() AND paliad.can_see_project(project_id));
|
||||
@@ -0,0 +1,70 @@
|
||||
-- t-paliad-243: drafts may exist without a project attached.
|
||||
--
|
||||
-- The global /submissions/new picker lets a lawyer start a Schriftsatz
|
||||
-- draft straight from the top-level Schriftsätze sidebar, with or
|
||||
-- without binding it to a project. project_id therefore becomes
|
||||
-- optional. Existing rows are unaffected; new rows may insert NULL.
|
||||
--
|
||||
-- RLS rewrite: every policy splits on (project_id IS NULL):
|
||||
--
|
||||
-- project_id IS NOT NULL → gate on paliad.can_see_project (existing
|
||||
-- inheritance-aware visibility).
|
||||
-- project_id IS NULL → owner-only (user_id = auth.uid()). A
|
||||
-- project-less draft is a personal scratch
|
||||
-- space — never shared, never visible to
|
||||
-- other team members.
|
||||
--
|
||||
-- INSERT enforces the same shape via WITH CHECK: a project-less insert
|
||||
-- only writes user_id = auth.uid(); a project-scoped insert additionally
|
||||
-- requires can_see_project.
|
||||
|
||||
ALTER TABLE paliad.submission_drafts
|
||||
ALTER COLUMN project_id DROP NOT NULL;
|
||||
|
||||
DROP POLICY IF EXISTS submission_drafts_select ON paliad.submission_drafts;
|
||||
CREATE POLICY submission_drafts_select
|
||||
ON paliad.submission_drafts FOR SELECT TO authenticated
|
||||
USING (
|
||||
(project_id IS NULL AND user_id = auth.uid())
|
||||
OR (project_id IS NOT NULL AND paliad.can_see_project(project_id))
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS submission_drafts_insert ON paliad.submission_drafts;
|
||||
CREATE POLICY submission_drafts_insert
|
||||
ON paliad.submission_drafts FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
user_id = auth.uid()
|
||||
AND (
|
||||
project_id IS NULL
|
||||
OR paliad.can_see_project(project_id)
|
||||
)
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS submission_drafts_update ON paliad.submission_drafts;
|
||||
CREATE POLICY submission_drafts_update
|
||||
ON paliad.submission_drafts FOR UPDATE TO authenticated
|
||||
USING (
|
||||
user_id = auth.uid()
|
||||
AND (
|
||||
project_id IS NULL
|
||||
OR paliad.can_see_project(project_id)
|
||||
)
|
||||
)
|
||||
WITH CHECK (
|
||||
user_id = auth.uid()
|
||||
AND (
|
||||
project_id IS NULL
|
||||
OR paliad.can_see_project(project_id)
|
||||
)
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS submission_drafts_delete ON paliad.submission_drafts;
|
||||
CREATE POLICY submission_drafts_delete
|
||||
ON paliad.submission_drafts FOR DELETE TO authenticated
|
||||
USING (
|
||||
user_id = auth.uid()
|
||||
AND (
|
||||
project_id IS NULL
|
||||
OR paliad.can_see_project(project_id)
|
||||
)
|
||||
);
|
||||
@@ -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';
|
||||
@@ -0,0 +1,6 @@
|
||||
-- t-paliad-258: revert the additive custom_rule_text column.
|
||||
-- Drop the column; rows that used the Custom path lose their free-text
|
||||
-- label and read as "no rule".
|
||||
|
||||
ALTER TABLE paliad.deadlines
|
||||
DROP COLUMN IF EXISTS custom_rule_text;
|
||||
26
internal/db/migrations/122_deadlines_custom_rule_text.up.sql
Normal file
26
internal/db/migrations/122_deadlines_custom_rule_text.up.sql
Normal file
@@ -0,0 +1,26 @@
|
||||
-- t-paliad-258 / m/paliad#89 — binary Auto/Custom Rule model on the
|
||||
-- deadline form.
|
||||
--
|
||||
-- t-paliad-251 shipped the form with a full deadline_rules catalog
|
||||
-- dropdown. m's verdict: too noisy (4 "Oral hearings" across UPC CFI,
|
||||
-- UPC CoA, DPMA, EPO etc.). Replace with a binary model:
|
||||
--
|
||||
-- 1. Auto — rule_id derived from the chosen event_type, displayed
|
||||
-- read-only.
|
||||
-- 2. Custom — rule_id is NULL and the lawyer's free-text label is
|
||||
-- stored here.
|
||||
--
|
||||
-- The column is additive + nullable: existing rows keep their
|
||||
-- deadline_rule_id and read as Auto-equivalent. A future row with both
|
||||
-- columns NULL renders as "keine Regel" (matches today's no-rule state).
|
||||
|
||||
ALTER TABLE paliad.deadlines
|
||||
ADD COLUMN IF NOT EXISTS custom_rule_text text;
|
||||
|
||||
COMMENT ON COLUMN paliad.deadlines.custom_rule_text IS
|
||||
'Free-text rule label entered when the lawyer chose Custom on the '
|
||||
'deadline form (t-paliad-258). Mutually exclusive with rule_id at '
|
||||
'the application layer: Auto path sets rule_id and leaves this '
|
||||
'NULL; Custom path sets this and leaves rule_id NULL. Display '
|
||||
'surfaces prefer the rule_id-joined deadline_rules.name when '
|
||||
'present, else fall back to custom_rule_text + a "Custom" badge.';
|
||||
11
internal/db/migrations/123_backups.down.sql
Normal file
11
internal/db/migrations/123_backups.down.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
-- t-paliad-246 / m/paliad#77 — revert Backup Mode catalog table.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 123 down: drop paliad.backups catalog (t-paliad-246 / m/paliad#77 Slice A)',
|
||||
true);
|
||||
|
||||
DROP POLICY IF EXISTS backups_select_admin ON paliad.backups;
|
||||
DROP INDEX IF EXISTS paliad.backups_kind_status_idx;
|
||||
DROP INDEX IF EXISTS paliad.backups_started_at_desc_idx;
|
||||
DROP TABLE IF EXISTS paliad.backups;
|
||||
86
internal/db/migrations/123_backups.up.sql
Normal file
86
internal/db/migrations/123_backups.up.sql
Normal file
@@ -0,0 +1,86 @@
|
||||
-- t-paliad-246 / m/paliad#77 — Backup Mode catalog table.
|
||||
--
|
||||
-- Design: docs/design-backup-mode-2026-05-25.md §4. One row per backup
|
||||
-- run (on-demand or scheduled). The catalog is operational metadata for
|
||||
-- the /admin/backups UI (size, row counts, storage URI, status). The
|
||||
-- audit chain stays on paliad.system_audit_log — this table is the
|
||||
-- richer-shape duplicate that the UI lists from without parsing JSON.
|
||||
--
|
||||
-- INSERT/UPDATE happen only through the Go service path (BackupRunner)
|
||||
-- under the migration-runner role, so we don't add a write RLS policy
|
||||
-- for end users. SELECT is admin-only, mirroring system_audit_log.
|
||||
--
|
||||
-- Idempotent: CREATE TABLE / INDEX / POLICY all guarded.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 123: add paliad.backups catalog for Backup Mode (t-paliad-246 / m/paliad#77 Slice A)',
|
||||
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 is NULL for kind='scheduled' (no human caller).
|
||||
requested_by uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
|
||||
-- requested_by_email is captured at write time so the row survives
|
||||
-- a subsequent user deletion. For scheduled runs we write a sentinel
|
||||
-- like 'system@paliad' (no real user attached).
|
||||
requested_by_email text NOT NULL,
|
||||
-- audit_id back-references the system_audit_log row written before
|
||||
-- the artifact is generated. Nullable so a catalog row can still be
|
||||
-- INSERTed if the audit write itself fails (defense-in-depth).
|
||||
audit_id uuid REFERENCES paliad.system_audit_log(id) ON DELETE SET NULL,
|
||||
-- storage_uri is populated when status flips to 'done'. Resolves
|
||||
-- through the Go-side ArtifactStore interface ('file://...' for
|
||||
-- LocalDiskStore today; future stores get their own URI scheme).
|
||||
storage_uri text,
|
||||
size_bytes bigint,
|
||||
row_counts jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
sheet_count int,
|
||||
warnings jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||
-- error is NULL unless status='failed'. Free-form, captured from
|
||||
-- the Go-side error.Error().
|
||||
error text,
|
||||
started_at timestamptz NOT NULL DEFAULT now(),
|
||||
finished_at timestamptz,
|
||||
-- deleted_at marks artifacts the lifecycle cleanup removed from
|
||||
-- storage (Slice B). The catalog row itself stays forever — it's
|
||||
-- part of the audit chain. NULL means "still on disk".
|
||||
deleted_at timestamptz
|
||||
);
|
||||
|
||||
-- Read patterns:
|
||||
-- - "show me recent backups" — started_at DESC
|
||||
-- - "find last successful scheduled backup today" — kind + status + started_at
|
||||
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. INSERT/UPDATE/DELETE happen via the Go service path
|
||||
-- under the migration-runner role (no end-user write surface).
|
||||
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'
|
||||
)
|
||||
);
|
||||
|
||||
COMMENT ON TABLE paliad.backups IS
|
||||
'Catalog of org-scope backup runs (t-paliad-246 / m/paliad#77). One row per scheduled or on-demand backup. status transitions: running → done | failed. storage_uri is resolved by the Go-side ArtifactStore interface. audit_id links to system_audit_log; the catalog row is the richer-shape duplicate, the audit row is the trust signal.';
|
||||
|
||||
COMMENT ON COLUMN paliad.backups.requested_by_email IS
|
||||
'Captured at write time so the row survives user deletion. Sentinel ''system@paliad'' for scheduled runs.';
|
||||
|
||||
COMMENT ON COLUMN paliad.backups.storage_uri IS
|
||||
'Resolved by the Go-side ArtifactStore implementation. file://... for LocalDiskStore; future stores use their own URI scheme.';
|
||||
|
||||
COMMENT ON COLUMN paliad.backups.deleted_at IS
|
||||
'Set when the artifact is removed from storage by lifecycle cleanup. Catalog row stays forever (audit chain). NULL means artifact is still on disk.';
|
||||
@@ -326,6 +326,56 @@ func handleRevokeApprovalRequest(w http.ResponseWriter, r *http.Request) {
|
||||
handleApprovalDecision(w, r, "revoke")
|
||||
}
|
||||
|
||||
// POST /api/approval-requests/{id}/edit-entity — t-paliad-252 / m/paliad#83.
|
||||
//
|
||||
// Lets the requester revise the in-flight entity (e.g. tweak the title on a
|
||||
// pending create) without withdrawing the request. The non-destructive
|
||||
// sibling of /revoke that m asked for after noticing that withdraw silently
|
||||
// deletes the underlying event.
|
||||
//
|
||||
// Body: {"fields": {<entity-shape>}}
|
||||
// 200: {"status": "ok"}
|
||||
//
|
||||
// Status mapping (mapApprovalError):
|
||||
//
|
||||
// 400 suggestion_requires_change — payload has no allowlisted fields
|
||||
// 403 not_authorized — caller isn't the requested_by
|
||||
// 404 — request not found / not visible
|
||||
// 409 request_not_pending — request already decided / revoked
|
||||
type editPendingEntityBody struct {
|
||||
Fields map[string]any `json:"fields"`
|
||||
}
|
||||
|
||||
func handleEditPendingEntity(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
requestID, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request id"})
|
||||
return
|
||||
}
|
||||
var body editPendingEntityBody
|
||||
if r.Body != nil && r.ContentLength > 0 {
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{
|
||||
"code": "invalid_body",
|
||||
"message": "Ungültiger Body.",
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := dbSvc.approval.EditPendingEntity(r.Context(), requestID, uid, body.Fields); err != nil {
|
||||
writeApprovalError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
// suggestChangesBody is the JSON body for POST /api/approval-requests/{id}/suggest-changes.
|
||||
// counter_payload is an entity-shaped jsonb of the approver's edited
|
||||
// values (allowlist enforced server-side); note is the optional free-text
|
||||
|
||||
247
internal/handlers/backups.go
Normal file
247
internal/handlers/backups.go
Normal file
@@ -0,0 +1,247 @@
|
||||
package handlers
|
||||
|
||||
// Admin Backup Mode handlers (t-paliad-246 / m/paliad#77 Slice A).
|
||||
//
|
||||
// POST /api/admin/backups/run — kick off an on-demand backup
|
||||
// GET /api/admin/backups — chronological list
|
||||
// GET /api/admin/backups/{id} — single catalog row
|
||||
// GET /api/admin/backups/{id}/file — stream the artifact (records
|
||||
// a backup_downloaded audit row)
|
||||
// GET /admin/backups — admin page (SPA shell)
|
||||
//
|
||||
// Authorisation: every route registers behind adminGate(users, …) in
|
||||
// handlers.go, so every handler in this file can assume the caller is a
|
||||
// global_admin and only validate the request shape.
|
||||
//
|
||||
// The runner is wired in cmd/server/main.go only when PALIAD_EXPORT_DIR
|
||||
// is set. When unset, every handler returns 503 — same shape as
|
||||
// requireDB.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
)
|
||||
|
||||
// backupRequestTimeout caps a single on-demand backup. At firm-scale
|
||||
// data shapes (today: ~600 user-content rows + ~1000 reference rows)
|
||||
// a backup runs sub-second; the watchdog surfaces "stuck" as a 500
|
||||
// instead of letting the client hang forever.
|
||||
const backupRequestTimeout = 5 * time.Minute
|
||||
|
||||
// requireBackup writes a 503 if the BackupRunner is not wired (typically
|
||||
// PALIAD_EXPORT_DIR is unset) and returns false. Mirrors requireDB.
|
||||
func requireBackup(w http.ResponseWriter) bool {
|
||||
if dbSvc == nil || dbSvc.backup == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
|
||||
"error": "backup service not configured — set PALIAD_EXPORT_DIR on the server",
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// handleAdminBackupsPage renders the /admin/backups SPA shell. The
|
||||
// catalog rows are fetched client-side via /api/admin/backups.
|
||||
func handleAdminBackupsPage(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFile(w, r, "dist/admin-backups.html")
|
||||
}
|
||||
|
||||
// handleAdminRunBackup kicks off a synchronous on-demand backup and
|
||||
// returns the resulting BackupSummary as JSON. Synchronous: at firm-
|
||||
// scale the whole run is under 5s; an async path with polling is Slice
|
||||
// B (the scheduler reuses the same runner internally).
|
||||
//
|
||||
// Returns 201 on success with the catalog row, 500 on failure (the
|
||||
// catalog/audit rows are still flipped to failed/backup_failed before
|
||||
// the response).
|
||||
func handleAdminRunBackup(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) || !requireBackup(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), backupRequestTimeout)
|
||||
defer cancel()
|
||||
|
||||
user, err := dbSvc.users.GetByID(ctx, uid)
|
||||
if err != nil || user == nil {
|
||||
log.Printf("backup: user lookup failed for %s: %v", uid, err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{
|
||||
"error": "user lookup failed",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
actor := services.BackupActor{
|
||||
ID: &uid,
|
||||
Email: user.Email,
|
||||
Label: user.DisplayName,
|
||||
}
|
||||
result, err := dbSvc.backup.Run(ctx, services.BackupKindOnDemand, actor)
|
||||
if err != nil {
|
||||
log.Printf("backup: Run failed for admin=%s: %v", uid, err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{
|
||||
"error": "backup generation failed: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Return the freshly-written catalog row so the UI doesn't need a
|
||||
// follow-up GET to render the new line item.
|
||||
row, err := dbSvc.backup.GetBackup(ctx, result.ID)
|
||||
if err != nil {
|
||||
// The backup did succeed — log + return the bare result.
|
||||
log.Printf("backup: post-run GetBackup failed for %s: %v", result.ID, err)
|
||||
writeJSON(w, http.StatusCreated, result)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, row)
|
||||
}
|
||||
|
||||
// handleAdminListBackups returns the most recent N catalog rows as
|
||||
// JSON. ?limit=N caps the page (default 100).
|
||||
func handleAdminListBackups(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) || !requireBackup(w) {
|
||||
return
|
||||
}
|
||||
limit := 100
|
||||
if q := strings.TrimSpace(r.URL.Query().Get("limit")); q != "" {
|
||||
if n, err := strconv.Atoi(q); err == nil && n > 0 && n <= 500 {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
rows, err := dbSvc.backup.ListBackups(r.Context(), limit)
|
||||
if err != nil {
|
||||
log.Printf("backup: list failed: %v", err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{
|
||||
"error": "list failed",
|
||||
})
|
||||
return
|
||||
}
|
||||
if rows == nil {
|
||||
rows = []services.BackupSummary{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, rows)
|
||||
}
|
||||
|
||||
// handleAdminGetBackup returns one catalog row. Used by the UI for
|
||||
// "is the backup I just kicked off done yet?" polling — though at the
|
||||
// synchronous shape today this rarely matters.
|
||||
func handleAdminGetBackup(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) || !requireBackup(w) {
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
row, err := dbSvc.backup.GetBackup(r.Context(), id)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
|
||||
return
|
||||
}
|
||||
log.Printf("backup: get failed for %s: %v", id, err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "get failed"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, row)
|
||||
}
|
||||
|
||||
// handleAdminDownloadBackup streams the artifact bytes through the
|
||||
// ArtifactStore (LocalDiskStore for v1). Records a backup_downloaded
|
||||
// audit row before flushing.
|
||||
//
|
||||
// 404 if the catalog row is missing; 410 (Gone) if the artifact was
|
||||
// already lifecycle-deleted; 409 if status is not 'done'; 500 on any
|
||||
// store/IO error.
|
||||
func handleAdminDownloadBackup(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) || !requireBackup(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
|
||||
row, err := dbSvc.backup.GetBackup(r.Context(), id)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
|
||||
return
|
||||
}
|
||||
log.Printf("backup: download GetBackup failed for %s: %v", id, err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "get failed"})
|
||||
return
|
||||
}
|
||||
if row.Status != services.BackupStatusDone || row.StorageURI == nil {
|
||||
writeJSON(w, http.StatusConflict, map[string]string{
|
||||
"error": "backup not available for download",
|
||||
"status": row.Status,
|
||||
})
|
||||
return
|
||||
}
|
||||
if row.DeletedAt != nil {
|
||||
// 410 Gone — the artifact is past its retention window. Catalog
|
||||
// row stays as the audit trail; clients should not retry.
|
||||
writeJSON(w, http.StatusGone, map[string]string{
|
||||
"error": "artifact has been removed (retention)",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
rc, size, err := dbSvc.backup.Store().Get(r.Context(), *row.StorageURI)
|
||||
if err != nil {
|
||||
log.Printf("backup: download store.Get failed for %s: %v", id, err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "store read failed"})
|
||||
return
|
||||
}
|
||||
defer rc.Close()
|
||||
|
||||
// Record the download audit row before flushing. If the audit
|
||||
// write fails we still serve the file (the user can see it; the
|
||||
// chain just missed a row — surface in logs).
|
||||
user, uErr := dbSvc.users.GetByID(r.Context(), uid)
|
||||
if uErr == nil && user != nil {
|
||||
auditErr := dbSvc.backup.RecordDownload(r.Context(), id, services.BackupActor{
|
||||
ID: &uid,
|
||||
Email: user.Email,
|
||||
Label: user.DisplayName,
|
||||
})
|
||||
if auditErr != nil {
|
||||
log.Printf("backup: RecordDownload failed for %s by %s: %v", id, uid, auditErr)
|
||||
}
|
||||
} else if uErr != nil {
|
||||
log.Printf("backup: user lookup for audit failed (%s): %v", uid, uErr)
|
||||
}
|
||||
|
||||
filename := fmt.Sprintf("paliad-backup-%s.zip", row.StartedAt.UTC().Format("20060102T1504Z"))
|
||||
w.Header().Set("Content-Type", "application/zip")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename=%q`, filename))
|
||||
w.Header().Set("Content-Length", strconv.FormatInt(size, 10))
|
||||
w.Header().Set("X-Paliad-Backup-Id", id.String())
|
||||
if _, err := io.Copy(w, rc); err != nil {
|
||||
log.Printf("backup: response write failed for %s: %v", id, err)
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,11 @@ type fileEntry struct {
|
||||
//
|
||||
// The URL slug ("hl-patents-style.dotm") is preserved as a stable public
|
||||
// identifier so existing bookmarks keep working post-rebrand.
|
||||
//
|
||||
// Per-submission templates (slug `submission/<code>.docx`) are server-only:
|
||||
// only the submission-draft editor reaches them via fetchSubmissionTemplateBytes.
|
||||
// handleFileDownload serves any slug that lands here, but the public URL
|
||||
// surface for submission templates is the export endpoint, not /files.
|
||||
var fileRegistry = map[string]fileEntry{
|
||||
"hl-patents-style.dotm": {
|
||||
RawURL: "https://mgit.msbls.de/m/mWorkRepo/raw/branch/main/6%20-%20material/Templates/Word/HL%20Patents%20Style.dotm",
|
||||
@@ -46,6 +51,92 @@ var fileRegistry = map[string]fileEntry{
|
||||
RepoName: "mWorkRepo",
|
||||
FilePath: "6 - material/Templates/Word/HL Patents Style.dotm",
|
||||
},
|
||||
// Per-submission demo template (t-paliad-241). Exercises every
|
||||
// placeholder SubmissionVarsService resolves so the
|
||||
// /projects/{id}/submissions/{code}/draft editor has variables to
|
||||
// substitute. One file per submission_code; future codes register
|
||||
// the same way — slug shape "submission/<code>.docx" so the
|
||||
// namespace stays separate from the universal style template.
|
||||
"submission/de.inf.lg.erwidg.docx": {
|
||||
RawURL: "https://mgit.msbls.de/m/mWorkRepo/raw/branch/main/6%20-%20material/Templates/Word/Paliad/" + branding.Name + "/de.inf.lg.erwidg.docx",
|
||||
DownloadName: "Klageerwiderung — " + branding.Name + ".docx",
|
||||
ContentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
RepoOwner: "m",
|
||||
RepoName: "mWorkRepo",
|
||||
FilePath: "6 - material/Templates/Word/Paliad/" + branding.Name + "/de.inf.lg.erwidg.docx",
|
||||
},
|
||||
// Universal skeleton (t-paliad-259). Code-agnostic Schriftsatz starter
|
||||
// that carries every placeholder SubmissionVarsService resolves but no
|
||||
// submission_code-specific body structure. Slot between the per-firm
|
||||
// per-code template and the bare HL Patents Style .dotm fallback: every
|
||||
// submission_code without a dedicated template still renders with
|
||||
// variables substituted instead of the macro-only letterhead.
|
||||
skeletonSubmissionSlug: {
|
||||
RawURL: "https://mgit.msbls.de/m/mWorkRepo/raw/branch/main/6%20-%20material/Templates/Word/Paliad/" + branding.Name + "/_skeleton.docx",
|
||||
DownloadName: branding.Name + " — Schriftsatz-Skelett.docx",
|
||||
ContentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
RepoOwner: "m",
|
||||
RepoName: "mWorkRepo",
|
||||
FilePath: "6 - material/Templates/Word/Paliad/" + branding.Name + "/_skeleton.docx",
|
||||
},
|
||||
}
|
||||
|
||||
// skeletonSubmissionSlug names the universal skeleton template inside
|
||||
// the shared fileRegistry cache. Exported via a const so handler code
|
||||
// (resolveSubmissionTemplate, hlPatentsStyleSHA's sibling) refers to
|
||||
// the same string the registry uses.
|
||||
const skeletonSubmissionSlug = "submission/_skeleton.docx"
|
||||
|
||||
// submissionTemplateRegistry maps a deadline-rule submission_code to a
|
||||
// fileRegistry slug. Lookup order matches the cronus design fallback
|
||||
// chain §8: per-firm `templates/{FIRM_NAME}/{code}.docx` first, then
|
||||
// universal HL Patents Style as the global fallback.
|
||||
//
|
||||
// Add new entries here as the firm authors per-submission templates;
|
||||
// the file itself lives in mWorkRepo and is served through the shared
|
||||
// Gitea proxy cache so refreshes are visible to all consumers in one
|
||||
// place.
|
||||
var submissionTemplateRegistry = map[string]string{
|
||||
"de.inf.lg.erwidg": "submission/de.inf.lg.erwidg.docx",
|
||||
}
|
||||
|
||||
// fetchSubmissionTemplateBytes returns the per-submission_code template
|
||||
// bytes (and provenance SHA) when one is registered. The bool result
|
||||
// distinguishes "no per-code template registered" (callers fall back to
|
||||
// HL Patents Style) from an upstream fetch error.
|
||||
func fetchSubmissionTemplateBytes(ctx context.Context, submissionCode string) ([]byte, string, bool, error) {
|
||||
slug, ok := submissionTemplateRegistry[submissionCode]
|
||||
if !ok {
|
||||
return nil, "", false, nil
|
||||
}
|
||||
entry, ok := fileRegistry[slug]
|
||||
if !ok {
|
||||
return nil, "", false, fmt.Errorf("file proxy: submission template slug %q not registered", slug)
|
||||
}
|
||||
ce := getCacheEntry(slug)
|
||||
|
||||
ce.mu.RLock()
|
||||
hasData := len(ce.data) > 0
|
||||
needsCheck := time.Since(ce.lastChecked) >= checkInterval
|
||||
ce.mu.RUnlock()
|
||||
|
||||
if !hasData {
|
||||
if err := fileFetch(ce, entry); err != nil {
|
||||
return nil, "", false, err
|
||||
}
|
||||
} else if needsCheck {
|
||||
go fileCheckAndRefresh(ce, entry)
|
||||
}
|
||||
|
||||
ce.mu.RLock()
|
||||
defer ce.mu.RUnlock()
|
||||
if len(ce.data) == 0 {
|
||||
return nil, "", false, fmt.Errorf("file proxy: %s cache empty after fetch", slug)
|
||||
}
|
||||
out := make([]byte, len(ce.data))
|
||||
copy(out, ce.data)
|
||||
_ = ctx
|
||||
return out, ce.sha, true, nil
|
||||
}
|
||||
|
||||
type cacheEntry struct {
|
||||
@@ -118,6 +209,46 @@ func handleFileRefresh(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"ok": "true", "message": "Cache cleared"})
|
||||
}
|
||||
|
||||
// fetchSubmissionSkeletonBytes returns the cached universal skeleton
|
||||
// template bytes plus its provenance SHA. Sits between the per-firm
|
||||
// per-submission_code template (fetchSubmissionTemplateBytes) and the
|
||||
// bare universal HL Patents Style .dotm (fetchHLPatentsStyleBytes) in
|
||||
// resolveSubmissionTemplate's fallback chain — used for every
|
||||
// submission_code that has no dedicated template registered. Same
|
||||
// stale-while-revalidate semantics as the rest of the file proxy: first
|
||||
// call warms the cache synchronously from mWorkRepo via Gitea; later
|
||||
// calls return immediately while a background refresh runs.
|
||||
func fetchSubmissionSkeletonBytes(ctx context.Context) ([]byte, string, error) {
|
||||
entry, ok := fileRegistry[skeletonSubmissionSlug]
|
||||
if !ok {
|
||||
return nil, "", fmt.Errorf("file proxy: %s not registered", skeletonSubmissionSlug)
|
||||
}
|
||||
ce := getCacheEntry(skeletonSubmissionSlug)
|
||||
|
||||
ce.mu.RLock()
|
||||
hasData := len(ce.data) > 0
|
||||
needsCheck := time.Since(ce.lastChecked) >= checkInterval
|
||||
ce.mu.RUnlock()
|
||||
|
||||
if !hasData {
|
||||
if err := fileFetch(ce, entry); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
} else if needsCheck {
|
||||
go fileCheckAndRefresh(ce, entry)
|
||||
}
|
||||
|
||||
ce.mu.RLock()
|
||||
defer ce.mu.RUnlock()
|
||||
if len(ce.data) == 0 {
|
||||
return nil, "", fmt.Errorf("file proxy: %s cache empty after fetch", skeletonSubmissionSlug)
|
||||
}
|
||||
out := make([]byte, len(ce.data))
|
||||
copy(out, ce.data)
|
||||
_ = ctx
|
||||
return out, ce.sha, nil
|
||||
}
|
||||
|
||||
// fetchHLPatentsStyleBytes returns the cached HL Patents Style .dotm
|
||||
// bytes. Shared accessor used by both the /files/{slug} download path
|
||||
// (Word auto-update channel) and the submission generator
|
||||
|
||||
@@ -98,6 +98,14 @@ type Services struct {
|
||||
Projection *services.ProjectionService
|
||||
Export *services.ExportService
|
||||
|
||||
// t-paliad-246 — Backup Mode (org-scope admin backups). Nil when
|
||||
// DATABASE_URL or PALIAD_EXPORT_DIR is unset; the /admin/backups
|
||||
// routes return 503 in that case.
|
||||
Backup *services.BackupRunner
|
||||
|
||||
// t-paliad-238 — dedicated Submissions/Schriftsätze editor.
|
||||
SubmissionDraft *services.SubmissionDraftService
|
||||
|
||||
// Paliadin is wired when DATABASE_URL is set. The concrete backend
|
||||
// is picked in cmd/server/main.go based on PALIADIN_REMOTE_HOST
|
||||
// (remote → mRiver via SSH) or local tmux availability. Stays nil
|
||||
@@ -159,6 +167,8 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
firmDashboardDefault: svc.FirmDashboardDefault,
|
||||
projection: svc.Projection,
|
||||
export: svc.Export,
|
||||
backup: svc.Backup,
|
||||
submissionDraft: svc.SubmissionDraft,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -272,6 +282,11 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
protected.HandleFunc("POST /api/checklist-instances/{id}/reset", handleResetChecklistInstance)
|
||||
protected.HandleFunc("DELETE /api/checklist-instances/{id}", handleDeleteChecklistInstance)
|
||||
protected.HandleFunc("GET /api/projects/{id}/checklists", handleListChecklistInstancesForProject)
|
||||
// t-paliad-240 — global Schriftsätze drafts index (top-level sidebar
|
||||
// entry). Lists every draft the caller owns across visible projects.
|
||||
// The per-project Schriftsätze tab keeps the editor itself project-
|
||||
// scoped; this index is the cross-project landing.
|
||||
protected.HandleFunc("GET /submissions", gateOnboarded(handleSubmissionsIndexPage))
|
||||
protected.HandleFunc("GET /courts", handleCourtsPage)
|
||||
protected.HandleFunc("GET /api/courts", handleCourtsAPI)
|
||||
protected.HandleFunc("POST /api/courts/feedback", handleCourtsFeedback)
|
||||
@@ -313,6 +328,33 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
// writes an audit row.
|
||||
protected.HandleFunc("GET /api/projects/{id}/submissions", handleListProjectSubmissions)
|
||||
protected.HandleFunc("POST /api/projects/{id}/submissions/{code}/generate", handleGenerateProjectSubmission)
|
||||
// t-paliad-238 — dedicated Submissions/Schriftsätze draft editor.
|
||||
// Per (project, submission_code, user) named drafts with autosaved
|
||||
// variable overrides; export merges the bag with the universal HL
|
||||
// Patents Style template (Slice A) or the per-code template
|
||||
// (Slice B, future).
|
||||
protected.HandleFunc("GET /api/projects/{id}/submissions/{code}/drafts", handleListSubmissionDrafts)
|
||||
protected.HandleFunc("POST /api/projects/{id}/submissions/{code}/drafts", handleCreateSubmissionDraft)
|
||||
protected.HandleFunc("GET /api/projects/{id}/submissions/{code}/drafts/{draft_id}", handleGetSubmissionDraft)
|
||||
protected.HandleFunc("PATCH /api/projects/{id}/submissions/{code}/drafts/{draft_id}", handlePatchSubmissionDraft)
|
||||
protected.HandleFunc("DELETE /api/projects/{id}/submissions/{code}/drafts/{draft_id}", handleDeleteSubmissionDraft)
|
||||
protected.HandleFunc("GET /api/projects/{id}/submissions/{code}/drafts/{draft_id}/preview", handlePreviewSubmissionDraft)
|
||||
protected.HandleFunc("POST /api/projects/{id}/submissions/{code}/drafts/{draft_id}/export", handleExportSubmissionDraft)
|
||||
// t-paliad-240 — global drafts index (across visible projects).
|
||||
protected.HandleFunc("GET /api/user/submission-drafts", handleListUserSubmissionDrafts)
|
||||
// t-paliad-243 — global Schriftsätze drafts with optional project
|
||||
// binding. The picker page at /submissions/new lists the full
|
||||
// cross-proceeding catalog (without a project context) and posts to
|
||||
// POST /api/submission-drafts to spawn a draft. The
|
||||
// /api/submission-drafts/{draft_id}* endpoints back the project-less
|
||||
// editor and ALSO accept project-scoped drafts (the draft row
|
||||
// carries its own project_id so the project segment is redundant).
|
||||
protected.HandleFunc("GET /api/submissions/catalog", handleListSubmissionCatalog)
|
||||
protected.HandleFunc("POST /api/submission-drafts", handleCreateGlobalSubmissionDraft)
|
||||
protected.HandleFunc("GET /api/submission-drafts/{draft_id}", handleGetGlobalSubmissionDraft)
|
||||
protected.HandleFunc("PATCH /api/submission-drafts/{draft_id}", handleGlobalPatchSubmissionDraft)
|
||||
protected.HandleFunc("DELETE /api/submission-drafts/{draft_id}", handleGlobalDeleteSubmissionDraft)
|
||||
protected.HandleFunc("POST /api/submission-drafts/{draft_id}/export", handleGlobalExportSubmissionDraft)
|
||||
// /counterclaim creates a CCR sub-project linked via the new
|
||||
// paliad.projects.counterclaim_of FK (t-paliad-174 Slice 3).
|
||||
protected.HandleFunc("POST /api/projects/{id}/counterclaim", handleCreateProjectCounterclaim)
|
||||
@@ -470,6 +512,16 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
// Without this route the deep-link 404s; the tab still works via
|
||||
// in-page click since it just toggles a panel.
|
||||
protected.HandleFunc("GET /projects/{id}/submissions", gateOnboarded(handleProjectsDetailPage))
|
||||
// t-paliad-238 — dedicated submission draft editor. Both routes
|
||||
// render the project detail page; the actual editor mounts
|
||||
// client-side based on the URL path.
|
||||
protected.HandleFunc("GET /projects/{id}/submissions/{code}/draft", gateOnboarded(handleSubmissionDraftPage))
|
||||
protected.HandleFunc("GET /projects/{id}/submissions/{code}/draft/{draft_id}", gateOnboarded(handleSubmissionDraftPage))
|
||||
// t-paliad-243 — global Schriftsätze pages: picker + project-less
|
||||
// editor. Both render dist/* files; client bundles parse the URL
|
||||
// and branch on whether a project segment is present.
|
||||
protected.HandleFunc("GET /submissions/new", gateOnboarded(handleSubmissionsNewPage))
|
||||
protected.HandleFunc("GET /submissions/draft/{draft_id}", gateOnboarded(handleSubmissionDraftGlobalPage))
|
||||
// t-paliad-177 — standalone Project Timeline / Chart page (Slice 1).
|
||||
// Horizontal SVG renderer mounted client-side; reuses the existing
|
||||
// /api/projects/{id}/timeline JSON endpoint for data.
|
||||
@@ -524,6 +576,17 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
protected.HandleFunc("GET /admin/email-templates", adminGate(users, gateOnboarded(handleAdminEmailTemplatesPage)))
|
||||
protected.HandleFunc("GET /admin/email-templates/{key}", adminGate(users, gateOnboarded(handleAdminEmailTemplatesEditPage)))
|
||||
protected.HandleFunc("GET /admin/event-types", adminGate(users, gateOnboarded(handleAdminEventTypesPage)))
|
||||
|
||||
// t-paliad-246 / m/paliad#77 Slice A — Backup Mode admin page +
|
||||
// API. Routes only register when Users is wired (matches the
|
||||
// other admin routes); per-request 503 if BackupRunner itself
|
||||
// is unwired (PALIAD_EXPORT_DIR unset).
|
||||
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))
|
||||
|
||||
protected.HandleFunc("GET /api/admin/users", adminGate(users, handleAdminListUsers))
|
||||
protected.HandleFunc("POST /api/admin/users", adminGate(users, handleAdminCreateUser))
|
||||
protected.HandleFunc("POST /api/admin/users/full", adminGate(users, handleAdminCreateFullUser))
|
||||
@@ -612,6 +675,9 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
protected.HandleFunc("POST /api/approval-requests/{id}/approve", handleApproveApprovalRequest)
|
||||
protected.HandleFunc("POST /api/approval-requests/{id}/reject", handleRejectApprovalRequest)
|
||||
protected.HandleFunc("POST /api/approval-requests/{id}/revoke", handleRevokeApprovalRequest)
|
||||
// t-paliad-252 — non-destructive sibling of /revoke: lets the
|
||||
// requester revise the in-flight entity without withdrawing.
|
||||
protected.HandleFunc("POST /api/approval-requests/{id}/edit-entity", handleEditPendingEntity)
|
||||
protected.HandleFunc("POST /api/approval-requests/{id}/suggest-changes", handleSuggestChangesApprovalRequest)
|
||||
|
||||
// t-paliad-154 — form-time effective policy lookup. Reachable by
|
||||
|
||||
@@ -61,6 +61,13 @@ type dbServices struct {
|
||||
firmDashboardDefault *services.FirmDashboardDefaultService
|
||||
projection *services.ProjectionService
|
||||
export *services.ExportService
|
||||
|
||||
// t-paliad-246 — Backup Mode orchestrator. Nil when DATABASE_URL or
|
||||
// PALIAD_EXPORT_DIR is unset (the /admin/backups routes return 503).
|
||||
backup *services.BackupRunner
|
||||
|
||||
// t-paliad-238 — submission draft editor.
|
||||
submissionDraft *services.SubmissionDraftService
|
||||
}
|
||||
|
||||
var dbSvc *dbServices
|
||||
|
||||
1102
internal/handlers/submission_drafts.go
Normal file
1102
internal/handlers/submission_drafts.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,27 +1,32 @@
|
||||
package handlers
|
||||
|
||||
// Submission generator HTTP layer (t-paliad-230 — format-only scope
|
||||
// reduction of t-paliad-215).
|
||||
// reduction of t-paliad-215; t-paliad-242 broadened the list endpoint
|
||||
// to the full cross-proceeding catalog; t-paliad-253 promoted /generate
|
||||
// from format-only to the same merge engine the draft editor uses).
|
||||
//
|
||||
// Endpoints:
|
||||
//
|
||||
// GET /api/projects/{id}/submissions
|
||||
// Lists the project's proceeding-relevant filing rules.
|
||||
// has_template is unconditionally true: every project gets
|
||||
// offered the universal HL Patents Style template.
|
||||
// Lists every published filing rule across every active
|
||||
// proceeding the platform knows about, joined with its
|
||||
// proceeding_type so the frontend can group by proceeding.
|
||||
// has_template flips per-row: true when a per-submission .docx
|
||||
// is wired in submissionTemplateRegistry, false when the
|
||||
// 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
|
||||
@@ -62,26 +67,48 @@ const hlPatentsStyleSlug = "hl-patents-style.dotm"
|
||||
|
||||
// submissionListEntry is one row in the Schriftsätze panel.
|
||||
type submissionListEntry struct {
|
||||
SubmissionCode string `json:"submission_code"`
|
||||
Name string `json:"name"`
|
||||
NameEN string `json:"name_en"`
|
||||
EventType string `json:"event_type,omitempty"`
|
||||
PrimaryParty string `json:"primary_party,omitempty"`
|
||||
LegalSource string `json:"legal_source,omitempty"`
|
||||
HasTemplate bool `json:"has_template"`
|
||||
SubmissionCode string `json:"submission_code"`
|
||||
Name string `json:"name"`
|
||||
NameEN string `json:"name_en"`
|
||||
EventType string `json:"event_type,omitempty"`
|
||||
PrimaryParty string `json:"primary_party,omitempty"`
|
||||
LegalSource string `json:"legal_source,omitempty"`
|
||||
HasTemplate bool `json:"has_template"`
|
||||
ProceedingCode string `json:"proceeding_code"`
|
||||
ProceedingName string `json:"proceeding_name"`
|
||||
ProceedingNameEN string `json:"proceeding_name_en"`
|
||||
}
|
||||
|
||||
// submissionListResponse wraps the list with a project-level header.
|
||||
//
|
||||
// ProjectProceedingCode names the project's own proceeding so the
|
||||
// frontend can pin its group to the top of the grouped catalog
|
||||
// (t-paliad-242). nil when the project hasn't bound a proceeding yet.
|
||||
type submissionListResponse struct {
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
ProceedingTypeID *int `json:"proceeding_type_id,omitempty"`
|
||||
Entries []submissionListEntry `json:"entries"`
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
ProceedingTypeID *int `json:"proceeding_type_id,omitempty"`
|
||||
ProjectProceedingCode *string `json:"project_proceeding_code,omitempty"`
|
||||
Entries []submissionListEntry `json:"entries"`
|
||||
}
|
||||
|
||||
// handleListProjectSubmissions returns the published filing rules for
|
||||
// the project's proceeding_type. has_template is true for every row —
|
||||
// Slice 1 (t-paliad-230) ships one universal template, so the only
|
||||
// "no template" case is a project that has no proceeding_type bound.
|
||||
// handleListProjectSubmissions returns every published filing rule
|
||||
// across every active proceeding the platform knows about, joined with
|
||||
// its proceeding_type so the Schriftsätze tab can group rows by
|
||||
// proceeding (t-paliad-242 — m wants to see the entire catalog from any
|
||||
// project, not just the rules for the project's own proceeding).
|
||||
//
|
||||
// Visibility is gated on the PROJECT (paliad.can_see_project via
|
||||
// ProjectService.GetByID); the rules themselves are static reference
|
||||
// data shared across the firm.
|
||||
//
|
||||
// has_template flips when a per-submission .docx is wired into
|
||||
// submissionTemplateRegistry (files.go). When false, the universal HL
|
||||
// Patents Style .dotm is the fallback — the editor (t-paliad-238)
|
||||
// resolves both flavours transparently, so every row remains
|
||||
// generatable and editable from the UI.
|
||||
//
|
||||
// Rows are sorted by (proceeding_code, submission_code) so the
|
||||
// frontend's groupBy stays cheap and the order is stable.
|
||||
func handleListProjectSubmissions(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
@@ -110,53 +137,145 @@ func handleListProjectSubmissions(w http.ResponseWriter, r *http.Request) {
|
||||
Entries: []submissionListEntry{},
|
||||
}
|
||||
|
||||
if project.ProceedingTypeID == nil {
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
return
|
||||
}
|
||||
|
||||
rules, err := dbSvc.rules.List(ctx, project.ProceedingTypeID)
|
||||
entries, ownCode, err := loadSubmissionCatalog(ctx, project.ProceedingTypeID)
|
||||
if err != nil {
|
||||
log.Printf("submissions: list rules for proceeding %d: %v", *project.ProceedingTypeID, err)
|
||||
log.Printf("submissions: list submission catalog: %v", err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "rule lookup failed"})
|
||||
return
|
||||
}
|
||||
|
||||
for _, rule := range rules {
|
||||
if rule.SubmissionCode == nil || *rule.SubmissionCode == "" {
|
||||
continue
|
||||
}
|
||||
if rule.EventType == nil || *rule.EventType != "filing" {
|
||||
continue
|
||||
}
|
||||
if rule.LifecycleState != "published" {
|
||||
continue
|
||||
}
|
||||
entry := submissionListEntry{
|
||||
SubmissionCode: *rule.SubmissionCode,
|
||||
Name: rule.Name,
|
||||
NameEN: rule.NameEN,
|
||||
HasTemplate: true,
|
||||
}
|
||||
if rule.EventType != nil {
|
||||
entry.EventType = *rule.EventType
|
||||
}
|
||||
if rule.PrimaryParty != nil {
|
||||
entry.PrimaryParty = *rule.PrimaryParty
|
||||
}
|
||||
if rule.LegalSource != nil {
|
||||
entry.LegalSource = *rule.LegalSource
|
||||
}
|
||||
resp.Entries = append(resp.Entries, entry)
|
||||
}
|
||||
resp.Entries = entries
|
||||
resp.ProjectProceedingCode = ownCode
|
||||
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// 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.
|
||||
// handleListSubmissionCatalog returns the same cross-proceeding catalog
|
||||
// without a project context — used by the global /submissions/new
|
||||
// picker (t-paliad-243). No project_proceeding_code is returned since
|
||||
// the picker isn't pinned to one project.
|
||||
func handleListSubmissionCatalog(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
if _, ok := requireUser(w, r); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
entries, _, err := loadSubmissionCatalog(r.Context(), nil)
|
||||
if err != nil {
|
||||
log.Printf("submissions: list global submission catalog: %v", err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "rule lookup failed"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"entries": entries})
|
||||
}
|
||||
|
||||
// loadSubmissionCatalog runs the shared catalog query. When
|
||||
// projectProceedingTypeID is non-nil, the returned ownCode points at
|
||||
// that proceeding's code so the frontend can pin its group to the top;
|
||||
// otherwise ownCode is nil.
|
||||
func loadSubmissionCatalog(ctx context.Context, projectProceedingTypeID *int) ([]submissionListEntry, *string, error) {
|
||||
type catalogRow struct {
|
||||
SubmissionCode string `db:"submission_code"`
|
||||
Name string `db:"name"`
|
||||
NameEN string `db:"name_en"`
|
||||
EventType *string `db:"event_type"`
|
||||
PrimaryParty *string `db:"primary_party"`
|
||||
LegalSource *string `db:"legal_source"`
|
||||
ProceedingID int `db:"proceeding_type_id"`
|
||||
ProceedingCode string `db:"proceeding_code"`
|
||||
ProceedingName string `db:"proceeding_name"`
|
||||
ProceedingNameEN string `db:"proceeding_name_en"`
|
||||
}
|
||||
|
||||
var rows []catalogRow
|
||||
err := dbSvc.projects.DB().SelectContext(ctx, &rows,
|
||||
`SELECT dr.submission_code AS submission_code,
|
||||
dr.name AS name,
|
||||
dr.name_en AS name_en,
|
||||
dr.event_type AS event_type,
|
||||
dr.primary_party AS primary_party,
|
||||
dr.legal_source AS legal_source,
|
||||
dr.proceeding_type_id AS proceeding_type_id,
|
||||
pt.code AS proceeding_code,
|
||||
pt.name AS proceeding_name,
|
||||
pt.name_en AS proceeding_name_en
|
||||
FROM paliad.deadline_rules dr
|
||||
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
|
||||
WHERE dr.is_active = true
|
||||
AND dr.lifecycle_state = 'published'
|
||||
AND dr.event_type = 'filing'
|
||||
AND dr.submission_code IS NOT NULL
|
||||
AND dr.submission_code <> ''
|
||||
AND pt.is_active = true
|
||||
ORDER BY pt.code ASC, dr.submission_code ASC`)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
entries := make([]submissionListEntry, 0, len(rows))
|
||||
var ownCode *string
|
||||
for _, row := range rows {
|
||||
entry := submissionListEntry{
|
||||
SubmissionCode: row.SubmissionCode,
|
||||
Name: row.Name,
|
||||
NameEN: row.NameEN,
|
||||
HasTemplate: hasPerSubmissionTemplate(row.SubmissionCode),
|
||||
ProceedingCode: row.ProceedingCode,
|
||||
ProceedingName: row.ProceedingName,
|
||||
ProceedingNameEN: row.ProceedingNameEN,
|
||||
}
|
||||
if row.EventType != nil {
|
||||
entry.EventType = *row.EventType
|
||||
}
|
||||
if row.PrimaryParty != nil {
|
||||
entry.PrimaryParty = *row.PrimaryParty
|
||||
}
|
||||
if row.LegalSource != nil {
|
||||
entry.LegalSource = *row.LegalSource
|
||||
}
|
||||
entries = append(entries, entry)
|
||||
if projectProceedingTypeID != nil && row.ProceedingID == *projectProceedingTypeID && ownCode == nil {
|
||||
code := row.ProceedingCode
|
||||
ownCode = &code
|
||||
}
|
||||
}
|
||||
|
||||
// If the project's proceeding has no filing rules of its own, fall
|
||||
// back to a direct proceeding_types lookup so the frontend can still
|
||||
// pin the right group even when the catalog ordering wouldn't have
|
||||
// surfaced the code via a row.
|
||||
if projectProceedingTypeID != nil && ownCode == nil {
|
||||
var code string
|
||||
if err := dbSvc.projects.DB().GetContext(ctx, &code,
|
||||
`SELECT code FROM paliad.proceeding_types WHERE id = $1`, *projectProceedingTypeID); err == nil && code != "" {
|
||||
ownCode = &code
|
||||
}
|
||||
}
|
||||
|
||||
return entries, ownCode, nil
|
||||
}
|
||||
|
||||
// hasPerSubmissionTemplate reports whether a per-submission .docx is
|
||||
// wired in the fileRegistry (files.go). false means the editor falls
|
||||
// back to the universal HL Patents Style — still renderable, still
|
||||
// editable, but the UI may want to surface a "universal Vorlage"
|
||||
// indicator. Read-only — no I/O, just a map lookup.
|
||||
func hasPerSubmissionTemplate(submissionCode string) bool {
|
||||
_, ok := submissionTemplateRegistry[submissionCode]
|
||||
return ok
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -165,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"})
|
||||
@@ -179,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)
|
||||
}
|
||||
|
||||
@@ -244,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 —
|
||||
|
||||
@@ -313,6 +313,14 @@ type Deadline struct {
|
||||
// changes to paliad.deadline_rules and accepts citations from
|
||||
// outside that table.
|
||||
RuleCode *string `db:"rule_code" json:"rule_code,omitempty"`
|
||||
// CustomRuleText holds the lawyer's free-text rule label when the
|
||||
// deadline form is in Custom mode (t-paliad-258 / m/paliad#89).
|
||||
// Mutually exclusive with RuleID at the application layer: the Auto
|
||||
// path sets RuleID and leaves this NULL; the Custom path sets this
|
||||
// and leaves RuleID NULL. Display surfaces prefer the joined
|
||||
// deadline_rules.name when RuleID is set, else fall back to this
|
||||
// text + a "Custom" badge.
|
||||
CustomRuleText *string `db:"custom_rule_text" json:"custom_rule_text,omitempty"`
|
||||
Status string `db:"status" json:"status"`
|
||||
CompletedAt *time.Time `db:"completed_at" json:"completed_at,omitempty"`
|
||||
CalDAVUID *string `db:"caldav_uid" json:"caldav_uid,omitempty"`
|
||||
@@ -721,6 +729,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
|
||||
|
||||
@@ -41,6 +41,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -364,6 +365,135 @@ func (s *ApprovalService) Revoke(ctx context.Context, requestID, callerID uuid.U
|
||||
return s.decide(ctx, requestID, callerID, RequestStatusRevoked, "")
|
||||
}
|
||||
|
||||
// EditPendingEntity lets the REQUESTER of a pending approval_request revise
|
||||
// the in-flight entity (e.g. tweak the title or due_date on a pending
|
||||
// create) without withdrawing the request. t-paliad-252 / m/paliad#83 added
|
||||
// this as the non-destructive sibling of Revoke — m's mental model is
|
||||
// "withdraw deletes the event; let me edit the event instead, keep the
|
||||
// approval request alive".
|
||||
//
|
||||
// Authorization: caller MUST be the original requested_by (no approver can
|
||||
// edit on the requester's behalf — that would collapse into SuggestChanges).
|
||||
// Request status MUST be pending.
|
||||
//
|
||||
// Allowlist: uses the WIDER counter-allowlist already maintained for
|
||||
// SuggestChanges (buildCounterSetClauses) — every editable field on the
|
||||
// entity, not just the date-bearing approval triggers. Unknown keys are
|
||||
// silently dropped. Returns ErrSuggestionRequiresChange when fields carries
|
||||
// no allowlisted key for the entity_type (would be a no-op write).
|
||||
//
|
||||
// Side effects in one tx: entity columns updated (and event_type_ids junction
|
||||
// rewritten for deadlines), approval_request.payload merged with the new
|
||||
// values so the approver sees what was revised, and a distinct
|
||||
// `<entity>_approval_edited_by_requester` project_event emitted so the
|
||||
// Verlauf shows the revision separately from the original *_requested row.
|
||||
//
|
||||
// The approval_request stays pending; entity.approval_status stays pending.
|
||||
// The approver inbox sees a fresh updated_at + the merged payload.
|
||||
func (s *ApprovalService) EditPendingEntity(ctx context.Context, requestID, callerID uuid.UUID, fields map[string]any) error {
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck
|
||||
|
||||
req, err := s.getRequestForUpdate(ctx, tx, requestID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if req.Status != RequestStatusPending {
|
||||
return fmt.Errorf("%w: status=%s", ErrRequestNotPending, req.Status)
|
||||
}
|
||||
if callerID != req.RequestedBy {
|
||||
return ErrNotApprover
|
||||
}
|
||||
|
||||
// Validate the counter-allowlist intersect produces at least one
|
||||
// settable column. applyEntityUpdate also wraps this check; pre-checking
|
||||
// here lets us emit a cleaner error before opening the entity-write.
|
||||
if _, _, err := buildCounterSetClauses(req.EntityType, fields); err != nil {
|
||||
// Already wraps ErrSuggestionRequiresChange for empty / title-cleared
|
||||
// cases. Propagate verbatim.
|
||||
return err
|
||||
}
|
||||
|
||||
// Apply the field updates to the entity row via the shared
|
||||
// counter-allowlist path (same as SuggestChanges).
|
||||
if err := s.applyEntityUpdate(ctx, tx, req.EntityType, req.EntityID, fields); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Merge new fields into the request payload so the approver's inbox
|
||||
// reflects what the requester revised to. Keys overwrite; event_type_ids
|
||||
// is replaced wholesale per the same semantics applyEntityUpdate uses
|
||||
// for the junction rewrite.
|
||||
var existing map[string]any
|
||||
if len(req.Payload) > 0 {
|
||||
if err := json.Unmarshal(req.Payload, &existing); err != nil {
|
||||
return fmt.Errorf("unmarshal payload: %w", err)
|
||||
}
|
||||
}
|
||||
if existing == nil {
|
||||
existing = map[string]any{}
|
||||
}
|
||||
maps.Copy(existing, fields)
|
||||
merged, err := json.Marshal(existing)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal merged payload: %w", err)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE paliad.approval_requests
|
||||
SET payload = $1, updated_at = $2
|
||||
WHERE id = $3`,
|
||||
merged, now, requestID); err != nil {
|
||||
return fmt.Errorf("update payload: %w", err)
|
||||
}
|
||||
|
||||
// Audit emit. Distinct event_type so the Verlauf surfaces the revision
|
||||
// separately from the original *_requested or any decision row.
|
||||
verlaufKind := "edited_by_requester"
|
||||
eventType := approvalEventType(req.EntityType, verlaufKind)
|
||||
descPtr := approvalDescription(verlaufKind, req.RequiredRole, req.LifecycleEvent)
|
||||
editedKeys := sortedKeys(fields)
|
||||
meta := map[string]any{
|
||||
"approval_request_id": req.ID.String(),
|
||||
"lifecycle_event": req.LifecycleEvent,
|
||||
req.EntityType + "_id": req.EntityID.String(),
|
||||
"edited_fields": editedKeys,
|
||||
}
|
||||
if err := insertProjectEventWithMeta(ctx, tx, req.ProjectID, callerID, eventType, eventType, descPtr, meta); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// sortedKeys returns m's keys in stable alphabetical order so the audit-log
|
||||
// metadata is byte-for-byte stable across calls (helps when diffing audit
|
||||
// logs or asserting on them in tests).
|
||||
func sortedKeys(m map[string]any) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
// Use the stdlib sort; the slice is small (≤ counter-allowlist size).
|
||||
sortStrings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// sortStrings: indirection so we don't add a new top-level import group.
|
||||
// In Go 1.21+ slices.Sort exists; this package is currently importing
|
||||
// strings + standard libs and adding "sort" would re-fan the imports.
|
||||
// Kept as a one-line wrapper to localise the dependency if a later move
|
||||
// to slices.Sort feels right.
|
||||
func sortStrings(s []string) {
|
||||
for i := 1; i < len(s); i++ {
|
||||
for j := i; j > 0 && s[j-1] > s[j]; j-- {
|
||||
s[j-1], s[j] = s[j], s[j-1]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SuggestChanges is the fourth approval action (t-paliad-216). The caller
|
||||
// proposes a counter-payload + optional free-text note; in one transaction
|
||||
// we close the old request as 'changes_requested', revert the entity from
|
||||
|
||||
555
internal/services/backup_service.go
Normal file
555
internal/services/backup_service.go
Normal file
@@ -0,0 +1,555 @@
|
||||
package services
|
||||
|
||||
// Backup Mode runtime (t-paliad-246 / m/paliad#77 Slice A).
|
||||
//
|
||||
// One file because all four pieces are tightly coupled:
|
||||
//
|
||||
// - ArtifactStore interface + LocalDiskStore implementation
|
||||
// (storage abstraction; m picked local disk for v1, the interface
|
||||
// stays so a future swap to Supabase Storage is one impl away).
|
||||
//
|
||||
// - BackupRunner — the orchestration the on-demand handler and the
|
||||
// (Slice B) scheduler share. Wraps the export pipeline:
|
||||
// 1. INSERT paliad.backups (status='running')
|
||||
// 2. INSERT paliad.system_audit_log (event_type='backup_created')
|
||||
// 3. ExportService.WriteOrg → in-memory buffer
|
||||
// 4. ArtifactStore.Put → file
|
||||
// 5. UPDATE paliad.backups (status='done', storage_uri, …)
|
||||
// 6. PATCH paliad.system_audit_log metadata
|
||||
//
|
||||
// Design: docs/design-backup-mode-2026-05-25.md.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ArtifactStore interface + LocalDiskStore impl
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ArtifactStore persists the bytes of a backup artifact. The interface
|
||||
// is deliberately small so Slice B can drop in a SupabaseStorageStore
|
||||
// (or any object-store implementation) without changing the runner.
|
||||
//
|
||||
// URIs returned by Put are opaque to callers — they round-trip through
|
||||
// Get/Delete. v1's LocalDiskStore uses `file://<absolute-path>`.
|
||||
type ArtifactStore interface {
|
||||
// Put writes the given body to the store under the given key and
|
||||
// returns the URI for later retrieval. Implementations must overwrite
|
||||
// an existing object at the same key (catalog rows make keys unique
|
||||
// in practice, but the contract is overwrite-on-conflict to keep
|
||||
// retries idempotent).
|
||||
Put(ctx context.Context, key string, body []byte) (uri string, err error)
|
||||
// Get streams the artifact bytes at the given URI.
|
||||
Get(ctx context.Context, uri string) (rc io.ReadCloser, size int64, err error)
|
||||
// Delete removes the artifact at the given URI. Returns nil if the
|
||||
// artifact is already absent (idempotent).
|
||||
Delete(ctx context.Context, uri string) error
|
||||
}
|
||||
|
||||
// LocalDiskStore is the v1 ArtifactStore — writes artifacts to a local
|
||||
// directory specified at construction time. Mode 0700 on the directory
|
||||
// + 0600 on artifact files keeps the files private to the paliad
|
||||
// process owner on the Dokploy host.
|
||||
type LocalDiskStore struct {
|
||||
dir string
|
||||
}
|
||||
|
||||
// NewLocalDiskStore creates a LocalDiskStore rooted at dir. Creates the
|
||||
// directory (0700) if it doesn't exist. Returns an error if dir is
|
||||
// empty or the mkdir fails.
|
||||
func NewLocalDiskStore(dir string) (*LocalDiskStore, error) {
|
||||
if strings.TrimSpace(dir) == "" {
|
||||
return nil, errors.New("LocalDiskStore: empty directory")
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("LocalDiskStore mkdir %q: %w", dir, err)
|
||||
}
|
||||
abs, err := filepath.Abs(dir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("LocalDiskStore abs %q: %w", dir, err)
|
||||
}
|
||||
return &LocalDiskStore{dir: abs}, nil
|
||||
}
|
||||
|
||||
// Put writes body to <dir>/<key>. Returns a file:// URI.
|
||||
func (s *LocalDiskStore) Put(_ context.Context, key string, body []byte) (string, error) {
|
||||
if err := validateKey(key); err != nil {
|
||||
return "", err
|
||||
}
|
||||
full := filepath.Join(s.dir, key)
|
||||
if err := os.WriteFile(full, body, 0o600); err != nil {
|
||||
return "", fmt.Errorf("LocalDiskStore write %q: %w", full, err)
|
||||
}
|
||||
return "file://" + full, nil
|
||||
}
|
||||
|
||||
// Get opens the file referenced by uri. Returns a *os.File (io.ReadCloser)
|
||||
// + the file's size in bytes.
|
||||
func (s *LocalDiskStore) Get(_ context.Context, uri string) (io.ReadCloser, int64, error) {
|
||||
path, err := s.pathFromURI(uri)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("LocalDiskStore stat %q: %w", path, err)
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("LocalDiskStore open %q: %w", path, err)
|
||||
}
|
||||
return f, info.Size(), nil
|
||||
}
|
||||
|
||||
// Delete removes the file referenced by uri. Idempotent — missing file
|
||||
// is treated as success.
|
||||
func (s *LocalDiskStore) Delete(_ context.Context, uri string) error {
|
||||
path, err := s.pathFromURI(uri)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("LocalDiskStore remove %q: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// pathFromURI parses a file:// URI and validates that the resolved
|
||||
// path is inside this store's directory. Defense-in-depth against a
|
||||
// malformed catalog row pointing at an arbitrary file.
|
||||
func (s *LocalDiskStore) pathFromURI(uri string) (string, error) {
|
||||
u, err := url.Parse(uri)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("LocalDiskStore parse uri %q: %w", uri, err)
|
||||
}
|
||||
if u.Scheme != "file" {
|
||||
return "", fmt.Errorf("LocalDiskStore: unsupported uri scheme %q (want file://)", u.Scheme)
|
||||
}
|
||||
// url.Parse drops the leading "/" for file:// URIs into u.Path.
|
||||
path := u.Path
|
||||
if u.Host != "" {
|
||||
// "file://host/path" — we don't issue these. Reject.
|
||||
return "", fmt.Errorf("LocalDiskStore: file:// uri with host is unsupported (%q)", uri)
|
||||
}
|
||||
clean := filepath.Clean(path)
|
||||
rel, err := filepath.Rel(s.dir, clean)
|
||||
if err != nil || strings.HasPrefix(rel, "..") {
|
||||
return "", fmt.Errorf("LocalDiskStore: uri %q resolves outside store dir %q", uri, s.dir)
|
||||
}
|
||||
return clean, nil
|
||||
}
|
||||
|
||||
// validateKey rejects keys that would escape the store dir (path
|
||||
// separators, "..", absolute paths). Backup runner uses
|
||||
// "<uuid>.zip" so this is a defensive guard.
|
||||
func validateKey(key string) error {
|
||||
if key == "" {
|
||||
return errors.New("ArtifactStore: empty key")
|
||||
}
|
||||
if strings.ContainsAny(key, "/\\") {
|
||||
return fmt.Errorf("ArtifactStore: key %q contains path separator", key)
|
||||
}
|
||||
if strings.Contains(key, "..") {
|
||||
return fmt.Errorf("ArtifactStore: key %q contains traversal", key)
|
||||
}
|
||||
if filepath.IsAbs(key) {
|
||||
return fmt.Errorf("ArtifactStore: key %q is absolute", key)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BackupRunner
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// BackupKind discriminates a scheduled run from an on-demand one.
|
||||
const (
|
||||
BackupKindOnDemand = "on_demand"
|
||||
BackupKindScheduled = "scheduled"
|
||||
)
|
||||
|
||||
// BackupStatus values mirror the paliad.backups status check constraint.
|
||||
const (
|
||||
BackupStatusRunning = "running"
|
||||
BackupStatusDone = "done"
|
||||
BackupStatusFailed = "failed"
|
||||
)
|
||||
|
||||
// SystemActorEmail is the sentinel actor_email written for scheduled
|
||||
// backups (kind='scheduled'). Matches design §3.4 — we don't seed a
|
||||
// phantom user, we just stamp the audit row with a stable sentinel.
|
||||
const SystemActorEmail = "system@paliad"
|
||||
|
||||
// BackupActor identifies who requested a backup. For kind='scheduled'
|
||||
// pass (nil, SystemActorEmail, "Paliad Backup System"). For on-demand
|
||||
// pass the calling admin's id/email/display_name.
|
||||
type BackupActor struct {
|
||||
ID *uuid.UUID
|
||||
Email string
|
||||
Label string
|
||||
}
|
||||
|
||||
// BackupResult is what Run returns to the caller. Empty on failure
|
||||
// (the error gets the failure detail; the catalog/audit rows are
|
||||
// already updated).
|
||||
type BackupResult struct {
|
||||
ID uuid.UUID
|
||||
AuditID uuid.UUID
|
||||
StorageURI string
|
||||
SizeBytes int64
|
||||
RowCounts map[string]int
|
||||
SheetCount int
|
||||
}
|
||||
|
||||
// BackupRunner orchestrates one backup run. Stateless except for the
|
||||
// wired dependencies; safe to share across goroutines (the handler
|
||||
// holds one instance; the Slice B scheduler will hold the same one).
|
||||
type BackupRunner struct {
|
||||
db *sqlx.DB
|
||||
export *ExportService
|
||||
store ArtifactStore
|
||||
}
|
||||
|
||||
// NewBackupRunner wires the runner. All three deps are required; the
|
||||
// caller (cmd/server/main.go) is responsible for instantiating the
|
||||
// ArtifactStore from env config.
|
||||
func NewBackupRunner(db *sqlx.DB, export *ExportService, store ArtifactStore) *BackupRunner {
|
||||
return &BackupRunner{db: db, export: export, store: store}
|
||||
}
|
||||
|
||||
// Store returns the configured store. Exposed for the download handler
|
||||
// to stream artifacts via Get.
|
||||
func (r *BackupRunner) Store() ArtifactStore { return r.store }
|
||||
|
||||
// Run performs one backup. Writes catalog + audit rows, generates the
|
||||
// bundle via ExportService.WriteOrg, uploads to the configured store,
|
||||
// patches catalog + audit on success/failure.
|
||||
//
|
||||
// On any error after the catalog/audit rows are written, the rows are
|
||||
// patched to status='failed' / event_type='backup_failed' before
|
||||
// returning. The returned error is always the export/upload failure —
|
||||
// catalog-update failures during the failure-recovery path are best-
|
||||
// effort logged but not surfaced (the real error is the one to bubble).
|
||||
func (r *BackupRunner) Run(ctx context.Context, kind string, actor BackupActor) (BackupResult, error) {
|
||||
if kind != BackupKindOnDemand && kind != BackupKindScheduled {
|
||||
return BackupResult{}, fmt.Errorf("BackupRunner.Run: invalid kind %q", kind)
|
||||
}
|
||||
if actor.Email == "" {
|
||||
return BackupResult{}, errors.New("BackupRunner.Run: empty actor email")
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
spec := ExportSpec{
|
||||
Scope: ExportScopeOrg,
|
||||
ActorID: uuid.Nil, // overwritten below when actor.ID != nil
|
||||
ActorEmail: actor.Email,
|
||||
ActorLabel: actor.Label,
|
||||
GeneratedAt: now,
|
||||
}
|
||||
if actor.ID != nil {
|
||||
spec.ActorID = *actor.ID
|
||||
}
|
||||
|
||||
// Step 1+2: catalog row (status='running') + audit row
|
||||
// (event_type='backup_created'). Both happen before the export
|
||||
// generation so failure paths can always find them.
|
||||
catalogID, err := r.insertCatalogRow(ctx, kind, actor, uuid.Nil, now)
|
||||
if err != nil {
|
||||
return BackupResult{}, fmt.Errorf("backup catalog insert: %w", err)
|
||||
}
|
||||
auditID, err := r.insertAuditRow(ctx, kind, actor, catalogID, now)
|
||||
if err != nil {
|
||||
// Best-effort patch on the catalog row so it doesn't sit
|
||||
// "running" forever.
|
||||
r.patchCatalogRowFailed(context.Background(), catalogID, fmt.Errorf("audit insert: %w", err))
|
||||
return BackupResult{}, fmt.Errorf("backup audit insert: %w", err)
|
||||
}
|
||||
// Back-link the audit id into the catalog row so the UI can JOIN.
|
||||
if err := r.linkAuditID(ctx, catalogID, auditID); err != nil {
|
||||
// Non-fatal — the link is for UI convenience, not correctness.
|
||||
// The error is logged via the patch path; we keep going.
|
||||
}
|
||||
|
||||
// Step 3: generate the bundle into an in-memory buffer. We materialise
|
||||
// fully before uploading so a partial upload doesn't strand bytes in
|
||||
// the store under a "done" catalog row.
|
||||
var buf bytes.Buffer
|
||||
meta, err := r.export.WriteOrg(ctx, &buf, spec)
|
||||
if err != nil {
|
||||
r.failRun(context.Background(), catalogID, auditID, fmt.Errorf("generate: %w", err))
|
||||
return BackupResult{}, fmt.Errorf("backup generate: %w", err)
|
||||
}
|
||||
|
||||
// Step 4: upload to storage. Key = "<catalog_id>.zip".
|
||||
key := catalogID.String() + ".zip"
|
||||
uri, err := r.store.Put(ctx, key, buf.Bytes())
|
||||
if err != nil {
|
||||
r.failRun(context.Background(), catalogID, auditID, fmt.Errorf("upload: %w", err))
|
||||
return BackupResult{}, fmt.Errorf("backup upload: %w", err)
|
||||
}
|
||||
|
||||
// Step 5+6: patch catalog + audit on success.
|
||||
size := int64(buf.Len())
|
||||
sheetCount := len(meta.RowCounts)
|
||||
if err := r.patchCatalogRowDone(ctx, catalogID, uri, size, sheetCount, meta); err != nil {
|
||||
// At this point the artifact is on disk, the audit row was
|
||||
// inserted, and the only thing that failed is the catalog
|
||||
// flip. Surface as an error so the handler can log; the
|
||||
// artifact is recoverable manually via the audit metadata.
|
||||
return BackupResult{}, fmt.Errorf("backup catalog patch: %w", err)
|
||||
}
|
||||
if err := r.patchAuditRowDone(ctx, auditID, uri, size, sheetCount, meta); err != nil {
|
||||
// Non-fatal — the catalog row is already authoritative; the
|
||||
// audit row is the audit-trail twin. Log via the caller.
|
||||
}
|
||||
|
||||
return BackupResult{
|
||||
ID: catalogID,
|
||||
AuditID: auditID,
|
||||
StorageURI: uri,
|
||||
SizeBytes: size,
|
||||
RowCounts: meta.RowCounts,
|
||||
SheetCount: sheetCount,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// RecordDownload writes a paliad.system_audit_log row of
|
||||
// event_type='backup_downloaded' when an admin downloads a backup
|
||||
// via /api/admin/backups/{id}/file. Separate row per click — the
|
||||
// existing 'backup_created' row stays untouched.
|
||||
func (r *BackupRunner) RecordDownload(ctx context.Context, backupID uuid.UUID, by BackupActor) error {
|
||||
if by.Email == "" {
|
||||
return errors.New("BackupRunner.RecordDownload: empty actor email")
|
||||
}
|
||||
meta, _ := json.Marshal(map[string]any{
|
||||
"backup_id": backupID.String(),
|
||||
"downloaded_by_email": by.Email,
|
||||
"downloaded_at": time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
var actorID any
|
||||
if by.ID != nil {
|
||||
actorID = *by.ID
|
||||
}
|
||||
_, err := r.db.ExecContext(ctx,
|
||||
`INSERT INTO paliad.system_audit_log
|
||||
(event_type, actor_id, actor_email, scope, scope_root, metadata)
|
||||
VALUES ('backup_downloaded', $1, $2, 'org', NULL, $3::jsonb)`,
|
||||
actorID, by.Email, string(meta),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("backup_downloaded audit insert: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Catalog read helpers (List + Get for the admin UI)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// BackupSummary is the row shape returned by ListBackups + GetBackup —
|
||||
// shaped for the /admin/backups UI. Nullable columns are pointers.
|
||||
type BackupSummary struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
Kind string `db:"kind" json:"kind"`
|
||||
Status string `db:"status" json:"status"`
|
||||
RequestedBy *uuid.UUID `db:"requested_by" json:"requested_by,omitempty"`
|
||||
RequestedByEmail string `db:"requested_by_email" json:"requested_by_email"`
|
||||
AuditID *uuid.UUID `db:"audit_id" json:"audit_id,omitempty"`
|
||||
StorageURI *string `db:"storage_uri" json:"storage_uri,omitempty"`
|
||||
SizeBytes *int64 `db:"size_bytes" json:"size_bytes,omitempty"`
|
||||
RowCounts []byte `db:"row_counts" json:"row_counts,omitempty"`
|
||||
SheetCount *int `db:"sheet_count" json:"sheet_count,omitempty"`
|
||||
Warnings []byte `db:"warnings" json:"warnings,omitempty"`
|
||||
Error *string `db:"error" json:"error,omitempty"`
|
||||
StartedAt time.Time `db:"started_at" json:"started_at"`
|
||||
FinishedAt *time.Time `db:"finished_at" json:"finished_at,omitempty"`
|
||||
DeletedAt *time.Time `db:"deleted_at" json:"deleted_at,omitempty"`
|
||||
}
|
||||
|
||||
// ListBackups returns the most recent backups (highest started_at first),
|
||||
// capped at limit. limit <= 0 means default (100).
|
||||
func (r *BackupRunner) ListBackups(ctx context.Context, limit int) ([]BackupSummary, error) {
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
var rows []BackupSummary
|
||||
err := r.db.SelectContext(ctx, &rows,
|
||||
`SELECT id, kind, status, requested_by, requested_by_email, audit_id,
|
||||
storage_uri, size_bytes, row_counts, sheet_count, warnings,
|
||||
error, started_at, finished_at, deleted_at
|
||||
FROM paliad.backups
|
||||
ORDER BY started_at DESC
|
||||
LIMIT $1`,
|
||||
limit,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list backups: %w", err)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// GetBackup fetches one backup by id. Returns sql.ErrNoRows when not
|
||||
// found (caller maps to 404).
|
||||
func (r *BackupRunner) GetBackup(ctx context.Context, id uuid.UUID) (BackupSummary, error) {
|
||||
var row BackupSummary
|
||||
err := r.db.GetContext(ctx, &row,
|
||||
`SELECT id, kind, status, requested_by, requested_by_email, audit_id,
|
||||
storage_uri, size_bytes, row_counts, sheet_count, warnings,
|
||||
error, started_at, finished_at, deleted_at
|
||||
FROM paliad.backups
|
||||
WHERE id = $1`,
|
||||
id,
|
||||
)
|
||||
if err != nil {
|
||||
return BackupSummary{}, err
|
||||
}
|
||||
return row, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Catalog + audit SQL helpers (private — used by Run + RecordDownload).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func (r *BackupRunner) insertCatalogRow(ctx context.Context, kind string, actor BackupActor, auditID uuid.UUID, now time.Time) (uuid.UUID, error) {
|
||||
var actorID any
|
||||
if actor.ID != nil {
|
||||
actorID = *actor.ID
|
||||
}
|
||||
var auditArg any
|
||||
if auditID != uuid.Nil {
|
||||
auditArg = auditID
|
||||
}
|
||||
var id uuid.UUID
|
||||
err := r.db.QueryRowxContext(ctx,
|
||||
`INSERT INTO paliad.backups
|
||||
(kind, status, requested_by, requested_by_email, audit_id, started_at)
|
||||
VALUES ($1, 'running', $2, $3, $4, $5)
|
||||
RETURNING id`,
|
||||
kind, actorID, actor.Email, auditArg, now,
|
||||
).Scan(&id)
|
||||
if err != nil {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (r *BackupRunner) insertAuditRow(ctx context.Context, kind string, actor BackupActor, catalogID uuid.UUID, now time.Time) (uuid.UUID, error) {
|
||||
meta, _ := json.Marshal(map[string]any{
|
||||
"kind": kind,
|
||||
"catalog_id": catalogID.String(),
|
||||
"requested_by_email": actor.Email,
|
||||
"requested_at": now.Format(time.RFC3339),
|
||||
})
|
||||
var actorID any
|
||||
if actor.ID != nil {
|
||||
actorID = *actor.ID
|
||||
}
|
||||
var id uuid.UUID
|
||||
err := r.db.QueryRowxContext(ctx,
|
||||
`INSERT INTO paliad.system_audit_log
|
||||
(event_type, actor_id, actor_email, scope, scope_root, metadata)
|
||||
VALUES ('backup_created', $1, $2, 'org', NULL, $3::jsonb)
|
||||
RETURNING id`,
|
||||
actorID, actor.Email, string(meta),
|
||||
).Scan(&id)
|
||||
if err != nil {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (r *BackupRunner) linkAuditID(ctx context.Context, catalogID, auditID uuid.UUID) error {
|
||||
_, err := r.db.ExecContext(ctx,
|
||||
`UPDATE paliad.backups SET audit_id = $2 WHERE id = $1`,
|
||||
catalogID, auditID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *BackupRunner) patchCatalogRowDone(ctx context.Context, id uuid.UUID, uri string, size int64, sheetCount int, meta ExportMeta) error {
|
||||
rcJSON, _ := json.Marshal(meta.RowCounts)
|
||||
warnJSON, _ := json.Marshal(meta.Warnings)
|
||||
if meta.Warnings == nil {
|
||||
warnJSON = []byte("[]")
|
||||
}
|
||||
_, err := r.db.ExecContext(ctx,
|
||||
`UPDATE paliad.backups
|
||||
SET status = 'done',
|
||||
storage_uri = $2,
|
||||
size_bytes = $3,
|
||||
sheet_count = $4,
|
||||
row_counts = $5::jsonb,
|
||||
warnings = $6::jsonb,
|
||||
finished_at = now()
|
||||
WHERE id = $1`,
|
||||
id, uri, size, sheetCount, string(rcJSON), string(warnJSON),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *BackupRunner) patchCatalogRowFailed(ctx context.Context, id uuid.UUID, runErr error) {
|
||||
_, _ = r.db.ExecContext(ctx,
|
||||
`UPDATE paliad.backups
|
||||
SET status = 'failed',
|
||||
error = $2,
|
||||
finished_at = now()
|
||||
WHERE id = $1`,
|
||||
id, runErr.Error(),
|
||||
)
|
||||
}
|
||||
|
||||
func (r *BackupRunner) patchAuditRowDone(ctx context.Context, id uuid.UUID, uri string, size int64, sheetCount int, meta ExportMeta) error {
|
||||
payload, _ := json.Marshal(map[string]any{
|
||||
"row_counts": meta.RowCounts,
|
||||
"file_size_bytes": size,
|
||||
"sheet_count": sheetCount,
|
||||
"storage_uri": uri,
|
||||
"warnings": meta.Warnings,
|
||||
"completed_at": time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
_, err := r.db.ExecContext(ctx,
|
||||
`UPDATE paliad.system_audit_log
|
||||
SET metadata = metadata || $2::jsonb,
|
||||
updated_at = now()
|
||||
WHERE id = $1`,
|
||||
id, string(payload),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *BackupRunner) patchAuditRowFailed(ctx context.Context, id uuid.UUID, runErr error) {
|
||||
payload, _ := json.Marshal(map[string]any{
|
||||
"error": runErr.Error(),
|
||||
"failed_at": time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
_, _ = r.db.ExecContext(ctx,
|
||||
`UPDATE paliad.system_audit_log
|
||||
SET event_type = 'backup_failed',
|
||||
metadata = metadata || $2::jsonb,
|
||||
updated_at = now()
|
||||
WHERE id = $1`,
|
||||
id, string(payload),
|
||||
)
|
||||
}
|
||||
|
||||
// failRun is the shared failure-recovery path: patch the catalog +
|
||||
// audit rows to their failed states. Uses a context.Background so the
|
||||
// patch happens even if the original ctx is already cancelled.
|
||||
func (r *BackupRunner) failRun(ctx context.Context, catalogID, auditID uuid.UUID, runErr error) {
|
||||
r.patchCatalogRowFailed(ctx, catalogID, runErr)
|
||||
r.patchAuditRowFailed(ctx, auditID, runErr)
|
||||
}
|
||||
193
internal/services/backup_service_test.go
Normal file
193
internal/services/backup_service_test.go
Normal file
@@ -0,0 +1,193 @@
|
||||
package services
|
||||
|
||||
// Pure-function tests for the Backup Mode runtime (t-paliad-246 / m/paliad#77).
|
||||
//
|
||||
// Live DB behaviour (the actual org dump end-to-end) needs a Postgres;
|
||||
// it would live in backup_service_live_test.go under TEST_DATABASE_URL.
|
||||
// This file covers the bits that don't need a database:
|
||||
//
|
||||
// - orgSheetQueries registry shape: no duplicates, no excluded
|
||||
// paliadin sheets, predictable prefix split between entity and ref.
|
||||
// - LocalDiskStore Put / Get / Delete round-trip, key validation,
|
||||
// URI traversal rejection.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// orgSheetQueries registry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestOrgSheetQueries_NoDuplicates(t *testing.T) {
|
||||
seen := map[string]bool{}
|
||||
for _, sq := range orgSheetQueries() {
|
||||
if seen[sq.SheetName] {
|
||||
t.Fatalf("duplicate sheet name in orgSheetQueries: %q", sq.SheetName)
|
||||
}
|
||||
seen[sq.SheetName] = true
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrgSheetQueries_ExcludesPaliadinTables(t *testing.T) {
|
||||
// m's t-paliad-214 Q5 decision + this design's §11 Q3 default:
|
||||
// paliadin_turns and paliadin_aichat_conversation must be ABSENT
|
||||
// from the registry (structural exclusion, not just column-drop).
|
||||
for _, sq := range orgSheetQueries() {
|
||||
name := sq.SheetName
|
||||
if strings.Contains(name, "paliadin") {
|
||||
t.Fatalf("orgSheetQueries leaked paliadin sheet: %q (m's Q3 mandates structural exclusion)", name)
|
||||
}
|
||||
// Belt-and-braces: SQL bodies should not reference the tables
|
||||
// either (no UNION joins, no subqueries pulling them in).
|
||||
if strings.Contains(sq.SQL, "paliadin_turns") || strings.Contains(sq.SQL, "paliadin_aichat_conversation") {
|
||||
t.Fatalf("orgSheetQueries[%q] SQL references a paliadin table: %s", name, sq.SQL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrgSheetQueries_RefSheetsPrefixed(t *testing.T) {
|
||||
// Every sheet whose data is read-only reference material is
|
||||
// expected to use the `ref__` prefix. The writer's downstream
|
||||
// consumers rely on this convention to group reference data
|
||||
// visually in the workbook.
|
||||
for _, sq := range orgSheetQueries() {
|
||||
if !strings.HasPrefix(sq.SheetName, "ref__") {
|
||||
continue
|
||||
}
|
||||
// Reference sheets shouldn't carry per-row WHERE clauses (they
|
||||
// dump the whole reference table for portability).
|
||||
if strings.Contains(strings.ToUpper(sq.SQL), "WHERE") {
|
||||
t.Fatalf("orgSheetQueries[%q] is ref__ but has a WHERE clause; reference sheets dump the whole table", sq.SheetName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrgSheetQueries_OrderByForDeterminism(t *testing.T) {
|
||||
// Every sheet must specify an ORDER BY so the byte-deterministic
|
||||
// contract from t-paliad-214 §3 holds across runs.
|
||||
for _, sq := range orgSheetQueries() {
|
||||
if !strings.Contains(strings.ToUpper(sq.SQL), "ORDER BY") {
|
||||
t.Fatalf("orgSheetQueries[%q] missing ORDER BY (determinism contract): %s", sq.SheetName, sq.SQL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LocalDiskStore round-trip
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestLocalDiskStore_RoundTrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store, err := NewLocalDiskStore(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("NewLocalDiskStore: %v", err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
want := []byte("hello backup\n")
|
||||
|
||||
uri, err := store.Put(ctx, "test.zip", want)
|
||||
if err != nil {
|
||||
t.Fatalf("Put: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(uri, "file://") {
|
||||
t.Fatalf("expected file:// uri, got %q", uri)
|
||||
}
|
||||
rc, size, err := store.Get(ctx, uri)
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
defer rc.Close()
|
||||
if size != int64(len(want)) {
|
||||
t.Fatalf("Get size = %d, want %d", size, len(want))
|
||||
}
|
||||
got, err := io.ReadAll(rc)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadAll: %v", err)
|
||||
}
|
||||
if !bytes.Equal(got, want) {
|
||||
t.Fatalf("Get body = %q, want %q", got, want)
|
||||
}
|
||||
if err := store.Delete(ctx, uri); err != nil {
|
||||
t.Fatalf("Delete: %v", err)
|
||||
}
|
||||
// File should be gone; Get returns an error.
|
||||
if _, _, err := store.Get(ctx, uri); err == nil {
|
||||
t.Fatalf("Get after Delete should fail")
|
||||
}
|
||||
// Delete is idempotent.
|
||||
if err := store.Delete(ctx, uri); err != nil {
|
||||
t.Fatalf("idempotent Delete: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalDiskStore_RejectsBadKeys(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store, err := NewLocalDiskStore(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("NewLocalDiskStore: %v", err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
cases := []string{
|
||||
"",
|
||||
"sub/dir/file.zip",
|
||||
"..\\evil.zip",
|
||||
"../escape.zip",
|
||||
"/abs/path.zip",
|
||||
}
|
||||
for _, k := range cases {
|
||||
if _, err := store.Put(ctx, k, []byte("x")); err == nil {
|
||||
t.Fatalf("Put with bad key %q should fail", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalDiskStore_RejectsURIOutsideDir(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store, err := NewLocalDiskStore(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("NewLocalDiskStore: %v", err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
// A file:// URI pointing outside the store dir must be rejected
|
||||
// by both Get and Delete (defense in depth against a corrupted
|
||||
// catalog row).
|
||||
outside := "file://" + filepath.Join(filepath.Dir(dir), "elsewhere.zip")
|
||||
if _, _, err := store.Get(ctx, outside); err == nil {
|
||||
t.Fatalf("Get outside store dir should fail")
|
||||
}
|
||||
if err := store.Delete(ctx, outside); err == nil {
|
||||
t.Fatalf("Delete outside store dir should fail")
|
||||
}
|
||||
// Wrong scheme is also rejected.
|
||||
if _, _, err := store.Get(ctx, "https://example.com/foo.zip"); err == nil {
|
||||
t.Fatalf("Get with non-file:// scheme should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalDiskStore_CreatesDir(t *testing.T) {
|
||||
// A non-existent parent gets created at construction; mode 0700.
|
||||
base := t.TempDir()
|
||||
target := filepath.Join(base, "nested", "exports")
|
||||
store, err := NewLocalDiskStore(target)
|
||||
if err != nil {
|
||||
t.Fatalf("NewLocalDiskStore(non-existent): %v", err)
|
||||
}
|
||||
info, err := os.Stat(target)
|
||||
if err != nil {
|
||||
t.Fatalf("expected store dir to exist: %v", err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
t.Fatalf("expected directory, got file")
|
||||
}
|
||||
// Smoke-write to confirm the dir is actually usable.
|
||||
if _, err := store.Put(context.Background(), "ok.zip", []byte{}); err != nil {
|
||||
t.Fatalf("Put into fresh dir: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -66,7 +66,7 @@ func (s *DeadlineService) pendingApprovalErr(ctx context.Context, deadlineID uui
|
||||
}
|
||||
|
||||
const deadlineColumns = `id, project_id, title, description, due_date, original_due_date,
|
||||
warning_date, source, rule_id, rule_code, status, completed_at, caldav_uid, caldav_etag,
|
||||
warning_date, source, rule_id, rule_code, custom_rule_text, status, completed_at, caldav_uid, caldav_etag,
|
||||
notes, created_by, created_at, updated_at,
|
||||
approval_status, pending_request_id, approved_by, approved_at`
|
||||
|
||||
@@ -81,6 +81,11 @@ type CreateDeadlineInput struct {
|
||||
// Sent by the Fristenrechner save flow so the title can stay clean
|
||||
// instead of carrying the citation as a prefix.
|
||||
RuleCode *string `json:"rule_code,omitempty"`
|
||||
// CustomRuleText is the lawyer's free-text rule label when the
|
||||
// deadline form is in Custom mode (t-paliad-258). Mutually exclusive
|
||||
// with RuleID at the application layer; the service trims and treats
|
||||
// an all-whitespace value as nil.
|
||||
CustomRuleText *string `json:"custom_rule_text,omitempty"`
|
||||
Source string `json:"source,omitempty"` // default "manual"
|
||||
Notes *string `json:"notes,omitempty"`
|
||||
EventTypeIDs []uuid.UUID `json:"event_type_ids,omitempty"`
|
||||
@@ -108,6 +113,20 @@ type UpdateDeadlineInput struct {
|
||||
Status *string `json:"status,omitempty"`
|
||||
ProjectID *uuid.UUID `json:"project_id,omitempty"`
|
||||
EventTypeIDs *[]uuid.UUID `json:"event_type_ids,omitempty"`
|
||||
|
||||
// Rule pointer pair (t-paliad-258 / m/paliad#89). Three valid
|
||||
// shapes; the service rejects "both set":
|
||||
// - RuleSet=true, RuleID non-nil, CustomRuleText nil → Auto:
|
||||
// bind to the catalog rule, clear custom_rule_text.
|
||||
// - RuleSet=true, RuleID nil, CustomRuleText non-nil → Custom:
|
||||
// store free text, clear rule_id.
|
||||
// - RuleSet=true, RuleID nil, CustomRuleText nil → No rule:
|
||||
// clear both columns.
|
||||
// RuleSet=false leaves both columns untouched (the rest of the
|
||||
// PATCH body doesn't carry rule changes).
|
||||
RuleSet bool `json:"rule_set,omitempty"`
|
||||
RuleID *uuid.UUID `json:"rule_id,omitempty"`
|
||||
CustomRuleText *string `json:"custom_rule_text,omitempty"`
|
||||
}
|
||||
|
||||
// DeadlineStatusFilter is a server-side bucket for ListVisibleForUser.
|
||||
@@ -241,7 +260,7 @@ func (s *DeadlineService) ListVisibleForUser(ctx context.Context, userID uuid.UU
|
||||
|
||||
query := `
|
||||
SELECT f.id, f.project_id, f.title, f.description, f.due_date, f.original_due_date,
|
||||
f.warning_date, f.source, f.rule_id, f.rule_code, f.status, f.completed_at,
|
||||
f.warning_date, f.source, f.rule_id, f.rule_code, f.custom_rule_text, f.status, f.completed_at,
|
||||
f.caldav_uid, f.caldav_etag, f.notes, f.created_by,
|
||||
f.created_at, f.updated_at,
|
||||
f.approval_status, f.pending_request_id, f.approved_by, f.approved_at,
|
||||
@@ -514,6 +533,23 @@ func (s *DeadlineService) Update(ctx context.Context, userID, deadlineID uuid.UU
|
||||
}
|
||||
}
|
||||
|
||||
// Auto/Custom rule swap (t-paliad-258). Mutually exclusive at the
|
||||
// persistence boundary: setting one column NULLs the other.
|
||||
if input.RuleSet {
|
||||
if input.RuleID != nil && input.CustomRuleText != nil {
|
||||
return nil, fmt.Errorf("%w: rule_id and custom_rule_text are mutually exclusive", ErrInvalidInput)
|
||||
}
|
||||
appendSet("rule_id", input.RuleID)
|
||||
var customText *string
|
||||
if input.CustomRuleText != nil {
|
||||
trimmed := strings.TrimSpace(*input.CustomRuleText)
|
||||
if trimmed != "" {
|
||||
customText = &trimmed
|
||||
}
|
||||
}
|
||||
appendSet("custom_rule_text", customText)
|
||||
}
|
||||
|
||||
// Project move (t-paliad-140). Visibility on the destination is enforced
|
||||
// the same way as on Create — a GetByID round-trip through ProjectService
|
||||
// returns ErrNotVisible if the user can't see the target. Same-project
|
||||
@@ -587,7 +623,7 @@ func (s *DeadlineService) Update(ctx context.Context, userID, deadlineID uuid.UU
|
||||
// Did the PATCH touch anything beyond the project move?
|
||||
otherFieldsTouched := input.Title != nil || input.Description != nil ||
|
||||
input.DueDate != nil || input.Notes != nil || input.Status != nil ||
|
||||
input.EventTypeIDs != nil
|
||||
input.EventTypeIDs != nil || input.RuleSet
|
||||
if otherFieldsTouched {
|
||||
auditProject := current.ProjectID
|
||||
if movedFromProject != nil {
|
||||
@@ -1012,15 +1048,27 @@ func (s *DeadlineService) insertTx(ctx context.Context, tx *sqlx.Tx, userID, pro
|
||||
}
|
||||
}
|
||||
|
||||
// Auto vs Custom (t-paliad-258): RuleID and CustomRuleText are
|
||||
// mutually exclusive. If the caller passes both, the catalog rule
|
||||
// wins and the free-text is dropped — keeps the invariant simple at
|
||||
// the persistence boundary.
|
||||
var customRuleText *string
|
||||
if input.CustomRuleText != nil && input.RuleID == nil {
|
||||
trimmed := strings.TrimSpace(*input.CustomRuleText)
|
||||
if trimmed != "" {
|
||||
customRuleText = &trimmed
|
||||
}
|
||||
}
|
||||
|
||||
id := uuid.New()
|
||||
now := time.Now().UTC()
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO paliad.deadlines
|
||||
(id, project_id, title, description, due_date, original_due_date,
|
||||
source, rule_id, rule_code, status, notes, created_by, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'pending', $10, $11, $12, $12)`,
|
||||
source, rule_id, rule_code, custom_rule_text, status, notes, created_by, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'pending', $11, $12, $13, $13)`,
|
||||
id, projectID, title, input.Description, due, orig,
|
||||
source, input.RuleID, ruleCode, input.Notes, userID, now,
|
||||
source, input.RuleID, ruleCode, customRuleText, input.Notes, userID, now,
|
||||
); err != nil {
|
||||
return uuid.Nil, fmt.Errorf("insert deadline: %w", err)
|
||||
}
|
||||
|
||||
@@ -107,11 +107,15 @@ type EventListItem struct {
|
||||
Status *string `json:"status,omitempty"`
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||
Source *string `json:"source,omitempty"`
|
||||
RuleID *uuid.UUID `json:"rule_id,omitempty"`
|
||||
RuleCode *string `json:"rule_code,omitempty"`
|
||||
RuleName *string `json:"rule_name,omitempty"`
|
||||
RuleNameEN *string `json:"rule_name_en,omitempty"`
|
||||
EventTypeIDs []uuid.UUID `json:"event_type_ids,omitempty"`
|
||||
RuleID *uuid.UUID `json:"rule_id,omitempty"`
|
||||
RuleCode *string `json:"rule_code,omitempty"`
|
||||
RuleName *string `json:"rule_name,omitempty"`
|
||||
RuleNameEN *string `json:"rule_name_en,omitempty"`
|
||||
// CustomRuleText surfaces the lawyer's free-text rule label when the
|
||||
// deadline was created via the Custom rule path (t-paliad-258).
|
||||
// Display surfaces fall back to it when RuleName is absent.
|
||||
CustomRuleText *string `json:"custom_rule_text,omitempty"`
|
||||
EventTypeIDs []uuid.UUID `json:"event_type_ids,omitempty"`
|
||||
|
||||
// Appointment-only.
|
||||
StartAt *time.Time `json:"start_at,omitempty"`
|
||||
@@ -236,6 +240,7 @@ func projectDeadline(d models.DeadlineWithProject) EventListItem {
|
||||
RuleCode: d.RuleCode,
|
||||
RuleName: d.RuleName,
|
||||
RuleNameEN: d.RuleNameEN,
|
||||
CustomRuleText: d.CustomRuleText,
|
||||
EventTypeIDs: d.EventTypeIDs,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ import (
|
||||
"archive/zip"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"encoding/csv"
|
||||
@@ -185,7 +186,7 @@ func (s *ExportService) WritePersonal(ctx context.Context, w io.Writer, spec Exp
|
||||
}
|
||||
|
||||
sheets := personalSheetQueries(spec.ActorID)
|
||||
if err := s.writeBundle(ctx, w, sheets, &meta); err != nil {
|
||||
if err := s.writeBundle(ctx, s.db, w, sheets, &meta); err != nil {
|
||||
return meta, err
|
||||
}
|
||||
return meta, nil
|
||||
@@ -238,7 +239,7 @@ func (s *ExportService) WriteProject(ctx context.Context, w io.Writer, spec Expo
|
||||
}
|
||||
|
||||
sheets := projectSheetQueries(*spec.ScopeRoot, spec.DirectOnly)
|
||||
if err := s.writeBundle(ctx, w, sheets, &meta); err != nil {
|
||||
if err := s.writeBundle(ctx, s.db, w, sheets, &meta); err != nil {
|
||||
return meta, err
|
||||
}
|
||||
|
||||
@@ -254,6 +255,55 @@ func (s *ExportService) WriteProject(ctx context.Context, w io.Writer, spec Expo
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
// WriteOrg streams the full org-scope backup bundle into w. Bypasses
|
||||
// paliad.can_see_project — admin-only, gated at the handler layer (the
|
||||
// service trusts the caller has been authorised).
|
||||
//
|
||||
// Wraps the entire read pass in a REPEATABLE READ READ ONLY transaction
|
||||
// so every sheet sees the same snapshot. Without this a backup that runs
|
||||
// while users are editing can land internally inconsistent rows (e.g. a
|
||||
// deadlines.project_id pointing at a project the projects sheet just
|
||||
// missed). Design §3.3.
|
||||
//
|
||||
// The handler is responsible for the audit-row INSERT / PATCH (the
|
||||
// org-scope backup uses BackupRunner.Run, not WriteAuditRow, because the
|
||||
// event_type is 'backup_created' not 'data_export').
|
||||
func (s *ExportService) WriteOrg(ctx context.Context, w io.Writer, spec ExportSpec) (ExportMeta, error) {
|
||||
if spec.Scope == "" {
|
||||
spec.Scope = ExportScopeOrg
|
||||
}
|
||||
if spec.GeneratedAt.IsZero() {
|
||||
spec.GeneratedAt = time.Now().UTC()
|
||||
}
|
||||
meta := ExportMeta{
|
||||
SchemaVersion: ExportSchemaVersion,
|
||||
FirmName: s.firmName,
|
||||
Scope: spec.Scope,
|
||||
GeneratedAt: spec.GeneratedAt,
|
||||
GeneratedByID: spec.ActorID,
|
||||
GeneratedByEml: spec.ActorEmail,
|
||||
GeneratedByLbl: spec.ActorLabel,
|
||||
RowCounts: map[string]int{},
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTxx(ctx, &sql.TxOptions{
|
||||
Isolation: sql.LevelRepeatableRead,
|
||||
ReadOnly: true,
|
||||
})
|
||||
if err != nil {
|
||||
return meta, fmt.Errorf("backup snapshot tx: %w", err)
|
||||
}
|
||||
// Always rollback — the tx is read-only by construction, the rollback
|
||||
// is just bookkeeping that releases the snapshot.
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
sheets := orgSheetQueries()
|
||||
if err := s.writeBundle(ctx, tx, w, sheets, &meta); err != nil {
|
||||
return meta, err
|
||||
}
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
// detectCrossSubtreeFKs scans subtree-resident projects for FKs that
|
||||
// point outside the subtree (today: only projects.counterclaim_of). One
|
||||
// warning row per outbound reference. Best-effort: a query error here
|
||||
@@ -300,13 +350,17 @@ type collectedSheet struct {
|
||||
// xlsx sheet + one JSON branch + one CSV per sheet, packs everything into
|
||||
// the outer zip in sorted file-list order so two runs of the same row
|
||||
// state produce byte-identical bundles.
|
||||
func (s *ExportService) writeBundle(ctx context.Context, w io.Writer, sheets []sheetQuery, meta *ExportMeta) error {
|
||||
//
|
||||
// queryer is the executor for sheet queries — typically s.db, but
|
||||
// WriteOrg passes a REPEATABLE READ *sqlx.Tx so the org dump sees a
|
||||
// consistent snapshot across all sheets (design §3.3).
|
||||
func (s *ExportService) writeBundle(ctx context.Context, queryer sqlx.QueryerContext, w io.Writer, sheets []sheetQuery, meta *ExportMeta) error {
|
||||
collectedSheets := make([]collectedSheet, 0, len(sheets))
|
||||
jsonTables := make(map[string][]map[string]string, len(sheets))
|
||||
warnings := []string{}
|
||||
|
||||
for _, sq := range sheets {
|
||||
cols, rowMatrix, dropped, err := s.runSheetQuery(ctx, sq)
|
||||
cols, rowMatrix, dropped, err := s.runSheetQuery(ctx, queryer, sq)
|
||||
if err != nil {
|
||||
return fmt.Errorf("export sheet %q: %w", sq.SheetName, err)
|
||||
}
|
||||
@@ -421,11 +475,13 @@ func (s *ExportService) writeBundle(ctx context.Context, w io.Writer, sheets []s
|
||||
return nil
|
||||
}
|
||||
|
||||
// runSheetQuery executes one sheetQuery and returns the kept columns,
|
||||
// row matrix (pre-stringified per the design's value-as-string convention),
|
||||
// and the list of columns that were dropped by the PII filter.
|
||||
func (s *ExportService) runSheetQuery(ctx context.Context, sq sheetQuery) (cols []string, rows [][]string, dropped []string, err error) {
|
||||
rs, err := s.db.QueryxContext(ctx, sq.SQL, sq.Args...)
|
||||
// runSheetQuery executes one sheetQuery against the given queryer and
|
||||
// returns the kept columns, row matrix (pre-stringified per the design's
|
||||
// value-as-string convention), and the list of columns that were dropped
|
||||
// by the PII filter. queryer is typically s.db, but WriteOrg passes a
|
||||
// REPEATABLE READ *sqlx.Tx (see writeBundle docs).
|
||||
func (s *ExportService) runSheetQuery(ctx context.Context, queryer sqlx.QueryerContext, sq sheetQuery) (cols []string, rows [][]string, dropped []string, err error) {
|
||||
rs, err := queryer.QueryxContext(ctx, sq.SQL, sq.Args...)
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("query: %w", err)
|
||||
}
|
||||
@@ -1470,3 +1526,107 @@ SELECT 'partner_unit_default'::text AS source,
|
||||
}
|
||||
return queries
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Org-scope sheet registry (Slice 3 / Backup Mode — t-paliad-246).
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// Full-schema dump. Bypasses paliad.can_see_project — admin-only,
|
||||
// gated at the handler layer (BackupRunner trusts the caller).
|
||||
//
|
||||
// Sheet ordering: entity sheets first (alphabetical), then ref__*
|
||||
// reference sheets (alphabetical). The xlsx writer iterates the slice
|
||||
// in order; downstream consumers get the same order across runs.
|
||||
//
|
||||
// Hard exclusions (per design §5.2 / m's Q3 decision):
|
||||
//
|
||||
// - paliadin_turns
|
||||
// - paliadin_aichat_conversation
|
||||
//
|
||||
// AI conversation history is the most-sensitive personal data paliad
|
||||
// carries; m's prior Q5 decision in t-paliad-214 made the exclusion
|
||||
// structural. The two tables are absent from the registry — not just
|
||||
// column-level redacted — so a future schema addition cannot
|
||||
// accidentally re-include them.
|
||||
//
|
||||
// Also excluded unconditionally (operational / shadow):
|
||||
//
|
||||
// - *_pre_NNN shadow tables (CREATE TABLE … AS SELECT backups
|
||||
// written by destructive migrations)
|
||||
// - paliad_schema_migrations (operational)
|
||||
// - auth.* (Supabase Auth schema — not ours)
|
||||
//
|
||||
// The PII column deny-regex (piiColumnDenyRegex) catches
|
||||
// secret|token|password|api_key|private_key on every sheet as a
|
||||
// belt-and-braces filter. user_caldav_config.password_encrypted is
|
||||
// explicitly named in DropColumns too.
|
||||
func orgSheetQueries() []sheetQuery {
|
||||
return []sheetQuery{
|
||||
// --- entity sheets (alphabetical) ---
|
||||
{SheetName: "appointment_caldav_targets", SQL: `SELECT * FROM paliad.appointment_caldav_targets ORDER BY appointment_id, calendar_binding_id`},
|
||||
{SheetName: "appointments", SQL: `SELECT * FROM paliad.appointments ORDER BY id`},
|
||||
{SheetName: "approval_policies", SQL: `SELECT * FROM paliad.approval_policies ORDER BY id`},
|
||||
{SheetName: "approval_requests", SQL: `SELECT * FROM paliad.approval_requests ORDER BY id`},
|
||||
// backups is self-reflexive — including it makes "what backups
|
||||
// have we taken" recoverable from any prior backup. Tiny table.
|
||||
{SheetName: "backups", SQL: `SELECT * FROM paliad.backups ORDER BY started_at, id`},
|
||||
{SheetName: "caldav_sync_log", SQL: `SELECT * FROM paliad.caldav_sync_log ORDER BY occurred_at, id`},
|
||||
{SheetName: "checklist_instances", SQL: `SELECT * FROM paliad.checklist_instances ORDER BY id`},
|
||||
{SheetName: "checklist_shares", SQL: `SELECT * FROM paliad.checklist_shares ORDER BY id`},
|
||||
{SheetName: "checklists", SQL: `SELECT * FROM paliad.checklists ORDER BY id`},
|
||||
{SheetName: "deadline_rule_audit", SQL: `SELECT * FROM paliad.deadline_rule_audit ORDER BY changed_at, id`},
|
||||
{SheetName: "deadlines", SQL: `SELECT * FROM paliad.deadlines ORDER BY id`},
|
||||
// documents: ai_extracted jsonb dropped (verbose AI prompts;
|
||||
// matches the personal/project precedent). Binaries are not in
|
||||
// the export — only metadata.
|
||||
{
|
||||
SheetName: "documents",
|
||||
SQL: `SELECT id, project_id, title, doc_type, file_path, file_size, mime_type, uploaded_by, created_at, updated_at
|
||||
FROM paliad.documents
|
||||
ORDER BY id`,
|
||||
},
|
||||
{SheetName: "email_broadcasts", SQL: `SELECT * FROM paliad.email_broadcasts ORDER BY id`},
|
||||
{SheetName: "email_template_versions", SQL: `SELECT * FROM paliad.email_template_versions ORDER BY id`},
|
||||
{SheetName: "email_templates", SQL: `SELECT * FROM paliad.email_templates ORDER BY id`},
|
||||
{SheetName: "firm_dashboard_default", SQL: `SELECT * FROM paliad.firm_dashboard_default ORDER BY id`},
|
||||
{SheetName: "invitations", SQL: `SELECT * FROM paliad.invitations ORDER BY sent_at, id`},
|
||||
{SheetName: "notes", SQL: `SELECT * FROM paliad.notes ORDER BY id`},
|
||||
{SheetName: "parties", SQL: `SELECT * FROM paliad.parties ORDER BY id`},
|
||||
{SheetName: "partner_unit_events", SQL: `SELECT * FROM paliad.partner_unit_events ORDER BY id`},
|
||||
{SheetName: "partner_unit_members", SQL: `SELECT * FROM paliad.partner_unit_members ORDER BY partner_unit_id, user_id`},
|
||||
{SheetName: "partner_units", SQL: `SELECT * FROM paliad.partner_units ORDER BY id`},
|
||||
{SheetName: "policy_audit_log", SQL: `SELECT * FROM paliad.policy_audit_log ORDER BY changed_at, id`},
|
||||
{SheetName: "project_events", SQL: `SELECT * FROM paliad.project_events ORDER BY id`},
|
||||
{SheetName: "project_partner_units", SQL: `SELECT * FROM paliad.project_partner_units ORDER BY project_id, partner_unit_id`},
|
||||
{SheetName: "project_teams", SQL: `SELECT * FROM paliad.project_teams ORDER BY project_id, user_id`},
|
||||
{SheetName: "projects", SQL: `SELECT * FROM paliad.projects ORDER BY id`},
|
||||
{SheetName: "reminder_log", SQL: `SELECT * FROM paliad.reminder_log ORDER BY sent_at, id`},
|
||||
{SheetName: "submission_drafts", SQL: `SELECT * FROM paliad.submission_drafts ORDER BY id`},
|
||||
{SheetName: "system_audit_log", SQL: `SELECT * FROM paliad.system_audit_log ORDER BY created_at, id`},
|
||||
{
|
||||
SheetName: "user_caldav_config",
|
||||
SQL: `SELECT * FROM paliad.user_caldav_config ORDER BY user_id`,
|
||||
DropColumns: []string{"password_encrypted"}, // belt-and-braces; piiColumnDenyRegex also catches it
|
||||
},
|
||||
{SheetName: "user_calendar_bindings", SQL: `SELECT * FROM paliad.user_calendar_bindings ORDER BY user_id, calendar_path`},
|
||||
{SheetName: "user_card_layouts", SQL: `SELECT * FROM paliad.user_card_layouts ORDER BY id`},
|
||||
{SheetName: "user_dashboard_layouts", SQL: `SELECT * FROM paliad.user_dashboard_layouts ORDER BY user_id`},
|
||||
{SheetName: "user_pinned_projects", SQL: `SELECT * FROM paliad.user_pinned_projects ORDER BY user_id, project_id`},
|
||||
{SheetName: "user_views", SQL: `SELECT * FROM paliad.user_views ORDER BY id`},
|
||||
{SheetName: "users", SQL: `SELECT * FROM paliad.users ORDER BY id`},
|
||||
|
||||
// --- reference data (alphabetical, prefixed ref__) ---
|
||||
{SheetName: "ref__countries", SQL: `SELECT * FROM paliad.countries ORDER BY code`},
|
||||
{SheetName: "ref__courts", SQL: `SELECT * FROM paliad.courts ORDER BY id`},
|
||||
{SheetName: "ref__deadline_concept_event_types", SQL: `SELECT * FROM paliad.deadline_concept_event_types ORDER BY concept_id, event_type_id`},
|
||||
{SheetName: "ref__deadline_concepts", SQL: `SELECT * FROM paliad.deadline_concepts ORDER BY id`},
|
||||
{SheetName: "ref__deadline_event_types", SQL: `SELECT * FROM paliad.deadline_event_types ORDER BY rule_id, event_type_id`},
|
||||
{SheetName: "ref__deadline_rules", SQL: `SELECT * FROM paliad.deadline_rules ORDER BY id`},
|
||||
{SheetName: "ref__event_categories", SQL: `SELECT * FROM paliad.event_categories ORDER BY id`},
|
||||
{SheetName: "ref__event_category_concepts", SQL: `SELECT * FROM paliad.event_category_concepts ORDER BY category_id, concept_id`},
|
||||
{SheetName: "ref__event_types", SQL: `SELECT * FROM paliad.event_types ORDER BY id`},
|
||||
{SheetName: "ref__holidays", SQL: `SELECT * FROM paliad.holidays ORDER BY date, country`},
|
||||
{SheetName: "ref__proceeding_types", SQL: `SELECT * FROM paliad.proceeding_types ORDER BY id`},
|
||||
{SheetName: "ref__trigger_events", SQL: `SELECT * FROM paliad.trigger_events ORDER BY id`},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
570
internal/services/submission_draft_service.go
Normal file
570
internal/services/submission_draft_service.go
Normal file
@@ -0,0 +1,570 @@
|
||||
package services
|
||||
|
||||
// Submission draft service — CRUD over paliad.submission_drafts plus
|
||||
// the render+export entry points that combine the variable bag, lawyer
|
||||
// overrides, and template fetch into a .docx or HTML preview
|
||||
// (t-paliad-238 Slice A, design doc
|
||||
// docs/design-submission-page-2026-05-22.md §5.2).
|
||||
//
|
||||
// Each draft is owned by one user; multiple drafts per (project,
|
||||
// submission_code, user_id) are supported via the `name` column. The
|
||||
// override semantics are explicit:
|
||||
//
|
||||
// variables = {"project.case_number": "2 O 999/25"} → use this value
|
||||
// variables = {"project.case_number": ""} → force [KEIN WERT: …]
|
||||
// key absent → fall back to bag
|
||||
//
|
||||
// Visibility flows through ProjectService.GetByID — every read and
|
||||
// write gates on paliad.can_see_project. RLS in the DB enforces the
|
||||
// owner-scoped UPDATE/DELETE constraint independently of the Go layer.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/models"
|
||||
)
|
||||
|
||||
// SubmissionDraft mirrors a row in paliad.submission_drafts.
|
||||
//
|
||||
// ProjectID is nullable since t-paliad-243 — a draft started from the
|
||||
// global /submissions/new picker without picking a project is private
|
||||
// to its creator and carries an empty variable bag (no project /
|
||||
// parties / deadline state to resolve). All callers must check for nil
|
||||
// before treating it as a uuid.
|
||||
type SubmissionDraft struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
ProjectID *uuid.UUID `db:"project_id" json:"project_id,omitempty"`
|
||||
SubmissionCode string `db:"submission_code" json:"submission_code"`
|
||||
UserID uuid.UUID `db:"user_id" json:"user_id"`
|
||||
Name string `db:"name" json:"name"`
|
||||
VariablesRaw []byte `db:"variables" json:"-"`
|
||||
LastExportedAt *time.Time `db:"last_exported_at" json:"last_exported_at,omitempty"`
|
||||
LastExportedSHA *string `db:"last_exported_sha" json:"last_exported_sha,omitempty"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
|
||||
// Variables is the decoded overrides map; populated on read by the
|
||||
// service so callers don't have to unmarshal manually.
|
||||
Variables PlaceholderMap `json:"variables"`
|
||||
}
|
||||
|
||||
// SubmissionDraftService handles CRUD on submission_drafts and exposes
|
||||
// the render/preview/export entry points the handler layer calls.
|
||||
type SubmissionDraftService struct {
|
||||
db *sqlx.DB
|
||||
projects *ProjectService
|
||||
vars *SubmissionVarsService
|
||||
renderer *SubmissionRenderer
|
||||
}
|
||||
|
||||
// NewSubmissionDraftService wires the service.
|
||||
func NewSubmissionDraftService(db *sqlx.DB, projects *ProjectService, vars *SubmissionVarsService, renderer *SubmissionRenderer) *SubmissionDraftService {
|
||||
return &SubmissionDraftService{
|
||||
db: db,
|
||||
projects: projects,
|
||||
vars: vars,
|
||||
renderer: renderer,
|
||||
}
|
||||
}
|
||||
|
||||
// DraftPatch carries optional fields for Update. nil pointer = "no
|
||||
// change"; non-nil = "set to this". Variables is replace-semantics —
|
||||
// the lawyer's sidebar sends the full map every save.
|
||||
//
|
||||
// ProjectID uses a two-level pointer (t-paliad-243) so we can encode
|
||||
// the three operations the global drafts flow needs:
|
||||
//
|
||||
// patch.ProjectID == nil → no change
|
||||
// *patch.ProjectID == nil → detach (re-set to NULL)
|
||||
// **patch.ProjectID → attach (assign a project)
|
||||
//
|
||||
// The detach path stays as scope for symmetry with attach even though
|
||||
// the current frontend only exposes attach.
|
||||
type DraftPatch struct {
|
||||
Name *string
|
||||
Variables *PlaceholderMap
|
||||
ProjectID **uuid.UUID
|
||||
}
|
||||
|
||||
// ErrSubmissionDraftNotFound is the sentinel for "no draft with that id
|
||||
// visible to this user". Maps to 404 in the handler.
|
||||
var ErrSubmissionDraftNotFound = errors.New("submission draft: not found")
|
||||
|
||||
// ErrSubmissionDraftNameTaken is the sentinel for duplicate names per
|
||||
// (project, submission_code, user). Maps to 409 in the handler.
|
||||
var ErrSubmissionDraftNameTaken = errors.New("submission draft: name already taken")
|
||||
|
||||
// draftColumns is the canonical select list — kept in one place so
|
||||
// every fetch stays in sync.
|
||||
const draftColumns = `id, project_id, submission_code, user_id, name,
|
||||
variables, last_exported_at, last_exported_sha,
|
||||
created_at, updated_at`
|
||||
|
||||
// List returns every draft for (project, submission_code, user)
|
||||
// ordered by updated_at DESC. Visibility flows through projects.GetByID.
|
||||
func (s *SubmissionDraftService) List(ctx context.Context, userID, projectID uuid.UUID, submissionCode string) ([]SubmissionDraft, error) {
|
||||
if _, err := s.projects.GetByID(ctx, userID, projectID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var rows []SubmissionDraft
|
||||
err := s.db.SelectContext(ctx, &rows,
|
||||
`SELECT `+draftColumns+`
|
||||
FROM paliad.submission_drafts
|
||||
WHERE project_id = $1 AND submission_code = $2 AND user_id = $3
|
||||
ORDER BY updated_at DESC`,
|
||||
projectID, submissionCode, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list submission drafts: %w", err)
|
||||
}
|
||||
for i := range rows {
|
||||
if err := rows[i].decodeVariables(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// DraftWithProject is the row shape for the global /submissions index —
|
||||
// a draft joined with the minimal project metadata the table needs.
|
||||
// Visibility is gated by paliad.can_see_project in the SELECT itself.
|
||||
//
|
||||
// ProjectTitle / ProjectReference are pointer-nullable since
|
||||
// t-paliad-243 — project-less drafts surface in the same list with a
|
||||
// NULL project ref, and the frontend renders them with a dedicated
|
||||
// "kein Projekt" label.
|
||||
type DraftWithProject struct {
|
||||
SubmissionDraft
|
||||
ProjectTitle *string `db:"project_title" json:"project_title,omitempty"`
|
||||
ProjectReference *string `db:"project_reference" json:"project_reference,omitempty"`
|
||||
}
|
||||
|
||||
// ListAllForUser returns every draft the user owns across visible
|
||||
// projects PLUS every project-less draft the user owns, ordered by
|
||||
// updated_at DESC. LEFT JOIN on paliad.projects keeps project-less rows
|
||||
// in the result set; the WHERE clause permits project_id IS NULL or a
|
||||
// visible can_see_project hit, so a draft on a project the user no
|
||||
// longer has access to is silently dropped.
|
||||
func (s *SubmissionDraftService) ListAllForUser(ctx context.Context, userID uuid.UUID) ([]DraftWithProject, error) {
|
||||
var rows []DraftWithProject
|
||||
err := s.db.SelectContext(ctx, &rows,
|
||||
`SELECT d.id, d.project_id, d.submission_code, d.user_id, d.name,
|
||||
d.variables, d.last_exported_at, d.last_exported_sha,
|
||||
d.created_at, d.updated_at,
|
||||
p.title AS project_title,
|
||||
p.reference AS project_reference
|
||||
FROM paliad.submission_drafts d
|
||||
LEFT JOIN paliad.projects p ON p.id = d.project_id
|
||||
WHERE d.user_id = $1
|
||||
AND (
|
||||
d.project_id IS NULL
|
||||
OR paliad.can_see_project(d.project_id)
|
||||
)
|
||||
ORDER BY d.updated_at DESC`,
|
||||
userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list all submission drafts for user: %w", err)
|
||||
}
|
||||
for i := range rows {
|
||||
if err := rows[i].decodeVariables(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// Get returns a single draft by id, gated on project visibility AND
|
||||
// owner-only — the caller can only fetch drafts they own. RLS in the
|
||||
// DB enforces this independently; the Go check makes the 404 semantics
|
||||
// explicit at the service boundary.
|
||||
//
|
||||
// A project-less draft (ProjectID == nil) skips the can_see_project
|
||||
// gate — the owner-only constraint is the entire visibility check.
|
||||
func (s *SubmissionDraftService) Get(ctx context.Context, userID, draftID uuid.UUID) (*SubmissionDraft, error) {
|
||||
var d SubmissionDraft
|
||||
err := s.db.GetContext(ctx, &d,
|
||||
`SELECT `+draftColumns+`
|
||||
FROM paliad.submission_drafts
|
||||
WHERE id = $1 AND user_id = $2`,
|
||||
draftID, userID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrSubmissionDraftNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get submission draft: %w", err)
|
||||
}
|
||||
if d.ProjectID != nil {
|
||||
if _, err := s.projects.GetByID(ctx, userID, *d.ProjectID); err != nil {
|
||||
// Project no longer visible → behave as not-found rather than
|
||||
// leaking the draft's existence. ON DELETE CASCADE keeps this
|
||||
// rare in practice.
|
||||
if errors.Is(err, ErrNotVisible) {
|
||||
return nil, ErrSubmissionDraftNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := d.decodeVariables(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &d, nil
|
||||
}
|
||||
|
||||
// EnsureLatest returns the user's most-recently-updated draft for
|
||||
// (project, submission_code). Creates "Entwurf 1" / "Draft 1" if none
|
||||
// exists. Idempotent on repeat calls — once a draft exists, EnsureLatest
|
||||
// always returns the freshest one rather than spawning new rows.
|
||||
func (s *SubmissionDraftService) EnsureLatest(ctx context.Context, userID, projectID uuid.UUID, submissionCode, lang string) (*SubmissionDraft, error) {
|
||||
if _, err := s.projects.GetByID(ctx, userID, projectID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var d SubmissionDraft
|
||||
err := s.db.GetContext(ctx, &d,
|
||||
`SELECT `+draftColumns+`
|
||||
FROM paliad.submission_drafts
|
||||
WHERE project_id = $1 AND submission_code = $2 AND user_id = $3
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT 1`,
|
||||
projectID, submissionCode, userID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return s.Create(ctx, userID, &projectID, submissionCode, lang)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ensure latest submission draft: %w", err)
|
||||
}
|
||||
if err := d.decodeVariables(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &d, nil
|
||||
}
|
||||
|
||||
// Create makes a new draft with an auto-incremented "Entwurf N" name
|
||||
// ("Draft N" for English locale). Lawyer can rename via Update.
|
||||
//
|
||||
// A nil projectID creates a project-less draft (t-paliad-243); the
|
||||
// visibility check is skipped — the caller is the owner and the row is
|
||||
// private to them.
|
||||
func (s *SubmissionDraftService) Create(ctx context.Context, userID uuid.UUID, projectID *uuid.UUID, submissionCode, lang string) (*SubmissionDraft, error) {
|
||||
if projectID != nil {
|
||||
if _, err := s.projects.GetByID(ctx, userID, *projectID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
name, err := s.nextDraftName(ctx, projectID, submissionCode, userID, lang)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var d SubmissionDraft
|
||||
err = s.db.GetContext(ctx, &d,
|
||||
`INSERT INTO paliad.submission_drafts
|
||||
(project_id, submission_code, user_id, name)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING `+draftColumns,
|
||||
projectID, submissionCode, userID, name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create submission draft: %w", err)
|
||||
}
|
||||
if err := d.decodeVariables(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &d, nil
|
||||
}
|
||||
|
||||
// nextDraftName returns "Entwurf N" / "Draft N" with N = (highest
|
||||
// existing N + 1), or N=1 if no draft yet. Falls back to a unique
|
||||
// suffix if two callers race; the unique constraint on the table is
|
||||
// the final guard.
|
||||
//
|
||||
// A nil projectID scopes the search to the user's project-less drafts
|
||||
// for this submission_code — matches the row-uniqueness contract on
|
||||
// the DB side (project_id, submission_code, user_id, name) where
|
||||
// project_id IS NULL is its own equivalence class.
|
||||
func (s *SubmissionDraftService) nextDraftName(ctx context.Context, projectID *uuid.UUID, submissionCode string, userID uuid.UUID, lang string) (string, error) {
|
||||
prefix := "Entwurf"
|
||||
if strings.EqualFold(lang, "en") {
|
||||
prefix = "Draft"
|
||||
}
|
||||
var names []string
|
||||
var err error
|
||||
if projectID == nil {
|
||||
err = s.db.SelectContext(ctx, &names,
|
||||
`SELECT name FROM paliad.submission_drafts
|
||||
WHERE project_id IS NULL AND submission_code = $1 AND user_id = $2`,
|
||||
submissionCode, userID)
|
||||
} else {
|
||||
err = s.db.SelectContext(ctx, &names,
|
||||
`SELECT name FROM paliad.submission_drafts
|
||||
WHERE project_id = $1 AND submission_code = $2 AND user_id = $3`,
|
||||
*projectID, submissionCode, userID)
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("scan existing draft names: %w", err)
|
||||
}
|
||||
highest := 0
|
||||
for _, n := range names {
|
||||
var idx int
|
||||
if _, scanErr := fmt.Sscanf(n, prefix+" %d", &idx); scanErr == nil && idx > highest {
|
||||
highest = idx
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("%s %d", prefix, highest+1), nil
|
||||
}
|
||||
|
||||
// Update patches the draft. Variables is replace-semantics — pass the
|
||||
// full map. Name patches go through a uniqueness check to surface
|
||||
// ErrSubmissionDraftNameTaken cleanly instead of a raw constraint
|
||||
// violation.
|
||||
func (s *SubmissionDraftService) Update(ctx context.Context, userID, draftID uuid.UUID, patch DraftPatch) (*SubmissionDraft, error) {
|
||||
existing, err := s.Get(ctx, userID, draftID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
setParts := []string{}
|
||||
args := []any{}
|
||||
idx := 1
|
||||
|
||||
if patch.Name != nil {
|
||||
newName := strings.TrimSpace(*patch.Name)
|
||||
if newName == "" {
|
||||
return nil, ErrInvalidInput
|
||||
}
|
||||
if newName != existing.Name {
|
||||
// Pre-check for the unique constraint so we can return a
|
||||
// typed error instead of a raw PG conflict. NULL project_id
|
||||
// is its own equivalence class in the unique index (NULLs
|
||||
// don't collide), so the no-project flow checks `IS NULL`.
|
||||
var dup int
|
||||
var qErr error
|
||||
if existing.ProjectID == nil {
|
||||
qErr = s.db.GetContext(ctx, &dup,
|
||||
`SELECT COUNT(*) FROM paliad.submission_drafts
|
||||
WHERE project_id IS NULL AND submission_code = $1
|
||||
AND user_id = $2 AND name = $3 AND id <> $4`,
|
||||
existing.SubmissionCode, userID, newName, draftID)
|
||||
} else {
|
||||
qErr = s.db.GetContext(ctx, &dup,
|
||||
`SELECT COUNT(*) FROM paliad.submission_drafts
|
||||
WHERE project_id = $1 AND submission_code = $2
|
||||
AND user_id = $3 AND name = $4 AND id <> $5`,
|
||||
*existing.ProjectID, existing.SubmissionCode, userID, newName, draftID)
|
||||
}
|
||||
if qErr != nil {
|
||||
return nil, fmt.Errorf("check name uniqueness: %w", qErr)
|
||||
}
|
||||
if dup > 0 {
|
||||
return nil, ErrSubmissionDraftNameTaken
|
||||
}
|
||||
}
|
||||
setParts = append(setParts, fmt.Sprintf("name = $%d", idx))
|
||||
args = append(args, newName)
|
||||
idx++
|
||||
}
|
||||
|
||||
if patch.Variables != nil {
|
||||
raw, err := json.Marshal(*patch.Variables)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal variables: %w", err)
|
||||
}
|
||||
setParts = append(setParts, fmt.Sprintf("variables = $%d::jsonb", idx))
|
||||
args = append(args, string(raw))
|
||||
idx++
|
||||
}
|
||||
|
||||
if patch.ProjectID != nil {
|
||||
newPID := *patch.ProjectID // *uuid.UUID — nil means detach
|
||||
if newPID != nil {
|
||||
// Caller must be able to see the project they're attaching
|
||||
// the draft to; same gate as Create.
|
||||
if _, err := s.projects.GetByID(ctx, userID, *newPID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
setParts = append(setParts, fmt.Sprintf("project_id = $%d", idx))
|
||||
args = append(args, newPID)
|
||||
idx++
|
||||
}
|
||||
|
||||
if len(setParts) == 0 {
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
args = append(args, draftID, userID)
|
||||
q := fmt.Sprintf(
|
||||
`UPDATE paliad.submission_drafts
|
||||
SET %s
|
||||
WHERE id = $%d AND user_id = $%d
|
||||
RETURNING %s`,
|
||||
strings.Join(setParts, ", "), idx, idx+1, draftColumns,
|
||||
)
|
||||
|
||||
var d SubmissionDraft
|
||||
err = s.db.GetContext(ctx, &d, q, args...)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrSubmissionDraftNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("update submission draft: %w", err)
|
||||
}
|
||||
if err := d.decodeVariables(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &d, nil
|
||||
}
|
||||
|
||||
// Delete removes the draft. Visibility-gated via Get; the DELETE itself
|
||||
// is owner-scoped (user_id = caller).
|
||||
func (s *SubmissionDraftService) Delete(ctx context.Context, userID, draftID uuid.UUID) error {
|
||||
if _, err := s.Get(ctx, userID, draftID); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`DELETE FROM paliad.submission_drafts WHERE id = $1 AND user_id = $2`,
|
||||
draftID, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete submission draft: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarkExported updates the last_exported_* columns after a successful
|
||||
// export. Background-context safe.
|
||||
func (s *SubmissionDraftService) MarkExported(ctx context.Context, draftID uuid.UUID, templateSHA string) error {
|
||||
var sha any
|
||||
if templateSHA != "" {
|
||||
sha = templateSHA
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`UPDATE paliad.submission_drafts
|
||||
SET last_exported_at = now(),
|
||||
last_exported_sha = $1
|
||||
WHERE id = $2`,
|
||||
sha, draftID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mark submission draft exported: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BuildRenderBag composes the placeholder map for a draft — pulls
|
||||
// project/parties/rule/deadline state from SubmissionVarsService, then
|
||||
// layers the lawyer's overrides on top.
|
||||
//
|
||||
// Override semantics:
|
||||
//
|
||||
// variables[key] = "" → delete the key (force [KEIN WERT: key])
|
||||
// variables[key] = "X" → bag[key] = "X"
|
||||
// key absent → bag[key] unchanged (falls back to resolved value)
|
||||
//
|
||||
// Returns the final PlaceholderMap along with the SubmissionVarsResult
|
||||
// so callers (export, file naming) get the resolved entities too. A
|
||||
// project-less draft (ProjectID == nil, t-paliad-243) skips project /
|
||||
// parties / deadline lookups — the resolved bag carries only the
|
||||
// user-independent variables (firm, today) plus the user.* group; the
|
||||
// lawyer's overrides fill the rest.
|
||||
func (s *SubmissionDraftService) BuildRenderBag(ctx context.Context, draft *SubmissionDraft) (PlaceholderMap, *SubmissionVarsResult, error) {
|
||||
resolved, err := s.vars.Build(ctx, SubmissionVarsContext{
|
||||
UserID: draft.UserID,
|
||||
ProjectID: draft.ProjectID,
|
||||
SubmissionCode: draft.SubmissionCode,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
bag := PlaceholderMap{}
|
||||
maps.Copy(bag, resolved.Placeholders)
|
||||
for k, v := range draft.Variables {
|
||||
if v == "" {
|
||||
delete(bag, k)
|
||||
continue
|
||||
}
|
||||
bag[k] = v
|
||||
}
|
||||
return bag, resolved, nil
|
||||
}
|
||||
|
||||
// RenderPreview returns the HTML preview of the merged document body
|
||||
// for the draft-editor preview pane. Read-only; emits one <p> per <w:p>
|
||||
// with <strong>/<em> spans for runs flagged bold/italic.
|
||||
func (s *SubmissionDraftService) RenderPreview(ctx context.Context, draft *SubmissionDraft, templateBytes []byte) (string, error) {
|
||||
bag, resolved, err := s.BuildRenderBag(ctx, draft)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return s.renderer.RenderHTML(templateBytes, bag, DefaultMissingMarker(resolved.Lang))
|
||||
}
|
||||
|
||||
// Export renders the merged .docx for download. Returns the bytes, the
|
||||
// resolved bag (for audit row + file naming), and the variables result
|
||||
// (lang, rule.Name, project.case_number). Callers wire MarkExported and
|
||||
// the audit writes.
|
||||
func (s *SubmissionDraftService) Export(ctx context.Context, draft *SubmissionDraft, templateBytes []byte) ([]byte, *SubmissionVarsResult, error) {
|
||||
bag, resolved, err := s.BuildRenderBag(ctx, draft)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
out, err := s.renderer.Render(templateBytes, bag, DefaultMissingMarker(resolved.Lang))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
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 {
|
||||
if len(d.VariablesRaw) == 0 {
|
||||
d.Variables = PlaceholderMap{}
|
||||
return nil
|
||||
}
|
||||
out := PlaceholderMap{}
|
||||
if err := json.Unmarshal(d.VariablesRaw, &out); err != nil {
|
||||
return fmt.Errorf("decode submission draft variables: %w", err)
|
||||
}
|
||||
d.Variables = out
|
||||
return nil
|
||||
}
|
||||
|
||||
// Compile-time guard: ensure the *models.User reference in the import
|
||||
// graph doesn't get optimised away by linters. The service doesn't
|
||||
// dereference User directly — that happens in SubmissionVarsService —
|
||||
// but the import keeps the package compile-time-aware of the dependency
|
||||
// chain that wires us into the bundle.
|
||||
var _ = (*models.User)(nil)
|
||||
447
internal/services/submission_merge.go
Normal file
447
internal/services/submission_merge.go
Normal file
@@ -0,0 +1,447 @@
|
||||
package services
|
||||
|
||||
// Submission template renderer — in-house engine for the submission
|
||||
// draft editor (t-paliad-238, design doc
|
||||
// docs/design-submission-page-2026-05-22.md §3 / §6.2).
|
||||
//
|
||||
// Resurrected from commit 8ea3509 (the original t-paliad-215 Slice 1
|
||||
// "in-house .docx render engine"). Kept in a separate file from the
|
||||
// format-only converter (submission_render.go) so the t-paliad-230
|
||||
// /generate one-click path stays unchanged and the merge engine doesn't
|
||||
// have to share zip-helper names with it.
|
||||
//
|
||||
// Why not lukasjarosch/go-docx: the library's "nested placeholder" guard
|
||||
// treats sibling placeholders inside the same <w:t> run (e.g.
|
||||
// "{{a}} ./. {{b}}") as nested and refuses to replace either. Patent
|
||||
// submissions routinely have multiple placeholders per paragraph (party
|
||||
// blocks especially), so the library is a non-starter. This renderer
|
||||
// handles single-run placeholders (preserving run-level formatting) AND
|
||||
// cross-run placeholders (rewriting the paragraph as one run when Word
|
||||
// has fragmented the placeholder across runs).
|
||||
//
|
||||
// Placeholder grammar: {{[A-Za-z][A-Za-z0-9_.]*}} with optional
|
||||
// whitespace inside braces ({{ project.case_number }} ≡
|
||||
// {{project.case_number}}).
|
||||
//
|
||||
// Missing-value behaviour: when a placeholder has no binding in the
|
||||
// PlaceholderMap, the renderer emits a marker token so the lawyer sees
|
||||
// the gap in Word rather than failing the request.
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// PlaceholderMap is the variable bag built by SubmissionVarsService.
|
||||
// Keys are dotted paths without braces (e.g. "project.case_number").
|
||||
// Values are the substituted text — already locale-aware, pretty-
|
||||
// printed, and sanitised by the caller.
|
||||
type PlaceholderMap map[string]string
|
||||
|
||||
// MissingPlaceholderFn translates an unbound placeholder key into the
|
||||
// in-document marker token. The default in DefaultMissingMarker is
|
||||
// "[KEIN WERT: <key>]" / "[NO VALUE: <key>]" depending on lang.
|
||||
type MissingPlaceholderFn func(key string) string
|
||||
|
||||
// DefaultMissingMarker returns the standard missing-value marker for
|
||||
// the given UI language.
|
||||
func DefaultMissingMarker(lang string) MissingPlaceholderFn {
|
||||
prefix := "KEIN WERT"
|
||||
if strings.EqualFold(lang, "en") {
|
||||
prefix = "NO VALUE"
|
||||
}
|
||||
return func(key string) string {
|
||||
return "[" + prefix + ": " + key + "]"
|
||||
}
|
||||
}
|
||||
|
||||
// placeholderRegex matches a single placeholder. The capture group
|
||||
// extracts the key name without braces or surrounding whitespace.
|
||||
//
|
||||
// Restricted to [A-Za-z][A-Za-z0-9_.]* so that stray "{{" sequences in
|
||||
// legal prose don't get mistaken for placeholders. A genuine placeholder
|
||||
// always starts with an ASCII letter.
|
||||
var placeholderRegex = regexp.MustCompile(`\{\{\s*([A-Za-z][A-Za-z0-9_.]*)\s*\}\}`)
|
||||
|
||||
// SubmissionRenderer renders a .docx template into a .docx output by
|
||||
// substituting {{placeholder}} tokens with values from a PlaceholderMap.
|
||||
// Stateless; safe for concurrent use.
|
||||
type SubmissionRenderer struct{}
|
||||
|
||||
// NewSubmissionRenderer constructs the renderer.
|
||||
func NewSubmissionRenderer() *SubmissionRenderer {
|
||||
return &SubmissionRenderer{}
|
||||
}
|
||||
|
||||
// Render reads the .docx template at templateBytes, substitutes every
|
||||
// placeholder from vars (or emits the missing-marker token), and returns
|
||||
// the merged .docx bytes. Unknown placeholders never fail the render —
|
||||
// the lawyer sees the marker in Word and fixes it.
|
||||
//
|
||||
// Pre-pass: ConvertDotmToDocx is called on the input so a .dotm
|
||||
// template (macro-bearing) is downgraded to a plain .docx before the
|
||||
// merge step runs. Idempotent on inputs that are already plain .docx.
|
||||
func (r *SubmissionRenderer) Render(templateBytes []byte, vars PlaceholderMap, missing MissingPlaceholderFn) ([]byte, error) {
|
||||
if missing == nil {
|
||||
missing = DefaultMissingMarker("de")
|
||||
}
|
||||
cleanBytes, err := ConvertDotmToDocx(templateBytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("submission render: pre-pass convert: %w", err)
|
||||
}
|
||||
zr, err := zip.NewReader(bytes.NewReader(cleanBytes), int64(len(cleanBytes)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("submission render: open zip: %w", err)
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
zw := zip.NewWriter(&out)
|
||||
|
||||
for _, entry := range zr.File {
|
||||
body, err := readMergeZipEntry(entry)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("submission render: read %s: %w", entry.Name, err)
|
||||
}
|
||||
if isWordXMLEntry(entry.Name) {
|
||||
body = substituteInDocumentXML(body, vars, missing)
|
||||
}
|
||||
w, err := zw.CreateHeader(&zip.FileHeader{
|
||||
Name: entry.Name,
|
||||
Method: entry.Method,
|
||||
Modified: entry.Modified,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("submission render: write header %s: %w", entry.Name, err)
|
||||
}
|
||||
if _, err := w.Write(body); err != nil {
|
||||
return nil, fmt.Errorf("submission render: write %s: %w", entry.Name, err)
|
||||
}
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
return nil, fmt.Errorf("submission render: finalise zip: %w", err)
|
||||
}
|
||||
return out.Bytes(), nil
|
||||
}
|
||||
|
||||
// RenderHTML produces a read-only HTML rendering of the merged document
|
||||
// body for the draft-editor preview pane. Walks the SAME placeholder
|
||||
// substitution as Render, then extracts the body text from word/document.xml
|
||||
// and emits semantic HTML — one <p> per <w:p>, with <strong>/<em> spans
|
||||
// for runs that carry <w:b>/<w:i> formatting. Tables, lists, and complex
|
||||
// formatting collapse to plain paragraphs (the preview is a fidelity
|
||||
// guide, not a WYSIWYG editor — final formatting comes from Word at
|
||||
// export).
|
||||
//
|
||||
// Returns escaped HTML safe to inject into the page via dangerouslySet
|
||||
// or innerHTML. The caller is responsible for wrapping in an outer
|
||||
// container; this method emits only the body fragment.
|
||||
func (r *SubmissionRenderer) RenderHTML(templateBytes []byte, vars PlaceholderMap, missing MissingPlaceholderFn) (string, error) {
|
||||
if missing == nil {
|
||||
missing = DefaultMissingMarker("de")
|
||||
}
|
||||
cleanBytes, err := ConvertDotmToDocx(templateBytes)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("submission render html: pre-pass convert: %w", err)
|
||||
}
|
||||
zr, err := zip.NewReader(bytes.NewReader(cleanBytes), int64(len(cleanBytes)))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("submission render html: open zip: %w", err)
|
||||
}
|
||||
var docXML []byte
|
||||
for _, entry := range zr.File {
|
||||
if entry.Name != "word/document.xml" {
|
||||
continue
|
||||
}
|
||||
docXML, err = readMergeZipEntry(entry)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("submission render html: read document.xml: %w", err)
|
||||
}
|
||||
break
|
||||
}
|
||||
if docXML == nil {
|
||||
return "", fmt.Errorf("submission render html: word/document.xml missing")
|
||||
}
|
||||
merged := substituteInDocumentXML(docXML, vars, missing)
|
||||
return docXMLToHTML(merged), nil
|
||||
}
|
||||
|
||||
// isWordXMLEntry returns true for the .docx parts that contain
|
||||
// substitutable text. We touch document.xml plus header*.xml and
|
||||
// footer*.xml (templates may put firm letterhead in a header) but
|
||||
// skip styles, theme, settings, comments, footnotes — none of which
|
||||
// should carry merge placeholders in a well-formed template.
|
||||
func isWordXMLEntry(name string) bool {
|
||||
switch {
|
||||
case name == "word/document.xml":
|
||||
return true
|
||||
case strings.HasPrefix(name, "word/header") && strings.HasSuffix(name, ".xml"):
|
||||
return true
|
||||
case strings.HasPrefix(name, "word/footer") && strings.HasSuffix(name, ".xml"):
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// readMergeZipEntry slurps a zip entry's bytes. Named distinctly from
|
||||
// the helper in submission_render.go (readZipFile) to keep this file
|
||||
// self-contained — the two are functionally identical.
|
||||
func readMergeZipEntry(f *zip.File) ([]byte, error) {
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rc.Close()
|
||||
return io.ReadAll(rc)
|
||||
}
|
||||
|
||||
// substituteInDocumentXML walks document XML and replaces every
|
||||
// {{placeholder}} occurrence inside <w:t> text nodes. Handles both
|
||||
// single-run placeholders (the common case for freshly authored
|
||||
// templates) and cross-run placeholders (where Word's autocorrect or
|
||||
// manual editing has split a placeholder across runs).
|
||||
//
|
||||
// Two-pass strategy:
|
||||
//
|
||||
// 1. Pass 1: replace placeholders that fit entirely within one
|
||||
// <w:t>…</w:t>. This is the 99% case and preserves all run-level
|
||||
// formatting (bold, italic, font runs).
|
||||
// 2. Pass 2: for paragraphs that still contain orphan "{{" or "}}"
|
||||
// markers after pass 1, merge the text of every <w:t> inside the
|
||||
// paragraph, run the replacement on the merged text, and rewrite
|
||||
// the paragraph's runs as a single <w:r><w:t>…</w:t></w:r> using
|
||||
// the formatting properties of the first run.
|
||||
func substituteInDocumentXML(body []byte, vars PlaceholderMap, missing MissingPlaceholderFn) []byte {
|
||||
replaced := substituteInTextNodes(body, vars, missing)
|
||||
if !needsCrossRunMerge(replaced) {
|
||||
return replaced
|
||||
}
|
||||
return substituteAcrossRuns(replaced, vars, missing)
|
||||
}
|
||||
|
||||
// wTextNodeRegex matches one <w:t …>contents</w:t> element, capturing
|
||||
// the contents.
|
||||
var wTextNodeRegex = regexp.MustCompile(`<w:t(\s[^>]*)?>([^<]*)</w:t>`)
|
||||
|
||||
// substituteInTextNodes runs the placeholder replacement inside each
|
||||
// <w:t> text node independently. Format-preserving for single-run
|
||||
// placeholders.
|
||||
func substituteInTextNodes(body []byte, vars PlaceholderMap, missing MissingPlaceholderFn) []byte {
|
||||
return wTextNodeRegex.ReplaceAllFunc(body, func(match []byte) []byte {
|
||||
sub := wTextNodeRegex.FindSubmatch(match)
|
||||
attrs := string(sub[1])
|
||||
contents := xmlDecode(string(sub[2]))
|
||||
replaced := replacePlaceholders(contents, vars, missing)
|
||||
if replaced == contents {
|
||||
return match
|
||||
}
|
||||
if !strings.Contains(attrs, "xml:space") &&
|
||||
(strings.HasPrefix(replaced, " ") || strings.HasSuffix(replaced, " ")) {
|
||||
attrs += ` xml:space="preserve"`
|
||||
}
|
||||
return []byte(`<w:t` + attrs + `>` + xmlEncode(replaced) + `</w:t>`)
|
||||
})
|
||||
}
|
||||
|
||||
// needsCrossRunMerge returns true when the body still contains an
|
||||
// unmatched "{{" or "}}" inside any <w:t> after pass 1.
|
||||
func needsCrossRunMerge(body []byte) bool {
|
||||
for _, m := range wTextNodeRegex.FindAllSubmatch(body, -1) {
|
||||
t := string(m[2])
|
||||
if strings.Contains(t, "{{") || strings.Contains(t, "}}") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// wParagraphRegex matches one <w:p>…</w:p> paragraph block. Greedy
|
||||
// inner-content match is safe — <w:p> elements do not nest.
|
||||
var wParagraphRegex = regexp.MustCompile(`(?s)<w:p\b[^>]*>.*?</w:p>`)
|
||||
|
||||
// wRunPropsRegex pulls the first <w:rPr>…</w:rPr> block from a paragraph.
|
||||
var wRunPropsRegex = regexp.MustCompile(`(?s)<w:rPr>.*?</w:rPr>`)
|
||||
|
||||
// wParagraphPropsRegex pulls the optional <w:pPr>…</w:pPr>.
|
||||
var wParagraphPropsRegex = regexp.MustCompile(`(?s)<w:pPr>.*?</w:pPr>`)
|
||||
|
||||
// substituteAcrossRuns is pass 2: concatenate every text node in a
|
||||
// fragmented-placeholder paragraph, run replacement, rewrite as one run.
|
||||
func substituteAcrossRuns(body []byte, vars PlaceholderMap, missing MissingPlaceholderFn) []byte {
|
||||
return wParagraphRegex.ReplaceAllFunc(body, func(para []byte) []byte {
|
||||
textNodes := wTextNodeRegex.FindAllSubmatch(para, -1)
|
||||
if len(textNodes) == 0 {
|
||||
return para
|
||||
}
|
||||
var merged strings.Builder
|
||||
for _, m := range textNodes {
|
||||
merged.WriteString(xmlDecode(string(m[2])))
|
||||
}
|
||||
original := merged.String()
|
||||
if !strings.Contains(original, "{{") {
|
||||
return para
|
||||
}
|
||||
replaced := replacePlaceholders(original, vars, missing)
|
||||
if replaced == original {
|
||||
return para
|
||||
}
|
||||
pPr := wParagraphPropsRegex.Find(para)
|
||||
rPr := wRunPropsRegex.Find(para)
|
||||
var rebuilt bytes.Buffer
|
||||
rebuilt.WriteString(`<w:p>`)
|
||||
if pPr != nil {
|
||||
rebuilt.Write(pPr)
|
||||
}
|
||||
rebuilt.WriteString(`<w:r>`)
|
||||
if rPr != nil {
|
||||
rebuilt.Write(rPr)
|
||||
}
|
||||
rebuilt.WriteString(`<w:t xml:space="preserve">`)
|
||||
rebuilt.WriteString(xmlEncode(replaced))
|
||||
rebuilt.WriteString(`</w:t></w:r></w:p>`)
|
||||
return rebuilt.Bytes()
|
||||
})
|
||||
}
|
||||
|
||||
// replacePlaceholders performs the actual substitution on a plain
|
||||
// string. Unbound placeholders render the missing marker.
|
||||
func replacePlaceholders(s string, vars PlaceholderMap, missing MissingPlaceholderFn) string {
|
||||
return placeholderRegex.ReplaceAllStringFunc(s, func(match string) string {
|
||||
sub := placeholderRegex.FindStringSubmatch(match)
|
||||
if len(sub) < 2 {
|
||||
return match
|
||||
}
|
||||
key := sub[1]
|
||||
if value, ok := vars[key]; ok {
|
||||
return value
|
||||
}
|
||||
return missing(key)
|
||||
})
|
||||
}
|
||||
|
||||
// xmlDecode reverses the five standard XML entities Word emits in
|
||||
// <w:t> content.
|
||||
func xmlDecode(s string) string {
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
s = strings.ReplaceAll(s, ">", ">")
|
||||
s = strings.ReplaceAll(s, """, `"`)
|
||||
s = strings.ReplaceAll(s, "'", "'")
|
||||
s = strings.ReplaceAll(s, "&", "&")
|
||||
return s
|
||||
}
|
||||
|
||||
// xmlEncode escapes for safe insertion back into <w:t> content. & first
|
||||
// to avoid double-encoding the entity prefixes.
|
||||
func xmlEncode(s string) string {
|
||||
s = strings.ReplaceAll(s, "&", "&")
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
s = strings.ReplaceAll(s, ">", ">")
|
||||
s = strings.ReplaceAll(s, `"`, """)
|
||||
s = strings.ReplaceAll(s, "'", "'")
|
||||
return s
|
||||
}
|
||||
|
||||
// docXMLToHTML walks the post-merge document XML and emits HTML for
|
||||
// the preview pane. One <p> per <w:p>; <strong>/<em> spans for runs
|
||||
// carrying <w:b>/<w:i>. Tables/lists/images collapse to text. Output
|
||||
// is HTML-escaped except for the structural <p>/<strong>/<em> tags
|
||||
// this function emits.
|
||||
func docXMLToHTML(docXML []byte) string {
|
||||
paragraphs := wParagraphRegex.FindAll(docXML, -1)
|
||||
var out bytes.Buffer
|
||||
for _, para := range paragraphs {
|
||||
out.WriteString("<p>")
|
||||
out.WriteString(paragraphToHTML(para))
|
||||
out.WriteString("</p>\n")
|
||||
}
|
||||
if out.Len() == 0 {
|
||||
return "<p></p>"
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
// wRunRegex matches one <w:r>…</w:r> run. Greedy match safe — <w:r>
|
||||
// elements do not nest.
|
||||
var wRunRegex = regexp.MustCompile(`(?s)<w:r\b[^>]*>.*?</w:r>`)
|
||||
|
||||
// wBoldRegex / wItalicRegex detect the bold/italic flags inside a run's
|
||||
// <w:rPr>. Word emits <w:b/> or <w:b w:val="true"/>; matching the open
|
||||
// tag covers both forms.
|
||||
var (
|
||||
wBoldRegex = regexp.MustCompile(`<w:b\b[^>]*/?>`)
|
||||
wItalicRegex = regexp.MustCompile(`<w:i\b[^>]*/?>`)
|
||||
)
|
||||
|
||||
// paragraphToHTML extracts the text from each <w:r> inside a paragraph,
|
||||
// wraps runs flagged bold/italic with the corresponding HTML tags, and
|
||||
// HTML-escapes the text content.
|
||||
func paragraphToHTML(para []byte) string {
|
||||
runs := wRunRegex.FindAll(para, -1)
|
||||
if len(runs) == 0 {
|
||||
// Empty paragraph (line break).
|
||||
return ""
|
||||
}
|
||||
var out bytes.Buffer
|
||||
for _, run := range runs {
|
||||
text := extractRunText(run)
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
// Check for bold/italic on the run's <w:rPr>.
|
||||
rPr := wRunPropsRegex.Find(run)
|
||||
bold := rPr != nil && wBoldRegex.Match(rPr) && !isFalseFlag(rPr, wBoldRegex)
|
||||
italic := rPr != nil && wItalicRegex.Match(rPr) && !isFalseFlag(rPr, wItalicRegex)
|
||||
|
||||
if bold {
|
||||
out.WriteString("<strong>")
|
||||
}
|
||||
if italic {
|
||||
out.WriteString("<em>")
|
||||
}
|
||||
out.WriteString(htmlEscape(text))
|
||||
if italic {
|
||||
out.WriteString("</em>")
|
||||
}
|
||||
if bold {
|
||||
out.WriteString("</strong>")
|
||||
}
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
// extractRunText concatenates every <w:t> inside a run, XML-decoding
|
||||
// the content as it goes.
|
||||
func extractRunText(run []byte) string {
|
||||
var out strings.Builder
|
||||
for _, m := range wTextNodeRegex.FindAllSubmatch(run, -1) {
|
||||
out.WriteString(xmlDecode(string(m[2])))
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
// isFalseFlag returns true if the matched tag explicitly carries
|
||||
// w:val="false" or w:val="0" — Word's way of turning off an inherited
|
||||
// format. The default match (just `<w:b/>` or `<w:b w:val="true"/>`)
|
||||
// is "on".
|
||||
func isFalseFlag(rPr []byte, rx *regexp.Regexp) bool {
|
||||
match := rx.Find(rPr)
|
||||
if match == nil {
|
||||
return false
|
||||
}
|
||||
s := string(match)
|
||||
return strings.Contains(s, `w:val="false"`) || strings.Contains(s, `w:val="0"`)
|
||||
}
|
||||
|
||||
// htmlEscape escapes the five HTML-significant characters for safe
|
||||
// insertion into the preview pane.
|
||||
func htmlEscape(s string) string {
|
||||
s = strings.ReplaceAll(s, "&", "&")
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
s = strings.ReplaceAll(s, ">", ">")
|
||||
s = strings.ReplaceAll(s, `"`, """)
|
||||
s = strings.ReplaceAll(s, "'", "'")
|
||||
return s
|
||||
}
|
||||
307
internal/services/submission_merge_test.go
Normal file
307
internal/services/submission_merge_test.go
Normal file
@@ -0,0 +1,307 @@
|
||||
package services
|
||||
|
||||
// Submission merge-engine tests — resurrected from the original
|
||||
// t-paliad-215 Slice 1 (commit 8ea3509) + Slice 2 (commit 1765d5e).
|
||||
// Adapted: helper names suffixed with "Merge" so they don't collide
|
||||
// with the convert tests in submission_render_test.go (minimalDOTM,
|
||||
// unzipEntries) that test the format-only ConvertDotmToDocx path.
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// minimalMergeDOCX builds a tiny .docx zip with one document.xml that
|
||||
// contains the given body. Just enough to exercise the merge engine.
|
||||
func minimalMergeDOCX(t *testing.T, documentBody string) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
w, err := zw.Create("word/document.xml")
|
||||
if err != nil {
|
||||
t.Fatalf("create document.xml: %v", err)
|
||||
}
|
||||
if _, err := io.WriteString(w, documentBody); err != nil {
|
||||
t.Fatalf("write document.xml: %v", err)
|
||||
}
|
||||
w2, err := zw.Create("[Content_Types].xml")
|
||||
if err != nil {
|
||||
t.Fatalf("create content types: %v", err)
|
||||
}
|
||||
// Use a docx-compatible content type so the convert pre-pass treats
|
||||
// the input as already-clean (no .dotm rewrites needed).
|
||||
body := `<?xml version="1.0"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">` +
|
||||
`<Override PartName="/word/document.xml" ContentType="` + docxMainContentType + `"/></Types>`
|
||||
if _, err := io.WriteString(w2, body); err != nil {
|
||||
t.Fatalf("write content types: %v", err)
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatalf("close zip: %v", err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// readMergeDocumentXML pulls word/document.xml out of a rendered .docx.
|
||||
func readMergeDocumentXML(t *testing.T, b []byte) string {
|
||||
t.Helper()
|
||||
zr, err := zip.NewReader(bytes.NewReader(b), int64(len(b)))
|
||||
if err != nil {
|
||||
t.Fatalf("open rendered zip: %v", err)
|
||||
}
|
||||
for _, f := range zr.File {
|
||||
if f.Name != "word/document.xml" {
|
||||
continue
|
||||
}
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
t.Fatalf("open document.xml: %v", err)
|
||||
}
|
||||
defer rc.Close()
|
||||
body, err := io.ReadAll(rc)
|
||||
if err != nil {
|
||||
t.Fatalf("read document.xml: %v", err)
|
||||
}
|
||||
return string(body)
|
||||
}
|
||||
t.Fatal("rendered .docx had no word/document.xml")
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestRender_SingleRunPlaceholder(t *testing.T) {
|
||||
doc := `<w:document><w:body><w:p><w:r><w:t>{{firm.name}}</w:t></w:r></w:p></w:body></w:document>`
|
||||
tmpl := minimalMergeDOCX(t, doc)
|
||||
r := NewSubmissionRenderer()
|
||||
out, err := r.Render(tmpl, PlaceholderMap{"firm.name": "HLC"}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("render: %v", err)
|
||||
}
|
||||
body := readMergeDocumentXML(t, out)
|
||||
if !strings.Contains(body, ">HLC<") {
|
||||
t.Errorf("expected HLC in body, got %q", body)
|
||||
}
|
||||
if strings.Contains(body, "{{") {
|
||||
t.Errorf("unreplaced placeholder marker in body: %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRender_MultiplePlaceholdersPerRun(t *testing.T) {
|
||||
doc := `<w:document><w:body><w:p><w:r><w:t>{{parties.claimant.name}}, vertreten durch {{parties.claimant.representative}}</w:t></w:r></w:p></w:body></w:document>`
|
||||
tmpl := minimalMergeDOCX(t, doc)
|
||||
r := NewSubmissionRenderer()
|
||||
out, err := r.Render(tmpl, PlaceholderMap{
|
||||
"parties.claimant.name": "Acme Inc.",
|
||||
"parties.claimant.representative": "Kanzlei Müller",
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("render: %v", err)
|
||||
}
|
||||
body := readMergeDocumentXML(t, out)
|
||||
if !strings.Contains(body, "Acme Inc.") || !strings.Contains(body, "Kanzlei Müller") {
|
||||
t.Errorf("expected both party values, got %q", body)
|
||||
}
|
||||
if strings.Contains(body, "{{") {
|
||||
t.Errorf("unreplaced placeholder marker in body: %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRender_MissingMarker(t *testing.T) {
|
||||
doc := `<w:document><w:body><w:p><w:r><w:t>{{project.case_number}}</w:t></w:r></w:p></w:body></w:document>`
|
||||
tmpl := minimalMergeDOCX(t, doc)
|
||||
r := NewSubmissionRenderer()
|
||||
out, err := r.Render(tmpl, PlaceholderMap{}, DefaultMissingMarker("de"))
|
||||
if err != nil {
|
||||
t.Fatalf("render: %v", err)
|
||||
}
|
||||
body := readMergeDocumentXML(t, out)
|
||||
if !strings.Contains(body, "[KEIN WERT: project.case_number]") {
|
||||
t.Errorf("expected KEIN WERT marker, got %q", body)
|
||||
}
|
||||
outEN, err := r.Render(tmpl, PlaceholderMap{}, DefaultMissingMarker("en"))
|
||||
if err != nil {
|
||||
t.Fatalf("render en: %v", err)
|
||||
}
|
||||
bodyEN := readMergeDocumentXML(t, outEN)
|
||||
if !strings.Contains(bodyEN, "[NO VALUE: project.case_number]") {
|
||||
t.Errorf("expected NO VALUE marker, got %q", bodyEN)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRender_CrossRunPlaceholder(t *testing.T) {
|
||||
doc := `<w:document><w:body><w:p><w:r><w:t>Hello {{</w:t></w:r><w:r><w:t>project</w:t></w:r><w:r><w:t>.case_number}}!</w:t></w:r></w:p></w:body></w:document>`
|
||||
tmpl := minimalMergeDOCX(t, doc)
|
||||
r := NewSubmissionRenderer()
|
||||
out, err := r.Render(tmpl, PlaceholderMap{"project.case_number": "7 O 1234/26"}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("render: %v", err)
|
||||
}
|
||||
body := readMergeDocumentXML(t, out)
|
||||
if !strings.Contains(body, "7 O 1234/26") {
|
||||
t.Errorf("expected case number after cross-run merge, got %q", body)
|
||||
}
|
||||
if strings.Contains(body, "{{") {
|
||||
t.Errorf("orphan placeholder marker remained: %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRender_XMLEscaping(t *testing.T) {
|
||||
doc := `<w:document><w:body><w:p><w:r><w:t>{{user.display_name}}</w:t></w:r></w:p></w:body></w:document>`
|
||||
tmpl := minimalMergeDOCX(t, doc)
|
||||
r := NewSubmissionRenderer()
|
||||
out, err := r.Render(tmpl, PlaceholderMap{
|
||||
"user.display_name": `Müller & Söhne <GmbH> "Special"`,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("render: %v", err)
|
||||
}
|
||||
body := readMergeDocumentXML(t, out)
|
||||
if !strings.Contains(body, "Müller & Söhne <GmbH> "Special"") {
|
||||
t.Errorf("expected escaped value, got %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaceholderRegex_Boundaries(t *testing.T) {
|
||||
tests := []struct {
|
||||
in string
|
||||
matches []string
|
||||
}{
|
||||
{"plain text", nil},
|
||||
{"{{foo}}", []string{"{{foo}}"}},
|
||||
{"{{ foo }}", []string{"{{ foo }}"}},
|
||||
{"{{foo.bar}}", []string{"{{foo.bar}}"}},
|
||||
{"{{ foo.bar_baz }}", []string{"{{ foo.bar_baz }}"}},
|
||||
{"{{1bad}}", nil},
|
||||
{"{{ foo }} and {{ bar }}", []string{"{{ foo }}", "{{ bar }}"}},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.in, func(t *testing.T) {
|
||||
got := placeholderRegex.FindAllString(tc.in, -1)
|
||||
if len(got) != len(tc.matches) {
|
||||
t.Fatalf("got %d matches, want %d (in=%q)", len(got), len(tc.matches), tc.in)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != tc.matches[i] {
|
||||
t.Errorf("match %d: got %q, want %q", i, got[i], tc.matches[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegalSourcePretty(t *testing.T) {
|
||||
tests := []struct {
|
||||
src, lang, want string
|
||||
}{
|
||||
{"DE.ZPO.276.1", "de", "§ 276 Abs. 1 ZPO"},
|
||||
{"DE.ZPO.276.1", "en", "Section 276(1) ZPO"},
|
||||
{"DE.ZPO.253", "de", "§ 253 ZPO"},
|
||||
{"DE.ZPO.253", "en", "Section 253 ZPO"},
|
||||
{"UPC.RoP.23.1", "de", "Regel 23.1 VerfO UPC"},
|
||||
{"UPC.RoP.23.1", "en", "Rule 23.1 RoP UPC"},
|
||||
{"UPC.RoP.198", "de", "Regel 198 VerfO UPC"},
|
||||
{"DE.PatG.83", "de", "§ 83 PatG"},
|
||||
{"EPC.123", "de", "Art. 123 EPÜ"},
|
||||
{"EPC.123", "en", "Art. 123 EPC"},
|
||||
{"FOO.BAR.123", "de", "FOO.BAR.123"},
|
||||
{"", "de", ""},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.src+"/"+tc.lang, func(t *testing.T) {
|
||||
got := legalSourcePretty(tc.src, tc.lang)
|
||||
if got != tc.want {
|
||||
t.Errorf("legalSourcePretty(%q, %q) = %q, want %q", tc.src, tc.lang, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOurSideTranslations(t *testing.T) {
|
||||
cases := []struct {
|
||||
in, wantDE, wantEN string
|
||||
}{
|
||||
{"claimant", "Klägerin", "Claimant"},
|
||||
{"defendant", "Beklagte", "Defendant"},
|
||||
{"court", "Gericht", "Court"},
|
||||
{"both", "Klägerin und Beklagte", "Claimant and Defendant"},
|
||||
{"", "", ""},
|
||||
{"unknown", "", ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.in, func(t *testing.T) {
|
||||
if got := ourSideDE(tc.in); got != tc.wantDE {
|
||||
t.Errorf("ourSideDE(%q) = %q, want %q", tc.in, got, tc.wantDE)
|
||||
}
|
||||
if got := ourSideEN(tc.in); got != tc.wantEN {
|
||||
t.Errorf("ourSideEN(%q) = %q, want %q", tc.in, got, tc.wantEN)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPatentNumberUPC(t *testing.T) {
|
||||
tests := []struct {
|
||||
in, want string
|
||||
}{
|
||||
{"EP 1 234 567 B1", "EP 1 234 567 (B1)"},
|
||||
{"EP 4 056 049 A1", "EP 4 056 049 (A1)"},
|
||||
{"DE 10 2020 123 456 A1", "DE 10 2020 123 456 (A1)"},
|
||||
{"EP 1 234 567", "EP 1 234 567"},
|
||||
{" EP 1 234 567 B1 ", "EP 1 234 567 (B1)"},
|
||||
{"", ""},
|
||||
{"WO/2023/123456", "WO/2023/123456"},
|
||||
{"EP 1 234 567 B12", "EP 1 234 567 B12"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.in, func(t *testing.T) {
|
||||
got := patentNumberUPC(tc.in)
|
||||
if got != tc.want {
|
||||
t.Errorf("patentNumberUPC(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenderHTML_ExtractsParagraphsAndFormatting verifies the preview
|
||||
// HTML emitter walks <w:p> / <w:r> / <w:t> correctly and carries
|
||||
// bold/italic through to <strong>/<em>.
|
||||
func TestRenderHTML_ExtractsParagraphsAndFormatting(t *testing.T) {
|
||||
doc := `<w:document><w:body>` +
|
||||
`<w:p><w:r><w:t>Hello {{firm.name}}</w:t></w:r></w:p>` +
|
||||
`<w:p><w:r><w:rPr><w:b/></w:rPr><w:t>Bold line</w:t></w:r></w:p>` +
|
||||
`<w:p><w:r><w:rPr><w:i/></w:rPr><w:t>Italic line</w:t></w:r></w:p>` +
|
||||
`</w:body></w:document>`
|
||||
tmpl := minimalMergeDOCX(t, doc)
|
||||
r := NewSubmissionRenderer()
|
||||
html, err := r.RenderHTML(tmpl, PlaceholderMap{"firm.name": "HLC"}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("render html: %v", err)
|
||||
}
|
||||
if !strings.Contains(html, "<p>Hello HLC</p>") {
|
||||
t.Errorf("expected merged paragraph, got %q", html)
|
||||
}
|
||||
if !strings.Contains(html, "<strong>Bold line</strong>") {
|
||||
t.Errorf("expected bold span, got %q", html)
|
||||
}
|
||||
if !strings.Contains(html, "<em>Italic line</em>") {
|
||||
t.Errorf("expected italic span, got %q", html)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenderHTML_EscapesContent confirms the preview emitter HTML-escapes
|
||||
// special characters in placeholder values.
|
||||
func TestRenderHTML_EscapesContent(t *testing.T) {
|
||||
doc := `<w:document><w:body><w:p><w:r><w:t>{{user.display_name}}</w:t></w:r></w:p></w:body></w:document>`
|
||||
tmpl := minimalMergeDOCX(t, doc)
|
||||
r := NewSubmissionRenderer()
|
||||
html, err := r.RenderHTML(tmpl, PlaceholderMap{
|
||||
"user.display_name": `M&S <Inc> "X"`,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("render html: %v", err)
|
||||
}
|
||||
if !strings.Contains(html, "M&S <Inc> "X"") {
|
||||
t.Errorf("expected escaped value in HTML, got %q", html)
|
||||
}
|
||||
}
|
||||
554
internal/services/submission_vars.go
Normal file
554
internal/services/submission_vars.go
Normal file
@@ -0,0 +1,554 @@
|
||||
package services
|
||||
|
||||
// Submission variable bag — builds the PlaceholderMap that
|
||||
// SubmissionRenderer fills into a template (t-paliad-215, design doc
|
||||
// docs/design-submission-generator-2026-05-19.md §6.2).
|
||||
//
|
||||
// Variables span six namespaces:
|
||||
//
|
||||
// firm.* process-wide (branding.Name)
|
||||
// user.* caller's user row
|
||||
// today.* server time in Europe/Berlin, locale-aware
|
||||
// project.* paliad.projects + joined proceeding type
|
||||
// parties.* paliad.parties grouped by role
|
||||
// rule.* paliad.deadline_rules row keyed by submission_code
|
||||
// deadline.* next open paliad.deadlines row for (project, rule), if any
|
||||
//
|
||||
// Locale handling: every long-form date string is computed in both DE
|
||||
// and EN; the renderer picks based on the user's lang preference. The
|
||||
// rule pretty-printer (legalSourcePretty) also has DE/EN variants.
|
||||
//
|
||||
// Visibility: caller passes userID; ProjectService.GetByID enforces
|
||||
// paliad.can_see_project — unauthorised callers get the standard
|
||||
// ErrNotFound before any variable construction runs.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/branding"
|
||||
"mgit.msbls.de/m/paliad/internal/models"
|
||||
)
|
||||
|
||||
// SubmissionVarsService assembles the placeholder map.
|
||||
type SubmissionVarsService struct {
|
||||
db *sqlx.DB
|
||||
projects *ProjectService
|
||||
parties *PartyService
|
||||
users *UserService
|
||||
}
|
||||
|
||||
// NewSubmissionVarsService wires the service.
|
||||
func NewSubmissionVarsService(db *sqlx.DB, projects *ProjectService, parties *PartyService, users *UserService) *SubmissionVarsService {
|
||||
return &SubmissionVarsService{
|
||||
db: db,
|
||||
projects: projects,
|
||||
parties: parties,
|
||||
users: users,
|
||||
}
|
||||
}
|
||||
|
||||
// SubmissionVarsContext is the input bundle that produces a render.
|
||||
//
|
||||
// ProjectID is optional since t-paliad-243 — a global Schriftsatz draft
|
||||
// started from /submissions/new without picking a project carries
|
||||
// nil here and the project / parties / deadline lookups are skipped.
|
||||
type SubmissionVarsContext struct {
|
||||
UserID uuid.UUID
|
||||
ProjectID *uuid.UUID
|
||||
SubmissionCode string
|
||||
}
|
||||
|
||||
// SubmissionVarsResult bundles the placeholder map with the lookup
|
||||
// values the handler needs for the audit row + file naming
|
||||
// (rule.Name, project.case_number, etc.).
|
||||
type SubmissionVarsResult struct {
|
||||
Placeholders PlaceholderMap
|
||||
|
||||
// Resolved entities for audit + naming.
|
||||
User *models.User
|
||||
Project *models.Project
|
||||
Rule *models.DeadlineRule
|
||||
ProceedingType *models.ProceedingType
|
||||
Parties []models.Party
|
||||
NextDeadline *models.Deadline
|
||||
|
||||
// Lang is the user's UI language used to pick locale-aware values
|
||||
// during the build. Returned so the renderer can use the matching
|
||||
// missing-marker function.
|
||||
Lang string
|
||||
}
|
||||
|
||||
// ErrSubmissionRuleNotFound is returned when no published deadline_rule
|
||||
// matches the requested submission_code. Maps to 404 in the handler.
|
||||
var ErrSubmissionRuleNotFound = errors.New("submission generator: no rule found for submission_code")
|
||||
|
||||
// Build resolves every entity and assembles the placeholder map. A nil
|
||||
// ProjectID skips project / parties / deadline lookups — the resolved
|
||||
// bag carries only firm.*, today.*, user.* and rule.* in that case;
|
||||
// every other placeholder falls through to the lawyer's overrides via
|
||||
// SubmissionDraftService.BuildRenderBag.
|
||||
func (s *SubmissionVarsService) Build(ctx context.Context, in SubmissionVarsContext) (*SubmissionVarsResult, error) {
|
||||
if s.projects == nil || s.users == nil {
|
||||
return nil, fmt.Errorf("submission vars: required services not wired")
|
||||
}
|
||||
|
||||
user, err := s.users.GetByID(ctx, in.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if user == nil {
|
||||
return nil, ErrNotVisible
|
||||
}
|
||||
|
||||
rule, err := s.loadPublishedRule(ctx, in.SubmissionCode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
lang := user.Lang
|
||||
if lang == "" {
|
||||
lang = "de"
|
||||
}
|
||||
bag := PlaceholderMap{}
|
||||
addFirmVars(bag)
|
||||
addTodayVars(bag, time.Now())
|
||||
addUserVars(bag, user)
|
||||
addRuleVars(bag, rule, lang)
|
||||
|
||||
out := &SubmissionVarsResult{
|
||||
Placeholders: bag,
|
||||
User: user,
|
||||
Rule: rule,
|
||||
Lang: lang,
|
||||
}
|
||||
|
||||
if in.ProjectID == nil {
|
||||
// Project-less draft (t-paliad-243): no project / parties /
|
||||
// deadline state to resolve. The lawyer's overrides will fill
|
||||
// the placeholder map; missing keys render as
|
||||
// [KEIN WERT: …] / [NO VALUE: …] in the preview.
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Visibility gate — GetByID returns ErrNotFound when the user
|
||||
// can't see the project, which is exactly the 404 the handler
|
||||
// wants to propagate.
|
||||
project, err := s.projects.GetByID(ctx, in.UserID, *in.ProjectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
pt, err := s.loadProceedingType(ctx, project.ProceedingTypeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
parties, err := s.parties.ListForProject(ctx, in.UserID, *in.ProjectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
next, err := s.nextOpenDeadline(ctx, *in.ProjectID, rule.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
addProjectVars(bag, project, pt, lang)
|
||||
addPartyVars(bag, parties)
|
||||
addDeadlineVars(bag, next, project, lang)
|
||||
|
||||
out.Project = project
|
||||
out.ProceedingType = pt
|
||||
out.Parties = parties
|
||||
out.NextDeadline = next
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// loadPublishedRule fetches the deadline_rule that owns the given
|
||||
// submission_code. Restricts to lifecycle_state='published' so drafts
|
||||
// never end up shaping a real submission.
|
||||
func (s *SubmissionVarsService) loadPublishedRule(ctx context.Context, submissionCode string) (*models.DeadlineRule, error) {
|
||||
if submissionCode == "" {
|
||||
return nil, ErrSubmissionRuleNotFound
|
||||
}
|
||||
var rule models.DeadlineRule
|
||||
err := s.db.GetContext(ctx, &rule,
|
||||
`SELECT `+ruleColumns+`
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code = $1
|
||||
AND lifecycle_state = 'published'
|
||||
AND is_active = true
|
||||
ORDER BY sequence_order
|
||||
LIMIT 1`, submissionCode)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrSubmissionRuleNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load rule by submission_code %q: %w", submissionCode, err)
|
||||
}
|
||||
return &rule, nil
|
||||
}
|
||||
|
||||
// loadProceedingType fetches the proceeding type row for the project's
|
||||
// proceeding_type_id. Tolerates a nil id (returns nil, nil) so projects
|
||||
// without a bound proceeding still render a meaningful template — the
|
||||
// {{project.proceeding.*}} placeholders just resolve to the missing
|
||||
// marker.
|
||||
func (s *SubmissionVarsService) loadProceedingType(ctx context.Context, id *int) (*models.ProceedingType, error) {
|
||||
if id == nil {
|
||||
return nil, nil
|
||||
}
|
||||
var pt models.ProceedingType
|
||||
err := s.db.GetContext(ctx, &pt,
|
||||
`SELECT `+proceedingTypeColumns+`
|
||||
FROM paliad.proceeding_types
|
||||
WHERE id = $1`, *id)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load proceeding type %d: %w", *id, err)
|
||||
}
|
||||
return &pt, nil
|
||||
}
|
||||
|
||||
// nextOpenDeadline finds the earliest pending paliad.deadlines row on
|
||||
// the given project that maps to the chosen rule. Returns (nil, nil)
|
||||
// when no matching deadline exists — common when the lawyer is drafting
|
||||
// the submission before the system has computed its deadline row.
|
||||
func (s *SubmissionVarsService) nextOpenDeadline(ctx context.Context, projectID, ruleID uuid.UUID) (*models.Deadline, error) {
|
||||
var d models.Deadline
|
||||
err := s.db.GetContext(ctx, &d,
|
||||
`SELECT id, project_id, title, description, due_date, original_due_date,
|
||||
warning_date, source, rule_id, rule_code, status, completed_at,
|
||||
caldav_uid, caldav_etag, notes, created_by, created_at, updated_at,
|
||||
approval_status, pending_request_id, approved_by, approved_at
|
||||
FROM paliad.deadlines
|
||||
WHERE project_id = $1
|
||||
AND rule_id = $2
|
||||
AND status = 'pending'
|
||||
ORDER BY due_date ASC
|
||||
LIMIT 1`, projectID, ruleID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load next deadline (project=%s rule=%s): %w", projectID, ruleID, err)
|
||||
}
|
||||
return &d, nil
|
||||
}
|
||||
|
||||
// addFirmVars populates the firm.* namespace.
|
||||
func addFirmVars(bag PlaceholderMap) {
|
||||
bag["firm.name"] = branding.Name
|
||||
// firm.signature_block is reserved for Phase 2; emit empty so
|
||||
// templates that already reference it don't render the missing
|
||||
// marker (less noisy for the lawyer).
|
||||
bag["firm.signature_block"] = ""
|
||||
}
|
||||
|
||||
// addTodayVars populates today.* in both DE and EN long forms. ISO
|
||||
// short form is the default {{today}}.
|
||||
func addTodayVars(bag PlaceholderMap, now time.Time) {
|
||||
loc, _ := time.LoadLocation("Europe/Berlin")
|
||||
if loc != nil {
|
||||
now = now.In(loc)
|
||||
}
|
||||
bag["today"] = now.Format("2006-01-02")
|
||||
bag["today.iso"] = now.Format("2006-01-02")
|
||||
bag["today.long_de"] = formatLongDateDE(now)
|
||||
bag["today.long_en"] = formatLongDateEN(now)
|
||||
}
|
||||
|
||||
// addUserVars populates user.*.
|
||||
func addUserVars(bag PlaceholderMap, u *models.User) {
|
||||
bag["user.display_name"] = u.DisplayName
|
||||
bag["user.email"] = u.Email
|
||||
bag["user.office"] = u.Office
|
||||
}
|
||||
|
||||
// addProjectVars populates project.* — title / case_number / court /
|
||||
// patent_number / dates / our_side / proceeding metadata.
|
||||
func addProjectVars(bag PlaceholderMap, p *models.Project, pt *models.ProceedingType, lang string) {
|
||||
bag["project.title"] = p.Title
|
||||
bag["project.reference"] = derefString(p.Reference)
|
||||
bag["project.case_number"] = derefString(p.CaseNumber)
|
||||
bag["project.court"] = derefString(p.Court)
|
||||
bag["project.patent_number"] = derefString(p.PatentNumber)
|
||||
// project.patent_number_upc is the UPC-brief convention — kind code
|
||||
// parenthesised ("EP 1 234 567 (B1)") instead of the DE form
|
||||
// ("EP 1 234 567 B1"). Pure-function rewrite; pass-through when no
|
||||
// kind code is present so the lawyer's draft never sees a worse
|
||||
// number than the source value.
|
||||
bag["project.patent_number_upc"] = patentNumberUPC(derefString(p.PatentNumber))
|
||||
bag["project.filing_date"] = formatDatePtr(p.FilingDate, "2006-01-02")
|
||||
bag["project.grant_date"] = formatDatePtr(p.GrantDate, "2006-01-02")
|
||||
bag["project.our_side"] = derefString(p.OurSide)
|
||||
bag["project.our_side_de"] = ourSideDE(derefString(p.OurSide))
|
||||
bag["project.our_side_en"] = ourSideEN(derefString(p.OurSide))
|
||||
bag["project.instance_level"] = derefString(p.InstanceLevel)
|
||||
bag["project.client_number"] = derefString(p.ClientNumber)
|
||||
bag["project.matter_number"] = derefString(p.MatterNumber)
|
||||
if pt != nil {
|
||||
bag["project.proceeding.code"] = pt.Code
|
||||
if strings.EqualFold(lang, "en") {
|
||||
bag["project.proceeding.name"] = pt.NameEN
|
||||
} else {
|
||||
bag["project.proceeding.name"] = pt.Name
|
||||
}
|
||||
bag["project.proceeding.name_de"] = pt.Name
|
||||
bag["project.proceeding.name_en"] = pt.NameEN
|
||||
}
|
||||
}
|
||||
|
||||
// addPartyVars populates parties.* using the first row of each role.
|
||||
// Multi-claimant / multi-defendant suits use the first row in Slice 1
|
||||
// per design §13.6; expanded grouping is Phase 2.
|
||||
func addPartyVars(bag PlaceholderMap, parties []models.Party) {
|
||||
var claimant, defendant, other *models.Party
|
||||
for i := range parties {
|
||||
role := strings.ToLower(strings.TrimSpace(derefString(parties[i].Role)))
|
||||
switch role {
|
||||
case "claimant", "kläger", "klaeger":
|
||||
if claimant == nil {
|
||||
claimant = &parties[i]
|
||||
}
|
||||
case "defendant", "beklagter", "beklagte":
|
||||
if defendant == nil {
|
||||
defendant = &parties[i]
|
||||
}
|
||||
default:
|
||||
if other == nil {
|
||||
other = &parties[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
if claimant != nil {
|
||||
bag["parties.claimant.name"] = claimant.Name
|
||||
bag["parties.claimant.representative"] = derefString(claimant.Representative)
|
||||
}
|
||||
if defendant != nil {
|
||||
bag["parties.defendant.name"] = defendant.Name
|
||||
bag["parties.defendant.representative"] = derefString(defendant.Representative)
|
||||
}
|
||||
if other != nil {
|
||||
bag["parties.other.name"] = other.Name
|
||||
bag["parties.other.representative"] = derefString(other.Representative)
|
||||
}
|
||||
}
|
||||
|
||||
// addRuleVars populates rule.* — submission_code, name(_en),
|
||||
// legal_source (+ pretty form), primary_party, event_type.
|
||||
func addRuleVars(bag PlaceholderMap, r *models.DeadlineRule, lang string) {
|
||||
bag["rule.submission_code"] = derefString(r.SubmissionCode)
|
||||
if strings.EqualFold(lang, "en") {
|
||||
bag["rule.name"] = r.NameEN
|
||||
} else {
|
||||
bag["rule.name"] = r.Name
|
||||
}
|
||||
bag["rule.name_de"] = r.Name
|
||||
bag["rule.name_en"] = r.NameEN
|
||||
bag["rule.legal_source"] = derefString(r.LegalSource)
|
||||
bag["rule.legal_source_pretty"] = legalSourcePretty(derefString(r.LegalSource), lang)
|
||||
bag["rule.primary_party"] = derefString(r.PrimaryParty)
|
||||
bag["rule.event_type"] = derefString(r.EventType)
|
||||
}
|
||||
|
||||
// addDeadlineVars populates deadline.* from the next pending row. When
|
||||
// no row exists the values fall through to the missing marker — the
|
||||
// lawyer sees [KEIN WERT: deadline.due_date] in Word and knows to fix.
|
||||
func addDeadlineVars(bag PlaceholderMap, d *models.Deadline, p *models.Project, lang string) {
|
||||
if d == nil {
|
||||
return
|
||||
}
|
||||
bag["deadline.due_date"] = d.DueDate.Format("2006-01-02")
|
||||
bag["deadline.due_date_long_de"] = formatLongDateDE(d.DueDate)
|
||||
bag["deadline.due_date_long_en"] = formatLongDateEN(d.DueDate)
|
||||
if d.OriginalDueDate != nil {
|
||||
bag["deadline.original_due_date"] = d.OriginalDueDate.Format("2006-01-02")
|
||||
}
|
||||
// computed_from carries the human-readable anchor description
|
||||
// (e.g. "Klagezustellung am 14.05.2026 + 6 Wochen"). Notes is
|
||||
// the closest existing field — the calculator stores anchor
|
||||
// metadata there. If empty we leave the placeholder unresolved.
|
||||
if d.Notes != nil && strings.TrimSpace(*d.Notes) != "" {
|
||||
bag["deadline.computed_from"] = strings.TrimSpace(*d.Notes)
|
||||
}
|
||||
bag["deadline.title"] = d.Title
|
||||
bag["deadline.source"] = d.Source
|
||||
_ = p // reserved for future shape decisions where the deadline
|
||||
// var depends on project context.
|
||||
_ = lang
|
||||
}
|
||||
|
||||
// derefString returns *s or "" when s is nil.
|
||||
func derefString(s *string) string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return *s
|
||||
}
|
||||
|
||||
// formatDatePtr formats a *time.Time, returning "" for nil.
|
||||
func formatDatePtr(t *time.Time, layout string) string {
|
||||
if t == nil {
|
||||
return ""
|
||||
}
|
||||
return t.Format(layout)
|
||||
}
|
||||
|
||||
// ourSideDE returns the German legal-prose form of an our_side value.
|
||||
func ourSideDE(side string) string {
|
||||
switch strings.ToLower(side) {
|
||||
case "claimant":
|
||||
return "Klägerin"
|
||||
case "defendant":
|
||||
return "Beklagte"
|
||||
case "court":
|
||||
return "Gericht"
|
||||
case "both":
|
||||
return "Klägerin und Beklagte"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ourSideEN returns the English legal-prose form of an our_side value.
|
||||
func ourSideEN(side string) string {
|
||||
switch strings.ToLower(side) {
|
||||
case "claimant":
|
||||
return "Claimant"
|
||||
case "defendant":
|
||||
return "Defendant"
|
||||
case "court":
|
||||
return "Court"
|
||||
case "both":
|
||||
return "Claimant and Defendant"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// formatLongDateDE renders a date in the German long form
|
||||
// ("19. Mai 2026"). Pure function for unit testing.
|
||||
func formatLongDateDE(t time.Time) string {
|
||||
months := []string{
|
||||
"Januar", "Februar", "März", "April", "Mai", "Juni",
|
||||
"Juli", "August", "September", "Oktober", "November", "Dezember",
|
||||
}
|
||||
idx := int(t.Month()) - 1
|
||||
if idx < 0 || idx >= len(months) {
|
||||
return t.Format("2006-01-02")
|
||||
}
|
||||
return fmt.Sprintf("%d. %s %d", t.Day(), months[idx], t.Year())
|
||||
}
|
||||
|
||||
// formatLongDateEN renders a date in the English long form
|
||||
// ("19 May 2026").
|
||||
func formatLongDateEN(t time.Time) string {
|
||||
return t.Format("2 January 2006")
|
||||
}
|
||||
|
||||
// legalSourcePretty rewrites the shorthand stored on deadline_rules
|
||||
// (DE.ZPO.276.1, UPC.RoP.23.1, …) into the form a lawyer would type
|
||||
// in a brief ("§ 276 Abs. 1 ZPO", "Rule 23.1 RoP UPC"). Unknown
|
||||
// prefixes pass through unchanged — preferring the raw shorthand over
|
||||
// an incorrect prettification.
|
||||
//
|
||||
// Lang controls the language of connective words (Abs / Section,
|
||||
// Regel / Rule, …). The pretty table covers the prefixes used by the
|
||||
// 254 published rules in the corpus today; new prefixes default to
|
||||
// pass-through and a follow-up CL extends the table.
|
||||
func legalSourcePretty(src, lang string) string {
|
||||
src = strings.TrimSpace(src)
|
||||
if src == "" {
|
||||
return ""
|
||||
}
|
||||
parts := strings.Split(src, ".")
|
||||
en := strings.EqualFold(lang, "en")
|
||||
|
||||
switch {
|
||||
case len(parts) == 4 && parts[0] == "DE" && parts[1] == "ZPO":
|
||||
if en {
|
||||
return fmt.Sprintf("Section %s(%s) ZPO", parts[2], parts[3])
|
||||
}
|
||||
return fmt.Sprintf("§ %s Abs. %s ZPO", parts[2], parts[3])
|
||||
case len(parts) == 3 && parts[0] == "DE" && parts[1] == "ZPO":
|
||||
if en {
|
||||
return fmt.Sprintf("Section %s ZPO", parts[2])
|
||||
}
|
||||
return fmt.Sprintf("§ %s ZPO", parts[2])
|
||||
case len(parts) == 4 && parts[0] == "UPC" && parts[1] == "RoP":
|
||||
if en {
|
||||
return fmt.Sprintf("Rule %s.%s RoP UPC", parts[2], parts[3])
|
||||
}
|
||||
return fmt.Sprintf("Regel %s.%s VerfO UPC", parts[2], parts[3])
|
||||
case len(parts) == 3 && parts[0] == "UPC" && parts[1] == "RoP":
|
||||
if en {
|
||||
return fmt.Sprintf("Rule %s RoP UPC", parts[2])
|
||||
}
|
||||
return fmt.Sprintf("Regel %s VerfO UPC", parts[2])
|
||||
case len(parts) >= 3 && parts[0] == "DE" && parts[1] == "PatG":
|
||||
if en {
|
||||
return fmt.Sprintf("Section %s PatG", parts[2])
|
||||
}
|
||||
return fmt.Sprintf("§ %s PatG", parts[2])
|
||||
case len(parts) == 2 && parts[0] == "EPC":
|
||||
if en {
|
||||
return fmt.Sprintf("Art. %s EPC", parts[1])
|
||||
}
|
||||
return fmt.Sprintf("Art. %s EPÜ", parts[1])
|
||||
}
|
||||
return src
|
||||
}
|
||||
|
||||
// patentNumberKindCodeRegex matches a trailing kind code on a patent
|
||||
// number: a whitespace-separated single uppercase letter followed by
|
||||
// a single digit (B1, A1, A2, B2, B9, C1, T2, U1, …). Capturing
|
||||
// groups split the base from the kind code so the formatter can
|
||||
// parenthesise the kind without touching the rest of the number.
|
||||
var patentNumberKindCodeRegex = regexp.MustCompile(`^(.*?)\s+([A-Z]\d)$`)
|
||||
|
||||
// patentNumberUPC reformats a patent number from the DE convention
|
||||
// ("EP 1 234 567 B1") to the UPC-brief convention
|
||||
// ("EP 1 234 567 (B1)"). The kind code is parenthesised; everything
|
||||
// else is preserved verbatim. Numbers without a recognised trailing
|
||||
// kind code pass through unchanged so a lawyer's draft never sees a
|
||||
// number worse than the source value.
|
||||
//
|
||||
// Recognised inputs:
|
||||
//
|
||||
// "EP 1 234 567 B1" → "EP 1 234 567 (B1)"
|
||||
// "EP 4 056 049 A1" → "EP 4 056 049 (A1)"
|
||||
// "DE 10 2020 123 456 A1" → "DE 10 2020 123 456 (A1)"
|
||||
// " EP 1 234 567 B1 " → "EP 1 234 567 (B1)" (trimmed)
|
||||
//
|
||||
// Pass-through:
|
||||
//
|
||||
// "EP 1 234 567" → "EP 1 234 567"
|
||||
// "WO/2023/123456" → "WO/2023/123456" (no kind code shape)
|
||||
// "" → ""
|
||||
//
|
||||
// Pure function; unit-tested in submission_vars_test.go.
|
||||
func patentNumberUPC(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
if m := patentNumberKindCodeRegex.FindStringSubmatch(s); m != nil {
|
||||
base := strings.TrimSpace(m[1])
|
||||
kind := m[2]
|
||||
if base == "" {
|
||||
return s
|
||||
}
|
||||
return base + " (" + kind + ")"
|
||||
}
|
||||
return s
|
||||
}
|
||||
346
scripts/gen-demo-submission-template/main.go
Normal file
346
scripts/gen-demo-submission-template/main.go
Normal file
@@ -0,0 +1,346 @@
|
||||
// Demo submission template generator (t-paliad-241).
|
||||
//
|
||||
// One-shot authoring tool that emits a minimal but Word-compatible
|
||||
// .docx file exercising every placeholder SubmissionVarsService
|
||||
// resolves. Drop the output into m/mWorkRepo at
|
||||
//
|
||||
// 6 - material/Templates/Word/Paliad/HLC/de.inf.lg.erwidg.docx
|
||||
//
|
||||
// so paliad's submission-draft editor (t-paliad-238 Slice A) can fetch
|
||||
// it via the per-submission_code fallback chain wired into
|
||||
// handlers/files.go. The structure is a fake Klageerwiderung skeleton
|
||||
// in German — fake legal prose, real placeholder tokens.
|
||||
//
|
||||
// Why a generator instead of authoring in Word: the per-placeholder
|
||||
// docx grammar is `{{[A-Za-z][A-Za-z0-9_.]*}}` and Word's autocorrect
|
||||
// happily fragments such tokens across <w:r> runs ({{ → "{", "{",
|
||||
// project.case_number, "}", "}"). A programmatic emitter writes each
|
||||
// placeholder as a single run so the renderer's pass-1 substitution
|
||||
// (format-preserving) catches it cleanly. The merge engine handles
|
||||
// cross-run cases too (pass 2) but pass 1 is the cheaper path.
|
||||
//
|
||||
// Run:
|
||||
//
|
||||
// go run ./scripts/gen-demo-submission-template -out /tmp/de.inf.lg.erwidg.docx
|
||||
//
|
||||
// Output is deterministic so re-generating to the same path produces a
|
||||
// byte-identical file (modulo zip mtime — we pin those to a fixed UTC
|
||||
// timestamp so the bytes are reproducible).
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
out := flag.String("out", "de.inf.lg.erwidg.docx", "output .docx path")
|
||||
flag.Parse()
|
||||
|
||||
docx, err := buildDocx()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "gen-demo-submission-template:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := os.WriteFile(*out, docx, 0o644); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "gen-demo-submission-template: write:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("wrote %s (%d bytes)\n", *out, len(docx))
|
||||
}
|
||||
|
||||
// fixedTime is the zip mtime stamp baked into every entry so the output
|
||||
// is byte-reproducible.
|
||||
var fixedTime = time.Date(2026, 5, 23, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
// buildDocx assembles the four-part .docx zip Word needs to open the
|
||||
// file cleanly: Content_Types, root rels, document.xml, and document
|
||||
// rels. Everything else (styles, theme, fonts) is optional — Word
|
||||
// supplies sane defaults when absent.
|
||||
func buildDocx() ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
|
||||
add := func(name, body string) error {
|
||||
hdr := &zip.FileHeader{
|
||||
Name: name,
|
||||
Method: zip.Deflate,
|
||||
Modified: fixedTime,
|
||||
}
|
||||
w, err := zw.CreateHeader(hdr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create %s: %w", name, err)
|
||||
}
|
||||
if _, err := w.Write([]byte(body)); err != nil {
|
||||
return fmt.Errorf("write %s: %w", name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := add("[Content_Types].xml", contentTypesXML); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := add("_rels/.rels", rootRelsXML); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := add("word/_rels/document.xml.rels", documentRelsXML); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := add("word/styles.xml", stylesXML); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := add("word/document.xml", buildDocumentXML()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := zw.Close(); err != nil {
|
||||
return nil, fmt.Errorf("finalise zip: %w", err)
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
const contentTypesXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
|
||||
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
|
||||
<Default Extension="xml" ContentType="application/xml"/>
|
||||
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
|
||||
<Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>
|
||||
</Types>`
|
||||
|
||||
const rootRelsXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
|
||||
</Relationships>`
|
||||
|
||||
const documentRelsXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
|
||||
</Relationships>`
|
||||
|
||||
// stylesXML provides minimal Heading1 + Heading2 paragraph styles so
|
||||
// the section headings render with visual weight. Body text falls
|
||||
// through to Word's Normal style.
|
||||
const stylesXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
||||
<w:style w:type="paragraph" w:styleId="Heading1">
|
||||
<w:name w:val="heading 1"/>
|
||||
<w:basedOn w:val="Normal"/>
|
||||
<w:pPr><w:spacing w:before="360" w:after="120"/></w:pPr>
|
||||
<w:rPr><w:b/><w:sz w:val="28"/></w:rPr>
|
||||
</w:style>
|
||||
<w:style w:type="paragraph" w:styleId="Heading2">
|
||||
<w:name w:val="heading 2"/>
|
||||
<w:basedOn w:val="Normal"/>
|
||||
<w:pPr><w:spacing w:before="240" w:after="80"/></w:pPr>
|
||||
<w:rPr><w:b/><w:sz w:val="24"/></w:rPr>
|
||||
</w:style>
|
||||
<w:style w:type="paragraph" w:default="1" w:styleId="Normal">
|
||||
<w:name w:val="Normal"/>
|
||||
</w:style>
|
||||
</w:styles>`
|
||||
|
||||
// Document body — a fake Klageerwiderung skeleton with every placeholder
|
||||
// SubmissionVarsService resolves embedded in natural positions. Each
|
||||
// placeholder is in its own run so pass-1 substitution catches it without
|
||||
// fragmentation worries. The DEMO marker in the header makes it obvious
|
||||
// this is not approved firm content.
|
||||
//
|
||||
// Structure mirrors a real submission:
|
||||
//
|
||||
// 1. Firm letterhead + author block (firm.*, user.*, today.*)
|
||||
// 2. Court caption (project.*, project.proceeding.*)
|
||||
// 3. Parties block (parties.*)
|
||||
// 4. Submission title + legal source (rule.*)
|
||||
// 5. Deadline (deadline.*)
|
||||
// 6. Boilerplate body + signature
|
||||
//
|
||||
// Order matches what a lawyer drafting a real Klageerwiderung would put
|
||||
// at the top of the document, so when the lawyer customises this
|
||||
// template later they don't have to restructure.
|
||||
func buildDocumentXML() string {
|
||||
var b strings.Builder
|
||||
b.WriteString(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`)
|
||||
b.WriteString(`<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">`)
|
||||
b.WriteString(`<w:body>`)
|
||||
|
||||
demoBanner(&b)
|
||||
|
||||
heading1(&b, "{{firm.name}} — Patentstreitsachen")
|
||||
plain(&b, "Bearbeiter: {{user.display_name}}")
|
||||
plain(&b, "E-Mail: {{user.email}} · Büro: {{user.office}}")
|
||||
plain(&b, "Datum: {{today.long_de}} ({{today.iso}})")
|
||||
|
||||
heading1(&b, "{{project.court}}")
|
||||
plain(&b, "Aktenzeichen: {{project.case_number}}")
|
||||
plain(&b, "Verfahrensart: {{project.proceeding.name}} ({{project.proceeding.code}})")
|
||||
plain(&b, "Instanz: {{project.instance_level}}")
|
||||
|
||||
heading2(&b, "In der Patentstreitsache")
|
||||
plain(&b, "{{parties.claimant.name}}")
|
||||
plain(&b, "vertreten durch {{parties.claimant.representative}}")
|
||||
bold(&b, "— Klägerin —")
|
||||
plain(&b, "")
|
||||
plain(&b, "gegen")
|
||||
plain(&b, "")
|
||||
plain(&b, "{{parties.defendant.name}}")
|
||||
plain(&b, "vertreten durch {{parties.defendant.representative}}")
|
||||
bold(&b, "— Beklagte —")
|
||||
plainOptional(&b, "Weitere Beteiligte: {{parties.other.name}}, vertreten durch {{parties.other.representative}}")
|
||||
|
||||
heading2(&b, "Betreff")
|
||||
plain(&b, "Streitpatent: {{project.patent_number}} (UPC: {{project.patent_number_upc}})")
|
||||
plain(&b, "Anmeldung: {{project.filing_date}} · Erteilung: {{project.grant_date}}")
|
||||
plain(&b, "Projekttitel: {{project.title}}")
|
||||
plain(&b, "Unsere Seite: {{project.our_side_de}} ({{project.our_side}})")
|
||||
plain(&b, "Mandant: {{project.client_number}} · Matter: {{project.matter_number}}")
|
||||
plain(&b, "Internes Aktenzeichen: {{project.reference}}")
|
||||
|
||||
heading1(&b, "{{rule.name}}")
|
||||
plain(&b, "(Schriftsatz-Code: {{rule.submission_code}})")
|
||||
plain(&b, "Rechtsgrundlage: {{rule.legal_source_pretty}} ({{rule.legal_source}})")
|
||||
plain(&b, "Typische Partei: {{rule.primary_party}} · Schriftsatz-Typ: {{rule.event_type}}")
|
||||
|
||||
heading2(&b, "Frist")
|
||||
plain(&b, "Diese Frist wurde berechnet aus: {{deadline.computed_from}}")
|
||||
plain(&b, "Fälligkeit: {{deadline.due_date_long_de}} ({{deadline.due_date}})")
|
||||
plainOptional(&b, "Ursprüngliche Frist: {{deadline.original_due_date}}")
|
||||
plain(&b, "Frist-Bezeichnung: {{deadline.title}} · Quelle: {{deadline.source}}")
|
||||
|
||||
heading2(&b, "I. Anträge")
|
||||
plain(&b, "Die Beklagte beantragt,")
|
||||
plain(&b, "")
|
||||
plain(&b, "1. die Klage abzuweisen;")
|
||||
plain(&b, "2. der Klägerin die Kosten des Rechtsstreits aufzuerlegen.")
|
||||
|
||||
heading2(&b, "II. Sachverhalt")
|
||||
plain(&b, "[DEMO-Platzhalter] Hier folgt der Sachvortrag der Beklagten zum Streitpatent {{project.patent_number}} und zu den von der Klägerin geltend gemachten Ansprüchen.")
|
||||
|
||||
heading2(&b, "III. Rechtsausführungen")
|
||||
plain(&b, "[DEMO-Platzhalter] Die Beklagte tritt der Klage aus den nachfolgenden Gründen entgegen.")
|
||||
|
||||
heading2(&b, "Schlussformel")
|
||||
plain(&b, "{{today.long_de}}")
|
||||
plain(&b, "")
|
||||
plain(&b, "{{user.display_name}}")
|
||||
plain(&b, "{{firm.name}}")
|
||||
plainOptional(&b, "{{firm.signature_block}}")
|
||||
|
||||
// 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. 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()
|
||||
}
|
||||
|
||||
// demoBanner writes a clearly-marked DEMO header so the file can't be
|
||||
// mistaken for approved firm content (HLC branding compliance has not
|
||||
// reviewed this — it's a developer-authored placeholder fixture).
|
||||
func demoBanner(b *strings.Builder) {
|
||||
b.WriteString(`<w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr><w:r><w:rPr><w:b/><w:color w:val="C00000"/></w:rPr><w:t xml:space="preserve">DEMO — interne Vorlage (nicht freigegeben)</w:t></w:r></w:p>`)
|
||||
}
|
||||
|
||||
// heading1 emits a styled "Heading 1" paragraph with placeholder runs
|
||||
// emitted intact (one run per placeholder so pass-1 substitution works).
|
||||
func heading1(b *strings.Builder, text string) { paragraph(b, "Heading1", text, false) }
|
||||
|
||||
// heading2 emits a "Heading 2" paragraph.
|
||||
func heading2(b *strings.Builder, text string) { paragraph(b, "Heading2", text, false) }
|
||||
|
||||
// plain emits a Normal-style paragraph.
|
||||
func plain(b *strings.Builder, text string) { paragraph(b, "", text, false) }
|
||||
|
||||
// plainOptional is a Normal paragraph rendered as italic so the lawyer
|
||||
// recognises rows that contain placeholders which may be empty
|
||||
// (parties.other.*, deadline.original_due_date, firm.signature_block).
|
||||
// Visual cue only; the merge engine still substitutes the same way.
|
||||
func plainOptional(b *strings.Builder, text string) { paragraph(b, "", text, true) }
|
||||
|
||||
// bold emits a Normal paragraph with bold run formatting.
|
||||
func bold(b *strings.Builder, text string) {
|
||||
b.WriteString(`<w:p>`)
|
||||
b.WriteString(`<w:r><w:rPr><w:b/></w:rPr><w:t xml:space="preserve">`)
|
||||
b.WriteString(xmlEscape(text))
|
||||
b.WriteString(`</w:t></w:r></w:p>`)
|
||||
}
|
||||
|
||||
// paragraph splits text on placeholder boundaries and emits one <w:r>
|
||||
// per segment. Each placeholder occupies a dedicated run so the
|
||||
// renderer's pass-1 substitution (format-preserving, single-run) hits
|
||||
// the placeholder without the cross-run fallback. Italic runs are
|
||||
// flagged via the italic argument.
|
||||
func paragraph(b *strings.Builder, style, text string, italic bool) {
|
||||
b.WriteString(`<w:p>`)
|
||||
if style != "" {
|
||||
b.WriteString(`<w:pPr><w:pStyle w:val="`)
|
||||
b.WriteString(style)
|
||||
b.WriteString(`"/></w:pPr>`)
|
||||
}
|
||||
for _, seg := range splitOnPlaceholders(text) {
|
||||
b.WriteString(`<w:r>`)
|
||||
if italic {
|
||||
b.WriteString(`<w:rPr><w:i/></w:rPr>`)
|
||||
}
|
||||
b.WriteString(`<w:t xml:space="preserve">`)
|
||||
b.WriteString(xmlEscape(seg))
|
||||
b.WriteString(`</w:t></w:r>`)
|
||||
}
|
||||
b.WriteString(`</w:p>`)
|
||||
}
|
||||
|
||||
// splitOnPlaceholders returns the input split into alternating text /
|
||||
// placeholder segments while keeping each placeholder intact in its own
|
||||
// segment. Empty input yields a single empty segment so the paragraph
|
||||
// still emits a (visible) blank line.
|
||||
func splitOnPlaceholders(s string) []string {
|
||||
if s == "" {
|
||||
return []string{""}
|
||||
}
|
||||
var out []string
|
||||
for {
|
||||
open := strings.Index(s, "{{")
|
||||
if open < 0 {
|
||||
out = append(out, s)
|
||||
return out
|
||||
}
|
||||
close := strings.Index(s[open:], "}}")
|
||||
if close < 0 {
|
||||
out = append(out, s)
|
||||
return out
|
||||
}
|
||||
end := open + close + 2
|
||||
if open > 0 {
|
||||
out = append(out, s[:open])
|
||||
}
|
||||
out = append(out, s[open:end])
|
||||
s = s[end:]
|
||||
if s == "" {
|
||||
return out
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// xmlEscape handles the five XML-significant characters for <w:t>
|
||||
// content. Whitespace is preserved by the xml:space="preserve" attr we
|
||||
// always emit on text runs.
|
||||
func xmlEscape(s string) string {
|
||||
s = strings.ReplaceAll(s, "&", "&")
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
s = strings.ReplaceAll(s, ">", ">")
|
||||
s = strings.ReplaceAll(s, `"`, """)
|
||||
s = strings.ReplaceAll(s, "'", "'")
|
||||
return s
|
||||
}
|
||||
303
scripts/gen-skeleton-submission-template/main.go
Normal file
303
scripts/gen-skeleton-submission-template/main.go
Normal file
@@ -0,0 +1,303 @@
|
||||
// Universal-skeleton submission template generator (t-paliad-259).
|
||||
//
|
||||
// One-shot authoring tool that emits a minimal but Word-compatible
|
||||
// .docx file exercising every placeholder SubmissionVarsService
|
||||
// resolves — without baking in any submission_code-specific prose.
|
||||
//
|
||||
// Drop the output into m/mWorkRepo at
|
||||
//
|
||||
// 6 - material/Templates/Word/Paliad/HLC/_skeleton.docx
|
||||
//
|
||||
// so paliad's submission generator picks it up via the fallback chain
|
||||
// slotted between the per-submission_code template and the bare
|
||||
// universal HL Patents Style .dotm. Any submission_code that has no
|
||||
// per-firm template still gets a draft populated with variables
|
||||
// instead of the macro-only letterhead.
|
||||
//
|
||||
// Why a separate file from de.inf.lg.erwidg.docx: that one is a
|
||||
// Klageerwiderung skeleton (DE LG, "I. Anträge / II. Sachverhalt /
|
||||
// III. Rechtsausführungen"). For a UPC SoC, an EPO opposition, a DPMA
|
||||
// appeal, that body structure is wrong. The universal skeleton drops
|
||||
// the structure and leaves a single neutral body block the lawyer
|
||||
// replaces — every variable still resolves regardless of code.
|
||||
//
|
||||
// Run:
|
||||
//
|
||||
// go run ./scripts/gen-skeleton-submission-template -out /tmp/_skeleton.docx
|
||||
//
|
||||
// Output is byte-reproducible (zip mtimes pinned to a fixed UTC
|
||||
// timestamp).
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
out := flag.String("out", "_skeleton.docx", "output .docx path")
|
||||
flag.Parse()
|
||||
|
||||
docx, err := buildDocx()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "gen-skeleton-submission-template:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := os.WriteFile(*out, docx, 0o644); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "gen-skeleton-submission-template: write:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("wrote %s (%d bytes)\n", *out, len(docx))
|
||||
}
|
||||
|
||||
var fixedTime = time.Date(2026, 5, 25, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
func buildDocx() ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
|
||||
add := func(name, body string) error {
|
||||
hdr := &zip.FileHeader{
|
||||
Name: name,
|
||||
Method: zip.Deflate,
|
||||
Modified: fixedTime,
|
||||
}
|
||||
w, err := zw.CreateHeader(hdr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create %s: %w", name, err)
|
||||
}
|
||||
if _, err := w.Write([]byte(body)); err != nil {
|
||||
return fmt.Errorf("write %s: %w", name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := add("[Content_Types].xml", contentTypesXML); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := add("_rels/.rels", rootRelsXML); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := add("word/_rels/document.xml.rels", documentRelsXML); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := add("word/styles.xml", stylesXML); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := add("word/document.xml", buildDocumentXML()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := zw.Close(); err != nil {
|
||||
return nil, fmt.Errorf("finalise zip: %w", err)
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
const contentTypesXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
|
||||
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
|
||||
<Default Extension="xml" ContentType="application/xml"/>
|
||||
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
|
||||
<Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>
|
||||
</Types>`
|
||||
|
||||
const rootRelsXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
|
||||
</Relationships>`
|
||||
|
||||
const documentRelsXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
|
||||
</Relationships>`
|
||||
|
||||
const stylesXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
||||
<w:style w:type="paragraph" w:styleId="Heading1">
|
||||
<w:name w:val="heading 1"/>
|
||||
<w:basedOn w:val="Normal"/>
|
||||
<w:pPr><w:spacing w:before="360" w:after="120"/></w:pPr>
|
||||
<w:rPr><w:b/><w:sz w:val="28"/></w:rPr>
|
||||
</w:style>
|
||||
<w:style w:type="paragraph" w:styleId="Heading2">
|
||||
<w:name w:val="heading 2"/>
|
||||
<w:basedOn w:val="Normal"/>
|
||||
<w:pPr><w:spacing w:before="240" w:after="80"/></w:pPr>
|
||||
<w:rPr><w:b/><w:sz w:val="24"/></w:rPr>
|
||||
</w:style>
|
||||
<w:style w:type="paragraph" w:default="1" w:styleId="Normal">
|
||||
<w:name w:val="Normal"/>
|
||||
</w:style>
|
||||
</w:styles>`
|
||||
|
||||
// Document body — a code-agnostic Schriftsatz skeleton: firm letterhead +
|
||||
// case caption + parties + submission heading + deadline + a single
|
||||
// neutral body block. Mirrors the variable bag from SubmissionVarsService
|
||||
// (48 keys across firm.* / today.* / user.* / project.* / parties.* /
|
||||
// rule.* / deadline.*) without baking in DE-LG-Klageerwiderung-specific
|
||||
// structure. A lawyer customising this template for a UPC SoC, EPO
|
||||
// opposition, or DPMA appeal replaces the [Schriftsatztext] block and
|
||||
// renames the party labels — every placeholder still resolves regardless
|
||||
// of the submission_code chosen.
|
||||
//
|
||||
// Every placeholder occupies its own <w:r> run so the renderer's pass-1
|
||||
// (format-preserving, single-run) substitution catches it. The
|
||||
// DEMO/SKELETON banner makes it obvious this is a starter template and
|
||||
// not approved firm content.
|
||||
func buildDocumentXML() string {
|
||||
var b strings.Builder
|
||||
b.WriteString(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`)
|
||||
b.WriteString(`<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">`)
|
||||
b.WriteString(`<w:body>`)
|
||||
|
||||
skeletonBanner(&b)
|
||||
|
||||
heading1(&b, "{{firm.name}}")
|
||||
plain(&b, "Bearbeiter: {{user.display_name}}")
|
||||
plain(&b, "E-Mail: {{user.email}} · Büro: {{user.office}}")
|
||||
plain(&b, "Datum: {{today.long_de}} ({{today.iso}})")
|
||||
plainOptional(&b, "{{firm.signature_block}}")
|
||||
|
||||
heading1(&b, "{{project.court}}")
|
||||
plain(&b, "Aktenzeichen: {{project.case_number}}")
|
||||
plain(&b, "Verfahrensart: {{project.proceeding.name}} ({{project.proceeding.code}})")
|
||||
plain(&b, "Instanz: {{project.instance_level}}")
|
||||
|
||||
heading2(&b, "In der Sache")
|
||||
plain(&b, "{{parties.claimant.name}}")
|
||||
plain(&b, "vertreten durch {{parties.claimant.representative}}")
|
||||
bold(&b, "— Klägerin / Patentinhaberin / Anmelderin —")
|
||||
plain(&b, "")
|
||||
plain(&b, "gegen")
|
||||
plain(&b, "")
|
||||
plain(&b, "{{parties.defendant.name}}")
|
||||
plain(&b, "vertreten durch {{parties.defendant.representative}}")
|
||||
bold(&b, "— Beklagte / Einsprechende / Beschwerdegegnerin —")
|
||||
plainOptional(&b, "Weitere Beteiligte: {{parties.other.name}}, vertreten durch {{parties.other.representative}}")
|
||||
|
||||
heading2(&b, "Betreff")
|
||||
plain(&b, "Streitpatent: {{project.patent_number}} (UPC: {{project.patent_number_upc}})")
|
||||
plain(&b, "Anmeldung: {{project.filing_date}} · Erteilung: {{project.grant_date}}")
|
||||
plain(&b, "Projekttitel: {{project.title}}")
|
||||
plain(&b, "Unsere Seite: {{project.our_side_de}} ({{project.our_side}})")
|
||||
plain(&b, "Mandant: {{project.client_number}} · Matter: {{project.matter_number}}")
|
||||
plain(&b, "Internes Aktenzeichen: {{project.reference}}")
|
||||
|
||||
heading1(&b, "{{rule.name}}")
|
||||
plain(&b, "(Schriftsatz-Code: {{rule.submission_code}})")
|
||||
plain(&b, "Rechtsgrundlage: {{rule.legal_source_pretty}} ({{rule.legal_source}})")
|
||||
plain(&b, "Typische Partei: {{rule.primary_party}} · Schriftsatz-Typ: {{rule.event_type}}")
|
||||
|
||||
heading2(&b, "Frist")
|
||||
plain(&b, "Diese Frist wurde berechnet aus: {{deadline.computed_from}}")
|
||||
plain(&b, "Fälligkeit: {{deadline.due_date_long_de}} ({{deadline.due_date}})")
|
||||
plainOptional(&b, "Ursprüngliche Frist: {{deadline.original_due_date}}")
|
||||
plain(&b, "Frist-Bezeichnung: {{deadline.title}} · Quelle: {{deadline.source}}")
|
||||
|
||||
heading2(&b, "Schriftsatztext")
|
||||
plain(&b, "[Hier folgt der eigentliche Schriftsatztext. Diese Skelett-Vorlage enthält keine vorgefertigte Struktur — bitte gemäß Schriftsatz-Typ ({{rule.name}}) ergänzen.]")
|
||||
plain(&b, "")
|
||||
plain(&b, "[Body of the submission goes here. This skeleton template carries no pre-baked structure — fill in according to submission type ({{rule.name_en}}).]")
|
||||
|
||||
heading2(&b, "Schlussformel")
|
||||
plain(&b, "{{today.long_de}}")
|
||||
plain(&b, "")
|
||||
plain(&b, "{{user.display_name}}")
|
||||
plain(&b, "{{firm.name}}")
|
||||
|
||||
// Locale-aware verification block — exercises every EN/DE alias the
|
||||
// variable bag carries (today.long_en, deadline.due_date_long_en,
|
||||
// project.our_side_en, project.proceeding.name_en, rule.name_en) and
|
||||
// the bare {{today}} alias. A lawyer customising the template can
|
||||
// delete this block; the renderer round-trips it cleanly today.
|
||||
heading2(&b, "Locale-aware variants (SKELETON)")
|
||||
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()
|
||||
}
|
||||
|
||||
func skeletonBanner(b *strings.Builder) {
|
||||
b.WriteString(`<w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr><w:r><w:rPr><w:b/><w:color w:val="C00000"/></w:rPr><w:t xml:space="preserve">SKELETON — universelle Vorlage (Schriftsatz-Typ-unabhängig, nicht freigegeben)</w:t></w:r></w:p>`)
|
||||
}
|
||||
|
||||
func heading1(b *strings.Builder, text string) { paragraph(b, "Heading1", text, false) }
|
||||
|
||||
func heading2(b *strings.Builder, text string) { paragraph(b, "Heading2", text, false) }
|
||||
|
||||
func plain(b *strings.Builder, text string) { paragraph(b, "", text, false) }
|
||||
|
||||
func plainOptional(b *strings.Builder, text string) { paragraph(b, "", text, true) }
|
||||
|
||||
func bold(b *strings.Builder, text string) {
|
||||
b.WriteString(`<w:p>`)
|
||||
b.WriteString(`<w:r><w:rPr><w:b/></w:rPr><w:t xml:space="preserve">`)
|
||||
b.WriteString(xmlEscape(text))
|
||||
b.WriteString(`</w:t></w:r></w:p>`)
|
||||
}
|
||||
|
||||
func paragraph(b *strings.Builder, style, text string, italic bool) {
|
||||
b.WriteString(`<w:p>`)
|
||||
if style != "" {
|
||||
b.WriteString(`<w:pPr><w:pStyle w:val="`)
|
||||
b.WriteString(style)
|
||||
b.WriteString(`"/></w:pPr>`)
|
||||
}
|
||||
for _, seg := range splitOnPlaceholders(text) {
|
||||
b.WriteString(`<w:r>`)
|
||||
if italic {
|
||||
b.WriteString(`<w:rPr><w:i/></w:rPr>`)
|
||||
}
|
||||
b.WriteString(`<w:t xml:space="preserve">`)
|
||||
b.WriteString(xmlEscape(seg))
|
||||
b.WriteString(`</w:t></w:r>`)
|
||||
}
|
||||
b.WriteString(`</w:p>`)
|
||||
}
|
||||
|
||||
func splitOnPlaceholders(s string) []string {
|
||||
if s == "" {
|
||||
return []string{""}
|
||||
}
|
||||
var out []string
|
||||
for {
|
||||
open := strings.Index(s, "{{")
|
||||
if open < 0 {
|
||||
out = append(out, s)
|
||||
return out
|
||||
}
|
||||
close := strings.Index(s[open:], "}}")
|
||||
if close < 0 {
|
||||
out = append(out, s)
|
||||
return out
|
||||
}
|
||||
end := open + close + 2
|
||||
if open > 0 {
|
||||
out = append(out, s[:open])
|
||||
}
|
||||
out = append(out, s[open:end])
|
||||
s = s[end:]
|
||||
if s == "" {
|
||||
return out
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func xmlEscape(s string) string {
|
||||
s = strings.ReplaceAll(s, "&", "&")
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
s = strings.ReplaceAll(s, ">", ">")
|
||||
s = strings.ReplaceAll(s, `"`, """)
|
||||
s = strings.ReplaceAll(s, "'", "'")
|
||||
return s
|
||||
}
|
||||
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