Compare commits
37 Commits
mai/knuth/
...
mai/demete
| Author | SHA1 | Date | |
|---|---|---|---|
| 716f6d7ece | |||
| 206f2917ea | |||
| 5df87f4129 | |||
| 898348a64a | |||
| 1714b788d2 | |||
| db8335253b | |||
| 5589cbb477 | |||
| 0059e3f15b | |||
| a911a2d0ee | |||
| b26f04ffe0 | |||
| 8e195cb497 | |||
| 1f7de99493 | |||
| 0adcc2c826 | |||
| 2c7ac6423f | |||
| 436c1b41bb | |||
| 2c5f85b802 | |||
| d3aade5aac | |||
| 17d2fff661 | |||
| c6a5416611 | |||
| d590be4bb7 | |||
| f7374a67cd | |||
| 3ff1b23238 | |||
| a88269c7c1 | |||
| 3d85ce5444 | |||
| 903225b593 | |||
| 4cd2f05d33 | |||
| b4f5af7f70 | |||
| 83b00d13fe | |||
| 34372ca4c8 | |||
| 65308651dd | |||
| d088de95eb | |||
| becf4f0ce3 | |||
| 924dbd9768 | |||
| 6c40823038 | |||
| 007ebc2794 | |||
| cdd27d674e | |||
| 28de2e56d0 |
@@ -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,
|
||||
|
||||
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";
|
||||
@@ -252,6 +255,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"),
|
||||
@@ -376,6 +382,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
|
||||
|
||||
@@ -210,6 +210,65 @@ describe("placeWidgets — vertical (multi-row) widgets", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("placeWidgets — includeHidden (edit mode)", () => {
|
||||
test("hidden widgets are skipped by default", () => {
|
||||
const out = placeWidgets([
|
||||
spec("visible", 0, 0, 6),
|
||||
spec("hidden", 0, 0, 6, 1, false),
|
||||
]);
|
||||
expect(out.has("visible")).toBe(true);
|
||||
expect(out.has("hidden")).toBe(false);
|
||||
});
|
||||
|
||||
test("includeHidden:true places hidden widgets after visible ones", () => {
|
||||
// Regression for m/paliad#73 / t-paliad-238: in edit mode hidden
|
||||
// widgets MUST receive a placement, otherwise applyLayout leaves
|
||||
// their inline grid-column empty and CSS Grid auto-flows them as
|
||||
// 1×1 slivers ("super slim greyed-out column").
|
||||
const out = placeWidgets([
|
||||
spec("active", 0, 0, 12),
|
||||
spec("hidden", 0, 0, 6, 1, false),
|
||||
], { includeHidden: true });
|
||||
expect(out.has("hidden")).toBe(true);
|
||||
const h = out.get("hidden")!;
|
||||
// Must keep its requested width (6), not collapse to 1.
|
||||
expect(h.w).toBe(6);
|
||||
// Must land below the visible widget — never overlap or steal cells.
|
||||
expect(h.y).toBeGreaterThanOrEqual(1);
|
||||
expect(hasOverlap(out)).toBeNull();
|
||||
});
|
||||
|
||||
test("includeHidden two-pass: visible widgets keep priority over hidden", () => {
|
||||
// Hidden widget stored at (0, 0) shouldn't displace a visible
|
||||
// widget that wants (0, 0). The visible pass runs first, claims
|
||||
// (0, 0); the hidden widget is then placed wherever free — the
|
||||
// placer happily fits it next to the visible widget on the same
|
||||
// row if there's room. The hard invariant is just no-overlap.
|
||||
const out = placeWidgets([
|
||||
spec("active", 0, 0, 6),
|
||||
spec("hidden-at-origin", 0, 0, 6, 1, false),
|
||||
], { includeHidden: true });
|
||||
expect(out.get("active")).toEqual({ x: 0, y: 0, w: 6, h: 1 });
|
||||
expect(out.has("hidden-at-origin")).toBe(true);
|
||||
expect(hasOverlap(out)).toBeNull();
|
||||
});
|
||||
|
||||
test("multiple hidden widgets all receive valid placements", () => {
|
||||
const out = placeWidgets([
|
||||
spec("a", 0, 0, 12),
|
||||
spec("h1", undefined, undefined, 6, 1, false),
|
||||
spec("h2", undefined, undefined, 6, 1, false),
|
||||
spec("h3", undefined, undefined, 12, 1, false),
|
||||
], { includeHidden: true });
|
||||
expect(out.size).toBe(4);
|
||||
for (const r of out.values()) {
|
||||
expect(r.w).toBeGreaterThanOrEqual(1);
|
||||
expect(r.x + r.w).toBeLessThanOrEqual(GRID_COLUMNS);
|
||||
}
|
||||
expect(hasOverlap(out)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("clamp helpers", () => {
|
||||
test("clampW respects min/max bounds", () => {
|
||||
expect(clampW(2, { min_w: 4, max_w: 12 })).toBe(4);
|
||||
|
||||
@@ -133,10 +133,30 @@ function findFreeSlot(
|
||||
return { x: 0, y: startY + MAX_SCAN_ROWS };
|
||||
}
|
||||
|
||||
// placeWidgets assigns no-overlap grid coordinates to every visible
|
||||
// widget. Hidden widgets are skipped and contribute no placement.
|
||||
// PlaceOptions tunes the placer for the caller's render-vs-persist
|
||||
// needs.
|
||||
export interface PlaceOptions {
|
||||
// When true, hidden widgets are placed too — for edit-mode rendering
|
||||
// where the user can see + un-hide them inline. The two-pass order
|
||||
// (visible first, then hidden) guarantees hidden widgets never
|
||||
// displace visible ones: they get whatever cells are left below the
|
||||
// active layout. Default false matches view-mode behaviour and the
|
||||
// persistence path (materializePositions) where hidden widgets
|
||||
// retain their stored coordinates instead of being repacked.
|
||||
//
|
||||
// Without this option, hidden widgets in edit mode were left without
|
||||
// an explicit grid-column inline style by applyLayout(), so CSS Grid
|
||||
// auto-flowed them into the next free cell at 1×1 — the "super slim
|
||||
// greyed-out column" symptom of m/paliad#73 / t-paliad-238.
|
||||
includeHidden?: boolean;
|
||||
}
|
||||
|
||||
// placeWidgets assigns no-overlap grid coordinates to widgets. By
|
||||
// default only visible widgets receive placements; pass
|
||||
// {includeHidden:true} to also place hidden widgets after the visible
|
||||
// pass (used by applyLayout in edit mode).
|
||||
//
|
||||
// Algorithm: iterate widgets in input order. For each visible widget:
|
||||
// Algorithm — per pass:
|
||||
// 1. Clamp w/h against catalog bounds.
|
||||
// 2. If the spec carries explicit x and y, try that slot. On a
|
||||
// collision, search downward starting at the requested y for the
|
||||
@@ -150,8 +170,15 @@ function findFreeSlot(
|
||||
// real-world layout — placing the explicit widgets first would change
|
||||
// the visual order, so we keep input order and let auto-flow widgets
|
||||
// step around any explicit blockers via the same collision search.
|
||||
//
|
||||
// Two-pass behaviour for hidden widgets: the visible pass owns its
|
||||
// own auto-flow cursor; the hidden pass continues from where the
|
||||
// visible pass left off so the hidden widgets stack right under the
|
||||
// active layout. The shared Occupancy bitmap guarantees the second
|
||||
// pass can never overlap a placed visible widget.
|
||||
export function placeWidgets(
|
||||
widgets: WidgetPlacementInput[],
|
||||
options: PlaceOptions = {},
|
||||
): Map<string, PlacedRect> {
|
||||
const out = new Map<string, PlacedRect>();
|
||||
const occ = new Occupancy();
|
||||
@@ -165,8 +192,7 @@ export function placeWidgets(
|
||||
let cursorY = 0;
|
||||
let rowMaxH = 0;
|
||||
|
||||
for (const w of widgets) {
|
||||
if (!w.visible) continue;
|
||||
const placeOne = (w: WidgetPlacementInput): void => {
|
||||
const dw = clampW(w.w ?? w.bound?.default_w ?? GRID_COLUMNS, w.bound);
|
||||
const dh = clampH(w.h ?? w.bound?.default_h ?? 1, w.bound);
|
||||
|
||||
@@ -210,6 +236,28 @@ export function placeWidgets(
|
||||
|
||||
occ.mark(placed.x, placed.y, dw, dh);
|
||||
out.set(w.key, { x: placed.x, y: placed.y, w: dw, h: dh });
|
||||
};
|
||||
|
||||
// Pass 1: visible widgets. They own the active layout.
|
||||
for (const w of widgets) {
|
||||
if (!w.visible) continue;
|
||||
placeOne(w);
|
||||
}
|
||||
|
||||
// Pass 2: hidden widgets (edit-mode only). Wrap the cursor to the
|
||||
// start of the next row before the second pass so the hidden tray
|
||||
// visually separates from the active layout — even if the last
|
||||
// visible widget left half a row open.
|
||||
if (options.includeHidden) {
|
||||
if (cursorX > 0) {
|
||||
cursorY += rowMaxH || 1;
|
||||
cursorX = 0;
|
||||
rowMaxH = 0;
|
||||
}
|
||||
for (const w of widgets) {
|
||||
if (w.visible) continue;
|
||||
placeOne(w);
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
|
||||
@@ -1922,10 +1922,15 @@ function applyLayout(): void {
|
||||
if (k) byKey.set(k, el);
|
||||
});
|
||||
|
||||
// Compute effective placements (with auto-flow fill-in for missing
|
||||
// y values). The visible widgets are placed deterministically so the
|
||||
// grid renders identically across reloads.
|
||||
const placements = computePlacements(currentLayout.widgets);
|
||||
// Compute effective placements. In edit mode we also include hidden
|
||||
// widgets so they render at their stored (or default) dimensions
|
||||
// dimmed-but-visible — without this they'd inherit no inline grid-
|
||||
// column and CSS Grid would auto-flow them as 1×1 slivers, producing
|
||||
// the "super slim greyed-out column" symptom (m/paliad#73). In view
|
||||
// mode hidden widgets are display:none and reserve no cells.
|
||||
const placements = computePlacements(currentLayout.widgets, {
|
||||
includeHidden: editMode,
|
||||
});
|
||||
|
||||
for (const w of currentLayout.widgets) {
|
||||
const el = byKey.get(w.key);
|
||||
@@ -1952,7 +1957,15 @@ function applyLayout(): void {
|
||||
// overlap invariant: if two widgets request colliding cells (drag-drop
|
||||
// swap with mismatched widths, resize-grow into a sibling, etc.) the
|
||||
// later one is shifted down to the next free row. See m/paliad#70.
|
||||
function computePlacements(widgets: DashboardWidgetRef[]): Map<string, PlacedRect> {
|
||||
//
|
||||
// includeHidden=true is used by applyLayout in edit mode to also place
|
||||
// hidden widgets after the visible pass — so the hidden tray renders
|
||||
// at proper size below the active layout. Default (false) matches the
|
||||
// persistence + render paths where hidden widgets carry no placement.
|
||||
function computePlacements(
|
||||
widgets: DashboardWidgetRef[],
|
||||
options: { includeHidden?: boolean } = {},
|
||||
): Map<string, PlacedRect> {
|
||||
const inputs: WidgetPlacementInput[] = widgets.map((w) => ({
|
||||
key: w.key,
|
||||
visible: w.visible,
|
||||
@@ -1962,7 +1975,7 @@ function computePlacements(widgets: DashboardWidgetRef[]): Map<string, PlacedRec
|
||||
h: w.h,
|
||||
bound: toBound(lookupCatalog(w.key)),
|
||||
}));
|
||||
return placeWidgets(inputs);
|
||||
return placeWidgets(inputs, options);
|
||||
}
|
||||
|
||||
function clampW(w: number, def: WidgetCatalogEntry | undefined): number {
|
||||
|
||||
@@ -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",
|
||||
@@ -1425,10 +1426,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 +1443,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).
|
||||
@@ -1550,9 +1600,14 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"projects.detail.checklisten.col.name": "Name",
|
||||
"projects.detail.checklisten.col.progress": "Fortschritt",
|
||||
"projects.detail.checklisten.col.created": "Angelegt",
|
||||
"projects.detail.checklisten.hint.prefix": "Instanzen werden auf der Vorlagen-Seite unter ",
|
||||
"projects.detail.checklisten.hint.prefix": "Vorlagen werden auf der ",
|
||||
"projects.detail.checklisten.hint.link": "Checklisten",
|
||||
"projects.detail.checklisten.hint.suffix": " angelegt.",
|
||||
"projects.detail.checklisten.hint.suffix": "-Seite angelegt und bearbeitet.",
|
||||
"projects.detail.checklisten.add": "Checkliste hinzuf\u00fcgen",
|
||||
"projects.detail.checklisten.add.search": "Vorlage suchen\u2026",
|
||||
"projects.detail.checklisten.add.empty_pick": "Keine passenden Vorlagen gefunden.",
|
||||
"projects.detail.checklisten.add.created": "Checkliste hinzugef\u00fcgt.",
|
||||
"projects.detail.checklisten.add.error": "Checkliste konnte nicht angelegt werden.",
|
||||
"projects.detail.delete": "Projekt archivieren",
|
||||
"projects.detail.delete.confirm.title": "Projekt wirklich archivieren?",
|
||||
"projects.detail.delete.confirm.body": "Das Projekt wird archiviert. Es kann nicht direkt wiederhergestellt werden.",
|
||||
@@ -2048,8 +2103,13 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"paliadin.error.timeout": "Paliadin antwortet nicht (Timeout 60s). Nochmal versuchen.",
|
||||
"paliadin.error.connection_lost": "Verbindung verloren.",
|
||||
"paliadin.error.upstream": "Fehler beim Senden.",
|
||||
"paliadin.error.upstream_silence": "Paliadin meldet sich nicht mehr — Verbindung wird beendet.",
|
||||
"paliadin.late.waiting": "Antwort wird nachgereicht, sobald sie eintrifft …",
|
||||
"paliadin.late.checking": "Verbindung verloren — Paliadin denkt vielleicht noch. Lade frische Antwort …",
|
||||
"paliadin.late.lost": "Antwort konnte nicht zugestellt werden — bitte Frage erneut stellen.",
|
||||
"paliadin.late.marker": "verspätet",
|
||||
"paliadin.thinking": "Paliadin denkt nach",
|
||||
"paliadin.thinking.seconds": "{seconds}s",
|
||||
"paliadin.widget.title": "Paliadin",
|
||||
"paliadin.widget.trigger": "Paliadin (Cmd+J)",
|
||||
"paliadin.widget.empty": "Was kann ich für dich tun?",
|
||||
@@ -2916,6 +2976,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",
|
||||
@@ -4292,10 +4353,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",
|
||||
@@ -4303,7 +4370,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.",
|
||||
@@ -4416,9 +4525,14 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"projects.detail.checklisten.col.name": "Name",
|
||||
"projects.detail.checklisten.col.progress": "Progress",
|
||||
"projects.detail.checklisten.col.created": "Created",
|
||||
"projects.detail.checklisten.hint.prefix": "Instances are created on the template page under ",
|
||||
"projects.detail.checklisten.hint.prefix": "Templates are created and edited on the ",
|
||||
"projects.detail.checklisten.hint.link": "Checklists",
|
||||
"projects.detail.checklisten.hint.suffix": ".",
|
||||
"projects.detail.checklisten.hint.suffix": " page.",
|
||||
"projects.detail.checklisten.add": "Add checklist",
|
||||
"projects.detail.checklisten.add.search": "Search template…",
|
||||
"projects.detail.checklisten.add.empty_pick": "No matching templates.",
|
||||
"projects.detail.checklisten.add.created": "Checklist added.",
|
||||
"projects.detail.checklisten.add.error": "Could not create checklist.",
|
||||
"projects.detail.delete": "Archive project",
|
||||
"projects.detail.delete.confirm.title": "Archive project?",
|
||||
"projects.detail.delete.confirm.body": "The project will be archived. It cannot be directly restored.",
|
||||
@@ -4907,8 +5021,13 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"paliadin.error.timeout": "Paliadin didn't respond in time (60s). Try again.",
|
||||
"paliadin.error.connection_lost": "Connection lost.",
|
||||
"paliadin.error.upstream": "Send failed.",
|
||||
"paliadin.error.upstream_silence": "Paliadin went silent — closing the connection.",
|
||||
"paliadin.late.waiting": "Will fill in the response when it arrives …",
|
||||
"paliadin.late.checking": "Connection lost — Paliadin may still be thinking. Fetching fresh answer …",
|
||||
"paliadin.late.lost": "Answer couldn't be delivered — please ask again.",
|
||||
"paliadin.late.marker": "late",
|
||||
"paliadin.thinking": "Paliadin is thinking",
|
||||
"paliadin.thinking.seconds": "{seconds}s",
|
||||
"paliadin.widget.title": "Paliadin",
|
||||
"paliadin.widget.trigger": "Paliadin (Cmd+J)",
|
||||
"paliadin.widget.empty": "What can I help you with?",
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
// Late-response polling. The Go backend's pollForResponse window is
|
||||
// 60 s; if Claude writes the response file after that (because the
|
||||
// tmux pane was busy mid-turn when the message arrived), the SSE
|
||||
// stream has already closed with an `error` event. The Janitor
|
||||
// (services.LocalPaliadinService.runJanitor) then patches the
|
||||
// paliadin_turns row when the file lands.
|
||||
// Late-response polling (t-paliad-235 rewrite).
|
||||
//
|
||||
// This module is the FE half of that loop: after the bubble shows an
|
||||
// error, the caller registers the turn here. We poll
|
||||
// `/api/paliadin/turns/{id}` every 3 s for up to 10 minutes; once the
|
||||
// row has a non-empty response, we hand it back so the caller can
|
||||
// swap the bubble content in place.
|
||||
// When the SSE stream closes mid-turn with an error event, the bubble
|
||||
// can't tell from the wire whether (a) the upstream is still finishing
|
||||
// the turn and we just lost transport, or (b) the upstream is truly
|
||||
// dead.
|
||||
//
|
||||
// This module hits the dispatching recovery endpoint
|
||||
// `/api/paliadin/turns/{id}/recover`, which knows the active backend:
|
||||
//
|
||||
// - aichat backend → asks aichat via its conversation API whether
|
||||
// the turn actually completed upstream
|
||||
// - legacy backend → reads the local row (paliad's filesystem
|
||||
// janitor patches it when claude writes the
|
||||
// response file late)
|
||||
//
|
||||
// The endpoint returns:
|
||||
//
|
||||
// recovery_state="recovered" → response is in the payload, render it
|
||||
// recovery_state="pending" → keep polling
|
||||
// recovery_state="lost" → upstream is truly gone, give up
|
||||
|
||||
export interface LateTurn {
|
||||
turn_id: string;
|
||||
@@ -28,6 +37,10 @@ export interface LatePollOptions {
|
||||
intervalMs?: number; // default 3000
|
||||
maxDurationMs?: number; // default 600000 (10 min)
|
||||
onLateResponse: (turn: LateTurn) => void;
|
||||
// onLost — backend confirmed the turn is unrecoverable. Caller should
|
||||
// swap the bubble copy to the "verloren" string. Distinct from
|
||||
// onGiveUp (which fires only on the local timeout).
|
||||
onLost?: () => void;
|
||||
onGiveUp?: () => void;
|
||||
}
|
||||
|
||||
@@ -35,6 +48,20 @@ export interface LatePollHandle {
|
||||
cancel: () => void;
|
||||
}
|
||||
|
||||
interface RecoverResponse {
|
||||
turn_id: string;
|
||||
started_at: string;
|
||||
response: string | null;
|
||||
error_code: string | null;
|
||||
finished_at: string | null;
|
||||
duration_ms: number | null;
|
||||
used_tools: string[];
|
||||
rows_seen: number[];
|
||||
chip_count: number;
|
||||
classifier_tag: string | null;
|
||||
recovery_state: "recovered" | "pending" | "lost";
|
||||
}
|
||||
|
||||
export function pollForLateResponse(opts: LatePollOptions): LatePollHandle {
|
||||
const interval = opts.intervalMs ?? 3000;
|
||||
const maxDuration = opts.maxDurationMs ?? 10 * 60 * 1000;
|
||||
@@ -50,18 +77,24 @@ export function pollForLateResponse(opts: LatePollOptions): LatePollHandle {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const r = await fetch(`/api/paliadin/turns/${opts.turnId}`, {
|
||||
const r = await fetch(`/api/paliadin/turns/${opts.turnId}/recover`, {
|
||||
credentials: "same-origin",
|
||||
});
|
||||
if (r.ok) {
|
||||
const turn = (await r.json()) as LateTurn;
|
||||
if (turn.response && turn.response.length > 0) {
|
||||
opts.onLateResponse(turn);
|
||||
const body = (await r.json()) as RecoverResponse;
|
||||
if (body.recovery_state === "recovered" && body.response) {
|
||||
opts.onLateResponse(toLateTurn(body));
|
||||
return;
|
||||
}
|
||||
}
|
||||
// 404: row gone (very unlikely) — give up.
|
||||
if (r.status === 404) {
|
||||
if (body.recovery_state === "lost") {
|
||||
opts.onLost?.();
|
||||
return;
|
||||
}
|
||||
// pending — keep polling
|
||||
} else if (r.status === 404) {
|
||||
// Row gone — give up. Different signal from `lost`: a missing row
|
||||
// is a paliad-side bookkeeping problem; aichat may still have the
|
||||
// answer but we can't surface it without the row.
|
||||
opts.onGiveUp?.();
|
||||
return;
|
||||
}
|
||||
@@ -72,7 +105,8 @@ export function pollForLateResponse(opts: LatePollOptions): LatePollHandle {
|
||||
};
|
||||
|
||||
// First poll deliberately runs after one interval so we don't race
|
||||
// the 60 s timeout on the very first tick.
|
||||
// the dispatch endpoint on the very first tick (gives the upstream a
|
||||
// moment to actually settle the row after the stream drop).
|
||||
timer = window.setTimeout(tick, interval);
|
||||
|
||||
return {
|
||||
@@ -82,3 +116,17 @@ export function pollForLateResponse(opts: LatePollOptions): LatePollHandle {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function toLateTurn(body: RecoverResponse): LateTurn {
|
||||
return {
|
||||
turn_id: body.turn_id,
|
||||
response: body.response,
|
||||
error_code: body.error_code,
|
||||
finished_at: body.finished_at,
|
||||
duration_ms: body.duration_ms,
|
||||
used_tools: body.used_tools ?? [],
|
||||
rows_seen: body.rows_seen ?? [],
|
||||
chip_count: body.chip_count ?? 0,
|
||||
classifier_tag: body.classifier_tag,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -381,11 +381,32 @@ async function sendTurn(): Promise<void> {
|
||||
const es = new EventSource(turnRes.sse_url);
|
||||
activeStream = es;
|
||||
|
||||
startWidgetThinking(placeholder);
|
||||
|
||||
let fullText = "";
|
||||
es.addEventListener("thinking", (ev) => {
|
||||
let elapsed = 0;
|
||||
try {
|
||||
const data = JSON.parse((ev as MessageEvent).data || "{}");
|
||||
if (typeof data.elapsed_seconds === "number") elapsed = data.elapsed_seconds;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
updateWidgetThinking(placeholder, elapsed);
|
||||
});
|
||||
es.addEventListener("content", (ev) => {
|
||||
try {
|
||||
const data = JSON.parse((ev as MessageEvent).data);
|
||||
if (typeof data.delta === "string" && data.delta) {
|
||||
// Streamed delta (aichat backend) — append.
|
||||
stopWidgetThinking(placeholder);
|
||||
fullText += data.delta;
|
||||
setBubbleText(placeholder, fullText);
|
||||
return;
|
||||
}
|
||||
// Legacy one-shot full-text payload.
|
||||
fullText = String(data.text || "");
|
||||
stopWidgetThinking(placeholder);
|
||||
setBubbleText(placeholder, fullText);
|
||||
} catch {
|
||||
/* ignore parse error */
|
||||
@@ -393,13 +414,15 @@ async function sendTurn(): Promise<void> {
|
||||
});
|
||||
es.addEventListener("end", () => {
|
||||
placeholder.dataset.streaming = "false";
|
||||
stopWidgetThinking(placeholder);
|
||||
history.push({ role: "assistant", text: fullText || "", ts: new Date().toISOString() });
|
||||
saveHistory();
|
||||
cleanupStream();
|
||||
});
|
||||
es.addEventListener("error", () => {
|
||||
stopWidgetThinking(placeholder);
|
||||
const errText = t("paliadin.error.connection_lost");
|
||||
setBubbleText(placeholder, errText + " " + t("paliadin.late.waiting"));
|
||||
setBubbleText(placeholder, errText + " " + t("paliadin.late.checking"));
|
||||
placeholder.classList.add("paliadin-widget-bubble--error");
|
||||
placeholder.classList.add("paliadin-widget-bubble--late-pending");
|
||||
placeholder.dataset.streaming = "false";
|
||||
@@ -412,6 +435,39 @@ async function sendTurn(): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
function startWidgetThinking(bubble: HTMLElement): void {
|
||||
if (bubble.querySelector(".paliadin-widget-thinking")) return;
|
||||
// Clear the static placeholder text — the live pulse + counter is
|
||||
// the canonical "denkt nach" signal.
|
||||
const textNode = bubble.querySelector(".paliadin-widget-bubble-text");
|
||||
if (textNode) textNode.textContent = "";
|
||||
const node = document.createElement("div");
|
||||
node.className = "paliadin-widget-thinking";
|
||||
node.innerHTML = `
|
||||
<span class="paliadin-widget-thinking-dot" aria-hidden="true"></span>
|
||||
<span class="paliadin-widget-thinking-label"></span>
|
||||
<span class="paliadin-widget-thinking-elapsed"></span>
|
||||
`;
|
||||
const label = node.querySelector(".paliadin-widget-thinking-label")!;
|
||||
label.textContent = t("paliadin.thinking");
|
||||
bubble.appendChild(node);
|
||||
updateWidgetThinking(bubble, 0);
|
||||
}
|
||||
|
||||
function updateWidgetThinking(bubble: HTMLElement, elapsedSeconds: number): void {
|
||||
const node = bubble.querySelector(".paliadin-widget-thinking") as HTMLElement | null;
|
||||
if (!node) return;
|
||||
const elapsed = node.querySelector(".paliadin-widget-thinking-elapsed");
|
||||
if (elapsed) {
|
||||
const s = elapsedSeconds < 0 ? 0 : Math.round(elapsedSeconds);
|
||||
elapsed.textContent = t("paliadin.thinking.seconds").replace("{seconds}", String(s));
|
||||
}
|
||||
}
|
||||
|
||||
function stopWidgetThinking(bubble: HTMLElement): void {
|
||||
bubble.querySelector(".paliadin-widget-thinking")?.remove();
|
||||
}
|
||||
|
||||
function cleanupStream(): void {
|
||||
activeStream?.close();
|
||||
activeStream = null;
|
||||
@@ -427,13 +483,24 @@ function startWidgetLatePoll(turnId: string, bubble: HTMLElement): void {
|
||||
lateWidgetPolls.delete(turnId);
|
||||
applyWidgetLateResponse(bubble, turn);
|
||||
},
|
||||
onLost: () => {
|
||||
lateWidgetPolls.delete(turnId);
|
||||
applyWidgetLost(bubble);
|
||||
},
|
||||
onGiveUp: () => {
|
||||
lateWidgetPolls.delete(turnId);
|
||||
applyWidgetLost(bubble);
|
||||
},
|
||||
});
|
||||
lateWidgetPolls.set(turnId, handle);
|
||||
}
|
||||
|
||||
function applyWidgetLost(bubble: HTMLElement): void {
|
||||
bubble.classList.remove("paliadin-widget-bubble--late-pending");
|
||||
bubble.classList.add("paliadin-widget-bubble--lost");
|
||||
setBubbleText(bubble, t("paliadin.late.lost"));
|
||||
}
|
||||
|
||||
function applyWidgetLateResponse(bubble: HTMLElement, turn: LateTurn): void {
|
||||
if (!turn.response) return;
|
||||
bubble.classList.remove(
|
||||
|
||||
@@ -3,16 +3,25 @@ import { initSidebar } from "./sidebar";
|
||||
import { renderResponseHTML } from "./paliadin-render";
|
||||
import { pollForLateResponse, type LateTurn, type LatePollHandle } from "./paliadin-late-poll";
|
||||
|
||||
// Paliadin chat panel client (t-paliad-146 PoC).
|
||||
// Paliadin chat panel client (t-paliad-146 PoC, streaming upgrade
|
||||
// t-paliad-235).
|
||||
//
|
||||
// State machine: empty → typing → sending → streaming → done.
|
||||
// State machine: empty → typing → sending → thinking → streaming → done.
|
||||
// History lives in localStorage under "paliadin:history:<sessionId>"
|
||||
// — design §0.5.4 session-only persistence.
|
||||
//
|
||||
// SSE consumer subscribes to `event: meta`, `event: content`,
|
||||
// `event: end`, `event: error`, `event: ping`. Backend currently
|
||||
// emits one `content` blob per turn (real chunked streaming is
|
||||
// production-v1; PoC simulates with a typewriter effect).
|
||||
// `event: thinking`, `event: end`, `event: error`, `event: ping`.
|
||||
//
|
||||
// `content` events from the aichat backend arrive as incremental
|
||||
// `{delta: "..."}` chunks; the bubble accumulates them in real time —
|
||||
// no typewriter simulation needed. Legacy backends still emit a single
|
||||
// `{text: "..."}` payload and we fall back to the typewriter for that
|
||||
// shape.
|
||||
//
|
||||
// `thinking` events fire while the upstream is alive but hasn't
|
||||
// produced content yet (or stalled mid-stream); the bubble renders a
|
||||
// pulse + counter so the user can SEE the chat is still working.
|
||||
|
||||
interface HistoryEntry {
|
||||
role: "user" | "assistant";
|
||||
@@ -167,25 +176,53 @@ async function sendTurn(text: string): Promise<void> {
|
||||
const es = new EventSource(turnRes.sse_url);
|
||||
currentEventSource = es;
|
||||
|
||||
// Show the thinking pulse immediately — the placeholder text already
|
||||
// says "denkt nach", but the visible pulse + counter is the live
|
||||
// proof-of-life signal m needs to trust that the chat is working.
|
||||
startThinkingIndicator(placeholder);
|
||||
// Reset the streamed accumulator for this turn.
|
||||
placeholder.dataset.fullText = "";
|
||||
|
||||
es.addEventListener("meta", () => {
|
||||
// Could surface a "thinking" indicator; placeholder text already does.
|
||||
});
|
||||
|
||||
es.addEventListener("thinking", (ev) => {
|
||||
let elapsed = 0;
|
||||
try {
|
||||
const data = JSON.parse((ev as MessageEvent).data || "{}");
|
||||
if (typeof data.elapsed_seconds === "number") {
|
||||
elapsed = data.elapsed_seconds;
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
updateThinkingIndicator(placeholder, elapsed);
|
||||
});
|
||||
|
||||
es.addEventListener("content", (ev) => {
|
||||
const data = JSON.parse((ev as MessageEvent).data);
|
||||
const delta = typeof data.delta === "string" ? data.delta : "";
|
||||
if (delta) {
|
||||
// Aichat streaming path — accumulate the delta into the bubble.
|
||||
stopThinkingIndicator(placeholder);
|
||||
const current = placeholder.dataset.fullText ?? "";
|
||||
const next = current + delta;
|
||||
placeholder.dataset.fullText = next;
|
||||
writeStreamedText(placeholder, next);
|
||||
return;
|
||||
}
|
||||
// Legacy one-shot path — full body in `text`.
|
||||
const text = String(data.text || "");
|
||||
// Cache the full text on the bubble so finishBubble can render the
|
||||
// complete response even when the typewriter is mid-flight when end
|
||||
// arrives. textContent reflects only what's been typed so far and
|
||||
// would otherwise truncate the rendered Markdown (m, 2026-05-08 —
|
||||
// saw "## Proje" instead of the full 1408-byte body).
|
||||
placeholder.dataset.fullText = text;
|
||||
stopThinkingIndicator(placeholder);
|
||||
typewriter(placeholder, text);
|
||||
});
|
||||
|
||||
es.addEventListener("end", (ev) => {
|
||||
const data = JSON.parse((ev as MessageEvent).data);
|
||||
placeholder.dataset.streaming = "false";
|
||||
stopThinkingIndicator(placeholder);
|
||||
finishBubble(placeholder, data);
|
||||
history.push({
|
||||
role: "assistant",
|
||||
@@ -210,12 +247,12 @@ async function sendTurn(text: string): Promise<void> {
|
||||
|
||||
es.addEventListener("error", (ev) => {
|
||||
const errText = friendlyErrorMessage((ev as MessageEvent).data);
|
||||
// Annotate the error bubble with a "warten auf späte Antwort" hint
|
||||
// so m knows the turn isn't dead; if Claude finishes after the
|
||||
// 60 s window the Janitor (services.LocalPaliadinService.runJanitor)
|
||||
// patches the row and pollForLateResponse swaps in the real reply.
|
||||
stopThinkingIndicator(placeholder);
|
||||
// Honest copy: we don't claim "nachgereicht" because the recovery
|
||||
// path may report "lost". Frame it as "checking" while we ask the
|
||||
// backend whether the turn actually completed upstream.
|
||||
placeholder.querySelector(".paliadin-bubble-text")!.textContent =
|
||||
errText + " " + t("paliadin.late.waiting");
|
||||
errText + " " + t("paliadin.late.checking");
|
||||
placeholder.classList.add("paliadin-bubble--error");
|
||||
placeholder.classList.add("paliadin-bubble--late-pending");
|
||||
placeholder.dataset.streaming = "false";
|
||||
@@ -232,6 +269,65 @@ async function sendTurn(text: string): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// thinking indicator — proof-of-life pulse + elapsed counter
|
||||
// =============================================================================
|
||||
|
||||
function startThinkingIndicator(bubble: HTMLElement): void {
|
||||
// Append a thinking node next to the bubble text (sibling, so the
|
||||
// typewriter rewriting text content doesn't clobber it). The node
|
||||
// shows a pulse dot + the elapsed counter.
|
||||
let node = bubble.querySelector(".paliadin-thinking") as HTMLElement | null;
|
||||
if (node) return; // already running
|
||||
// Clear the static placeholder text — the live pulse + counter is
|
||||
// now the canonical "denkt nach" signal. Leaving the text in place
|
||||
// would render the same phrase twice.
|
||||
const textNode = bubble.querySelector(".paliadin-bubble-text");
|
||||
if (textNode) textNode.textContent = "";
|
||||
node = document.createElement("div");
|
||||
node.className = "paliadin-thinking";
|
||||
node.innerHTML = `
|
||||
<span class="paliadin-thinking-dot" aria-hidden="true"></span>
|
||||
<span class="paliadin-thinking-label"></span>
|
||||
<span class="paliadin-thinking-elapsed"></span>
|
||||
`;
|
||||
const label = node.querySelector(".paliadin-thinking-label")!;
|
||||
label.textContent = t("paliadin.thinking");
|
||||
bubble.appendChild(node);
|
||||
// Initial 0s — replaced as soon as a thinking event arrives or our
|
||||
// local ticker fires.
|
||||
updateThinkingIndicator(bubble, 0);
|
||||
}
|
||||
|
||||
function updateThinkingIndicator(bubble: HTMLElement, elapsedSeconds: number): void {
|
||||
const node = bubble.querySelector(".paliadin-thinking") as HTMLElement | null;
|
||||
if (!node) return;
|
||||
const elapsed = node.querySelector(".paliadin-thinking-elapsed");
|
||||
if (elapsed) {
|
||||
elapsed.textContent = formatThinkingSeconds(elapsedSeconds);
|
||||
}
|
||||
}
|
||||
|
||||
function stopThinkingIndicator(bubble: HTMLElement): void {
|
||||
bubble.querySelector(".paliadin-thinking")?.remove();
|
||||
}
|
||||
|
||||
function formatThinkingSeconds(s: number): string {
|
||||
if (s < 0) s = 0;
|
||||
return t("paliadin.thinking.seconds").replace("{seconds}", String(Math.round(s)));
|
||||
}
|
||||
|
||||
// writeStreamedText fills the bubble with raw text as it accumulates.
|
||||
// Cheaper than the typewriter — we already have the real cadence from
|
||||
// the wire, no need to simulate it.
|
||||
function writeStreamedText(bubble: HTMLElement, text: string): void {
|
||||
const node = bubble.querySelector(".paliadin-bubble-text");
|
||||
if (!node) return;
|
||||
node.textContent = text;
|
||||
const stream = document.getElementById("paliadin-stream");
|
||||
if (stream) stream.scrollTop = stream.scrollHeight;
|
||||
}
|
||||
|
||||
// Server emits SSE error events as JSON `{code, message}`. Map known
|
||||
// codes to localised, user-friendly text; fall through to a generic
|
||||
// "connection lost" for anything we don't recognise (including raw
|
||||
@@ -361,11 +457,12 @@ function finishBubble(bubble: HTMLElement, data: any): void {
|
||||
}
|
||||
|
||||
|
||||
// startLatePoll registers the Janitor-patched row poller for one
|
||||
// errored turn. When the row gains a response we swap the bubble's
|
||||
// content + drop the error class + retroactively replace the history
|
||||
// entry (which was never written for the failed turn — append now so
|
||||
// reload renders the late reply).
|
||||
// startLatePoll registers the recovery-endpoint poller for one errored
|
||||
// turn. When the row gains a response we swap the bubble's content +
|
||||
// drop the error class + retroactively replace the history entry
|
||||
// (which was never written for the failed turn — append now so reload
|
||||
// renders the late reply). When the backend confirms the turn is
|
||||
// "lost", we swap the bubble to the honest "verloren" copy.
|
||||
function startLatePoll(turnId: string, bubble: HTMLElement): void {
|
||||
// Avoid duplicate pollers for the same turn (e.g. SSE error fires
|
||||
// twice in some browsers when the connection drops).
|
||||
@@ -376,13 +473,25 @@ function startLatePoll(turnId: string, bubble: HTMLElement): void {
|
||||
latePolls.delete(turnId);
|
||||
applyLateResponse(bubble, turn);
|
||||
},
|
||||
onLost: () => {
|
||||
latePolls.delete(turnId);
|
||||
applyLostResponse(bubble);
|
||||
},
|
||||
onGiveUp: () => {
|
||||
latePolls.delete(turnId);
|
||||
applyLostResponse(bubble);
|
||||
},
|
||||
});
|
||||
latePolls.set(turnId, handle);
|
||||
}
|
||||
|
||||
function applyLostResponse(bubble: HTMLElement): void {
|
||||
bubble.classList.remove("paliadin-bubble--late-pending");
|
||||
bubble.classList.add("paliadin-bubble--lost");
|
||||
const node = bubble.querySelector(".paliadin-bubble-text");
|
||||
if (node) node.textContent = t("paliadin.late.lost");
|
||||
}
|
||||
|
||||
function applyLateResponse(bubble: HTMLElement, turn: LateTurn): void {
|
||||
if (!turn.response) return;
|
||||
bubble.classList.remove("paliadin-bubble--error", "paliadin-bubble--late-pending");
|
||||
|
||||
@@ -175,7 +175,8 @@ type TabId =
|
||||
| "appointments"
|
||||
| "notes"
|
||||
| "checklists"
|
||||
| "submissions";
|
||||
| "submissions"
|
||||
| "settings";
|
||||
|
||||
const VALID_TABS: TabId[] = [
|
||||
"history",
|
||||
@@ -187,6 +188,7 @@ const VALID_TABS: TabId[] = [
|
||||
"notes",
|
||||
"checklists",
|
||||
"submissions",
|
||||
"settings",
|
||||
];
|
||||
|
||||
// Legacy German tab slugs that may appear in bookmarked URLs after the
|
||||
@@ -214,6 +216,9 @@ interface ChecklistTemplateSummary {
|
||||
slug: string;
|
||||
titleDE: string;
|
||||
titleEN: string;
|
||||
descriptionDE?: string;
|
||||
descriptionEN?: string;
|
||||
regime?: string;
|
||||
itemCount: number;
|
||||
}
|
||||
|
||||
@@ -1182,13 +1187,16 @@ function renderHeader() {
|
||||
netdocs.style.display = "none";
|
||||
}
|
||||
|
||||
// Delete visibility: partner/admin only
|
||||
// Delete visibility: partner/admin only. The Verwaltung tab's archive
|
||||
// sub-section mirrors the same gate (t-paliad-245) — it only points at
|
||||
// the Edit-modal danger zone, so it's pointless to show when the danger
|
||||
// zone itself is hidden.
|
||||
const deleteWrap = document.getElementById("project-delete-wrap")!;
|
||||
if (me && (me.global_role === "global_admin")) {
|
||||
deleteWrap.style.display = "";
|
||||
} else {
|
||||
deleteWrap.style.display = "none";
|
||||
}
|
||||
const archiveSection = document.getElementById("project-settings-archive");
|
||||
const canArchive = !!me && me.global_role === "global_admin";
|
||||
deleteWrap.style.display = canArchive ? "" : "none";
|
||||
if (archiveSection) archiveSection.style.display = canArchive ? "" : "none";
|
||||
updateSettingsTabVisibility();
|
||||
}
|
||||
|
||||
// wrapEventTitleLink — kept for the dashboard activity feed which reuses
|
||||
@@ -1631,18 +1639,34 @@ function showTab(tab: TabId) {
|
||||
}
|
||||
|
||||
let checklistInstancesInited = false;
|
||||
async function loadAndRenderChecklistInstances(projectID: string) {
|
||||
if (checklistInstancesInited) return;
|
||||
let checklistCatalogLoaded = false;
|
||||
|
||||
// loadChecklistCatalog populates `checklistTemplates` (slug → template) from
|
||||
// `/api/checklists`. Reused by the tab renderer and the add-instance modal so
|
||||
// the second open doesn't refetch the catalog (t-paliad-239).
|
||||
async function loadChecklistCatalog(): Promise<ChecklistTemplateSummary[]> {
|
||||
if (checklistCatalogLoaded) return Object.values(checklistTemplates);
|
||||
try {
|
||||
const resp = await fetch(`/api/checklists`);
|
||||
const list = resp.ok ? (((await resp.json()) as ChecklistTemplateSummary[]) ?? []) : [];
|
||||
checklistTemplates = {};
|
||||
for (const tpl of list) checklistTemplates[tpl.slug] = tpl;
|
||||
checklistCatalogLoaded = true;
|
||||
return list;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAndRenderChecklistInstances(projectID: string, force = false) {
|
||||
if (checklistInstancesInited && !force) return;
|
||||
checklistInstancesInited = true;
|
||||
try {
|
||||
const [instResp, tplResp] = await Promise.all([
|
||||
const [instResp] = await Promise.all([
|
||||
fetch(`/api/projects/${projectID}/checklists`),
|
||||
fetch(`/api/checklists`),
|
||||
loadChecklistCatalog(),
|
||||
]);
|
||||
checklistInstances = instResp.ok ? ((await instResp.json()) ?? []) : [];
|
||||
const templates = tplResp.ok ? (((await tplResp.json()) as ChecklistTemplateSummary[]) ?? []) : [];
|
||||
checklistTemplates = {};
|
||||
for (const tpl of templates) checklistTemplates[tpl.slug] = tpl;
|
||||
} catch {
|
||||
checklistInstances = [];
|
||||
}
|
||||
@@ -1702,6 +1726,143 @@ function renderChecklistInstances() {
|
||||
});
|
||||
}
|
||||
|
||||
// initAddChecklistModal wires the "Checkliste hinzufügen" button on the
|
||||
// project-detail Checklists tab (t-paliad-239). Opens a template picker
|
||||
// modal; on pick, POSTs to /api/checklists/{slug}/instances with the
|
||||
// current project_id and the template title as the instance name.
|
||||
function initAddChecklistModal(projectID: string) {
|
||||
const addBtn = document.getElementById("checklist-add-btn") as HTMLButtonElement | null;
|
||||
const modal = document.getElementById("add-checklist-modal") as HTMLDivElement | null;
|
||||
const closeBtn = document.getElementById("add-checklist-close") as HTMLButtonElement | null;
|
||||
const search = document.getElementById("add-checklist-search") as HTMLInputElement | null;
|
||||
const list = document.getElementById("add-checklist-list") as HTMLDivElement | null;
|
||||
const empty = document.getElementById("add-checklist-empty") as HTMLParagraphElement | null;
|
||||
const modalMsg = document.getElementById("add-checklist-msg") as HTMLParagraphElement | null;
|
||||
const tabMsg = document.getElementById("project-checklists-msg") as HTMLParagraphElement | null;
|
||||
if (!addBtn || !modal || !closeBtn || !search || !list || !empty || !modalMsg || !tabMsg) return;
|
||||
|
||||
const close = () => {
|
||||
modal.style.display = "none";
|
||||
modalMsg.textContent = "";
|
||||
modalMsg.className = "form-msg";
|
||||
};
|
||||
|
||||
const renderPicker = () => {
|
||||
const isEN = getLang() === "en";
|
||||
const q = search.value.trim().toLowerCase();
|
||||
const all = Object.values(checklistTemplates);
|
||||
all.sort((a, b) => {
|
||||
const at = (isEN ? a.titleEN : a.titleDE) || a.slug;
|
||||
const bt = (isEN ? b.titleEN : b.titleDE) || b.slug;
|
||||
return at.localeCompare(bt, isEN ? "en" : "de");
|
||||
});
|
||||
const filtered = q
|
||||
? all.filter((tpl) => {
|
||||
const title = (isEN ? tpl.titleEN : tpl.titleDE) || "";
|
||||
const desc = (isEN ? tpl.descriptionEN : tpl.descriptionDE) || "";
|
||||
return title.toLowerCase().includes(q)
|
||||
|| desc.toLowerCase().includes(q)
|
||||
|| (tpl.regime || "").toLowerCase().includes(q);
|
||||
})
|
||||
: all;
|
||||
|
||||
if (filtered.length === 0) {
|
||||
list.innerHTML = "";
|
||||
empty.style.display = "";
|
||||
return;
|
||||
}
|
||||
empty.style.display = "none";
|
||||
list.innerHTML = filtered.map((tpl) => {
|
||||
const title = (isEN ? tpl.titleEN : tpl.titleDE) || tpl.slug;
|
||||
const desc = (isEN ? tpl.descriptionEN : tpl.descriptionDE) || "";
|
||||
const regime = tpl.regime || "";
|
||||
const regimeChip = regime
|
||||
? `<span class="checklist-regime checklist-regime-${escapeHtml(regime)}">${escapeHtml(regime)}</span>`
|
||||
: "";
|
||||
const descLine = desc ? `<p class="add-checklist-row-desc">${escapeHtml(desc)}</p>` : "";
|
||||
return `<button type="button" class="add-checklist-row" data-slug="${escapeHtml(tpl.slug)}">
|
||||
<div class="add-checklist-row-head">
|
||||
<span class="add-checklist-row-title">${escapeHtml(title)}</span>
|
||||
${regimeChip}
|
||||
</div>
|
||||
${descLine}
|
||||
</button>`;
|
||||
}).join("");
|
||||
|
||||
list.querySelectorAll<HTMLButtonElement>(".add-checklist-row").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const slug = btn.dataset.slug!;
|
||||
void pickTemplate(slug, btn);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const pickTemplate = async (slug: string, btn: HTMLButtonElement) => {
|
||||
const tpl = checklistTemplates[slug];
|
||||
if (!tpl) return;
|
||||
const isEN = getLang() === "en";
|
||||
const name = (isEN ? tpl.titleEN : tpl.titleDE) || tpl.slug;
|
||||
|
||||
list.querySelectorAll<HTMLButtonElement>(".add-checklist-row").forEach((b) => {
|
||||
b.disabled = true;
|
||||
});
|
||||
modalMsg.textContent = "";
|
||||
modalMsg.className = "form-msg";
|
||||
|
||||
try {
|
||||
const resp = await fetch(`/api/checklists/${encodeURIComponent(slug)}/instances`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name, project_id: projectID }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
modalMsg.textContent = t("projects.detail.checklisten.add.error");
|
||||
modalMsg.className = "form-msg form-msg-error";
|
||||
list.querySelectorAll<HTMLButtonElement>(".add-checklist-row").forEach((b) => {
|
||||
b.disabled = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
close();
|
||||
flashTabMsg(t("projects.detail.checklisten.add.created"));
|
||||
await loadAndRenderChecklistInstances(projectID, true);
|
||||
} catch {
|
||||
modalMsg.textContent = t("projects.detail.checklisten.add.error");
|
||||
modalMsg.className = "form-msg form-msg-error";
|
||||
list.querySelectorAll<HTMLButtonElement>(".add-checklist-row").forEach((b) => {
|
||||
b.disabled = false;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let flashTimer = 0;
|
||||
const flashTabMsg = (text: string) => {
|
||||
tabMsg.textContent = text;
|
||||
tabMsg.className = "form-msg form-msg-success";
|
||||
if (flashTimer) window.clearTimeout(flashTimer);
|
||||
flashTimer = window.setTimeout(() => {
|
||||
tabMsg.textContent = "";
|
||||
tabMsg.className = "form-msg";
|
||||
}, 3500);
|
||||
};
|
||||
|
||||
addBtn.addEventListener("click", async () => {
|
||||
await loadChecklistCatalog();
|
||||
search.value = "";
|
||||
modalMsg.textContent = "";
|
||||
modalMsg.className = "form-msg";
|
||||
renderPicker();
|
||||
modal.style.display = "flex";
|
||||
search.focus();
|
||||
});
|
||||
closeBtn.addEventListener("click", close);
|
||||
modal.addEventListener("click", (e) => { if (e.target === e.currentTarget) close(); });
|
||||
search.addEventListener("input", renderPicker);
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && modal.style.display !== "none") close();
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s;
|
||||
@@ -1889,6 +2050,17 @@ function initEditModal() {
|
||||
});
|
||||
}
|
||||
|
||||
// Verwaltung → Projekt archivieren — opens the edit modal scrolled to
|
||||
// the danger-zone archive button (t-paliad-245).
|
||||
const archiveLink = document.getElementById(
|
||||
"project-settings-archive-link",
|
||||
) as HTMLButtonElement | null;
|
||||
if (archiveLink) {
|
||||
archiveLink.addEventListener("click", () => {
|
||||
openEditModal("project-delete-btn");
|
||||
});
|
||||
}
|
||||
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
if (!project) return;
|
||||
@@ -2105,6 +2277,7 @@ async function main() {
|
||||
initSmartTimelineClientToggle(id);
|
||||
initSmartTimelineAddModal(id);
|
||||
initAttachUnitForm(id);
|
||||
initAddChecklistModal(id);
|
||||
initNotesContainer(id);
|
||||
mountVerlaufFilterBar(id);
|
||||
wireExportButton(id);
|
||||
@@ -2834,17 +3007,21 @@ function canExportProject(): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
// wireExportButton reveals + hooks up the project-export button on the
|
||||
// tabs nav. Triggers a download via a transient <a download> — same
|
||||
// pattern as the personal export in client/settings.ts.
|
||||
// wireExportButton reveals the Export sub-section of the Verwaltung tab
|
||||
// (t-paliad-245) and hooks up the project-export button. Triggers a
|
||||
// download via a transient <a download> — same pattern as the personal
|
||||
// export in client/settings.ts.
|
||||
function wireExportButton(projectID: string): void {
|
||||
const section = document.getElementById("project-settings-export") as HTMLElement | null;
|
||||
const btn = document.getElementById("project-export-btn") as HTMLButtonElement | null;
|
||||
if (!btn) return;
|
||||
if (!section || !btn) return;
|
||||
if (!canExportProject()) {
|
||||
btn.style.display = "none";
|
||||
section.style.display = "none";
|
||||
updateSettingsTabVisibility();
|
||||
return;
|
||||
}
|
||||
btn.style.display = "";
|
||||
section.style.display = "";
|
||||
updateSettingsTabVisibility();
|
||||
btn.addEventListener("click", () => {
|
||||
const a = document.createElement("a");
|
||||
a.href = `/api/projects/${encodeURIComponent(projectID)}/export`;
|
||||
@@ -2855,6 +3032,17 @@ function wireExportButton(projectID: string): void {
|
||||
});
|
||||
}
|
||||
|
||||
// updateSettingsTabVisibility hides the Verwaltung tab when none of its
|
||||
// sub-sections are visible to the current user — an empty tab is worse
|
||||
// UX than no tab. Called whenever a sub-section's visibility flips.
|
||||
function updateSettingsTabVisibility(): void {
|
||||
const tab = document.querySelector<HTMLElement>('.entity-tab[data-tab="settings"]');
|
||||
if (!tab) return;
|
||||
const exportShown = document.getElementById("project-settings-export")?.style.display !== "none";
|
||||
const archiveShown = document.getElementById("project-settings-archive")?.style.display !== "none";
|
||||
tab.style.display = exportShown || archiveShown ? "" : "none";
|
||||
}
|
||||
|
||||
function canRemoveTeamMember(m: ProjectTeamMember): boolean {
|
||||
if (!me) return false;
|
||||
if (m.user_id === me.id) return true;
|
||||
|
||||
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
|
||||
|
||||
@@ -99,6 +99,21 @@ export interface PredecessorMissingPayload {
|
||||
message_en: string;
|
||||
}
|
||||
|
||||
// t-paliad-237 — server tells us the anchored rule belongs to the
|
||||
// parent infringement project, not this CCR. Frontend renders the
|
||||
// message with a clickable link to the parent project.
|
||||
export interface CrossProceedingAnchorPayload {
|
||||
error: "cross_proceeding_anchor";
|
||||
requested_rule_code: string;
|
||||
requested_rule_name_de: string;
|
||||
requested_rule_name_en: string;
|
||||
parent_project_id: string;
|
||||
parent_project_title: string;
|
||||
parent_project_url: string;
|
||||
message_de: string;
|
||||
message_en: string;
|
||||
}
|
||||
|
||||
export interface RenderOptions {
|
||||
// Today's date as ISO YYYY-MM-DD; defaults to "now in browser TZ".
|
||||
today?: string;
|
||||
@@ -822,7 +837,13 @@ function buildAnchorEditor(
|
||||
return;
|
||||
}
|
||||
if (resp.status === 409) {
|
||||
const payload = (await resp.json()) as PredecessorMissingPayload;
|
||||
const payload = (await resp.json()) as
|
||||
| PredecessorMissingPayload
|
||||
| CrossProceedingAnchorPayload;
|
||||
if (payload.error === "cross_proceeding_anchor") {
|
||||
renderCrossProceedingError(msg, payload, opts);
|
||||
return;
|
||||
}
|
||||
renderPredecessorError(msg, payload, ev, opts, dateInput, submit, cancel);
|
||||
return;
|
||||
}
|
||||
@@ -886,6 +907,34 @@ function renderPredecessorError(
|
||||
msg.appendChild(link);
|
||||
}
|
||||
|
||||
// t-paliad-237 — rule belongs to the parent inf project, not this CCR.
|
||||
// Render the bilingual message + a link to the parent project so the
|
||||
// user can navigate over and anchor the rule there. We deliberately do
|
||||
// NOT auto-route the write across projects (out of scope per brief).
|
||||
function renderCrossProceedingError(
|
||||
msg: HTMLElement,
|
||||
payload: CrossProceedingAnchorPayload,
|
||||
opts: RenderOptions,
|
||||
): void {
|
||||
msg.innerHTML = "";
|
||||
msg.classList.add("smart-timeline-anchor-msg--error");
|
||||
msg.classList.add("smart-timeline-anchor-msg--cross-proceeding");
|
||||
|
||||
const lang = (opts.lang ?? getLang()) === "en" ? "en" : "de";
|
||||
const main = document.createElement("p");
|
||||
main.textContent = lang === "en" ? payload.message_en : payload.message_de;
|
||||
msg.appendChild(main);
|
||||
|
||||
const link = document.createElement("a");
|
||||
link.className = "smart-timeline-anchor-parent-link";
|
||||
link.href = payload.parent_project_url;
|
||||
link.textContent =
|
||||
lang === "en"
|
||||
? `Open „${payload.parent_project_title}“`
|
||||
: `„${payload.parent_project_title}“ öffnen`;
|
||||
msg.appendChild(link);
|
||||
}
|
||||
|
||||
function findRowForRuleCode(ruleCode: string): HTMLElement | null {
|
||||
const rows = document.querySelectorAll<HTMLElement>(".smart-timeline-row");
|
||||
for (const r of Array.from(rows)) {
|
||||
|
||||
@@ -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) +
|
||||
|
||||
@@ -1904,6 +1904,7 @@ export type I18nKey =
|
||||
| "nav.paliadin"
|
||||
| "nav.projekte"
|
||||
| "nav.soon.tooltip"
|
||||
| "nav.submissions"
|
||||
| "nav.team"
|
||||
| "nav.termine"
|
||||
| "nav.user_views.new"
|
||||
@@ -1985,8 +1986,11 @@ export type I18nKey =
|
||||
| "paliadin.error.shim_error"
|
||||
| "paliadin.error.timeout"
|
||||
| "paliadin.error.upstream"
|
||||
| "paliadin.error.upstream_silence"
|
||||
| "paliadin.heading"
|
||||
| "paliadin.input.placeholder"
|
||||
| "paliadin.late.checking"
|
||||
| "paliadin.late.lost"
|
||||
| "paliadin.late.marker"
|
||||
| "paliadin.late.waiting"
|
||||
| "paliadin.reset"
|
||||
@@ -1996,6 +2000,8 @@ export type I18nKey =
|
||||
| "paliadin.starter.week"
|
||||
| "paliadin.stop"
|
||||
| "paliadin.tagline"
|
||||
| "paliadin.thinking"
|
||||
| "paliadin.thinking.seconds"
|
||||
| "paliadin.title"
|
||||
| "paliadin.widget.close"
|
||||
| "paliadin.widget.context.on_page"
|
||||
@@ -2132,6 +2138,11 @@ export type I18nKey =
|
||||
| "projects.detail.appointments.form.cancel"
|
||||
| "projects.detail.appointments.form.submit"
|
||||
| "projects.detail.back"
|
||||
| "projects.detail.checklisten.add"
|
||||
| "projects.detail.checklisten.add.created"
|
||||
| "projects.detail.checklisten.add.empty_pick"
|
||||
| "projects.detail.checklisten.add.error"
|
||||
| "projects.detail.checklisten.add.search"
|
||||
| "projects.detail.checklisten.col.created"
|
||||
| "projects.detail.checklisten.col.name"
|
||||
| "projects.detail.checklisten.col.progress"
|
||||
@@ -2177,6 +2188,11 @@ export type I18nKey =
|
||||
| "projects.detail.parteien.role.defendant"
|
||||
| "projects.detail.parteien.role.thirdparty"
|
||||
| "projects.detail.save"
|
||||
| "projects.detail.settings.archive.cta"
|
||||
| "projects.detail.settings.archive.description"
|
||||
| "projects.detail.settings.archive.heading"
|
||||
| "projects.detail.settings.export.description"
|
||||
| "projects.detail.settings.export.heading"
|
||||
| "projects.detail.smarttimeline.add.cancel"
|
||||
| "projects.detail.smarttimeline.add.choice.amend"
|
||||
| "projects.detail.smarttimeline.add.choice.appointment"
|
||||
@@ -2250,6 +2266,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"
|
||||
@@ -2265,6 +2282,7 @@ export type I18nKey =
|
||||
| "projects.detail.tab.kinder"
|
||||
| "projects.detail.tab.notizen"
|
||||
| "projects.detail.tab.parteien"
|
||||
| "projects.detail.tab.settings"
|
||||
| "projects.detail.tab.submissions"
|
||||
| "projects.detail.tab.team"
|
||||
| "projects.detail.tab.termine"
|
||||
@@ -2485,6 +2503,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.
|
||||
@@ -596,6 +585,12 @@ export function renderProjectsDetail(): string {
|
||||
|
||||
{/* Checklists (Checklisten) */}
|
||||
<section className="entity-tab-panel" id="tab-checklists" style="display:none">
|
||||
<div className="party-controls">
|
||||
<button id="checklist-add-btn" className="btn-primary btn-cta-lime btn-small" type="button" data-i18n="projects.detail.checklisten.add">
|
||||
Checkliste hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
<p className="form-msg" id="project-checklists-msg" />
|
||||
<p id="project-checklists-empty" className="entity-events-empty" style="display:none" data-i18n="projects.detail.checklisten.empty">
|
||||
Für dieses Projekt sind noch keine Checklisten-Instanzen erfasst.
|
||||
</p>
|
||||
@@ -613,23 +608,24 @@ export function renderProjectsDetail(): string {
|
||||
</table>
|
||||
</div>
|
||||
<p className="tool-subtitle checklists-hint">
|
||||
<span data-i18n="projects.detail.checklisten.hint.prefix">Instanzen werden auf der Vorlagen-Seite unter </span>
|
||||
<span data-i18n="projects.detail.checklisten.hint.prefix">Vorlagen werden auf der </span>
|
||||
<a href="/checklists" data-i18n="projects.detail.checklisten.hint.link">Checklisten</a>
|
||||
<span data-i18n="projects.detail.checklisten.hint.suffix"> angelegt.</span>
|
||||
<span data-i18n="projects.detail.checklisten.hint.suffix">-Seite angelegt und bearbeitet.</span>
|
||||
</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"
|
||||
@@ -640,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">
|
||||
@@ -660,11 +656,38 @@ export function renderProjectsDetail(): string {
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div className="entity-detail-footer" id="project-delete-wrap" style="display:none">
|
||||
<button id="project-delete-btn" className="btn-secondary" type="button" data-i18n="projects.detail.delete">
|
||||
Projekt archivieren
|
||||
</button>
|
||||
</div>
|
||||
{/* 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. */}
|
||||
@@ -696,6 +719,33 @@ export function renderProjectsDetail(): string {
|
||||
<button type="submit" className="btn-primary btn-cta-lime" data-i18n="projects.detail.save">Speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
{/* Danger zone — destructive action, visually separated from Save/Cancel. */}
|
||||
<div className="modal-danger-zone" id="project-delete-wrap" style="display:none">
|
||||
<button id="project-delete-btn" className="btn-link-danger" type="button" data-i18n="projects.detail.delete">
|
||||
Projekt archivieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add-checklist-instance modal (t-paliad-239) — picks a
|
||||
template and POSTs to /api/checklists/{slug}/instances
|
||||
with the current project_id; the row appears in the
|
||||
Checklists tab list on success. */}
|
||||
<div className="modal-overlay" id="add-checklist-modal" style="display:none">
|
||||
<div className="modal-card modal-card-wide">
|
||||
<div className="modal-header">
|
||||
<h2 data-i18n="projects.detail.checklisten.add">Checkliste hinzufügen</h2>
|
||||
<button className="modal-close" id="add-checklist-close" type="button">×</button>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<input type="text" id="add-checklist-search" autocomplete="off" data-i18n-placeholder="projects.detail.checklisten.add.search" placeholder="Vorlage suchen…" />
|
||||
</div>
|
||||
<div className="add-checklist-list" id="add-checklist-list" />
|
||||
<p className="entity-events-empty" id="add-checklist-empty" style="display:none" data-i18n="projects.detail.checklisten.add.empty_pick">
|
||||
Keine passenden Vorlagen gefunden.
|
||||
</p>
|
||||
<p className="form-msg" id="add-checklist-msg" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -5340,6 +5340,63 @@ dialog.modal::backdrop {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
/* --- Add-checklist template picker (t-paliad-239) ------------------
|
||||
Modal shown from the project Checklists tab. Vertical scrollable
|
||||
list of clickable template rows; each row is a full-width <button>
|
||||
with title + regime chip + optional description. */
|
||||
.add-checklist-list {
|
||||
margin-top: 0.5rem;
|
||||
max-height: 50vh;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
padding-right: 0.25rem;
|
||||
}
|
||||
|
||||
.add-checklist-row {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.65rem 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s, border-color 0.12s;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.add-checklist-row:hover:not(:disabled) {
|
||||
background: rgb(var(--hlc-lime-rgb) / 0.06);
|
||||
border-color: rgb(var(--hlc-lime-rgb) / 0.45);
|
||||
}
|
||||
|
||||
.add-checklist-row:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: progress;
|
||||
}
|
||||
|
||||
.add-checklist-row-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.add-checklist-row-title {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.add-checklist-row-desc {
|
||||
margin: 0.25rem 0 0;
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-text-muted);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
/* --- Checklist detail --- */
|
||||
|
||||
.checklist-back {
|
||||
@@ -5529,10 +5586,370 @@ 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;
|
||||
}
|
||||
|
||||
.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;
|
||||
@@ -6128,12 +6545,18 @@ dialog.modal::backdrop {
|
||||
|
||||
/* Each filter is a label-above-control cell so the caption sits on top of
|
||||
its select / button. The whole filter-row stays a horizontal flex-wrap
|
||||
of these column-cells (t-paliad-117). */
|
||||
of these column-cells (t-paliad-117).
|
||||
|
||||
min-width: 0 + max-width: 100% lets the cell shrink to fit its flex
|
||||
container and prevents a native <select> with long option text from
|
||||
blowing the cell wider than the viewport (t-paliad-255). */
|
||||
.filter-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0.25rem;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
@@ -6147,6 +6570,10 @@ dialog.modal::backdrop {
|
||||
.filter-group .entity-select { width: 100%; }
|
||||
}
|
||||
|
||||
/* max-width: 100% caps the intrinsic width of a native <select> at its
|
||||
parent — without it, browsers size the select to the longest <option>
|
||||
text and a very long project title overflows the viewport on tablet
|
||||
widths above the 480px breakpoint (t-paliad-255). */
|
||||
.entity-select {
|
||||
padding: 0.4rem 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
@@ -6155,6 +6582,8 @@ dialog.modal::backdrop {
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.entity-select:focus {
|
||||
@@ -6874,6 +7303,20 @@ dialog.modal::backdrop {
|
||||
padding: 0.5rem 0 2rem;
|
||||
}
|
||||
|
||||
/* Verwaltung tab — rare admin actions (export, archive) live here as
|
||||
stacked sections. No accent, no oversized buttons (t-paliad-245). */
|
||||
.settings-section {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.settings-section:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.settings-section .tool-subtitle {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.entity-events {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
@@ -11020,6 +11463,18 @@ dialog.quick-add-sheet::backdrop {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
/* --- Modal danger zone (t-paliad-236) -------------------------------
|
||||
Destructive action separated from the form's Save / Cancel row so a
|
||||
rare action (Projekt archivieren) doesn't sit next to the primary CTA.
|
||||
Top border + muted padding mark it as a distinct, lower-priority area. */
|
||||
.modal-danger-zone {
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* --- Standardised tab toolbar action buttons (t-paliad-049) ----------
|
||||
The .party-controls toolbar above each project sub-tab table
|
||||
used a mix of <a class="btn-cta-lime btn-small"> and <button>; pin them
|
||||
@@ -13353,6 +13808,48 @@ dialog.quick-add-sheet::backdrop {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* lost: backend confirmed the turn is unrecoverable (t-paliad-235).
|
||||
Different from error: the upstream had a chance to finish but the
|
||||
conversation lookup didn't find a response — show the honest
|
||||
"verloren" copy. */
|
||||
.paliadin-bubble--lost {
|
||||
color: var(--status-red-fg);
|
||||
border-color: var(--status-red-border);
|
||||
background: var(--status-red-bg);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* Thinking indicator (t-paliad-235) — proof-of-life pulse + elapsed
|
||||
counter while the upstream is alive but no content has streamed
|
||||
yet. Lives as a sibling node inside the assistant bubble; removed
|
||||
once the first chunk arrives. */
|
||||
.paliadin-thinking {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.paliadin-thinking-dot {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
background: var(--color-bg-lime);
|
||||
animation: paliadin-thinking-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.paliadin-thinking-elapsed {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@keyframes paliadin-thinking-pulse {
|
||||
0%, 100% { opacity: 0.4; transform: scale(0.9); }
|
||||
50% { opacity: 1.0; transform: scale(1.1); }
|
||||
}
|
||||
|
||||
.paliadin-bubble-role {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
@@ -14718,6 +15215,55 @@ dialog.quick-add-sheet::backdrop {
|
||||
border: 1px solid var(--status-red-border, var(--color-border));
|
||||
}
|
||||
|
||||
/* late-pending: stream dropped, recovery endpoint still polling. */
|
||||
.paliadin-widget-bubble--late-pending {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* late: response arrived after the stream closed. */
|
||||
.paliadin-widget-bubble--late {
|
||||
color: inherit;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.paliadin-widget-bubble-late-tag {
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
/* lost: backend confirmed the turn is unrecoverable (t-paliad-235). */
|
||||
.paliadin-widget-bubble--lost {
|
||||
background: var(--status-red-bg, var(--color-surface-2));
|
||||
color: var(--status-red-fg, var(--color-text));
|
||||
border: 1px solid var(--status-red-border, var(--color-border));
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* Thinking indicator inside widget bubbles (t-paliad-235). */
|
||||
.paliadin-widget-thinking {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.paliadin-widget-thinking-dot {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
background: var(--color-bg-lime);
|
||||
animation: paliadin-thinking-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.paliadin-widget-thinking-elapsed {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.paliadin-widget-form {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
@@ -15410,6 +15956,20 @@ dialog.quick-add-sheet::backdrop {
|
||||
text-decoration: underline;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.smart-timeline-anchor-msg--cross-proceeding {
|
||||
background: var(--status-red-bg, #fde8e8);
|
||||
border: 1px solid var(--status-red-border, #f0bcbc);
|
||||
border-radius: 4px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
}
|
||||
.smart-timeline-anchor-msg--cross-proceeding p {
|
||||
margin: 0 0 0.4rem 0;
|
||||
}
|
||||
.smart-timeline-anchor-parent-link {
|
||||
color: var(--color-link, #1a6dc5);
|
||||
text-decoration: underline;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* Lookahead toggle row — small, centred under the future section. */
|
||||
.smart-timeline-lookahead {
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE paliad.paliadin_turns
|
||||
DROP COLUMN IF EXISTS aichat_conversation_id;
|
||||
@@ -0,0 +1,14 @@
|
||||
-- t-paliad-235: track aichat conversation id on each paliadin turn so the
|
||||
-- recovery endpoint can ask aichat for the late-arriving response when
|
||||
-- paliad's stream connection drops mid-turn.
|
||||
--
|
||||
-- The PALIADIN_BACKEND=aichat path persists this from the upstream
|
||||
-- /chat/turn/stream `done` frame's conversation_id. PALIADIN_BACKEND=legacy
|
||||
-- turns leave it NULL — the filesystem janitor is still the recovery path
|
||||
-- there.
|
||||
|
||||
ALTER TABLE paliad.paliadin_turns
|
||||
ADD COLUMN IF NOT EXISTS aichat_conversation_id uuid;
|
||||
|
||||
COMMENT ON COLUMN paliad.paliadin_turns.aichat_conversation_id IS
|
||||
'Aichat backend conversation id (t-paliad-235). Set when the streaming /chat/turn/stream done frame arrives, or when the recovery endpoint asks aichat to disambiguate which conversation this turn lives in. NULL for legacy backend turns and for aichat turns that errored before the conversation id was resolved.';
|
||||
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)
|
||||
)
|
||||
);
|
||||
@@ -33,11 +33,29 @@ func handleChecklistsAuthorPage(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func handleChecklistDetailPage(w http.ResponseWriter, r *http.Request) {
|
||||
slug := r.PathValue("slug")
|
||||
if _, ok := checklists.Find(slug); !ok {
|
||||
http.NotFound(w, r)
|
||||
// Static catalog match → serve unconditionally; the static templates
|
||||
// are always visible.
|
||||
if _, ok := checklists.Find(slug); ok {
|
||||
http.ServeFile(w, r, "dist/checklists-detail.html")
|
||||
return
|
||||
}
|
||||
http.ServeFile(w, r, "dist/checklists-detail.html")
|
||||
// Otherwise fall back to the DB-backed catalog (authored templates,
|
||||
// slug shape "u-a-..." from t-paliad-225). The catalog enforces
|
||||
// visibility per-user; a slug the caller can't see returns
|
||||
// ErrNotVisible and the user gets the same 404 they'd see for an
|
||||
// unknown slug. Without this branch authored checklists 404'd at the
|
||||
// page level even though they showed up in the overview, which is
|
||||
// exactly m's 2026-05-22 report.
|
||||
if dbSvc != nil && dbSvc.checklistCatalog != nil {
|
||||
uid, ok := auth.UserIDFromContext(r.Context())
|
||||
if ok {
|
||||
if _, err := dbSvc.checklistCatalog.Find(r.Context(), uid, slug); err == nil {
|
||||
http.ServeFile(w, r, "dist/checklists-detail.html")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
|
||||
func handleChecklistInstancePage(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -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,72 @@ 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",
|
||||
},
|
||||
}
|
||||
|
||||
// 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 {
|
||||
|
||||
@@ -98,6 +98,9 @@ type Services struct {
|
||||
Projection *services.ProjectionService
|
||||
Export *services.ExportService
|
||||
|
||||
// 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 +162,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
firmDashboardDefault: svc.FirmDashboardDefault,
|
||||
projection: svc.Projection,
|
||||
export: svc.Export,
|
||||
submissionDraft: svc.SubmissionDraft,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -272,6 +276,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 +322,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)
|
||||
@@ -466,6 +502,20 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
protected.HandleFunc("GET /projects/{id}/notes", gateOnboarded(handleProjectsDetailPage))
|
||||
protected.HandleFunc("GET /projects/{id}/checklists", gateOnboarded(handleProjectsDetailPage))
|
||||
protected.HandleFunc("GET /projects/{id}/team", gateOnboarded(handleProjectsDetailPage))
|
||||
// t-paliad-230 Schriftsätze tab — same shape as every other tab above.
|
||||
// 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.
|
||||
@@ -649,6 +699,11 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
protected.HandleFunc("POST /api/paliadin/turn", handlePaliadinTurn)
|
||||
protected.HandleFunc("GET /api/paliadin/stream/{id}", handlePaliadinStream)
|
||||
protected.HandleFunc("GET /api/paliadin/turns/{id}", handlePaliadinTurnGet)
|
||||
// Recovery endpoint (t-paliad-235): when the SSE stream drops mid-turn,
|
||||
// the frontend hits this to ask whether aichat actually finished the
|
||||
// turn upstream. Dispatches per backend — aichat hits the conversation
|
||||
// API; legacy backends fall through to the local row read + janitor.
|
||||
protected.HandleFunc("GET /api/paliadin/turns/{id}/recover", handlePaliadinTurnRecover)
|
||||
// Crash-resistant history hydrate (t-paliad-161 follow-up): both
|
||||
// Paliadin surfaces use this to seed their UI from the DB before
|
||||
// consulting localStorage.
|
||||
|
||||
@@ -185,16 +185,21 @@ func handlePaliadinTurn(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// runPaliadinTurnAsync executes the turn and writes events into ch.
|
||||
// Uses a 150 s hard timeout independently of the originating request,
|
||||
// which leaves headroom over the shim's 120 s run-turn cap + SSH
|
||||
// overhead (t-paliad-155: cold-start safety for skill + MCP discovery).
|
||||
//
|
||||
// Backend dispatch:
|
||||
// - StreamingPaliadin (aichat) → drives runStreamingTurn which relays
|
||||
// incremental chunks + upstream heartbeats. No hard ceiling on
|
||||
// stream duration; falls back to silence_timeout (silenceTimeout)
|
||||
// if the upstream goes dark.
|
||||
// - Plain Paliadin (legacy local/remote) → one-shot RunTurn with the
|
||||
// original 150 s ceiling (matches the shim's 120 s run-turn cap +
|
||||
// SSH overhead per t-paliad-155).
|
||||
func runPaliadinTurnAsync(turnID uuid.UUID, req services.TurnRequest, ch chan<- turnEvent) {
|
||||
defer func() {
|
||||
// Drain + close. The SSE handler reads until the channel closes.
|
||||
close(ch)
|
||||
}()
|
||||
|
||||
// Send a meta event so the client can show "Paliadin denkt nach …"
|
||||
send(ch, turnEvent{
|
||||
Kind: "meta",
|
||||
Data: map[string]any{
|
||||
@@ -203,6 +208,16 @@ func runPaliadinTurnAsync(turnID uuid.UUID, req services.TurnRequest, ch chan<-
|
||||
},
|
||||
})
|
||||
|
||||
if streamer, ok := paliadinSvc.(services.StreamingPaliadin); ok {
|
||||
runStreamingTurn(turnID, req, ch, streamer)
|
||||
return
|
||||
}
|
||||
runOneShotTurn(turnID, req, ch)
|
||||
}
|
||||
|
||||
// runOneShotTurn drives the legacy synchronous backends (local-tmux PoC,
|
||||
// remote ssh+paliadin-shim). Preserves the original 150 s ceiling.
|
||||
func runOneShotTurn(turnID uuid.UUID, req services.TurnRequest, ch chan<- turnEvent) {
|
||||
ctx, cancel := newDetachedContext(150 * time.Second)
|
||||
defer cancel()
|
||||
|
||||
@@ -220,9 +235,7 @@ func runPaliadinTurnAsync(turnID uuid.UUID, req services.TurnRequest, ch chan<-
|
||||
}
|
||||
|
||||
// One-shot content event with the full body. The frontend simulates
|
||||
// streaming with a typewriter effect (cf. design §0.5.5: real
|
||||
// chunked streaming would require Claude to write the response file
|
||||
// progressively — out of PoC scope).
|
||||
// streaming with a typewriter effect.
|
||||
send(ch, turnEvent{
|
||||
Kind: "content",
|
||||
Data: map[string]any{"text": result.Response},
|
||||
@@ -241,6 +254,224 @@ func runPaliadinTurnAsync(turnID uuid.UUID, req services.TurnRequest, ch chan<-
|
||||
})
|
||||
}
|
||||
|
||||
// silenceTimeout is the longest the aichat upstream may stay silent
|
||||
// (no chunk, no heartbeat) before runStreamingTurn gives up and fires
|
||||
// an error frame. 90 s comfortably exceeds aichat's 5 s heartbeat
|
||||
// cadence so a transient stall (model wedge, GC pause) doesn't kill
|
||||
// the turn, while still catching a hard upstream drop.
|
||||
const silenceTimeout = 90 * time.Second
|
||||
|
||||
// streamingThinkingInterval is the cadence at which we emit a synthetic
|
||||
// `thinking` event when the upstream has gone quiet but the connection
|
||||
// is still alive. 5 s matches aichat's own heartbeat tick so the UI
|
||||
// pulse never falls more than 5 s out of date.
|
||||
const streamingThinkingInterval = 5 * time.Second
|
||||
|
||||
// streamingTurnDeadline is the upper bound for a single streaming turn.
|
||||
// Far above any realistic Claude turn but finite so a runaway upstream
|
||||
// (or a paliad bug that never closes the channel) can't leak forever.
|
||||
const streamingTurnDeadline = 30 * time.Minute
|
||||
|
||||
// runStreamingTurn drives an incremental turn against the StreamingPaliadin
|
||||
// backend. Relays chunks → content events, upstream heartbeats →
|
||||
// thinking events, errors → error events. Adds its own silence-watch:
|
||||
// if the upstream emits no event for silenceTimeout, fire an error
|
||||
// frame so the client doesn't sit on a dead stream forever.
|
||||
func runStreamingTurn(turnID uuid.UUID, req services.TurnRequest, ch chan<- turnEvent, streamer services.StreamingPaliadin) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), streamingTurnDeadline)
|
||||
defer cancel()
|
||||
|
||||
events := make(chan services.StreamEvent, 32)
|
||||
startedAt := time.Now()
|
||||
|
||||
// streamerDone closes when the backend's RunTurnStream returns. We
|
||||
// race the silence watcher and the event pump against it so the
|
||||
// goroutine exit is clean either way.
|
||||
type runResult struct {
|
||||
result *services.TurnResult
|
||||
err error
|
||||
}
|
||||
runCh := make(chan runResult, 1)
|
||||
go func() {
|
||||
res, err := streamer.RunTurnStream(ctx, req, events)
|
||||
runCh <- runResult{res, err}
|
||||
}()
|
||||
|
||||
var (
|
||||
lastEventAt = time.Now()
|
||||
usedTools []string
|
||||
rowsSeen []int
|
||||
classifierTag string
|
||||
convID string
|
||||
gotChunk bool
|
||||
errorEmitted bool
|
||||
)
|
||||
|
||||
silenceTicker := time.NewTicker(streamingThinkingInterval)
|
||||
defer silenceTicker.Stop()
|
||||
|
||||
emitThinking := func(elapsedSeconds int) {
|
||||
// Don't emit `thinking` after the first real chunk arrives —
|
||||
// the frontend hides the pulse once content starts flowing
|
||||
// anyway, but we save bandwidth by stopping emission.
|
||||
send(ch, turnEvent{
|
||||
Kind: "thinking",
|
||||
Data: map[string]any{
|
||||
"elapsed_seconds": elapsedSeconds,
|
||||
"since_first": gotChunk,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case ev, more := <-events:
|
||||
if !more {
|
||||
events = nil // disable case
|
||||
continue
|
||||
}
|
||||
lastEventAt = time.Now()
|
||||
switch ev.Kind {
|
||||
case services.StreamChunk:
|
||||
gotChunk = true
|
||||
send(ch, turnEvent{
|
||||
Kind: "content",
|
||||
Data: map[string]any{
|
||||
"delta": ev.Content,
|
||||
"streamed": true,
|
||||
},
|
||||
})
|
||||
case services.StreamHeartbeat:
|
||||
// Upstream is alive but no chunks yet (or a mid-stream
|
||||
// stall). Pass through with our own thinking shape.
|
||||
send(ch, turnEvent{
|
||||
Kind: "thinking",
|
||||
Data: map[string]any{
|
||||
"elapsed_seconds": ev.ElapsedSeconds,
|
||||
"since_first": gotChunk,
|
||||
"upstream": true,
|
||||
},
|
||||
})
|
||||
case services.StreamMeta:
|
||||
usedTools = ev.UsedTools
|
||||
rowsSeen = ev.RowsSeen
|
||||
classifierTag = ev.ClassifierTag
|
||||
case services.StreamConversation:
|
||||
convID = ev.ConversationID
|
||||
case services.StreamError:
|
||||
errorEmitted = true
|
||||
send(ch, turnEvent{
|
||||
Kind: "error",
|
||||
Data: map[string]any{
|
||||
"code": ev.Code,
|
||||
"message": ev.Message,
|
||||
"retryable": ev.Retryable,
|
||||
},
|
||||
})
|
||||
}
|
||||
case <-silenceTicker.C:
|
||||
elapsed := time.Since(lastEventAt)
|
||||
if elapsed >= silenceTimeout {
|
||||
send(ch, turnEvent{
|
||||
Kind: "error",
|
||||
Data: map[string]any{
|
||||
"code": "upstream_silence",
|
||||
"message": "aichat upstream went silent for over " + silenceTimeout.String(),
|
||||
},
|
||||
})
|
||||
// Cancel the backend so it doesn't keep running.
|
||||
cancel()
|
||||
continue
|
||||
}
|
||||
emitThinking(int(time.Since(startedAt).Seconds()))
|
||||
case res := <-runCh:
|
||||
// Drain any remaining events the backend pushed before
|
||||
// closing the channel.
|
||||
if events != nil {
|
||||
for ev := range events {
|
||||
switch ev.Kind {
|
||||
case services.StreamChunk:
|
||||
gotChunk = true
|
||||
send(ch, turnEvent{
|
||||
Kind: "content",
|
||||
Data: map[string]any{
|
||||
"delta": ev.Content,
|
||||
"streamed": true,
|
||||
},
|
||||
})
|
||||
case services.StreamMeta:
|
||||
usedTools = ev.UsedTools
|
||||
rowsSeen = ev.RowsSeen
|
||||
classifierTag = ev.ClassifierTag
|
||||
case services.StreamConversation:
|
||||
convID = ev.ConversationID
|
||||
case services.StreamError:
|
||||
errorEmitted = true
|
||||
send(ch, turnEvent{
|
||||
Kind: "error",
|
||||
Data: map[string]any{
|
||||
"code": ev.Code,
|
||||
"message": ev.Message,
|
||||
"retryable": ev.Retryable,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
if res.err != nil {
|
||||
if !errorEmitted {
|
||||
send(ch, turnEvent{
|
||||
Kind: "error",
|
||||
Data: map[string]any{
|
||||
"code": "upstream_error",
|
||||
"message": res.err.Error(),
|
||||
},
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
result := res.result
|
||||
if result == nil {
|
||||
// Shouldn't happen — backend contract returns either err
|
||||
// or a result. Defensive bail.
|
||||
if !errorEmitted {
|
||||
send(ch, turnEvent{
|
||||
Kind: "error",
|
||||
Data: map[string]any{
|
||||
"code": "upstream_error",
|
||||
"message": "stream closed without result",
|
||||
},
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
if result.UsedTools != nil {
|
||||
usedTools = result.UsedTools
|
||||
}
|
||||
if result.RowsSeen != nil {
|
||||
rowsSeen = result.RowsSeen
|
||||
}
|
||||
if classifierTag == "" && result.ClassifierTag != "" {
|
||||
classifierTag = result.ClassifierTag
|
||||
}
|
||||
endData := map[string]any{
|
||||
"turn_id": turnID.String(),
|
||||
"used_tools": usedTools,
|
||||
"rows_seen": rowsSeen,
|
||||
"chip_count": result.ChipCount,
|
||||
"classifier_tag": classifierTag,
|
||||
"duration_ms": result.DurationMS,
|
||||
"streamed": true,
|
||||
}
|
||||
if convID != "" {
|
||||
endData["aichat_conversation_id"] = convID
|
||||
}
|
||||
send(ch, turnEvent{Kind: "end", Data: endData})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handlePaliadinStream is the SSE endpoint the EventSource subscribes
|
||||
// to. Reads from the per-turn channel + writes SSE-framed events.
|
||||
func handlePaliadinStream(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -354,6 +585,114 @@ func handlePaliadinTurnGet(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// handlePaliadinTurnRecover is the dispatching late-recovery endpoint
|
||||
// (t-paliad-235). Replaces the legacy direct-row-read for the aichat
|
||||
// backend. When the backend implements services.AichatRecoverer (the
|
||||
// PALIADIN_BACKEND=aichat path), we ask aichat directly via its
|
||||
// conversation API whether the turn actually completed upstream after
|
||||
// our stream connection dropped. When it doesn't implement it (legacy
|
||||
// local/remote backends), we fall back to reading the local row —
|
||||
// services.LocalPaliadinService.runJanitor is still the recovery path
|
||||
// there.
|
||||
//
|
||||
// Response shape mirrors handlePaliadinTurnGet so the frontend
|
||||
// late-poll module doesn't need a backend-specific code path.
|
||||
// Additional field `recovery_state` distinguishes:
|
||||
//
|
||||
// "recovered" — the response is in the row (already there, or freshly
|
||||
// written from the upstream check)
|
||||
// "pending" — still no response; caller should keep polling
|
||||
// "lost" — backend confirms the turn is gone (aichat doesn't
|
||||
// have it either). UI should degrade to "verloren".
|
||||
func handlePaliadinTurnRecover(w http.ResponseWriter, r *http.Request) {
|
||||
if !requirePaliadinOwner(w, r) {
|
||||
return
|
||||
}
|
||||
uid, _ := requireUser(w, r)
|
||||
turnID, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
http.Error(w, "invalid turn_id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Quick read first — gives us the row regardless of backend.
|
||||
row, err := paliadinSvc.GetTurn(r.Context(), uid, turnID)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
http.Error(w, "lookup failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
state := recoveryStateFor(row)
|
||||
|
||||
// Aichat backend: when the row still has no response, ask aichat
|
||||
// whether the turn actually finished upstream.
|
||||
if state == "pending" {
|
||||
if rec, ok := paliadinSvc.(services.AichatRecoverer); ok {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second)
|
||||
defer cancel()
|
||||
recovered, recErr := rec.RecoverTurn(ctx, uid, turnID)
|
||||
if recErr != nil {
|
||||
// Log + fall through to a plain pending response — a
|
||||
// transient aichat hiccup shouldn't flip the UI to
|
||||
// "lost".
|
||||
_ = recErr
|
||||
} else if recovered != nil {
|
||||
row = recovered
|
||||
state = recoveryStateFor(row)
|
||||
} else {
|
||||
// Aichat returned a clean "no, I don't have it either".
|
||||
// Only mark as lost when the turn is older than the
|
||||
// upstream's plausible turn budget — otherwise the
|
||||
// recovery just hit the window between paliad's stream
|
||||
// dropping and aichat finishing the run.
|
||||
if recoveryShouldGiveUp(row) {
|
||||
state = "lost"
|
||||
}
|
||||
}
|
||||
} else if recoveryShouldGiveUp(row) {
|
||||
// Legacy backends: rely on the janitor. If we're past the
|
||||
// give-up threshold and still no response, surface "lost".
|
||||
state = "lost"
|
||||
}
|
||||
}
|
||||
|
||||
resp := map[string]any{
|
||||
"turn_id": row.TurnID.String(),
|
||||
"started_at": row.StartedAt.Format(time.RFC3339),
|
||||
"response": row.Response,
|
||||
"error_code": row.ErrorCode,
|
||||
"finished_at": row.FinishedAt,
|
||||
"duration_ms": row.DurationMS,
|
||||
"used_tools": []string(row.UsedTools),
|
||||
"rows_seen": []int64(row.RowsSeen),
|
||||
"chip_count": row.ChipCount,
|
||||
"classifier_tag": row.ClassifierTag,
|
||||
"recovery_state": state,
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// recoveryStateFor returns the lifecycle state of a paliadin turn from
|
||||
// the recovery endpoint's perspective.
|
||||
func recoveryStateFor(row *services.PaliadinTurn) string {
|
||||
if row.Response != nil && *row.Response != "" {
|
||||
return "recovered"
|
||||
}
|
||||
return "pending"
|
||||
}
|
||||
|
||||
// recoveryShouldGiveUp returns true when a turn has been pending long
|
||||
// enough that we should surface "lost" rather than asking the user to
|
||||
// keep waiting. 12 minutes is comfortably beyond the longest realistic
|
||||
// Claude turn (cold-start + reasoning + tool calls all bundled).
|
||||
func recoveryShouldGiveUp(row *services.PaliadinTurn) bool {
|
||||
return time.Since(row.StartedAt) > 12*time.Minute
|
||||
}
|
||||
|
||||
// handlePaliadinHistory returns the caller's prior turns for a given
|
||||
// browser session id, oldest → newest. Both Paliadin surfaces (the
|
||||
// inline drawer and the standalone /paliadin page) hit this on mount
|
||||
|
||||
@@ -251,6 +251,23 @@ func handleProjectTimelineAnchor(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
return
|
||||
}
|
||||
if cpe, ok := services.IsCrossProceedingAnchor(err); ok {
|
||||
parentURL := "/projects/" + cpe.ParentProjectID.String()
|
||||
writeJSON(w, http.StatusConflict, map[string]any{
|
||||
"error": "cross_proceeding_anchor",
|
||||
"requested_rule_code": cpe.RequestedRuleCode,
|
||||
"requested_rule_name_de": cpe.RequestedRuleNameDE,
|
||||
"requested_rule_name_en": cpe.RequestedRuleNameEN,
|
||||
"parent_project_id": cpe.ParentProjectID.String(),
|
||||
"parent_project_title": cpe.ParentProjectTitle,
|
||||
"parent_project_url": parentURL,
|
||||
"message_de": "Diese Frist gehört zum Verletzungsverfahren „" +
|
||||
cpe.ParentProjectTitle + "“. Bitte den Anker dort setzen, nicht auf der Widerklage.",
|
||||
"message_en": "This deadline belongs to the infringement proceeding „" +
|
||||
cpe.ParentProjectTitle + "“. Anchor it on the parent project, not the counterclaim.",
|
||||
})
|
||||
return
|
||||
}
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -61,6 +61,9 @@ type dbServices struct {
|
||||
firmDashboardDefault *services.FirmDashboardDefaultService
|
||||
projection *services.ProjectionService
|
||||
export *services.ExportService
|
||||
|
||||
// t-paliad-238 — submission draft editor.
|
||||
submissionDraft *services.SubmissionDraftService
|
||||
}
|
||||
|
||||
var dbSvc *dbServices
|
||||
|
||||
1085
internal/handlers/submission_drafts.go
Normal file
1085
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 —
|
||||
|
||||
@@ -112,6 +112,11 @@ type AichatPaliadinService struct {
|
||||
// Hook for tests — when non-nil, callHTTP delegates here instead
|
||||
// of hitting the wire. Production code never sets this.
|
||||
httpHook func(ctx context.Context, method, path string, body any, out any) error
|
||||
|
||||
// Hook for tests — when non-nil, callStreamingHTTP delegates here
|
||||
// instead of opening a real SSE connection. Production code never
|
||||
// sets this.
|
||||
streamHook func(ctx context.Context, path string, body any, emit func(streamFrame)) error
|
||||
}
|
||||
|
||||
// ErrAichatAuthFailed signals the aichat service rejected the bearer
|
||||
|
||||
654
internal/services/aichat_paliadin_stream.go
Normal file
654
internal/services/aichat_paliadin_stream.go
Normal file
@@ -0,0 +1,654 @@
|
||||
package services
|
||||
|
||||
// Streaming + recovery support for AichatPaliadinService (t-paliad-235).
|
||||
//
|
||||
// =============================================================================
|
||||
// Upstream contract — /chat/turn/stream
|
||||
// =============================================================================
|
||||
//
|
||||
// Source of truth: m/mAi internal/aichat/api/stream.go. Captured here as
|
||||
// inline doc so future debugging doesn't require chasing across repos:
|
||||
//
|
||||
// Request body: same shape as POST /chat/turn (TurnRequest mirror in
|
||||
// aichat_paliadin.go). Persona must support streaming; paliad's
|
||||
// "paliadin" persona does.
|
||||
//
|
||||
// Response: text/event-stream. Two SSE event flavours:
|
||||
//
|
||||
// 1. The default unnamed `data:` event carries a discriminated-union
|
||||
// JSON object keyed by `"type"`:
|
||||
//
|
||||
// {"type":"chunk","content":"…"}
|
||||
// {"type":"meta","used_tools":[…],"rows_seen":[…],"classifier_tag":"…"}
|
||||
// {"type":"done","turn_id":"…","conversation_id":"…",
|
||||
// "duration_ms":1234,"pane_spawned":false,"resumed":false}
|
||||
// {"type":"error","code":"…","message":"…","retryable":true}
|
||||
//
|
||||
// 2. The named `event: heartbeat` event carries:
|
||||
//
|
||||
// {"elapsed_seconds": N}
|
||||
//
|
||||
// Emitted every 5 s by the upstream while the runner has been
|
||||
// silent (no content). aichat keeps emitting these for the lifetime
|
||||
// of the runner so the client can render "Paliadin denkt nach
|
||||
// (N s)" without conflating with actual content.
|
||||
//
|
||||
// Errors before the stream starts (auth failure, persona unknown,
|
||||
// validation) come back as a normal JSON envelope with the appropriate
|
||||
// HTTP status — not SSE. Those land in callHTTP via decodeAichatError.
|
||||
//
|
||||
// =============================================================================
|
||||
// Conversation-based late recovery
|
||||
// =============================================================================
|
||||
//
|
||||
// Aichat exposes:
|
||||
//
|
||||
// GET /chat/conversations?persona=…&username=…&user_id=…
|
||||
// → list of ConversationSummary, ordered last_turn_at DESC
|
||||
// GET /chat/conversations/{id}/turns
|
||||
// → list of TurnRow (role=user|assistant, body, created_at)
|
||||
//
|
||||
// When paliad's stream drops mid-turn we:
|
||||
// 1. Look up paliad.paliadin_turns.aichat_conversation_id for the row.
|
||||
// 2. If unset (stream dropped before the `done` frame): list the user's
|
||||
// conversations and take the most recent one for the persona —
|
||||
// that's the pane our turn ran against (aichat owns one active
|
||||
// conversation per persona+user, see m/mAi#243).
|
||||
// 3. GET that conversation's turns. Find the latest assistant turn
|
||||
// whose preceding user-role turn body matches our user_message.
|
||||
// 4. Persist the response (completeTurnLate) and return it.
|
||||
//
|
||||
// If aichat returns no matching assistant turn → the turn is truly lost
|
||||
// (transport drop + upstream crash). Recovery returns (nil, nil) and
|
||||
// the handler degrades the UI to "verloren".
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// =============================================================================
|
||||
// Streaming RunTurnStream
|
||||
// =============================================================================
|
||||
|
||||
// RunTurnStream drives one /chat/turn/stream turn against aichat and
|
||||
// relays incremental events onto `events`. Closes `events` before
|
||||
// returning. Implements StreamingPaliadin.
|
||||
func (s *AichatPaliadinService) RunTurnStream(ctx context.Context, req TurnRequest, events chan<- StreamEvent) (*TurnResult, error) {
|
||||
defer close(events)
|
||||
|
||||
s.turnMu.Lock()
|
||||
defer s.turnMu.Unlock()
|
||||
|
||||
turnID := uuid.New()
|
||||
startedAt := time.Now().UTC()
|
||||
|
||||
if err := s.insertTurnRow(ctx, &PaliadinTurn{
|
||||
TurnID: turnID,
|
||||
UserID: req.UserID,
|
||||
SessionID: req.SessionID,
|
||||
StartedAt: startedAt,
|
||||
UserMessage: req.UserMessage,
|
||||
PageOrigin: optionalString(req.PageOrigin),
|
||||
}, req.Context); err != nil {
|
||||
return nil, fmt.Errorf("paliadin: insert turn row: %w", err)
|
||||
}
|
||||
|
||||
if err := s.healthGate(ctx); err != nil {
|
||||
_ = s.markTurnError(ctx, turnID, "mriver_unreachable")
|
||||
safeSendStream(ctx, events, StreamEvent{
|
||||
Kind: StreamError,
|
||||
Code: "mriver_unreachable",
|
||||
Message: err.Error(),
|
||||
})
|
||||
return nil, err
|
||||
}
|
||||
|
||||
username := s.usernameFor(ctx, req.UserID)
|
||||
session := s.cfg.Persona + ":" + username
|
||||
primer := s.buildPrimerExchanges(ctx, session, req)
|
||||
|
||||
jwt, err := s.mintJWTIfConfigured(req.UserID)
|
||||
if err != nil {
|
||||
_ = s.markTurnError(ctx, turnID, "jwt_mint_failed")
|
||||
safeSendStream(ctx, events, StreamEvent{
|
||||
Kind: StreamError,
|
||||
Code: "shim_error",
|
||||
Message: fmt.Sprintf("mint turn jwt: %v", err),
|
||||
})
|
||||
return nil, fmt.Errorf("paliadin: mint turn jwt: %w", err)
|
||||
}
|
||||
|
||||
body := aichatTurnRequest{
|
||||
Persona: s.cfg.Persona,
|
||||
Username: username,
|
||||
UserID: req.UserID.String(),
|
||||
SessionID: req.SessionID,
|
||||
Message: sanitiseForTmux(req.UserMessage),
|
||||
JWT: jwt,
|
||||
Primer: primer,
|
||||
Meta: buildAichatMeta(req),
|
||||
}
|
||||
|
||||
// Stream the upstream call. acc accumulates the full text so we can
|
||||
// persist the row + return a TurnResult on success.
|
||||
var (
|
||||
acc strings.Builder
|
||||
streamMeta trailerMeta
|
||||
convID string
|
||||
paneSpawned bool
|
||||
upstreamDoneMs int64
|
||||
)
|
||||
|
||||
streamErr := s.callStreamingHTTP(ctx, "/chat/turn/stream", body, func(frame streamFrame) {
|
||||
switch {
|
||||
case frame.event == "heartbeat":
|
||||
safeSendStream(ctx, events, StreamEvent{
|
||||
Kind: StreamHeartbeat,
|
||||
ElapsedSeconds: frame.heartbeat.ElapsedSeconds,
|
||||
})
|
||||
case frame.data.Type == "chunk":
|
||||
if frame.data.Content == "" {
|
||||
return
|
||||
}
|
||||
acc.WriteString(frame.data.Content)
|
||||
safeSendStream(ctx, events, StreamEvent{
|
||||
Kind: StreamChunk,
|
||||
Content: frame.data.Content,
|
||||
})
|
||||
case frame.data.Type == "meta":
|
||||
streamMeta = trailerMeta{
|
||||
UsedTools: append([]string(nil), frame.data.UsedTools...),
|
||||
RowsSeen: coerceAichatRowsSeen(frame.data.RowsSeen),
|
||||
ClassifierTag: frame.data.ClassifierTag,
|
||||
}
|
||||
safeSendStream(ctx, events, StreamEvent{
|
||||
Kind: StreamMeta,
|
||||
UsedTools: streamMeta.UsedTools,
|
||||
RowsSeen: streamMeta.RowsSeen,
|
||||
ClassifierTag: streamMeta.ClassifierTag,
|
||||
})
|
||||
case frame.data.Type == "done":
|
||||
if frame.data.ConversationID != "" {
|
||||
convID = frame.data.ConversationID
|
||||
safeSendStream(ctx, events, StreamEvent{
|
||||
Kind: StreamConversation,
|
||||
ConversationID: convID,
|
||||
})
|
||||
}
|
||||
paneSpawned = frame.data.PaneSpawned
|
||||
upstreamDoneMs = frame.data.DurationMs
|
||||
case frame.data.Type == "error":
|
||||
// Forward as a stream error AND mark for non-nil err
|
||||
// propagation via the streamErr captured below.
|
||||
safeSendStream(ctx, events, StreamEvent{
|
||||
Kind: StreamError,
|
||||
Code: frame.data.Code,
|
||||
Message: frame.data.Message,
|
||||
Retryable: frame.data.Retryable,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
cleanBody := acc.String()
|
||||
tokens := approxTokenCount(cleanBody)
|
||||
chipCount := countChips(cleanBody)
|
||||
finished := time.Now().UTC()
|
||||
durationMS := int(finished.Sub(startedAt) / time.Millisecond)
|
||||
if upstreamDoneMs > 0 {
|
||||
durationMS = int(upstreamDoneMs)
|
||||
}
|
||||
|
||||
// Persist the conversation id we learned (best-effort — failure here
|
||||
// just means recovery for THIS turn will have to list conversations
|
||||
// rather than fast-path to a single id).
|
||||
if convID != "" {
|
||||
if err := s.setAichatConversationID(ctx, turnID, convID); err != nil {
|
||||
log.Printf("paliadin: persist aichat conversation id %s: %v", convID, err)
|
||||
}
|
||||
}
|
||||
|
||||
if streamErr != nil {
|
||||
// Don't overwrite an existing error_code we may have set above.
|
||||
_ = s.markTurnError(ctx, turnID, classifyAichatError(streamErr))
|
||||
return nil, streamErr
|
||||
}
|
||||
|
||||
// Aichat is stateless on user content; the client owns the primer.
|
||||
if paneSpawned {
|
||||
s.clearPrimed(session)
|
||||
} else {
|
||||
s.markPrimed(session)
|
||||
}
|
||||
|
||||
if cleanBody == "" {
|
||||
// Upstream closed cleanly with no error event but no content
|
||||
// either (unexpected — log + treat as upstream_error so the
|
||||
// handler doesn't ship an empty bubble).
|
||||
_ = s.markTurnError(ctx, turnID, "shim_error")
|
||||
return nil, errors.New("aichat: stream closed with no content and no error")
|
||||
}
|
||||
|
||||
if err := s.completeTurn(ctx, turnID, finished, durationMS, cleanBody, tokens, streamMeta, chipCount); err != nil {
|
||||
log.Printf("paliadin: complete turn %s: %v", turnID, err)
|
||||
}
|
||||
|
||||
return &TurnResult{
|
||||
TurnID: turnID,
|
||||
Response: cleanBody,
|
||||
UsedTools: streamMeta.UsedTools,
|
||||
RowsSeen: streamMeta.RowsSeen,
|
||||
ChipCount: chipCount,
|
||||
ClassifierTag: streamMeta.ClassifierTag,
|
||||
DurationMS: durationMS,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// streamFrame is one decoded SSE event.
|
||||
type streamFrame struct {
|
||||
event string // "" → default (data:) event
|
||||
data streamDataFrame
|
||||
heartbeat streamHeartbeatFrame
|
||||
}
|
||||
|
||||
type streamDataFrame struct {
|
||||
Type string `json:"type"`
|
||||
Content string `json:"content,omitempty"`
|
||||
UsedTools []string `json:"used_tools,omitempty"`
|
||||
RowsSeen []string `json:"rows_seen,omitempty"`
|
||||
ClassifierTag string `json:"classifier_tag,omitempty"`
|
||||
TurnID string `json:"turn_id,omitempty"`
|
||||
ConversationID string `json:"conversation_id,omitempty"`
|
||||
DurationMs int64 `json:"duration_ms,omitempty"`
|
||||
PaneSpawned bool `json:"pane_spawned,omitempty"`
|
||||
Resumed bool `json:"resumed,omitempty"`
|
||||
Code string `json:"code,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Retryable bool `json:"retryable,omitempty"`
|
||||
}
|
||||
|
||||
type streamHeartbeatFrame struct {
|
||||
ElapsedSeconds int `json:"elapsed_seconds"`
|
||||
}
|
||||
|
||||
// callStreamingHTTP opens a streaming POST to aichat and invokes `emit`
|
||||
// for each parsed SSE frame. Returns once the stream closes; surfaces
|
||||
// non-2xx responses via decodeAichatError, transport errors via the
|
||||
// underlying http.Client error.
|
||||
//
|
||||
// Tests can override the parsing path by setting streamHook (kept null
|
||||
// in production).
|
||||
func (s *AichatPaliadinService) callStreamingHTTP(ctx context.Context, path string, body any, emit func(streamFrame)) error {
|
||||
if s.streamHook != nil {
|
||||
return s.streamHook(ctx, path, body, emit)
|
||||
}
|
||||
|
||||
buf, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("aichat: encode %s body: %w", path, err)
|
||||
}
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, s.cfg.BaseURL+path, strings.NewReader(string(buf)))
|
||||
if err != nil {
|
||||
return fmt.Errorf("aichat: build %s request: %w", path, err)
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Accept", "text/event-stream")
|
||||
if s.cfg.BearerToken != "" {
|
||||
httpReq.Header.Set("Authorization", "Bearer "+s.cfg.BearerToken)
|
||||
}
|
||||
|
||||
// Use a dedicated client without the short Timeout — for streaming
|
||||
// we rely on the silence_timeout watch (no events for > 90 s ⇒ fail)
|
||||
// rather than a hard ceiling on the whole turn. The aichat upstream
|
||||
// keeps emitting heartbeats while it's alive, so a true upstream
|
||||
// stall is observable here.
|
||||
client := s.streamingClient()
|
||||
resp, err := client.Do(httpReq)
|
||||
if err != nil {
|
||||
return fmt.Errorf("aichat: POST %s: %w", path, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
respBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
|
||||
return decodeAichatError(resp.StatusCode, respBytes)
|
||||
}
|
||||
|
||||
return parseSSEStream(ctx, resp.Body, emit)
|
||||
}
|
||||
|
||||
// streamingClient returns an HTTP client tuned for streaming — no
|
||||
// per-request Timeout (kills mid-stream), but a long IdleConnTimeout so
|
||||
// the connection stays usable for multi-minute turns.
|
||||
func (s *AichatPaliadinService) streamingClient() *http.Client {
|
||||
if s.cfg.HTTPClient == nil {
|
||||
return &http.Client{Timeout: 0}
|
||||
}
|
||||
c := *s.cfg.HTTPClient
|
||||
c.Timeout = 0
|
||||
return &c
|
||||
}
|
||||
|
||||
// parseSSEStream tokenises an SSE byte stream into streamFrame events
|
||||
// and calls emit for each. Returns nil on clean EOF; returns the read
|
||||
// error otherwise.
|
||||
//
|
||||
// Frame format (per https://html.spec.whatwg.org/multipage/server-sent-events.html):
|
||||
//
|
||||
// event: <name>\n
|
||||
// data: <payload>\n
|
||||
// <blank line>\n
|
||||
//
|
||||
// Multiple `data:` lines per event are concatenated with `\n`. Lines
|
||||
// starting with `:` are comments and ignored.
|
||||
func parseSSEStream(ctx context.Context, r io.Reader, emit func(streamFrame)) error {
|
||||
br := bufio.NewReaderSize(r, 64<<10)
|
||||
var (
|
||||
eventName string
|
||||
dataLines []string
|
||||
)
|
||||
flush := func() {
|
||||
if len(dataLines) == 0 && eventName == "" {
|
||||
return
|
||||
}
|
||||
payload := strings.Join(dataLines, "\n")
|
||||
eventName = strings.TrimSpace(eventName)
|
||||
dataLines = nil
|
||||
eventOut := eventName
|
||||
eventName = ""
|
||||
if eventOut == "heartbeat" {
|
||||
var hb streamHeartbeatFrame
|
||||
if err := json.Unmarshal([]byte(payload), &hb); err != nil {
|
||||
return
|
||||
}
|
||||
emit(streamFrame{event: "heartbeat", heartbeat: hb})
|
||||
return
|
||||
}
|
||||
// Default event (unnamed) — discriminated by `type` field.
|
||||
var d streamDataFrame
|
||||
if err := json.Unmarshal([]byte(payload), &d); err != nil {
|
||||
return
|
||||
}
|
||||
emit(streamFrame{event: "", data: d})
|
||||
}
|
||||
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
line, err := br.ReadString('\n')
|
||||
if err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
// Final frame may not be terminated by a blank line on
|
||||
// abrupt close — flush whatever we accumulated.
|
||||
if line != "" {
|
||||
processSSELine(line, &eventName, &dataLines)
|
||||
}
|
||||
flush()
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("aichat: read sse: %w", err)
|
||||
}
|
||||
// Normalise line endings (some intermediaries send \r\n).
|
||||
line = strings.TrimRight(line, "\r\n")
|
||||
if line == "" {
|
||||
flush()
|
||||
continue
|
||||
}
|
||||
processSSELine(line, &eventName, &dataLines)
|
||||
}
|
||||
}
|
||||
|
||||
// processSSELine handles one line of the SSE wire format.
|
||||
func processSSELine(line string, eventName *string, dataLines *[]string) {
|
||||
if strings.HasPrefix(line, ":") {
|
||||
return // comment / keep-alive
|
||||
}
|
||||
if idx := strings.IndexByte(line, ':'); idx >= 0 {
|
||||
field := line[:idx]
|
||||
value := line[idx+1:]
|
||||
if strings.HasPrefix(value, " ") {
|
||||
value = value[1:]
|
||||
}
|
||||
switch field {
|
||||
case "event":
|
||||
*eventName = value
|
||||
case "data":
|
||||
*dataLines = append(*dataLines, value)
|
||||
}
|
||||
return
|
||||
}
|
||||
// Field with no value (rare). Treat the whole line as field name
|
||||
// per spec.
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// AichatRecoverer — late recovery via the conversation API
|
||||
// =============================================================================
|
||||
|
||||
// RecoverTurn asks aichat whether the given paliad turn has a response.
|
||||
// Returns the up-to-date row on success (including a freshly persisted
|
||||
// response when aichat had one), nil + nil when aichat doesn't know
|
||||
// either, or an error on transport / DB failures.
|
||||
func (s *AichatPaliadinService) RecoverTurn(ctx context.Context, callerID, turnID uuid.UUID) (*PaliadinTurn, error) {
|
||||
row, err := s.GetTurn(ctx, callerID, turnID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Fast path: the row already has a response (the janitor or a
|
||||
// concurrent stream finished writing). Return it as-is.
|
||||
if row.Response != nil && *row.Response != "" {
|
||||
return row, nil
|
||||
}
|
||||
|
||||
convID, err := s.resolveAichatConversationID(ctx, row)
|
||||
if err != nil {
|
||||
log.Printf("paliadin: recover %s: resolve conversation: %v", turnID, err)
|
||||
return nil, nil
|
||||
}
|
||||
if convID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
turns, err := s.fetchAichatConversationTurns(ctx, convID)
|
||||
if err != nil {
|
||||
log.Printf("paliadin: recover %s: fetch turns: %v", turnID, err)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
assistantBody := matchAssistantResponse(turns, row.UserMessage)
|
||||
if assistantBody == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
finished := time.Now().UTC()
|
||||
durationMS := int(finished.Sub(row.StartedAt) / time.Millisecond)
|
||||
tokens := approxTokenCount(assistantBody)
|
||||
chipCount := countChips(assistantBody)
|
||||
|
||||
if err := s.completeTurnLate(ctx, turnID, finished, durationMS, assistantBody, tokens, trailerMeta{}, chipCount); err != nil {
|
||||
log.Printf("paliadin: recover %s: complete late: %v", turnID, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Re-read so the caller gets a row that reflects the late-write.
|
||||
return s.GetTurn(ctx, callerID, turnID)
|
||||
}
|
||||
|
||||
// resolveAichatConversationID returns the conversation the turn lived
|
||||
// in. Fast path: read the column on the row. Fallback: list aichat
|
||||
// conversations for the user+persona and take the most recent.
|
||||
func (s *AichatPaliadinService) resolveAichatConversationID(ctx context.Context, row *PaliadinTurn) (string, error) {
|
||||
stored, err := s.getAichatConversationID(ctx, row.TurnID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if stored != "" {
|
||||
return stored, nil
|
||||
}
|
||||
username := s.usernameFor(ctx, row.UserID)
|
||||
convs, err := s.listAichatConversations(ctx, username, row.UserID.String())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(convs) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
// Aichat orders by last_turn_at DESC; the head is the most recently
|
||||
// active conversation, which is the pane the lost turn ran against.
|
||||
return convs[0].ID, nil
|
||||
}
|
||||
|
||||
// matchAssistantResponse walks the aichat turn list and returns the
|
||||
// body of the latest assistant turn whose preceding user-role turn body
|
||||
// matches `userMessage` (verbatim — aichat persists the raw message
|
||||
// the same way paliad does).
|
||||
//
|
||||
// Falls back to "the last assistant body in the conversation" when no
|
||||
// match is found but the conversation has assistant content. This
|
||||
// covers cases where aichat persisted the user turn with envelope
|
||||
// prefixes that don't exactly match our user_message (e.g. an embedded
|
||||
// [ctx …] block).
|
||||
func matchAssistantResponse(turns []aichatConversationTurn, userMessage string) string {
|
||||
wantedNorm := normaliseForMatch(userMessage)
|
||||
|
||||
for i := 0; i < len(turns)-1; i++ {
|
||||
t := turns[i]
|
||||
if t.Role != "user" {
|
||||
continue
|
||||
}
|
||||
if normaliseForMatch(t.Body) != wantedNorm {
|
||||
continue
|
||||
}
|
||||
next := turns[i+1]
|
||||
if next.Role == "assistant" && next.Body != "" {
|
||||
return next.Body
|
||||
}
|
||||
}
|
||||
|
||||
for i := len(turns) - 1; i >= 0; i-- {
|
||||
t := turns[i]
|
||||
if t.Role == "assistant" && t.Body != "" {
|
||||
return t.Body
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// normaliseForMatch lowercases, strips surrounding whitespace, and
|
||||
// collapses internal whitespace runs. Comparison only — no semantic
|
||||
// meaning beyond "did aichat persist the same prompt we sent".
|
||||
func normaliseForMatch(s string) string {
|
||||
s = strings.TrimSpace(strings.ToLower(s))
|
||||
for strings.Contains(s, " ") {
|
||||
s = strings.ReplaceAll(s, " ", " ")
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// aichat conversation API client helpers
|
||||
// =============================================================================
|
||||
|
||||
type aichatConversationSummary struct {
|
||||
ID string `json:"id"`
|
||||
Persona string `json:"persona"`
|
||||
LastTurnAt string `json:"last_turn_at"`
|
||||
}
|
||||
|
||||
type aichatListConversationsResponse struct {
|
||||
Conversations []aichatConversationSummary `json:"conversations"`
|
||||
}
|
||||
|
||||
type aichatConversationTurn struct {
|
||||
ID string `json:"id"`
|
||||
Seq int `json:"seq"`
|
||||
Role string `json:"role"`
|
||||
Body string `json:"body"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
type aichatGetConversationTurnsResponse struct {
|
||||
ConversationID string `json:"conversation_id"`
|
||||
Turns []aichatConversationTurn `json:"turns"`
|
||||
HasMore bool `json:"has_more"`
|
||||
}
|
||||
|
||||
// listAichatConversations calls GET /chat/conversations for the user.
|
||||
func (s *AichatPaliadinService) listAichatConversations(ctx context.Context, username, userID string) ([]aichatConversationSummary, error) {
|
||||
q := url.Values{}
|
||||
q.Set("persona", s.cfg.Persona)
|
||||
q.Set("username", username)
|
||||
q.Set("user_id", userID)
|
||||
q.Set("limit", "5")
|
||||
path := "/chat/conversations?" + q.Encode()
|
||||
var resp aichatListConversationsResponse
|
||||
if err := s.callHTTP(ctx, http.MethodGet, path, nil, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resp.Conversations, nil
|
||||
}
|
||||
|
||||
// fetchAichatConversationTurns calls GET /chat/conversations/{id}/turns.
|
||||
func (s *AichatPaliadinService) fetchAichatConversationTurns(ctx context.Context, convID string) ([]aichatConversationTurn, error) {
|
||||
q := url.Values{}
|
||||
q.Set("persona", s.cfg.Persona)
|
||||
q.Set("limit", "20")
|
||||
path := "/chat/conversations/" + url.PathEscape(convID) + "/turns?" + q.Encode()
|
||||
var resp aichatGetConversationTurnsResponse
|
||||
if err := s.callHTTP(ctx, http.MethodGet, path, nil, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resp.Turns, nil
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// DB helpers for paliadin_turns.aichat_conversation_id (migration 118)
|
||||
// =============================================================================
|
||||
|
||||
func (s *AichatPaliadinService) setAichatConversationID(ctx context.Context, turnID uuid.UUID, convID string) error {
|
||||
if convID == "" {
|
||||
return nil
|
||||
}
|
||||
convUUID, err := uuid.Parse(convID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid conversation id %q: %w", convID, err)
|
||||
}
|
||||
_, err = s.db.ExecContext(ctx, `
|
||||
UPDATE paliad.paliadin_turns
|
||||
SET aichat_conversation_id = $2
|
||||
WHERE turn_id = $1
|
||||
AND aichat_conversation_id IS DISTINCT FROM $2
|
||||
`, turnID, convUUID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *AichatPaliadinService) getAichatConversationID(ctx context.Context, turnID uuid.UUID) (string, error) {
|
||||
var convID *uuid.UUID
|
||||
err := s.db.QueryRowxContext(ctx,
|
||||
`SELECT aichat_conversation_id FROM paliad.paliadin_turns WHERE turn_id = $1`,
|
||||
turnID).Scan(&convID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if convID == nil {
|
||||
return "", nil
|
||||
}
|
||||
return convID.String(), nil
|
||||
}
|
||||
|
||||
// Compile-time interface conformance — fail the build if a streaming
|
||||
// method drifts off this backend.
|
||||
var _ StreamingPaliadin = (*AichatPaliadinService)(nil)
|
||||
var _ AichatRecoverer = (*AichatPaliadinService)(nil)
|
||||
259
internal/services/aichat_paliadin_stream_test.go
Normal file
259
internal/services/aichat_paliadin_stream_test.go
Normal file
@@ -0,0 +1,259 @@
|
||||
package services
|
||||
|
||||
// Streaming + recovery tests for AichatPaliadinService (t-paliad-235).
|
||||
//
|
||||
// Like the sync-path tests next door, every test bypasses the HTTP wire
|
||||
// (streamHook / httpHook). DB-write paths in RunTurnStream are out of
|
||||
// scope here for the same reason — paliad has no sqlx mock. We focus
|
||||
// on the SSE parser, the conversation-API client, and the
|
||||
// match-assistant-response helper.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// =============================================================================
|
||||
// SSE parser
|
||||
// =============================================================================
|
||||
|
||||
func TestParseSSEStream_DefaultEvents(t *testing.T) {
|
||||
body := `data: {"type":"chunk","content":"Hello "}
|
||||
|
||||
data: {"type":"chunk","content":"world"}
|
||||
|
||||
data: {"type":"meta","used_tools":["search"],"rows_seen":["3"],"classifier_tag":"howto"}
|
||||
|
||||
data: {"type":"done","turn_id":"abc","conversation_id":"11111111-1111-1111-1111-111111111111","duration_ms":1234,"pane_spawned":false,"resumed":false}
|
||||
|
||||
`
|
||||
var frames []streamFrame
|
||||
err := parseSSEStream(context.Background(), strings.NewReader(body), func(f streamFrame) {
|
||||
frames = append(frames, f)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("parseSSEStream: %v", err)
|
||||
}
|
||||
if len(frames) != 4 {
|
||||
t.Fatalf("got %d frames; want 4 (%+v)", len(frames), frames)
|
||||
}
|
||||
if frames[0].data.Type != "chunk" || frames[0].data.Content != "Hello " {
|
||||
t.Errorf("frame 0 = %+v; want chunk Hello ", frames[0])
|
||||
}
|
||||
if frames[1].data.Type != "chunk" || frames[1].data.Content != "world" {
|
||||
t.Errorf("frame 1 = %+v; want chunk world", frames[1])
|
||||
}
|
||||
if frames[2].data.Type != "meta" || frames[2].data.ClassifierTag != "howto" {
|
||||
t.Errorf("frame 2 = %+v; want meta howto", frames[2])
|
||||
}
|
||||
if frames[3].data.Type != "done" || frames[3].data.ConversationID == "" {
|
||||
t.Errorf("frame 3 = %+v; want done with conversation_id", frames[3])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSSEStream_HeartbeatEvent(t *testing.T) {
|
||||
body := `event: heartbeat
|
||||
data: {"elapsed_seconds": 5}
|
||||
|
||||
event: heartbeat
|
||||
data: {"elapsed_seconds": 10}
|
||||
|
||||
data: {"type":"chunk","content":"Hi"}
|
||||
|
||||
`
|
||||
var frames []streamFrame
|
||||
err := parseSSEStream(context.Background(), strings.NewReader(body), func(f streamFrame) {
|
||||
frames = append(frames, f)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("parseSSEStream: %v", err)
|
||||
}
|
||||
if len(frames) != 3 {
|
||||
t.Fatalf("got %d frames; want 3", len(frames))
|
||||
}
|
||||
if frames[0].event != "heartbeat" || frames[0].heartbeat.ElapsedSeconds != 5 {
|
||||
t.Errorf("frame 0 = %+v; want heartbeat 5s", frames[0])
|
||||
}
|
||||
if frames[1].event != "heartbeat" || frames[1].heartbeat.ElapsedSeconds != 10 {
|
||||
t.Errorf("frame 1 = %+v; want heartbeat 10s", frames[1])
|
||||
}
|
||||
if frames[2].data.Type != "chunk" || frames[2].data.Content != "Hi" {
|
||||
t.Errorf("frame 2 = %+v; want chunk Hi", frames[2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSSEStream_IgnoresComments(t *testing.T) {
|
||||
body := `: keep-alive comment line
|
||||
|
||||
data: {"type":"chunk","content":"x"}
|
||||
|
||||
`
|
||||
var frames []streamFrame
|
||||
err := parseSSEStream(context.Background(), strings.NewReader(body), func(f streamFrame) {
|
||||
frames = append(frames, f)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("parseSSEStream: %v", err)
|
||||
}
|
||||
if len(frames) != 1 || frames[0].data.Content != "x" {
|
||||
t.Errorf("frames = %+v; want 1 chunk", frames)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSSEStream_HandlesCRLF(t *testing.T) {
|
||||
body := "data: {\"type\":\"chunk\",\"content\":\"crlf\"}\r\n\r\n"
|
||||
var frames []streamFrame
|
||||
err := parseSSEStream(context.Background(), strings.NewReader(body), func(f streamFrame) {
|
||||
frames = append(frames, f)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("parseSSEStream: %v", err)
|
||||
}
|
||||
if len(frames) != 1 || frames[0].data.Content != "crlf" {
|
||||
t.Errorf("frames = %+v; want crlf chunk", frames)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSSEStream_MultilineData(t *testing.T) {
|
||||
// Two data: lines for the same event must concatenate with \n.
|
||||
body := `data: {"type":"chunk",
|
||||
data: "content":"x"}
|
||||
|
||||
`
|
||||
var frames []streamFrame
|
||||
err := parseSSEStream(context.Background(), strings.NewReader(body), func(f streamFrame) {
|
||||
frames = append(frames, f)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("parseSSEStream: %v", err)
|
||||
}
|
||||
if len(frames) != 1 || frames[0].data.Content != "x" {
|
||||
t.Errorf("frames = %+v; want 1 chunk x", frames)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// matchAssistantResponse
|
||||
// =============================================================================
|
||||
|
||||
func TestMatchAssistantResponse_PrefersUserPrecededAssistant(t *testing.T) {
|
||||
turns := []aichatConversationTurn{
|
||||
{Role: "user", Body: "first question"},
|
||||
{Role: "assistant", Body: "first answer"},
|
||||
{Role: "user", Body: "second question"},
|
||||
{Role: "assistant", Body: "second answer"},
|
||||
}
|
||||
got := matchAssistantResponse(turns, "second question")
|
||||
if got != "second answer" {
|
||||
t.Errorf("got %q; want %q", got, "second answer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchAssistantResponse_NormaliseCase(t *testing.T) {
|
||||
turns := []aichatConversationTurn{
|
||||
{Role: "user", Body: "Hello World"},
|
||||
{Role: "assistant", Body: "hi back"},
|
||||
}
|
||||
got := matchAssistantResponse(turns, " hello world ")
|
||||
if got != "hi back" {
|
||||
t.Errorf("got %q; want %q", got, "hi back")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchAssistantResponse_FallbackToLastAssistant(t *testing.T) {
|
||||
// User message doesn't match (aichat persisted with a different
|
||||
// envelope or wrapper). Fallback: take the last assistant turn.
|
||||
turns := []aichatConversationTurn{
|
||||
{Role: "user", Body: "[ctx route=x] my question"},
|
||||
{Role: "assistant", Body: "the answer"},
|
||||
}
|
||||
got := matchAssistantResponse(turns, "my question")
|
||||
if got != "the answer" {
|
||||
t.Errorf("got %q; want %q", got, "the answer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchAssistantResponse_NoAssistantTurns(t *testing.T) {
|
||||
turns := []aichatConversationTurn{
|
||||
{Role: "user", Body: "lonely"},
|
||||
}
|
||||
got := matchAssistantResponse(turns, "lonely")
|
||||
if got != "" {
|
||||
t.Errorf("got %q; want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchAssistantResponse_EmptyAssistantSkipped(t *testing.T) {
|
||||
turns := []aichatConversationTurn{
|
||||
{Role: "user", Body: "q1"},
|
||||
{Role: "assistant", Body: ""},
|
||||
{Role: "user", Body: "q2"},
|
||||
{Role: "assistant", Body: "a2"},
|
||||
}
|
||||
got := matchAssistantResponse(turns, "q1")
|
||||
if got != "a2" {
|
||||
// q1's immediate next is the empty-body assistant — we skip it
|
||||
// and fall back to the last non-empty assistant body.
|
||||
t.Errorf("got %q; want %q (fallback)", got, "a2")
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Conversation-API HTTP client
|
||||
// =============================================================================
|
||||
|
||||
func TestListAichatConversations_BuildsExpectedQuery(t *testing.T) {
|
||||
var seenPath string
|
||||
s := newAichatService(t, nil, func(ctx context.Context, method, path string, body any, out any) error {
|
||||
seenPath = path
|
||||
if dst, ok := out.(*aichatListConversationsResponse); ok {
|
||||
dst.Conversations = []aichatConversationSummary{{ID: "11111111-1111-1111-1111-111111111111"}}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
got, err := s.listAichatConversations(context.Background(), "alice", "00000000-0000-0000-0000-000000000001")
|
||||
if err != nil {
|
||||
t.Fatalf("listAichatConversations: %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0].ID == "" {
|
||||
t.Errorf("got = %+v; want one conversation", got)
|
||||
}
|
||||
for _, want := range []string{"/chat/conversations?", "persona=paliadin", "username=alice", "user_id=00000000-0000-0000-0000-000000000001", "limit=5"} {
|
||||
if !strings.Contains(seenPath, want) {
|
||||
t.Errorf("path %q missing %q", seenPath, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchAichatConversationTurns_BuildsPath(t *testing.T) {
|
||||
var seenPath string
|
||||
s := newAichatService(t, nil, func(ctx context.Context, method, path string, body any, out any) error {
|
||||
seenPath = path
|
||||
if dst, ok := out.(*aichatGetConversationTurnsResponse); ok {
|
||||
dst.Turns = []aichatConversationTurn{{Role: "assistant", Body: "answer"}}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
turns, err := s.fetchAichatConversationTurns(context.Background(), "11111111-1111-1111-1111-111111111111")
|
||||
if err != nil {
|
||||
t.Fatalf("fetchAichatConversationTurns: %v", err)
|
||||
}
|
||||
if len(turns) != 1 || turns[0].Body != "answer" {
|
||||
t.Errorf("turns = %+v; want one assistant", turns)
|
||||
}
|
||||
for _, want := range []string{"/chat/conversations/11111111-1111-1111-1111-111111111111/turns", "persona=paliadin", "limit=20"} {
|
||||
if !strings.Contains(seenPath, want) {
|
||||
t.Errorf("path %q missing %q", seenPath, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Interface conformance
|
||||
// =============================================================================
|
||||
|
||||
func TestAichatPaliadinService_ImplementsStreaming(t *testing.T) {
|
||||
var _ StreamingPaliadin = (*AichatPaliadinService)(nil)
|
||||
var _ AichatRecoverer = (*AichatPaliadinService)(nil)
|
||||
}
|
||||
@@ -226,10 +226,12 @@ func validatePosition(i int, w DashboardWidgetRef, def WidgetDef) error {
|
||||
}
|
||||
|
||||
// SanitizeForRead applies the forgiving read-path rules: drop entries whose
|
||||
// keys are not in the catalog (catalog has shrunk) and bump the version to
|
||||
// the current one if missing. Settings on surviving entries pass through
|
||||
// unchanged — invalid settings on read are not worth aborting over and the
|
||||
// next write will reject them anyway.
|
||||
// keys are not in the catalog (catalog has shrunk), bump the version to
|
||||
// the current one if missing, and clamp w/h/x against the catalog's
|
||||
// MinW/MaxW/MinH/MaxH/grid bounds so a stale row with out-of-range sizes
|
||||
// can't strand the user with unrenderable widgets (m/paliad#73). Settings
|
||||
// on surviving entries pass through unchanged — invalid settings on read
|
||||
// are not worth aborting over and the next write will reject them anyway.
|
||||
//
|
||||
// Returns true if anything was changed; callers can use that to decide
|
||||
// whether to PUT the cleaned spec back.
|
||||
@@ -244,16 +246,88 @@ func (s *DashboardLayoutSpec) SanitizeForRead() bool {
|
||||
}
|
||||
out := make([]DashboardWidgetRef, 0, len(s.Widgets))
|
||||
for _, w := range s.Widgets {
|
||||
if _, ok := LookupWidgetDef(w.Key); !ok {
|
||||
def, ok := LookupWidgetDef(w.Key)
|
||||
if !ok {
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
if normalizePosition(&w, def) {
|
||||
changed = true
|
||||
}
|
||||
out = append(out, w)
|
||||
}
|
||||
s.Widgets = out
|
||||
return changed
|
||||
}
|
||||
|
||||
// normalizePosition clamps a widget's W/H/X to the catalog bounds and the
|
||||
// grid extent. Returns true if any field was modified. Zero W/H stay zero
|
||||
// (auto-flow / default sentinel — the placer fills them in). Negative X
|
||||
// snaps to 0; X+W overflowing the grid snaps X down.
|
||||
func normalizePosition(w *DashboardWidgetRef, def WidgetDef) bool {
|
||||
changed := false
|
||||
|
||||
if w.W < 0 {
|
||||
w.W = 0
|
||||
changed = true
|
||||
}
|
||||
if w.W > DashboardGridColumns {
|
||||
w.W = DashboardGridColumns
|
||||
changed = true
|
||||
}
|
||||
// W == 0 is the "auto / default" sentinel — leave it untouched so
|
||||
// downstream renderers can substitute DefaultW. Only clamp non-zero
|
||||
// values against the per-widget Min/Max.
|
||||
if w.W > 0 {
|
||||
if def.MinW > 0 && w.W < def.MinW {
|
||||
w.W = def.MinW
|
||||
changed = true
|
||||
}
|
||||
if def.MaxW > 0 && w.W > def.MaxW {
|
||||
w.W = def.MaxW
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
if w.H < 0 {
|
||||
w.H = 0
|
||||
changed = true
|
||||
}
|
||||
if w.H > MaxGridRowSpan {
|
||||
w.H = MaxGridRowSpan
|
||||
changed = true
|
||||
}
|
||||
if w.H > 0 {
|
||||
if def.MinH > 0 && w.H < def.MinH {
|
||||
w.H = def.MinH
|
||||
changed = true
|
||||
}
|
||||
if def.MaxH > 0 && w.H > def.MaxH {
|
||||
w.H = def.MaxH
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
if w.X < 0 {
|
||||
w.X = 0
|
||||
changed = true
|
||||
}
|
||||
if w.X >= DashboardGridColumns {
|
||||
w.X = DashboardGridColumns - 1
|
||||
changed = true
|
||||
}
|
||||
if w.W > 0 && w.X+w.W > DashboardGridColumns {
|
||||
w.X = DashboardGridColumns - w.W
|
||||
changed = true
|
||||
}
|
||||
if w.Y < 0 {
|
||||
w.Y = 0
|
||||
changed = true
|
||||
}
|
||||
|
||||
return changed
|
||||
}
|
||||
|
||||
// ParseDashboardLayoutSpec decodes JSON bytes and validates. Used by the
|
||||
// HTTP handler on incoming request bodies.
|
||||
func ParseDashboardLayoutSpec(b []byte) (DashboardLayoutSpec, error) {
|
||||
|
||||
@@ -279,6 +279,128 @@ func TestDashboardLayoutSpec_SanitizeForRead_DropsUnknownKeys(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestDashboardLayoutSpec_SanitizeForRead_ClampsOutOfRange covers the
|
||||
// m/paliad#73 recovery path: a stale row in user_dashboard_layouts
|
||||
// carrying a W below MinW (or above MaxW) must be normalised on load so
|
||||
// the user doesn't get stranded with super-slim columns. Pre-fix the
|
||||
// sanitizer only dropped unknown keys; sizes passed through verbatim.
|
||||
func TestDashboardLayoutSpec_SanitizeForRead_ClampsOutOfRange(t *testing.T) {
|
||||
// upcoming-deadlines: MinW=4, MaxW=12, MinH=1, MaxH=4 (per catalog).
|
||||
def, ok := LookupWidgetDef(WidgetUpcomingDeadlines)
|
||||
if !ok {
|
||||
t.Fatal("LookupWidgetDef(WidgetUpcomingDeadlines) = !ok")
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
in DashboardWidgetRef
|
||||
wantW int
|
||||
wantH int
|
||||
wantX int
|
||||
wantY int
|
||||
wantOK bool // expected SanitizeForRead-returns-true
|
||||
}{
|
||||
{
|
||||
name: "W below MinW snaps to MinW",
|
||||
in: DashboardWidgetRef{Key: WidgetUpcomingDeadlines, Visible: true, X: 0, Y: 0, W: 1, H: 1},
|
||||
wantW: def.MinW,
|
||||
wantH: 1,
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "W above MaxW snaps to MaxW",
|
||||
in: DashboardWidgetRef{Key: WidgetUpcomingDeadlines, Visible: true, X: 0, Y: 0, W: 99, H: 1},
|
||||
wantW: DashboardGridColumns,
|
||||
wantH: 1,
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "W above grid width snaps to grid width",
|
||||
in: DashboardWidgetRef{Key: WidgetUpcomingDeadlines, Visible: true, X: 0, Y: 0, W: 50, H: 1},
|
||||
wantW: DashboardGridColumns,
|
||||
wantH: 1,
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "H above MaxGridRowSpan snaps to MaxGridRowSpan",
|
||||
in: DashboardWidgetRef{Key: WidgetUpcomingDeadlines, Visible: true, X: 0, Y: 0, W: 6, H: 99},
|
||||
wantW: 6,
|
||||
wantH: def.MaxH, // upcoming-deadlines MaxH=4 < MaxGridRowSpan=5
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "X+W overflowing grid snaps X down",
|
||||
in: DashboardWidgetRef{Key: WidgetUpcomingDeadlines, Visible: true, X: 10, Y: 0, W: 6, H: 1},
|
||||
wantW: 6,
|
||||
wantH: 1,
|
||||
wantX: 6, // 12 - 6 = 6
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "W=0 stays 0 (auto / default sentinel)",
|
||||
in: DashboardWidgetRef{Key: WidgetUpcomingDeadlines, Visible: true, X: 0, Y: 0, W: 0, H: 0},
|
||||
wantW: 0,
|
||||
wantH: 0,
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "negative X snaps to 0",
|
||||
in: DashboardWidgetRef{Key: WidgetUpcomingDeadlines, Visible: true, X: -3, Y: 0, W: 6, H: 1},
|
||||
wantW: 6,
|
||||
wantH: 1,
|
||||
wantX: 0,
|
||||
wantOK: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
s := DashboardLayoutSpec{Version: LayoutSpecVersion, Widgets: []DashboardWidgetRef{tc.in}}
|
||||
changed := s.SanitizeForRead()
|
||||
if changed != tc.wantOK {
|
||||
t.Errorf("SanitizeForRead returned %v; want %v", changed, tc.wantOK)
|
||||
}
|
||||
if len(s.Widgets) != 1 {
|
||||
t.Fatalf("expected 1 widget after sanitize, got %d", len(s.Widgets))
|
||||
}
|
||||
got := s.Widgets[0]
|
||||
if got.W != tc.wantW {
|
||||
t.Errorf("W = %d; want %d", got.W, tc.wantW)
|
||||
}
|
||||
if got.H != tc.wantH {
|
||||
t.Errorf("H = %d; want %d", got.H, tc.wantH)
|
||||
}
|
||||
if got.X != tc.wantX {
|
||||
t.Errorf("X = %d; want %d", got.X, tc.wantX)
|
||||
}
|
||||
if got.Y != tc.wantY {
|
||||
t.Errorf("Y = %d; want %d", got.Y, tc.wantY)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDashboardLayoutSpec_SanitizeForRead_ClampedSpecPassesValidate is
|
||||
// the round-trip guarantee — after the sanitiser heals a stale row, the
|
||||
// result must be acceptable to Validate so the next PUT doesn't reject
|
||||
// the user's layout. Without this guarantee, sanitizing on read could
|
||||
// produce a layout the validator won't accept on the autosave path.
|
||||
func TestDashboardLayoutSpec_SanitizeForRead_ClampedSpecPassesValidate(t *testing.T) {
|
||||
s := DashboardLayoutSpec{
|
||||
Version: LayoutSpecVersion,
|
||||
Widgets: []DashboardWidgetRef{
|
||||
{Key: WidgetUpcomingDeadlines, Visible: true, X: 0, Y: 0, W: 1, H: 1},
|
||||
{Key: WidgetUpcomingDeadlines, Visible: true, X: 50, Y: 0, W: 99, H: 99}, // duplicate key — Validate will reject; this case checks size clamp at least
|
||||
},
|
||||
}
|
||||
// Trim to one widget for the validate assertion (duplicates are a
|
||||
// separate concern).
|
||||
s.Widgets = s.Widgets[:1]
|
||||
s.SanitizeForRead()
|
||||
if err := s.Validate(); err != nil {
|
||||
t.Errorf("Validate after SanitizeForRead returned %v; want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashboardLayoutSpec_SanitizeForRead_NoopOnClean(t *testing.T) {
|
||||
s := FactoryDefaultLayout()
|
||||
if s.SanitizeForRead() {
|
||||
|
||||
@@ -197,7 +197,16 @@ func (s *DeadlineService) ListVisibleForUser(ctx context.Context, userID uuid.UU
|
||||
conds = append(conds, `f.status = 'pending' AND f.due_date < :today`)
|
||||
args["today"] = b.today
|
||||
case DeadlineFilterToday:
|
||||
conds = append(conds, `f.status = 'pending' AND f.due_date = :today`)
|
||||
// "Heute" includes pending due today AND items that were completed
|
||||
// today (rendered strikethrough/green client-side). m, 2026-05-22:
|
||||
// previously filtered out the moment a row was checked off, so a
|
||||
// user couldn't see their own progress on the day's deadlines.
|
||||
// Items completed on earlier days still drop out — the bucket
|
||||
// stays scoped to "today's work".
|
||||
// date(...) cast instead of `::date` — sqlx's named-parameter parser
|
||||
// reads `::date` as `::` + `:date` placeholder and rewrites it into
|
||||
// a syntax error against Postgres.
|
||||
conds = append(conds, `f.due_date = :today AND (f.status = 'pending' OR date(f.completed_at) = :today)`)
|
||||
args["today"] = b.today
|
||||
case DeadlineFilterThisWeek:
|
||||
conds = append(conds, `f.status = 'pending' AND f.due_date > :today AND f.due_date < :next_monday`)
|
||||
|
||||
@@ -42,12 +42,15 @@ func NewDerivationService(db *sqlx.DB, projects *ProjectService, partnerUnit *Pa
|
||||
// the configured derive_unit_roles. The frontend renders this on the
|
||||
// /projects/{id}/settings/team Partner Units section.
|
||||
type AttachedUnit struct {
|
||||
ProjectID uuid.UUID `db:"project_id" json:"project_id"`
|
||||
PartnerUnitID uuid.UUID `db:"partner_unit_id" json:"partner_unit_id"`
|
||||
UnitName string `db:"unit_name" json:"unit_name"`
|
||||
DeriveUnitRoles []string `db:"derive_unit_roles" json:"derive_unit_roles"`
|
||||
DeriveGrantsAuthority bool `db:"derive_grants_authority" json:"derive_grants_authority"`
|
||||
DerivedMemberCount int `db:"derived_member_count" json:"derived_member_count"`
|
||||
ProjectID uuid.UUID `db:"project_id" json:"project_id"`
|
||||
PartnerUnitID uuid.UUID `db:"partner_unit_id" json:"partner_unit_id"`
|
||||
UnitName string `db:"unit_name" json:"unit_name"`
|
||||
// derive_unit_roles is a Postgres text[]; sqlx returns it as []byte
|
||||
// without an array adapter, so we use pq.StringArray for the scan
|
||||
// and convert to []string in JSON via a tiny ergonomics wrapper.
|
||||
DeriveUnitRoles pq.StringArray `db:"derive_unit_roles" json:"derive_unit_roles"`
|
||||
DeriveGrantsAuthority bool `db:"derive_grants_authority" json:"derive_grants_authority"`
|
||||
DerivedMemberCount int `db:"derived_member_count" json:"derived_member_count"`
|
||||
}
|
||||
|
||||
// DerivedMembership is one (unit, role) pair through which a user currently
|
||||
|
||||
127
internal/services/paliadin_streaming.go
Normal file
127
internal/services/paliadin_streaming.go
Normal file
@@ -0,0 +1,127 @@
|
||||
package services
|
||||
|
||||
// Streaming support for the Paliadin chat surface (t-paliad-235).
|
||||
//
|
||||
// The legacy LocalPaliadinService.RunTurn returns the full response in
|
||||
// one shot — the chat UI gets one `content` blob and the typewriter
|
||||
// simulates streaming. That falls apart on long turns: the HTTP client
|
||||
// hits its 130 s ceiling, paliad's SSE stream closes, the bubble shows
|
||||
// "Verbindung verloren" and the response is lost.
|
||||
//
|
||||
// The aichat backend exposes a real streaming variant at
|
||||
// /chat/turn/stream that emits incremental chunks + named heartbeat
|
||||
// events while claude is thinking. AichatPaliadinService implements
|
||||
// the StreamingPaliadin interface defined here; the handler probes
|
||||
// for it via a type assertion and falls back to the one-shot RunTurn
|
||||
// when the backend doesn't support streaming (legacy path).
|
||||
//
|
||||
// Recovery (a separate axis): when the transport drops mid-turn,
|
||||
// the AichatRecoverer interface lets the handler ask the backend to
|
||||
// look up the late response via aichat's conversation API rather than
|
||||
// rely on the legacy filesystem janitor — which only knows about
|
||||
// LocalPaliadinService's per-turn response files.
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// StreamEvent is one increment of a streaming turn. The handler
|
||||
// receives these via the channel passed to RunTurnStream and forwards
|
||||
// them as SSE frames to the browser.
|
||||
//
|
||||
// Exactly one of Kind's payloads is meaningful per event:
|
||||
//
|
||||
// StreamChunk → Content holds the next slice of assistant text
|
||||
// StreamHeartbeat → ElapsedSeconds holds upstream "still thinking" tick
|
||||
// StreamMeta → UsedTools / RowsSeen / ClassifierTag populated
|
||||
// StreamError → Code / Message / Retryable populated
|
||||
//
|
||||
// StreamDone is implicit: when the channel closes without an error
|
||||
// event, the turn completed. The accompanying *TurnResult returned by
|
||||
// RunTurnStream carries the final accumulated body + meta + conversation
|
||||
// id for persistence and recovery.
|
||||
type StreamEvent struct {
|
||||
Kind StreamEventKind
|
||||
|
||||
// StreamChunk
|
||||
Content string
|
||||
|
||||
// StreamHeartbeat
|
||||
ElapsedSeconds int
|
||||
|
||||
// StreamMeta (terminal-side; may also be merged into final TurnResult)
|
||||
UsedTools []string
|
||||
RowsSeen []int
|
||||
ClassifierTag string
|
||||
|
||||
// StreamError
|
||||
Code string
|
||||
Message string
|
||||
Retryable bool
|
||||
|
||||
// StreamConversation — aichat sometimes resolves the conversation id
|
||||
// before the first chunk arrives. We surface it as soon as we have
|
||||
// it so the handler can persist it for recovery, even if the stream
|
||||
// is later interrupted.
|
||||
ConversationID string
|
||||
}
|
||||
|
||||
// StreamEventKind enumerates the meaningful flavours.
|
||||
type StreamEventKind string
|
||||
|
||||
const (
|
||||
StreamChunk StreamEventKind = "chunk"
|
||||
StreamHeartbeat StreamEventKind = "heartbeat"
|
||||
StreamMeta StreamEventKind = "meta"
|
||||
StreamError StreamEventKind = "error"
|
||||
StreamConversation StreamEventKind = "conversation"
|
||||
)
|
||||
|
||||
// StreamingPaliadin is the optional extension the AichatPaliadinService
|
||||
// implements. Handlers detect it via type assertion; backends that don't
|
||||
// implement it (the legacy local + remote paths) fall back to the
|
||||
// one-shot Paliadin.RunTurn.
|
||||
//
|
||||
// Contract:
|
||||
// - RunTurnStream MUST close `events` before returning, so the handler
|
||||
// loop terminates cleanly.
|
||||
// - Returning a non-nil error implies the audit row was already
|
||||
// stamped with an error_code; the handler does not double-stamp.
|
||||
// - The *TurnResult is populated even on partial failure when the
|
||||
// upstream produced any meaningful body — handlers may render it as
|
||||
// a salvaged best-effort result instead of an error.
|
||||
type StreamingPaliadin interface {
|
||||
Paliadin
|
||||
|
||||
// RunTurnStream drives one turn against the streaming upstream and
|
||||
// pushes StreamEvents onto `events` as they arrive. Blocks until the
|
||||
// upstream finishes or the context cancels. `events` is closed by
|
||||
// the implementation before this method returns.
|
||||
RunTurnStream(ctx context.Context, req TurnRequest, events chan<- StreamEvent) (*TurnResult, error)
|
||||
}
|
||||
|
||||
// AichatRecoverer is the optional extension that knows how to ask the
|
||||
// aichat backend "did this turn actually complete?" when paliad's local
|
||||
// audit row never got a response (because the transport dropped mid
|
||||
// turn). Implementations look up the persisted aichat_conversation_id,
|
||||
// query aichat's GET /chat/conversations/{id}/turns, find the matching
|
||||
// assistant turn, and write the response back to paliad's row.
|
||||
//
|
||||
// Returns (nil, nil) when aichat doesn't have the response either —
|
||||
// i.e. the turn is truly lost and the UI must degrade to "verloren"
|
||||
// copy rather than "wird nachgereicht".
|
||||
type AichatRecoverer interface {
|
||||
RecoverTurn(ctx context.Context, callerID, turnID uuid.UUID) (*PaliadinTurn, error)
|
||||
}
|
||||
|
||||
// safeSendStream pushes an event onto the channel, dropping on context
|
||||
// cancel. Mirrors the handler-side `send` helper but works against a
|
||||
// generic chan StreamEvent.
|
||||
func safeSendStream(ctx context.Context, ch chan<- StreamEvent, ev StreamEvent) {
|
||||
select {
|
||||
case ch <- ev:
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}
|
||||
@@ -924,7 +924,7 @@ func (s *ProjectService) Create(ctx context.Context, userID uuid.UUID, input Cre
|
||||
input.Title, input.Reference, input.Description, status,
|
||||
userID,
|
||||
input.Industry, input.Country, input.BillingReference,
|
||||
input.ClientNumber, input.MatterNumber, input.NetDocumentsURL,
|
||||
nullableTrimmed(input.ClientNumber), nullableTrimmed(input.MatterNumber), input.NetDocumentsURL,
|
||||
input.PatentNumber, input.FilingDate, input.GrantDate,
|
||||
input.Court, input.CaseNumber, input.ProceedingTypeID,
|
||||
nullableOurSide(input.OurSide),
|
||||
@@ -1038,10 +1038,13 @@ func (s *ProjectService) Update(ctx context.Context, userID, id uuid.UUID, input
|
||||
appendSet("billing_reference", *input.BillingReference)
|
||||
}
|
||||
if input.ClientNumber != nil {
|
||||
appendSetSkippable("client_number", *input.ClientNumber)
|
||||
// Coerce empty string → NULL so the
|
||||
// projekte_client_number_check ('^[0-9]{6}$' OR NULL) accepts a
|
||||
// blanked field (m, 2026-05-22 — "I cant add a project").
|
||||
appendSetSkippable("client_number", nullableTrimmed(input.ClientNumber))
|
||||
}
|
||||
if input.MatterNumber != nil {
|
||||
appendSet("matter_number", *input.MatterNumber)
|
||||
appendSet("matter_number", nullableTrimmed(input.MatterNumber))
|
||||
}
|
||||
if input.NetDocumentsURL != nil {
|
||||
appendSet("netdocuments_url", *input.NetDocumentsURL)
|
||||
@@ -2033,6 +2036,23 @@ func nullableInstanceLevel(p *string) any {
|
||||
return s
|
||||
}
|
||||
|
||||
// nullableTrimmed returns nil for an empty / whitespace value so the SQL
|
||||
// driver writes NULL, otherwise the trimmed string. Used for nullable
|
||||
// text columns whose constraints reject the empty string (e.g.
|
||||
// projekte_client_number_check requires NULL or 6 digits — a blank
|
||||
// client_number field on the project form would otherwise fail the
|
||||
// constraint instead of being treated as "not set").
|
||||
func nullableTrimmed(p *string) any {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
s := strings.TrimSpace(*p)
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// nullableOurSide returns nil for an empty / whitespace value so the
|
||||
// SQL driver writes NULL, otherwise the trimmed string. Mirrors the
|
||||
// Update payload contract: empty string from the form clears the
|
||||
|
||||
211
internal/services/projection_anchor_cross_test.go
Normal file
211
internal/services/projection_anchor_cross_test.go
Normal file
@@ -0,0 +1,211 @@
|
||||
package services
|
||||
|
||||
// Live-DB integration test for the CCR-anchors-inf-rule path
|
||||
// (t-paliad-237). The SmartTimeline on a CCR project surfaces parent
|
||||
// inf rules in the parent_context lane; clicking "Datum setzen" on
|
||||
// those rows used to bubble up as a generic 500 because the anchor
|
||||
// lookup was scoped to the CCR's own proceeding_type_id. The service
|
||||
// now detects the cross-proceeding case and rejects with a structured
|
||||
// error pointing at the parent project — verified end-to-end here.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/lib/pq"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/db"
|
||||
)
|
||||
|
||||
func TestRecordAnchor_CrossProceeding_RejectsWithParentPointer_Live(t *testing.T) {
|
||||
url := os.Getenv("TEST_DATABASE_URL")
|
||||
if url == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
|
||||
}
|
||||
if err := db.ApplyMigrations(url); err != nil {
|
||||
t.Fatalf("apply migrations: %v", err)
|
||||
}
|
||||
pool, err := sqlx.Connect("postgres", url)
|
||||
if err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
userID := uuid.New()
|
||||
patentID := uuid.New()
|
||||
parentCaseID := uuid.New()
|
||||
|
||||
// Resolve upc.inf.cfi + upc.rev.cfi ids.
|
||||
var upcInf, upcRev int
|
||||
if err := pool.GetContext(ctx, &upcInf,
|
||||
`SELECT id FROM paliad.proceeding_types WHERE code = $1`,
|
||||
CodeUPCInfringement); err != nil {
|
||||
t.Fatalf("resolve %s: %v", CodeUPCInfringement, err)
|
||||
}
|
||||
if err := pool.GetContext(ctx, &upcRev,
|
||||
`SELECT id FROM paliad.proceeding_types WHERE code = $1`,
|
||||
CodeUPCRevocation); err != nil {
|
||||
t.Fatalf("resolve %s: %v", CodeUPCRevocation, err)
|
||||
}
|
||||
|
||||
// Seed a unique inf-only rule for this test so the assertion does
|
||||
// not couple to the live seed corpus.
|
||||
infRuleID := uuid.New()
|
||||
infRuleCode := "test.t237.infonly." + uuid.NewString()[:8]
|
||||
|
||||
cleanup := func() {
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.deadline_rules WHERE id = $1`, infRuleID)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.project_events WHERE project_id IN (
|
||||
SELECT id FROM paliad.projects WHERE counterclaim_of = $1)`, parentCaseID)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.project_teams WHERE project_id IN (
|
||||
SELECT id FROM paliad.projects WHERE counterclaim_of = $1)`, parentCaseID)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE counterclaim_of = $1`, parentCaseID)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.project_events WHERE project_id IN ($1, $2)`, parentCaseID, patentID)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.project_teams WHERE project_id IN ($1, $2)`, parentCaseID, patentID)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE id IN ($1, $2)`, parentCaseID, patentID)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.users WHERE id = $1`, userID)
|
||||
pool.ExecContext(ctx, `DELETE FROM auth.users WHERE id = $1`, userID)
|
||||
}
|
||||
cleanup()
|
||||
defer cleanup()
|
||||
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO auth.users (id, email) VALUES ($1, 'cross-anchor-test@hlc.com')`,
|
||||
userID); err != nil {
|
||||
t.Fatalf("seed auth.users: %v", err)
|
||||
}
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO paliad.users (id, email, display_name, office, global_role, lang)
|
||||
VALUES ($1, 'cross-anchor-test@hlc.com', 'Cross-Anchor Test', 'munich', 'global_admin', 'de')`,
|
||||
userID); err != nil {
|
||||
t.Fatalf("seed paliad.users: %v", err)
|
||||
}
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO paliad.projects (id, type, path, title, patent_number, status, created_by)
|
||||
VALUES ($1, 'patent', $1::text, 'EP-T237 — Test Patent', 'EP-T237', 'active', $2)`,
|
||||
patentID, userID); err != nil {
|
||||
t.Fatalf("seed patent: %v", err)
|
||||
}
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO paliad.project_teams (project_id, user_id, role, responsibility, inherited, added_by)
|
||||
VALUES ($1, $2, 'lead', 'lead', false, $2)`,
|
||||
patentID, userID); err != nil {
|
||||
t.Fatalf("seed patent team: %v", err)
|
||||
}
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO paliad.projects
|
||||
(id, type, parent_id, path, title, status, created_by,
|
||||
proceeding_type_id, our_side)
|
||||
VALUES ($1, 'case', $2, $2::text || '.' || $1::text,
|
||||
'UPC-CFI München — Klage (T237)', 'active', $3, $4, 'claimant')`,
|
||||
parentCaseID, patentID, userID, upcInf); err != nil {
|
||||
t.Fatalf("seed parent case: %v", err)
|
||||
}
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO paliad.project_teams (project_id, user_id, role, responsibility, inherited, added_by)
|
||||
VALUES ($1, $2, 'lead', 'lead', false, $2)`,
|
||||
parentCaseID, userID); err != nil {
|
||||
t.Fatalf("seed parent team: %v", err)
|
||||
}
|
||||
|
||||
// Inf-only rule. Lives under upc.inf.cfi (upcInf), NOT upc.rev.cfi.
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`SELECT set_config('paliad.audit_reason', $1, true)`,
|
||||
"t-paliad-237 cross-anchor test seed"); err != nil {
|
||||
t.Fatalf("set audit_reason: %v", err)
|
||||
}
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO paliad.deadline_rules
|
||||
(id, proceeding_type_id, submission_code, name, name_en,
|
||||
duration_value, duration_unit, timing, is_court_set, is_spawn,
|
||||
sequence_order, is_active, priority,
|
||||
lifecycle_state, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, 'Klageschrift (T237)', 'Statement of Claim (T237)',
|
||||
0, 'days', 'after', false, false,
|
||||
0, true, 'mandatory', 'published', now(), now())`,
|
||||
infRuleID, upcInf, infRuleCode); err != nil {
|
||||
t.Fatalf("seed inf rule: %v", err)
|
||||
}
|
||||
|
||||
users := NewUserService(pool)
|
||||
projects := NewProjectService(pool, users)
|
||||
eventTypes := NewEventTypeService(pool, users)
|
||||
deadlines := NewDeadlineService(pool, projects, eventTypes)
|
||||
appointments := NewAppointmentService(pool, projects)
|
||||
rules := NewDeadlineRuleService(pool)
|
||||
holidays := NewHolidayService(pool)
|
||||
courts := NewCourtService(pool)
|
||||
fristen := NewFristenrechnerService(rules, holidays, courts)
|
||||
projection := NewProjectionService(pool, projects, deadlines, appointments, fristen, rules)
|
||||
|
||||
// Create the CCR child. Default proceeding_type → upc.rev.cfi.
|
||||
child, err := projects.CreateCounterclaim(ctx, userID, parentCaseID, CounterclaimOpts{})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateCounterclaim: %v", err)
|
||||
}
|
||||
if child.ProceedingTypeID == nil || *child.ProceedingTypeID != upcRev {
|
||||
t.Fatalf("CCR child proceeding_type_id = %v, want upcRev (%d)", child.ProceedingTypeID, upcRev)
|
||||
}
|
||||
|
||||
// Anchor attempt #1: rule lives in the parent's proceeding type, not
|
||||
// the CCR's. Must reject with CrossProceedingAnchorError.
|
||||
_, err = projection.RecordAnchor(ctx, userID, child.ID, AnchorInput{
|
||||
RuleCode: infRuleCode,
|
||||
ActualDate: time.Date(2026, 5, 22, 0, 0, 0, 0, time.UTC),
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected cross-proceeding rejection, got nil")
|
||||
}
|
||||
cpe, ok := IsCrossProceedingAnchor(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected CrossProceedingAnchorError, got %T: %v", err, err)
|
||||
}
|
||||
if cpe.RequestedRuleCode != infRuleCode {
|
||||
t.Errorf("RequestedRuleCode = %q, want %q", cpe.RequestedRuleCode, infRuleCode)
|
||||
}
|
||||
if cpe.ParentProjectID != parentCaseID {
|
||||
t.Errorf("ParentProjectID = %v, want %v", cpe.ParentProjectID, parentCaseID)
|
||||
}
|
||||
if cpe.RequestedRuleNameDE != "Klageschrift (T237)" {
|
||||
t.Errorf("RequestedRuleNameDE = %q", cpe.RequestedRuleNameDE)
|
||||
}
|
||||
if cpe.RequestedRuleNameEN != "Statement of Claim (T237)" {
|
||||
t.Errorf("RequestedRuleNameEN = %q", cpe.RequestedRuleNameEN)
|
||||
}
|
||||
|
||||
// Anchor attempt #2: same rule code, anchored on the PARENT inf
|
||||
// project. Must succeed — the legitimate happy path stays intact.
|
||||
res, err := projection.RecordAnchor(ctx, userID, parentCaseID, AnchorInput{
|
||||
RuleCode: infRuleCode,
|
||||
ActualDate: time.Date(2026, 5, 22, 0, 0, 0, 0, time.UTC),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("anchor on parent inf project failed: %v", err)
|
||||
}
|
||||
if res == nil || res.DeadlineID == nil {
|
||||
t.Fatalf("expected DeadlineID set on anchor result, got %+v", res)
|
||||
}
|
||||
|
||||
// Anchor attempt #3: rule code that exists in NEITHER project — must
|
||||
// still return the legacy "unknown submission_code" error, not the
|
||||
// cross-proceeding error.
|
||||
_, err = projection.RecordAnchor(ctx, userID, child.ID, AnchorInput{
|
||||
RuleCode: "test.t237.nonexistent." + uuid.NewString()[:8],
|
||||
ActualDate: time.Date(2026, 5, 22, 0, 0, 0, 0, time.UTC),
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown rule, got nil")
|
||||
}
|
||||
if _, ok := IsCrossProceedingAnchor(err); ok {
|
||||
t.Errorf("unknown rule should NOT surface as cross-proceeding error: %v", err)
|
||||
}
|
||||
if !errors.Is(err, ErrInvalidInput) {
|
||||
t.Errorf("unknown rule should wrap ErrInvalidInput, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -181,6 +181,42 @@ func TestRuleNameInLang(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossProceedingAnchorError(t *testing.T) {
|
||||
parentID := uuid.New()
|
||||
cpe := &CrossProceedingAnchorError{
|
||||
RequestedRuleCode: "upc.inf.cfi.soc",
|
||||
RequestedRuleNameDE: "Klageschrift",
|
||||
RequestedRuleNameEN: "Statement of Claim",
|
||||
ParentProjectID: parentID,
|
||||
ParentProjectTitle: "UPC-CFI München — Klage",
|
||||
}
|
||||
got, ok := IsCrossProceedingAnchor(cpe)
|
||||
if !ok {
|
||||
t.Fatal("IsCrossProceedingAnchor on direct error should return ok")
|
||||
}
|
||||
if got != cpe {
|
||||
t.Errorf("unwrapped pointer mismatch")
|
||||
}
|
||||
wrapped := wrap(cpe, "context")
|
||||
got2, ok2 := IsCrossProceedingAnchor(wrapped)
|
||||
if !ok2 {
|
||||
t.Fatal("IsCrossProceedingAnchor on wrapped error should return ok")
|
||||
}
|
||||
if got2 != cpe {
|
||||
t.Errorf("unwrapped wrapped pointer mismatch")
|
||||
}
|
||||
if _, ok := IsCrossProceedingAnchor(errOther{}); ok {
|
||||
t.Error("non-CPE should not unwrap as CPE")
|
||||
}
|
||||
// And the inverse: a PredecessorMissingError must NOT match the
|
||||
// cross-proceeding helper (the two coexist on the 409 response and a
|
||||
// mistaken unwrap would render the wrong UI).
|
||||
pme := &PredecessorMissingError{RequestedRuleCode: "x"}
|
||||
if _, ok := IsCrossProceedingAnchor(pme); ok {
|
||||
t.Error("PredecessorMissingError must not unwrap as CrossProceedingAnchorError")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPredecessorMissingError(t *testing.T) {
|
||||
pme := &PredecessorMissingError{
|
||||
MissingRuleCode: "upc.inf.cfi.soc",
|
||||
|
||||
@@ -1544,6 +1544,35 @@ func IsPredecessorMissing(err error) (*PredecessorMissingError, bool) {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// CrossProceedingAnchorError is returned when a user tries to anchor a
|
||||
// rule on a CCR sub-project, but the rule belongs to the parent
|
||||
// infringement project's proceeding type (t-paliad-237). The
|
||||
// SmartTimeline on a CCR project surfaces the parent's track in the
|
||||
// parent_context lane — clicking "Datum setzen" on a parent-track rule
|
||||
// would silently corrupt the inf project's actuals if written onto the
|
||||
// CCR. Reject with a clear pointer to the parent project.
|
||||
type CrossProceedingAnchorError struct {
|
||||
RequestedRuleCode string
|
||||
RequestedRuleNameDE string
|
||||
RequestedRuleNameEN string
|
||||
ParentProjectID uuid.UUID
|
||||
ParentProjectTitle string
|
||||
}
|
||||
|
||||
func (e *CrossProceedingAnchorError) Error() string {
|
||||
return fmt.Sprintf("rule %q belongs to parent project %s, not this CCR",
|
||||
e.RequestedRuleCode, e.ParentProjectID)
|
||||
}
|
||||
|
||||
// IsCrossProceedingAnchor unwraps a CrossProceedingAnchorError if present.
|
||||
func IsCrossProceedingAnchor(err error) (*CrossProceedingAnchorError, bool) {
|
||||
var cpe *CrossProceedingAnchorError
|
||||
if errors.As(err, &cpe) {
|
||||
return cpe, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// RecordAnchor writes (or PATCHes) the actual occurrence of a rule for
|
||||
// the given project. Implements the §6 click-to-anchor + #31 layer 3
|
||||
// sequence guard:
|
||||
@@ -1579,6 +1608,31 @@ func (s *ProjectionService) RecordAnchor(ctx context.Context, userID, projectID
|
||||
}
|
||||
|
||||
rule, err := s.lookupRuleBySubmissionCode(ctx, *proj.ProceedingTypeID, in.RuleCode)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
// Cross-proceeding fallback (t-paliad-237). On a CCR project,
|
||||
// the SmartTimeline renders the parent infringement project's
|
||||
// rules in the parent_context lane. The user can click "Datum
|
||||
// setzen" on those rows; writing the anchor onto the CCR
|
||||
// would corrupt the inf project's actuals. Detect this and
|
||||
// reject with a pointer to the parent project so the frontend
|
||||
// can guide the user to anchor there instead.
|
||||
if proj.CounterclaimOf != nil {
|
||||
parent, perr := s.projects.GetByID(ctx, userID, *proj.CounterclaimOf)
|
||||
if perr == nil && parent != nil && parent.ProceedingTypeID != nil {
|
||||
parentRule, plookErr := s.lookupRuleBySubmissionCode(ctx, *parent.ProceedingTypeID, in.RuleCode)
|
||||
if plookErr == nil && parentRule != nil {
|
||||
return nil, &CrossProceedingAnchorError{
|
||||
RequestedRuleCode: in.RuleCode,
|
||||
RequestedRuleNameDE: parentRule.Name,
|
||||
RequestedRuleNameEN: parentRule.NameEN,
|
||||
ParentProjectID: parent.ID,
|
||||
ParentProjectTitle: parent.Title,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("%w: unknown submission_code %q", ErrInvalidInput, in.RuleCode)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1663,7 +1717,9 @@ func (s *ProjectionService) RecordRuleSkipped(ctx context.Context, userID, proje
|
||||
}
|
||||
|
||||
// lookupRuleBySubmissionCode resolves (proceeding_type_id, submission_code)
|
||||
// → DeadlineRule.
|
||||
// → DeadlineRule. Returns sql.ErrNoRows when the rule is not present so
|
||||
// callers can implement cross-proceeding fallback logic (t-paliad-237);
|
||||
// other DB errors are wrapped.
|
||||
func (s *ProjectionService) lookupRuleBySubmissionCode(ctx context.Context, ptID int, code string) (*models.DeadlineRule, error) {
|
||||
var rule models.DeadlineRule
|
||||
err := s.db.GetContext(ctx, &rule,
|
||||
@@ -1672,7 +1728,7 @@ func (s *ProjectionService) lookupRuleBySubmissionCode(ctx context.Context, ptID
|
||||
WHERE proceeding_type_id = $1 AND submission_code = $2 AND is_active = true`,
|
||||
ptID, code)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, fmt.Errorf("%w: unknown submission_code %q", ErrInvalidInput, code)
|
||||
return nil, err
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("lookup rule by submission_code: %w", err)
|
||||
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user