Compare commits
95 Commits
mai/knuth/
...
mai/bohr/c
| Author | SHA1 | Date | |
|---|---|---|---|
| 1882468780 | |||
| c303c01652 | |||
| 97a2742f10 | |||
| b26360111a | |||
| e914bac79a | |||
| 713a4d4206 | |||
| cd3cd0230c | |||
| cd793b1d98 | |||
| a50ddc3927 | |||
| c639c5695c | |||
| a05ae1f2ae | |||
| 7fe37bb550 | |||
| 57310ab3a4 | |||
| b99b6d6fb5 | |||
| 5468a7259d | |||
| 230306518d | |||
| 6e56b9d51f | |||
| 375d631f1b | |||
| e3a604b4c4 | |||
| 0763b7daa2 | |||
| c4e3a74e35 | |||
| 9d234f275f | |||
| 83d5ed27e0 | |||
| 73f379d305 | |||
| 9a5ee93f2e | |||
| 213be10ada | |||
| 6dd9befba3 | |||
| e10b5e6546 | |||
| cd3f7843a7 | |||
| 4920328b09 | |||
| 385abc7a98 | |||
| 94adeeb8cb | |||
| d834b36313 | |||
| 4092c889c4 | |||
| db1040968f | |||
| f292338919 | |||
| 2b240e7dd0 | |||
| c945cbd330 | |||
| 639ff4f672 | |||
| 264cc39a6b | |||
| 28d860a07d | |||
| d913f4fc30 | |||
| e091716f48 | |||
| 8763ab013c | |||
| e1e8db7fc9 | |||
| b746ec36c7 | |||
| 28aaafeb05 | |||
| f9331e9bb9 | |||
| e53bcf8cc2 | |||
| 68fcbc6fbf | |||
| 31e15d4b20 | |||
| a111a82640 | |||
| 63a9bedf7e | |||
| b8709b903d | |||
| 938222d602 | |||
| 47deeaf5ed | |||
| a2da501917 | |||
| 8ea78fd376 | |||
| e189d3fe6a | |||
| 58907554fc | |||
| 9b8a865c5f | |||
| f8067c2fe5 | |||
| 78a30a7ee0 | |||
| 091804923a | |||
| 9201501941 | |||
| 05247d7bd7 | |||
| a81581878e | |||
| 8d8a882f46 | |||
| 9679a98666 | |||
| fcdfba209d | |||
| 3e93e94d10 | |||
| 28ea103260 | |||
| 1c77cb6e67 | |||
| 1f6e586c63 | |||
| a4b865d6bd | |||
| a905911cf4 | |||
| 88c03e922f | |||
| 6bcac2dd20 | |||
| 46dc4ec94b | |||
| 6c1d8cc0cf | |||
| 0c857026a2 | |||
| 3c840c0366 | |||
| 1b4b2e4758 | |||
| b78a984a7c | |||
| 1844df3ae6 | |||
| 0f3c30a647 | |||
| 2c2b93bc7c | |||
| 661d87273c | |||
| ed3c5d1f32 | |||
| be570c2fd0 | |||
| 58692513a8 | |||
| 702f786771 | |||
| 93c664c865 | |||
| 6506d7d862 | |||
| 2e6427dca6 |
@@ -174,6 +174,9 @@ func main() {
|
||||
submissionComposerSvc := services.NewSubmissionComposer(submissionRenderer)
|
||||
// t-paliad-315 Slice C — building-block library.
|
||||
submissionBuildingBlockSvc := services.NewBuildingBlockService(pool, branding.Name)
|
||||
// t-paliad-349 docforge slice 4/6 — uploaded-template store
|
||||
// (Postgres bytea) backing the authoring surface.
|
||||
templateStoreSvc := services.NewPgTemplateStore(pool)
|
||||
// t-paliad-225 Slice A — user-authored checklist templates.
|
||||
// Slice B adds checklist_shares grants + admin promotion.
|
||||
checklistCatalogSvc := services.NewChecklistCatalogService(pool)
|
||||
@@ -190,6 +193,7 @@ func main() {
|
||||
SubmissionSection: submissionSectionSvc,
|
||||
SubmissionComposer: submissionComposerSvc,
|
||||
SubmissionBuildingBlock: submissionBuildingBlockSvc,
|
||||
TemplateStore: templateStoreSvc,
|
||||
Deadline: deadlineSvc,
|
||||
Appointment: appointmentSvc,
|
||||
CalDAV: caldavSvc,
|
||||
@@ -233,6 +237,7 @@ func main() {
|
||||
CardLayout: services.NewCardLayoutService(pool),
|
||||
DashboardLayout: services.NewDashboardLayoutService(pool),
|
||||
FirmDashboardDefault: services.NewFirmDashboardDefaultService(pool),
|
||||
FirmNameComposition: services.NewFirmNameCompositionService(pool),
|
||||
Projection: services.NewProjectionService(pool, projectSvc, deadlineSvc, appointmentSvc, services.NewFristenrechnerService(rules, holidays, courts), rules),
|
||||
// t-paliad-214 Slice 1 — personal-scope data export. firm name
|
||||
// is captured into __meta of every export and printed in the
|
||||
@@ -246,6 +251,13 @@ func main() {
|
||||
// SSoT. Drives Verfahrensablauf + Mode B result-view conditional
|
||||
// rendering and per-rule selection state (`rule:<uuid>` keys).
|
||||
ScenarioFlags: services.NewScenarioFlagsService(pool, projectSvc),
|
||||
// t-paliad-340 / m/paliad#153 B0 (mig 157) — Litigation Builder.
|
||||
// CRUD over the new normalised scenarios + scenario_proceedings
|
||||
// + scenario_events + scenario_shares tables. B4 adds the
|
||||
// Akte-mode dual-write: project-backed scenarios write through
|
||||
// to paliad.projects.scenario_flags + paliad.deadlines via the
|
||||
// injected project + scenarioFlags services.
|
||||
ScenarioBuilder: services.NewScenarioBuilderService(pool, projectSvc, services.NewScenarioFlagsService(pool, projectSvc), services.NewFristenrechnerService(rules, holidays, courts)),
|
||||
}
|
||||
|
||||
// t-paliad-246 Slice A — Backup Mode runner. Wired only when
|
||||
|
||||
306
docs/design-rubrum-letterhead-autofill-2026-06-01.md
Normal file
306
docs/design-rubrum-letterhead-autofill-2026-06-01.md
Normal file
@@ -0,0 +1,306 @@
|
||||
# Rubrum + Briefkopf auto-fill — gap map (templates ↔ var bag ↔ data model)
|
||||
|
||||
**Task:** t-paliad-357 · **Author:** kepler (researcher) · **Date:** 2026-06-01
|
||||
**Status:** AUDIT + GAP-MAP ONLY. No code or template edits made. Head reviews
|
||||
before any wiring.
|
||||
|
||||
m's ask (2026-06-01 12:01): the **current** document templates should fill the
|
||||
**letterhead (Briefkopf)** and **recitals/Rubrum (case caption)** from project
|
||||
data on generation — "filled depending on project". This is the
|
||||
content-correctness layer, downstream of the (code-complete) docforge engine and
|
||||
parallel to leibniz's nomen naming train.
|
||||
|
||||
---
|
||||
|
||||
## TL;DR — the one fork for m
|
||||
|
||||
**The basic Rubrum is *already* wired and works today** (party names,
|
||||
representatives, role designations, case number, court, patent number — all
|
||||
data-driven, in both the demo per-code template and the Composer caption seed).
|
||||
**The letterhead is *not* data-driven at all** (the real HL letterhead is
|
||||
hardcoded inside the firm-skeleton's Word header/footer parts; `firm.signature_block`
|
||||
is empty). And the Rubrum we have is only a *basic* caption — a forum-correct one
|
||||
needs structured data paliad does not capture.
|
||||
|
||||
So the decision is **how complete a Rubrum we target**:
|
||||
|
||||
| | **Option A — wire what data already supports** | **Option B — forum-correct Rubrum** |
|
||||
|---|---|---|
|
||||
| Rubrum content | name · representative · role · case no. · court · patent | + structured address · Rechtsform · Sitz (registered office) · gesetzl. Vertreter · service addresses · court chamber/address |
|
||||
| Data model | **no new columns** — uses existing `parties.*` + `project.*` | **new structured fields** on `parties` (+ maybe `projects`) + capture UI |
|
||||
| Letterhead | tidy the existing path (firm.name/signature_block) | same as A (letterhead is orthogonal to the A/B choice) |
|
||||
| Effort | small — mostly template-seed wording + plug `firm.signature_block` | a proper feature — schema migration + party-form rework + Composer reseed |
|
||||
| Forum-correctness | a *workable* caption, not a *filing-correct* one | meets UPC RoP r.13 / ZPO §253 party-designation requirements |
|
||||
|
||||
Everything in Slice 1–2 below is Option A and is independent of the decision.
|
||||
Option B is Slice 3+ and is the part that needs m's go/no-go.
|
||||
|
||||
---
|
||||
|
||||
## 1. Architecture — there are THREE fill realities, not one
|
||||
|
||||
The audit's biggest correction to the starting mental model: "the templates" are
|
||||
not one thing, and the letterhead does **not** live where the Rubrum lives.
|
||||
|
||||
### Path 1 — legacy one-click `/generate` → `merge.go` (`SubmissionRenderer.Render`)
|
||||
- Handler `submissions.go:316` → `resolveSubmissionTemplate` → `RenderProjectSubmission`
|
||||
→ `renderer.Render` (`pkg/docforge/docx/merge.go`).
|
||||
- **Substitutes `{{key}}` tokens in `word/document.xml` *and* in `word/header*.xml`
|
||||
/ `word/footer*.xml`** (`isWordXMLEntry`, merge.go:189). So this path *can* fill a
|
||||
letterhead in a Word header — **if the header contains `{{placeholders}}`. None
|
||||
of the shipped headers do** (see §2).
|
||||
- Template chosen by a 6-tier fallback (`submission_drafts.go:1341`): per-(code,lang)
|
||||
→ per-code → EN-skeleton → firm-skeleton → universal-skeleton → HL-Patents-Style.
|
||||
|
||||
### Path 2 — Composer → `compose.go` (`Composer.Compose`)
|
||||
- Draft editor with a `base_id` set (t-paliad-313/315/317). Handler
|
||||
`submission_drafts.go:712` → `submissionComposer.Compose`.
|
||||
- Assembles `word/document.xml` from the draft's **`paliad.submission_sections`
|
||||
rows** (one per section: letterhead, caption, …), splicing each into the
|
||||
carrier's `{{#section:KEY}}` anchor, then substitutes `{{placeholder}}` inside the
|
||||
section bodies.
|
||||
- **Headers/footers pass through byte-for-byte UNTOUCHED** (compose.go:68, :188).
|
||||
So a Composer doc keeps the base .docx's letterhead chrome verbatim — it is
|
||||
never data-driven on this path.
|
||||
- Section bodies are seeded on draft-create from the base's
|
||||
`section_spec.defaults[*].seed_md_{de,en}` (migrations 146 / 150).
|
||||
|
||||
### Path 3 — skeleton as a direct merge fallback (a latent bug)
|
||||
- For any submission_code **without** a per-code template, `/generate` (Path 1)
|
||||
falls through to tiers 4/5 and renders the **firm/universal skeleton through
|
||||
merge.go**. But those skeletons contain only `{{#section:letterhead}}`-style
|
||||
*block markers*, which `placeholderRegex` (`[A-Za-z]…`) does **not** match (they
|
||||
start with `#`). **Result: the output Word doc shows literal
|
||||
`{{#section:letterhead}}` … text.** Only `de.inf.lg.erwidg` has a real per-code
|
||||
template today, so every other code's one-click `/generate` is exposed to this.
|
||||
⚠️ **Flag to verify with head** — may be masked if `/generate` is only surfaced
|
||||
for codes that have a per-code template.
|
||||
|
||||
> **Implication for m's ask:** "fill the letterhead from project data" means
|
||||
> different work on each path. On Path 1 it means *putting `{{firm.*}}` placeholders
|
||||
> into a header part*. On Path 2 it means *the letterhead is a body section already*
|
||||
> (and the chrome stays hardcoded in the base). These should be reconciled, not
|
||||
> both wired blindly — see Slice 2.
|
||||
|
||||
---
|
||||
|
||||
## 2. Gap table — TEMPLATE side
|
||||
|
||||
Fetched live from mgit (`m/mWorkRepo`, `6 - material/Templates/Word/…`), unzipped,
|
||||
inspected `document.xml` + every `header*.xml`/`footer*.xml`.
|
||||
|
||||
| Template | Has header/footer? | Letterhead | Rubrum / caption | Verdict |
|
||||
|---|---|---|---|---|
|
||||
| **`HLC/de.inf.lg.erwidg.docx`** (per-code, the only wired code) | no | *pseudo*-letterhead inline in body: `{{firm.name}} — Patentstreitsachen`, Bearbeiter `{{user.display_name}}`, `{{user.email}}`, `{{user.office}}`, `{{today.long_de}}`. `{{firm.signature_block}}` in closing (renders empty). | **full inline Rubrum, all data-driven**: `{{parties.claimant.name}}` / `.representative`, `— Klägerin —`, `gegen`, `{{parties.defendant.name}}` / `.representative`, `— Beklagte —`, `Weitere Beteiligte: {{parties.other.name}}`, `{{project.court}}`, `Aktenzeichen: {{project.case_number}}`, `{{project.patent_number}}`. | Works — but body-banner is **labelled "DEMO — interne Vorlage (nicht freigegeben)"**, not a real letterhead. |
|
||||
| **`HLC/_firm-skeleton.docx`** (Composer base `hlc-letterhead`) | **yes** — header1/2, footer1/2 | **Real HL letterhead, fully HARDCODED**: footer firm name is a Word SDT content-control literal "Hogan Lovells"; footer2 = static HL entity boilerplate (registered office, 50+ office cities); header2 = logo image only. **Zero `{{placeholders}}` in any header/footer.** | body `document.xml` has only `{{#section:KEY}}` markers (empty). Caption content comes from the section seed (§Composer). | Letterhead present but **not data-driven & not firm-agnostic** (contradicts `branding.Name` goal). |
|
||||
| **`HLC/_skeleton.docx`** (Composer base `neutral`) | no | none | `{{#section:KEY}}` markers only | Composer scaffold; unusable via merge.go (Path 3 bug). |
|
||||
| **`Composer/lg-duesseldorf.docx`** (base `lg-duesseldorf`, de.inf.lg) | no | none | `{{#section:KEY}}` markers only | Composer scaffold; letterhead must come from a header it doesn't have, or the body section. |
|
||||
| **`Composer/upc-formal.docx`** (base `upc-formal`, upc.inf.cfi) | no | none | `{{#section:KEY}}` markers only | same. |
|
||||
| **`HL Patents Style.dotm`** (last-ditch tier 6) | yes (same HL header/footer as firm-skeleton) | hardcoded HL letterhead | no placeholders | letterhead-only fallback. |
|
||||
| `HLC/_skeleton.en.docx` | **404 — does not exist** | — | — | EN drafts silently fall back to the DE skeleton (matches code comment at files.go:104). |
|
||||
|
||||
**Template-side takeaways**
|
||||
1. The Rubrum is template-complete on the demo per-code path and is a DB seed (not
|
||||
a template file) on the Composer path.
|
||||
2. The real letterhead exists only in the firm-skeleton/`.dotm` headers and is
|
||||
**100% hardcoded** — no placeholder, no `branding.Name`. A firm rename or a
|
||||
non-HLC deployment ships the wrong letterhead.
|
||||
3. The Composer caption/letterhead are **DB seeds (migrations 146/150)**, so
|
||||
"adjusting the template" for the Composer path means editing the
|
||||
`section_spec` seed Markdown, *not* the .docx.
|
||||
|
||||
---
|
||||
|
||||
## 3. Gap table — VAR-BAG side
|
||||
|
||||
For every placeholder a correct letterhead + Rubrum needs, is there a bag key?
|
||||
Bag built in `internal/services/submission_vars.go`.
|
||||
|
||||
| Need (letterhead + Rubrum) | Bag key | Status |
|
||||
|---|---|---|
|
||||
| Firm name | `firm.name` (← `branding.Name`) | ✅ wired |
|
||||
| Firm signature block | `firm.signature_block` | ⚠️ **key exists but emits `""`** (reserved "Phase 2", submission_vars.go:324). Template references it → renders blank. |
|
||||
| Author name / email / office | `user.display_name` / `.email` / `.office` | ✅ wired |
|
||||
| Date (today, long DE/EN, ISO) | `today` / `.long_de` / `.long_en` / `.iso` | ✅ wired |
|
||||
| Claimant name / rep (first + indexed + joined) | `parties.claimant.name`, `parties.claimant.0.name` / `.representative`, `parties.claimants` / `.representatives` | ✅ wired (3 forms, addPartyVars) |
|
||||
| Defendant name / rep | `parties.defendant.*` (same 3 forms) | ✅ wired |
|
||||
| Other parties (Streithelfer, Patentinhaberin…) | `parties.other.*` / `parties.others` | ✅ wired |
|
||||
| Case number | `project.case_number` | ✅ wired |
|
||||
| Court (name) | `project.court` | ✅ wired (free-text string) |
|
||||
| Patent number (DE + UPC forms) | `project.patent_number` / `.patent_number_upc` | ✅ wired |
|
||||
| Proceeding type / instance | `project.proceeding.name(_de/_en/.code)`, `project.instance_level` | ✅ wired |
|
||||
| Our side (DE/EN prose) | `project.our_side_de` / `_en` / raw | ✅ wired |
|
||||
| Client / matter / internal ref | `project.client_number` / `.matter_number` / `.reference` | ✅ wired |
|
||||
| **Party postal address** | — | ❌ **NO key** (needs data model) |
|
||||
| **Party legal form (Rechtsform)** | — | ❌ **NO key** |
|
||||
| **Party registered office / Sitz** | — | ❌ **NO key** (UPC r.13.1(a)/(b)) |
|
||||
| **Statutory representative (gesetzl. Vertreter, e.g. Geschäftsführer)** | — | ❌ **NO key** |
|
||||
| **Address/person for service (Zustellungsbevollmächtigter)** | — | ❌ **NO key** (UPC r.13.1(c)/(d)) |
|
||||
| **Court full address / chamber / Spruchkörper** | — | ❌ **NO key** (only the court *name* string exists) |
|
||||
| **Firm letterhead address / contact block** | — | ❌ **NO key** (hardcoded in .docx header) |
|
||||
|
||||
**Var-bag takeaways:** every placeholder the *current* templates use is wired,
|
||||
with one dud: **`firm.signature_block` always renders empty** — the single cheapest
|
||||
letterhead/closing win. Everything a *forum-correct* Rubrum additionally needs has
|
||||
**no key, because the data isn't captured** (§4).
|
||||
|
||||
---
|
||||
|
||||
## 4. Gap table — DATA-MODEL side
|
||||
|
||||
`models.Party` (models.go:539) carries **only**: `Name`, `Role`, `Representative`,
|
||||
`ContactInfo json.RawMessage`. `models.Project` carries `Court *string` (free text),
|
||||
`CaseNumber`, `PatentNumber`, dates, `OurSide`, `InstanceLevel`, client/matter.
|
||||
|
||||
- **`parties.contact_info` is a dormant jsonb column**: `PartyService.Create`
|
||||
defaults it to `{}` and **no UI ever writes it** (party form captures only
|
||||
Name / Role / Representative — `frontend/src/projects-detail.tsx:436–460`). It is
|
||||
a ready-made parking spot, but it is structurally empty today.
|
||||
- **No court registry / court-address table exists.** `project.court` is a plain
|
||||
string a user types.
|
||||
|
||||
| Forum-correct Rubrum needs | Derivable from existing fields? | Park in `contact_info` jsonb? | Needs new column + capture UI? | Cost |
|
||||
|---|---|---|---|---|
|
||||
| Party **postal address** | ❌ | ✅ feasible (`{address:{street,zip,city,country}}`) | UI: add fields to party form | **Low–Med** — jsonb, no migration; party-form + bag resolver |
|
||||
| Party **Rechtsform** (GmbH, LLP…) | ❌ (sometimes inside Name string, unreliable) | ✅ | UI field | **Low** |
|
||||
| Party **Sitz / registered office** (UPC r.13.1(a/b)) | ❌ | ✅ | UI field | **Low–Med** |
|
||||
| Party **statutory representative** (Geschäftsführer / vertreten durch …) | ⚠️ partial — `Representative` today means the *lawyer/Prozessbevollmächtigter*, not the *organ*; conflating them is wrong | ✅ (`{statutory_rep:…}`) | UI field + relabel existing `representative` | **Med** — semantic untangle |
|
||||
| **Address for service / Zustellungsbevollmächtigter** (UPC r.13.1(c/d)) | ❌ | ✅ | UI field | **Low–Med** |
|
||||
| **Court full address** | ❌ | n/a (project-level) | new `projects.court_address` col **or** a courts lookup table | **Med** (col) / **High** (registry) |
|
||||
| **Court chamber / Spruchkörper / panel** | ❌ | n/a | new `projects.court_chamber` col | **Low–Med** |
|
||||
| Firm letterhead address block | ❌ | n/a | `branding`-level config (env or table) | **Med** — touches firm-agnostic story |
|
||||
|
||||
**Recommendation on storage:** structured party attributes belong in **typed jsonb
|
||||
under `contact_info`** with a small Go struct (`models.PartyContact`) decoding it —
|
||||
not a column-per-attribute migration. It keeps the party table stable, is
|
||||
forum-shape-agnostic, and the bag resolver can emit `parties.claimant.0.address`,
|
||||
`.sitz`, `.rechtsform`, etc. Court chamber/address are project-level and small
|
||||
enough for two nullable columns; a full court **registry** is a separate, larger
|
||||
feature (nice for autofill + validation, not required for a correct caption).
|
||||
|
||||
---
|
||||
|
||||
## 5. Forum-dependence — does one parametric Rubrum cover UPC / LG / OLG / BPatG?
|
||||
|
||||
Grounded sources: **UPC RoP Rule 13** ("Contents of the Statement of claim") pulled
|
||||
verbatim from the house laws corpus (`data.laws`, `UPCRoP.013.*`). German ZPO/PatG
|
||||
caption conventions below are **standard German civil-procedure practice — these are
|
||||
NOT in the youpc corpus** (which is UPC/EPC-only), so they are flagged as
|
||||
practitioner-convention, to be confirmed by a DE-litigation reviewer (lexy) before
|
||||
wording is finalised.
|
||||
|
||||
**What UPC RoP r.13.1 demands (verified):**
|
||||
- (a) claimant name; if corporate, **location of registered office**; + claimant's representative
|
||||
- (b) defendant name; if corporate, **location of registered office**
|
||||
- (c) **postal + electronic addresses for service** on claimant + persons authorised to accept service
|
||||
- (d) postal/electronic service addresses on defendant + persons authorised, if known
|
||||
- (e) proprietor service addresses where claimant ≠ (sole) proprietor
|
||||
- (g) details of the patent including the **number**
|
||||
- (k) nature of the claim / order / remedy sought
|
||||
|
||||
→ paliad today supplies only **name** (a/b) and **patent number** (g). It captures
|
||||
**none** of: registered office/Sitz, postal/electronic service address, persons
|
||||
authorised. So a *filing-correct* UPC caption is firmly **Option B** territory.
|
||||
|
||||
**How the caption shape differs across forums (convention):**
|
||||
|
||||
| Forum | Heading | Party designations | "wegen" / subject | Court line |
|
||||
|---|---|---|---|---|
|
||||
| **DE LG** (Patentstreitkammer) | "In dem Rechtsstreit" / "In der Patentstreitsache" | Kläger(in) / Beklagte(r); parties need **Name, Anschrift, Rechtsform, ges. Vertreter** (ZPO §253 Abs. 2 Nr. 1, §130 Nr. 1 — *convention*) | "**wegen** Patentverletzung" | "an das Landgericht … , … Kammer" — court **name + chamber** |
|
||||
| **DE OLG** (Berufung) | "In dem Rechtsstreit" | **Berufungskläger / Berufungsbeklagte** (roles flip vs. first instance) | "wegen …" | "an das Oberlandesgericht …, … Senat" |
|
||||
| **BPatG** (Nichtigkeit/Beschwerde) | "In der Patentnichtigkeitssache" / "Beschwerdesache" | **Kläger/Beklagte** (nullity) or **Anmelder/Einsprechende**; patent-centric | patent + nullity ground | "an das Bundespatentgericht, … Senat" |
|
||||
| **UPC CFI** | "In the matter / In der Sache" | **Claimant / Defendant (Kläger/Beklagte)**; name + **registered office** + service address (r.13) | claim nature (r.13.1(k)) | division + "Aktenzeichen" (UPC case-number format `ACT_xxxxx/2026`) |
|
||||
|
||||
**Answer:** one *parametric* Rubrum block covers the **basic** caption across forums
|
||||
(swap designation labels + heading + court line from `our_side`/`instance_level`/
|
||||
`proceeding.code` — values the bag already has). It does **not** cover the
|
||||
forum-specific *content requirements* (UPC service addresses vs. ZPO Anschrift/
|
||||
Rechtsform vs. BPatG patent-centric framing). For Option B, the cleanest design is
|
||||
**one caption section whose seed Markdown is chosen per `proceeding_family`** (the
|
||||
Composer already keys bases by `proceeding_family` — `de.inf.lg`, `upc.inf.cfi`),
|
||||
i.e. **forum-specific caption seeds, shared resolver keys** — not a single
|
||||
universal block, and not N hand-maintained .docx files.
|
||||
|
||||
---
|
||||
|
||||
## 6. Sliced wiring proposal (tracer-bullet first)
|
||||
|
||||
Ordered so each slice ships value alone; the A/B fork only bites at Slice 3.
|
||||
|
||||
**Slice 1 — plug the empty letterhead key (pure win, no schema, no fork).**
|
||||
- Fill `firm.signature_block` in `addFirmVars` from `branding` (firm name + office /
|
||||
a configured block) instead of hardcoding `""`. Today every template that
|
||||
references it renders blank.
|
||||
- Decide letterhead source of truth: either (a) inject `{{firm.name}}` /
|
||||
`{{firm.address}}` placeholders into the firm-skeleton **header** parts (Path 1
|
||||
fills them; Composer leaves them — acceptable since chrome is firm-fixed), or
|
||||
(b) keep chrome hardcoded but make it firm-agnostic via `branding`. **Recommend
|
||||
(a)** so a firm rename / non-HLC deploy doesn't ship "Hogan Lovells".
|
||||
- Template edits: firm-skeleton `header1/footer1` get `{{firm.*}}` tokens. (mWorkRepo,
|
||||
authored as mAi — not this repo.)
|
||||
|
||||
**Slice 2 — reconcile the letterhead duplication + kill the Path-3 junk.**
|
||||
- The Composer seeds a body "letterhead" section *and* the base has a header
|
||||
letterhead → a Composer doc can show both. Decide: drop the body letterhead
|
||||
section for letterhead-bearing bases, or keep it only for `neutral`.
|
||||
- Fix Path 3: either give the universal/firm skeleton a **merge-safe** variant
|
||||
(real `{{key}}` Rubrum like the demo template) for non-Composer `/generate`, or
|
||||
gate `/generate` to codes that have a per-code template. (Verify with head which
|
||||
codes expose `/generate`.)
|
||||
|
||||
**Slice 3 — Option A "good basic Rubrum" (no new data).**
|
||||
- Promote the demo per-code Rubrum wording into a **published, forum-labelled
|
||||
caption** and align the Composer caption seeds (146/150) to the same wording.
|
||||
Parametrise designation labels + heading + "wegen" + court line off
|
||||
`our_side` / `instance_level` / `proceeding.code`. **No migration.**
|
||||
- This is the natural stopping point if m picks **A**.
|
||||
|
||||
**Slice 4 — Option B data model (the feature; needs m's go).**
|
||||
- Add `models.PartyContact` decoding typed `contact_info` jsonb:
|
||||
`{address, rechtsform, sitz, statutory_rep, service_address, service_agent}`.
|
||||
- Extend the party form (`projects-detail.tsx`) with those inputs; `PartyService`
|
||||
writes them.
|
||||
- Add `projects.court_address` + `projects.court_chamber` (nullable cols).
|
||||
- New bag keys in `addPartyVars` / `addProjectVars`:
|
||||
`parties.<role>.<i>.address|sitz|rechtsform|statutory_rep|service_address`,
|
||||
`project.court_address|court_chamber`.
|
||||
|
||||
**Slice 5 — Option B forum-correct caption seeds.**
|
||||
- Per-`proceeding_family` caption seed Markdown (UPC r.13 shape, DE-LG ZPO shape,
|
||||
OLG appeal-role shape, BPatG nullity shape), consuming the Slice-4 keys.
|
||||
- Reviewer (lexy) signs off DE conventions before publish.
|
||||
|
||||
**Slice 6 (optional) — court registry** for autofill/validation of court
|
||||
name+address+chamber. Larger; not required for a correct caption.
|
||||
|
||||
---
|
||||
|
||||
## 7. Key files (for the wiring worker)
|
||||
|
||||
- Var bag: `internal/services/submission_vars.go` (addFirmVars:319, addPartyVars:412,
|
||||
addProjectVars:349).
|
||||
- Render (Path 1, fills headers): `pkg/docforge/docx/merge.go` (isWordXMLEntry:189).
|
||||
- Compose (Path 2, headers pass-through): `pkg/docforge/docx/compose.go` (:68,:188);
|
||||
`internal/services/submission_compose.go`.
|
||||
- Template resolution: `internal/handlers/submission_drafts.go:1341`
|
||||
(`resolveSubmissionTemplate`, 6 tiers); paths in `internal/handlers/files.go`.
|
||||
- Composer base seeds (caption/letterhead Markdown): migrations
|
||||
`internal/db/migrations/146_submission_bases.up.sql`,
|
||||
`150_submission_bases_specialist.up.sql`.
|
||||
- Data model: `internal/models/models.go` (Party:539, Project:80);
|
||||
party form `frontend/src/projects-detail.tsx:436`.
|
||||
- Live templates: `m/mWorkRepo` `6 - material/Templates/Word/Paliad/{HLC,Composer}/`.
|
||||
|
||||
---
|
||||
|
||||
## 8. Open questions for m / head
|
||||
|
||||
1. **A or B?** (the §TL;DR fork). A = ship a good basic caption now, no data work.
|
||||
B = capture structured party/court data for a filing-correct Rubrum.
|
||||
2. **Letterhead source of truth:** placeholderise the firm-skeleton header (firm-agnostic)
|
||||
vs. keep hardcoded HL chrome? (Slice 1 recommends placeholderise.)
|
||||
3. **Path-3 junk:** is one-click `/generate` exposed for codes lacking a per-code
|
||||
template? If yes, the literal `{{#section:…}}` output is a live bug.
|
||||
4. **`representative` semantics:** today it's the lawyer (Prozessbevollmächtigter).
|
||||
A forum Rubrum also needs the party's *statutory* representative (Geschäftsführer).
|
||||
Keep them as two distinct fields under Option B.
|
||||
495
docs/plans/prd-docforge-2026-05-29.md
Normal file
495
docs/plans/prd-docforge-2026-05-29.md
Normal file
@@ -0,0 +1,495 @@
|
||||
# PRD — `docforge`: a modular document-generator engine
|
||||
|
||||
**Task:** t-paliad-349 (m/paliad#157) · **Author:** leibniz (inventor) · **Date:** 2026-05-29
|
||||
**Status:** DESIGN — awaiting head's go/no-go on the coder shift.
|
||||
**Supersedes nothing.** Extends and re-homes the submission generator designed in
|
||||
`docs/design-submission-generator-2026-05-19.md`, `…-v2-2026-05-26.md`, and
|
||||
`docs/design-submission-page-2026-05-22.md`.
|
||||
|
||||
---
|
||||
|
||||
## §0 Premises
|
||||
|
||||
### 0.1 What this is
|
||||
|
||||
m wants the paliad "doc generator" pulled apart into a clean, reusable engine.
|
||||
Verbatim direction (2026-05-29):
|
||||
|
||||
> I want to be able to create and modify word documents, using variables inside
|
||||
> the documents, "editing them live" and preview the results, export in the end.
|
||||
> We should have all that modular to keep it clean. The editor is something else
|
||||
> than the importing, exporting, variable exchange, data fetching etc.
|
||||
>
|
||||
> Currently I can't upload the base document to insert variables into to create a
|
||||
> template — and then later I want to fill the template using data, modifying it
|
||||
> manually where necessary, then exporting.
|
||||
|
||||
Two distinct user surfaces fall out of that:
|
||||
|
||||
- **Authoring** — upload a base `.docx` → place variable slots into it → save as a
|
||||
reusable template. *This is the gap that does not exist today.*
|
||||
- **Generation** — pick a template → bind variables to project data → manually edit
|
||||
where needed (live editor + preview) → export `.docx`.
|
||||
|
||||
### 0.2 Today's state (audited 2026-05-29, verified against the live tree)
|
||||
|
||||
The current submission generator is ~250 KB of Go plus a 115 KB editor bundle:
|
||||
|
||||
- `internal/services/submission_vars.go` — variable resolution across **7 namespaces**
|
||||
(`firm.*`, `today.*`, `user.*`, `project.*`, `parties.*`, `procedural_event.*`
|
||||
+ `rule.*` legacy aliases, `deadline.*`). Resolution is a **push** model: each
|
||||
namespace is a hardcoded `addXxxVars(bag PlaceholderMap, …)` function mutating a
|
||||
shared `map[string]string`. There is **no interface and no registry** — adding a
|
||||
namespace means hand-editing `Build` to call a new function.
|
||||
- `internal/services/submission_merge.go` — placeholder substitution. The regex
|
||||
(line 95, verified) is `\{\{\s*([A-Za-z][A-Za-z0-9_.]*)\s*\}\}`.
|
||||
Two-pass: single-run replace inside each `<w:t>`, then
|
||||
cross-run merge for fragmented placeholders. HTML preview wraps `(key,value)` in
|
||||
Private-Use-Area sentinels so `emitTextWithDraftVars` can reconstruct
|
||||
`<span class="draft-var" data-var="key">…</span>` for click-to-jump.
|
||||
- `internal/services/submission_md.go` — Markdown → OOXML runs. `parseInlineSpans`
|
||||
(lines 393–446) tokenises bold/italic and **preserves `{{…}}` verbatim**.
|
||||
- `internal/services/submission_compose.go` — assembles the final `.docx`: unzip base,
|
||||
render each included section's Markdown to OOXML, splice between
|
||||
`{{#section:KEY}}…{{/section:KEY}}` anchors, patch hyperlink rels, repack, then run
|
||||
the placeholder pass.
|
||||
- `internal/services/submission_{draft,section,building_block,base}_service.go` — the
|
||||
draft/section/building-block/base data model + CRUD.
|
||||
- `internal/handlers/submission_{drafts,sections,building_blocks,bases}.go` — the HTTP
|
||||
wire (the 53 KB `submission_drafts.go` is the bulk).
|
||||
- `frontend/src/client/submission-draft.ts` — the editor UI (**one `.ts` bundle; there is
|
||||
no `submission-draft.tsx`** — the brief was wrong on this point).
|
||||
|
||||
**OOXML approach (verified):** pure `archive/zip` + string manipulation of
|
||||
`word/document.xml`. **No third-party docx library** — `go.mod` has none.
|
||||
`lukasjarosch/go-docx` appears *only in a comment* (`submission_merge.go:13`)
|
||||
documenting why it was rejected (it refuses sibling placeholders in one run). The base
|
||||
stays byte-for-byte identical outside the regions we touch.
|
||||
|
||||
**Reference model:** `pkg/litigationplanner/` (t-paliad-292). The package **owns its
|
||||
types** and exposes **interfaces for stateful inputs** (`Catalog`, `HolidayCalendar`,
|
||||
`CourtRegistry`); paliad implements them against Postgres, youpc.org against an embedded
|
||||
JSON snapshot. `doc.go` is the package doc; `types_wire_test.go` locks the JSON contract.
|
||||
**docforge mirrors this packaging discipline exactly.**
|
||||
|
||||
### 0.3 Premise correction (load-bearing)
|
||||
|
||||
The brief lists **two consumers in scope: paliad + upc-commentary**. Verified against the
|
||||
live repo: **`UPCommentary/upc-kommentar` is Bun + SvelteKit + TypeScript + PLpgSQL —
|
||||
zero Go.** A SvelteKit app cannot `import` a Go `pkg/`. m's resolution (2026-05-29):
|
||||
**upc-kommentar is out of scope as a live consumer for now.** docforge is a pure Go
|
||||
package; paliad imports it in-process like `litigationplanner`. The interfaces are
|
||||
designed so an HTTP veneer (for a future TS consumer) is *addable later* without rework —
|
||||
but none is built now. See §4 D-P1 and §8.
|
||||
|
||||
### 0.4 Locked constraints (m, confirmed)
|
||||
|
||||
- One Go module: `pkg/docforge`. Same packaging model as `pkg/litigationplanner`.
|
||||
- docforge **owns no database tables** — data flows in via interfaces.
|
||||
- `.docx` first; engine designed format-pluggable for `.pdf`/`.html`/`.md` later.
|
||||
- Authoring and Generation are **distinct pages**, but share the engine + the generic
|
||||
editor plumbing.
|
||||
- Generation must support **minor manual content edits** (live editor, not just
|
||||
data-binding).
|
||||
- Editor stays per-consumer; the **generic UX plumbing** is extracted into a reusable UI
|
||||
package now.
|
||||
- The neutral model must be **lossless for our own `.docx`** (the uploaded base is an
|
||||
opaque carrier, preserved byte-for-byte outside touched regions).
|
||||
|
||||
### 0.5 Contracts that MUST survive the refactor
|
||||
|
||||
These are invariants. The migration (§6) protects each by moving it *with its file and its
|
||||
test*, unchanged:
|
||||
|
||||
1. **`placeholderRegex`** = `` `\{\{\s*([A-Za-z][A-Za-z0-9_.]*)\s*\}\}` `` — underscores
|
||||
and dots legal in keys; whitespace inside braces trimmed; case-sensitive.
|
||||
2. **Last night's underscore fix** (commit `b78a984`): `parseInlineSpans` short-circuits
|
||||
the inline scanner on `{{` and copies the placeholder literally to `}}`, so
|
||||
`{{project.case_number}}` is never mangled to `{{project.casenumber}}`.
|
||||
3. **`data-var` contract** — `data-var="<key>"` on both `.draft-var` preview spans and
|
||||
`.submission-draft-var-input` sidebar inputs; the click-to-jump and focus-highlight are
|
||||
bijective across repaints.
|
||||
4. **Missing-value markers** — `[KEIN WERT: key]` (DE) / `[NO VALUE: key]` (EN) render
|
||||
inline, never an error.
|
||||
5. **Legacy aliases** — `procedural_event.X ≡ rule.X` resolve identically
|
||||
(`submission_vars_aliases_test.go`); party variables emit comma-joined, indexed, and
|
||||
flat-legacy forms (`submission_vars_parties_test.go`).
|
||||
6. **Section anchor syntax** — `{{#section:KEY}}…{{/section:KEY}}`, `KEY` matched against
|
||||
`[A-Za-z0-9_]+`.
|
||||
7. **No binary retention** — exported `.docx` is regenerable from inputs; only audit rows
|
||||
persist (`system_audit_log` `submission.exported` + `project_events`).
|
||||
8. **V1 fallback path** — pre-Composer drafts (`base_id IS NULL`, no section rows) render
|
||||
via the pure-placeholder path. No auto-upgrade.
|
||||
9. **`{{…}}` pass-through** — the Markdown walker emits placeholders verbatim; the merge
|
||||
pass substitutes them afterward. Order is load-bearing (substitution runs *inside*
|
||||
compose, after section splicing).
|
||||
|
||||
---
|
||||
|
||||
## §1 Goals
|
||||
|
||||
**G1.** Extract the format-neutral document machinery (Markdown→OOXML walker, OOXML
|
||||
merge/compose, placeholder engine, `.dotm`→`.docx`) into `pkg/docforge` with a clean
|
||||
public surface and zero behavior change at the extraction step.
|
||||
|
||||
**G2.** Introduce a **neutral document/template model** so importers produce it, the engine
|
||||
binds variables on it, and exporters render it out — with `.docx` as the first
|
||||
importer+exporter pair, not the universe. Lossless for our own `.docx`.
|
||||
|
||||
**G3.** Replace the hardcoded `addXxxVars` push with a **`VariableResolver` interface per
|
||||
namespace** + a `ResolverSet` that composes them, preserves aliases, and exposes the key
|
||||
catalogue (label + group) so the frontend variable form/palette becomes data-driven
|
||||
instead of hardcoded in TS.
|
||||
|
||||
**G4.** Build the **Authoring surface**: upload `.docx` → WYSIWYG render → click/select →
|
||||
insert `{{slot}}` → save template. Closes the gap m named.
|
||||
|
||||
**G5.** Refactor **Generation** onto docforge + uploaded templates, preserving the live
|
||||
editor, preview, manual-edit, and export — and every contract in §0.5.
|
||||
|
||||
**G6.** Extract the **generic editor UX** into `frontend/src/lib/docforge-editor/`,
|
||||
consumed by both the generation and authoring shells.
|
||||
|
||||
**Non-goals (this PRD):** implementation, migration SQL, code. Formats beyond `.docx`
|
||||
(interface only). Live upc-kommentar integration. Multi-user concurrent editing of one
|
||||
draft. An HTTP service veneer.
|
||||
|
||||
---
|
||||
|
||||
## §2 User journeys
|
||||
|
||||
### 2.1 Authoring (new)
|
||||
|
||||
1. m opens **`/admin/templates`** (or `/templates/new`) and uploads a base `.docx`
|
||||
(firm letterhead with caption layout, signature block, etc.).
|
||||
2. docforge's `.docx` importer parses the upload into a **carrier** (opaque OOXML kept
|
||||
intact) + a renderable preview. The page shows a **WYSIWYG-ish render** of the document.
|
||||
3. m highlights a piece of text — e.g. `Az. 4c O 12/23` — and a **variable palette**
|
||||
(sourced from the `ResolverSet.Keys()` catalogue, grouped DE/EN) lets him pick
|
||||
`project.case_number`. The selection is **replaced with a `{{project.case_number}}`
|
||||
slot**; a `template_slots` row records the slot key + its anchor position.
|
||||
4. He repeats for every variable region, saves, and the template becomes pickable in
|
||||
Generation. (Editing the template later creates a new **version** — see §4 D-A3.)
|
||||
|
||||
**Scope guard:** v1 authoring places **text-level slots in body paragraphs**. Slots in
|
||||
headers/footers/tables/text-boxes are a flagged follow-up (§7 note), because the
|
||||
click→OOXML-run mapping there is materially harder.
|
||||
|
||||
### 2.2 Generation (refactor of today)
|
||||
|
||||
1. Lawyer picks a template (uploaded template *or* a legacy Gitea base — both supported
|
||||
during transition) for a submission code, optionally project-scoped.
|
||||
2. A **draft** is created. Its template **structure is snapshotted** at create
|
||||
(§4 D-A3) so later template edits don't shift an in-flight draft.
|
||||
3. The sidebar shows the variable form (data-driven from `ResolverSet.Keys()`); the
|
||||
resolved bag is merged with the lawyer's overrides; the live preview renders with
|
||||
`data-var` click-to-jump; manual prose edits autosave (500 ms debounce).
|
||||
4. Export → docforge binds the model + carrier + resolved variables → `.docx` bytes
|
||||
stream as a download. Audit rows written. No binary retained.
|
||||
|
||||
### 2.3 upc-kommentar parallel journey (deferred — validates the abstractions)
|
||||
|
||||
Not built now, but the abstractions are sized for it: upc-kommentar authors work in
|
||||
**Markdown** (and want to import **foreign doc/docx** as input — m, 2026-05-29 Q4). When
|
||||
it becomes a consumer, it would: implement its own `VariableResolver`(s) over its Postgres
|
||||
(commentary metadata), feed Markdown through docforge's **markdown importer** into the
|
||||
neutral model, edit live in its own Svelte shell (reusing the *wire contract*, not Go
|
||||
code), and export. The Go engine is reached over an HTTP veneer added at that point. This
|
||||
journey is the litmus test for §3's seams: **a new consumer adds resolvers + a transport,
|
||||
touches no engine internals.**
|
||||
|
||||
---
|
||||
|
||||
## §3 Module shape
|
||||
|
||||
### 3.1 Package tree
|
||||
|
||||
```
|
||||
pkg/docforge/
|
||||
doc.go // package doc (litigationplanner-style)
|
||||
model.go // neutral model: Document, Block, InlineSpan, Slot
|
||||
template.go // Template, TemplateSlot, Carrier
|
||||
variables.go // VariableResolver interface, VariableKey, ResolverSet, alias registry
|
||||
bind.go // binding engine: walk model, resolve slots, apply missing-marker policy
|
||||
render.go // RenderHTML (preview w/ data-var spans) — format-neutral entry
|
||||
importer.go // Importer interface
|
||||
exporter.go // Exporter interface
|
||||
store.go // TemplateStore interface (carrier bytes + slot persistence contract)
|
||||
errors.go // sentinel errors (ErrUnknownTemplate, ErrUnboundSlot, …)
|
||||
placeholder.go // placeholderRegex + substitution primitives (THE locked grammar)
|
||||
types_wire_test.go // locks the JSON wire shape consumed by the TS editor
|
||||
docx/ // the .docx adapter — first importer + exporter
|
||||
importer.go // DocxImporter: parse .docx -> Carrier + detect/locate slots
|
||||
exporter.go // DocxExporter: (model + carrier + vars) -> .docx bytes [today's compose+merge]
|
||||
ooxml.go // archive/zip + document.xml manipulation [today's submission_merge/compose internals]
|
||||
md_to_ooxml.go // Markdown -> OOXML runs [today's submission_md walker + the b78a984 fix]
|
||||
dotm.go // ConvertDotmToDocx [today's pre-pass]
|
||||
markdown/ // markdown importer (input content; foreign-docx import is a later sibling)
|
||||
importer.go // parse Markdown -> neutral blocks
|
||||
```
|
||||
|
||||
**What lives in docforge vs paliad:**
|
||||
|
||||
| Concern | Home | Why |
|
||||
|---|---|---|
|
||||
| Neutral model, binding, preview-render | `docforge` | format-neutral core |
|
||||
| `VariableResolver` interface + `ResolverSet` | `docforge` | the seam m wants clean |
|
||||
| Placeholder grammar + substitution | `docforge` | shared invariant (§0.5.1) |
|
||||
| `.docx` importer + exporter, MD→OOXML walker | `docforge/docx` | first format adapter (ships *inside* the pkg, like litigationplanner's embedded snapshot) |
|
||||
| Markdown importer | `docforge/markdown` | input-format adapter |
|
||||
| Concrete resolvers (`project`, `parties`, `firm`, `user`, `today`, `deadline`, `procedural_event`) | **paliad** `internal/…` | they read paliad's DB/services |
|
||||
| `TemplateStore` impl (Postgres bytea) | **paliad** | docforge owns no tables |
|
||||
| Section / building-block model, submission codes | **paliad** | consumer-specific composition concepts |
|
||||
| HTTP handlers, editor UI, authoring page | **paliad** | wire + per-consumer UI |
|
||||
|
||||
### 3.2 The neutral model + the carrier (resolving "intermediate, but lossless docx")
|
||||
|
||||
```go
|
||||
// A Document is the format-neutral content model importers produce and exporters consume.
|
||||
type Document struct {
|
||||
Blocks []Block
|
||||
}
|
||||
type Block struct {
|
||||
Kind BlockKind // paragraph | heading | list_item | blockquote | section_marker
|
||||
Style string // logical style key (mapped to a base stylemap on export)
|
||||
Spans []InlineSpan // text runs (bold/italic/link) + Slots
|
||||
// …list level, section key, etc.
|
||||
}
|
||||
type InlineSpan struct {
|
||||
Text string
|
||||
Bold bool
|
||||
Italic bool
|
||||
Link string
|
||||
Slot *Slot // non-nil => this span is a variable slot, not literal text
|
||||
}
|
||||
type Slot struct {
|
||||
Key string // e.g. "project.case_number" — the placeholder grammar key
|
||||
}
|
||||
```
|
||||
|
||||
**The carrier keeps the lossless guarantee.** The uploaded `.docx` chrome
|
||||
(letterhead, styles, caption, signature) is **never round-tripped through `Document`**.
|
||||
It is held as an opaque `Carrier` (the original OOXML), and the exporter splices the
|
||||
rendered neutral content into the carrier's named anchors, then substitutes slots — exactly
|
||||
today's compose mechanism, now formalised:
|
||||
|
||||
```go
|
||||
type Carrier struct {
|
||||
Format string // "docx"
|
||||
Bytes []byte // original upload, preserved byte-for-byte outside anchor regions
|
||||
Anchors []Anchor // {{#section:KEY}}…{{/section:KEY}} positions + slot positions
|
||||
}
|
||||
```
|
||||
|
||||
So **two layers**: editable content = `Document` (neutral, format-pluggable); base chrome =
|
||||
`Carrier` (opaque, lossless). Foreign-docx *import as input content* (Q4) does parse into
|
||||
`Document` and **is inherently lossy** — flagged as a boundary (§8), distinct from the
|
||||
lossless export of *our* templates.
|
||||
|
||||
### 3.3 The variable resolver seam (G3)
|
||||
|
||||
```go
|
||||
// VariableResolver answers keys within one dotted namespace.
|
||||
type VariableResolver interface {
|
||||
Namespace() string // e.g. "project"
|
||||
Resolve(key string) (value string, ok bool)// ok=false => unknown key => missing marker
|
||||
Keys() []VariableKey // catalogue for the palette + sidebar form
|
||||
}
|
||||
type VariableKey struct {
|
||||
Key, LabelDE, LabelEN, Group string
|
||||
}
|
||||
|
||||
// ResolverSet composes namespaced resolvers, registers canonical<->legacy aliases,
|
||||
// and offers BOTH a pull path (Resolve, used during binding) and a push path
|
||||
// (BuildBag, preserving today's resolved_bag/merged_bag wire).
|
||||
type ResolverSet struct{ /* … */ }
|
||||
func (s *ResolverSet) Resolve(key string) (string, bool)
|
||||
func (s *ResolverSet) BuildBag() map[string]string // == today's PlaceholderMap
|
||||
func (s *ResolverSet) Catalogue() []VariableKey // drives the data-driven form/palette
|
||||
func (s *ResolverSet) RegisterAlias(canonical, legacy string)
|
||||
```
|
||||
|
||||
paliad's seven `addXxxVars` functions become seven resolver types implementing this
|
||||
interface. `BuildBag()` reproduces today's flat map exactly (alias parity tests pin it).
|
||||
`Catalogue()` kills the hardcoded `VARIABLE_GROUPS`/`VARIABLE_LABELS` in the TS bundle.
|
||||
**Resolver model = hybrid** (pull-capable interface, push-driven `BuildBag` default —
|
||||
inventor pick, §4 D-I1).
|
||||
|
||||
### 3.4 Wire contract (Go ↔ TS) — preserved, locked by test
|
||||
|
||||
The editor wire stays as-is; `types_wire_test.go` pins it:
|
||||
|
||||
- `GET draft` → `{ draft, resolved_bag, merged_bag, preview_html, rule, parties, sections }`
|
||||
- preview HTML carries `<span class="draft-var" data-var="<key>">…</span>` (built by
|
||||
docforge's `RenderHTML`, today's `emitTextWithDraftVars`).
|
||||
- `PATCH draft` ← `{ variables: PlaceholderMap, … }` (presence-tracked optional fields).
|
||||
- export/preview endpoints unchanged.
|
||||
- **New (authoring):** `POST /api/templates` (upload), `GET /api/templates/:id` (carrier
|
||||
preview + slots), `POST /api/templates/:id/slots` (place slot), `GET /api/docforge/variables`
|
||||
(the `Catalogue()`).
|
||||
|
||||
---
|
||||
|
||||
## §4 Decisions (m's picks, 2026-05-29)
|
||||
|
||||
### Prose-grill resolutions (core metaphor)
|
||||
|
||||
| # | Question | m's decision | Note |
|
||||
|---|---|---|---|
|
||||
| P1 | Cross-language sharing model | **Go pkg only; upc-kommentar out of scope for now, "reuse later somehow"** | Interfaces sized so an HTTP veneer is addable without rework. No service built. |
|
||||
| P2 | Intermediate model? | **Yes — but lossless for our .docx** | → carrier (opaque OOXML) + neutral Document (editable content). §3.2. |
|
||||
| P3 | Authoring slot mechanic | **(b) click-to-insert** | Upload → render → click/select → inject `{{…}}`. |
|
||||
| P4 | Input formats | **Markdown primary; foreign doc/docx import later** | Markdown importer first; foreign-docx import is lossy (§8). |
|
||||
| P5 | Editor sharing | **Build paliad's UI; extract generic UX into a UI package** | `frontend/src/lib/docforge-editor/`. |
|
||||
|
||||
### Structured decisions
|
||||
|
||||
| # | Decision | m's pick | Rationale / divergence |
|
||||
|---|---|---|---|
|
||||
| A1 | Authoring UX | **WYSIWYG inline** | Matches "insert variables into the document". Hardest part — render fidelity + click→run mapping — flagged §7. |
|
||||
| A2 | Template storage | **Postgres bytea (interface-backed)** | m leans (1); flagged Supabase Storage as viable. Resolved: behind a `TemplateStore` interface, bytea impl now, Supabase Storage a one-impl swap later. No schema churn either way. |
|
||||
| A3 | Versioning of existing drafts | **Snapshot at draft-create** | Lawyer's in-flight draft won't shift under them; matches today's section-seeding. |
|
||||
| A4 | Migration strategy | **Extract-in-place, then extend** | Lowest risk to the recent fixes — they move with their files + tests; behavior identical at each step. |
|
||||
| B1 | Package name | **`docforge`** | — |
|
||||
| B2 | Schema scope | **New generic tables** (`templates`, `template_slots`, `template_versions`) | Authoring is domain-neutral; submission_bases (Gitea/section_spec) stays for legacy bases with a converge path. |
|
||||
| B3 | UI package extraction | **Extract now** | Authoring reuses it this cycle — earns its keep, not speculative. |
|
||||
| B4 | Exporter pluggability | **Interface now, docx-only impl** | Cheap insurance; matches "pluggable for later". |
|
||||
|
||||
### Inventor picks (m delegated — "whatever works best")
|
||||
|
||||
| # | Pick | Reasoning |
|
||||
|---|---|---|
|
||||
| I1 | `VariableResolver` = pull-capable interface, push `BuildBag()` default | Preserves today's flat-map wire while enabling on-demand resolution + the `Catalogue()` that data-drives the form. |
|
||||
| I2 | `.docx` adapter ships **inside** `pkg/docforge/docx` | Mirrors litigationplanner shipping its embedded snapshot in-package; keeps the first adapter co-located with the engine it proves. |
|
||||
| I3 | Carrier-vs-Document split (§3.2) | Only way to satisfy "intermediate model" AND "lossless our .docx" simultaneously. |
|
||||
|
||||
---
|
||||
|
||||
## §5 Data model deltas (paliad-side — docforge owns none)
|
||||
|
||||
**New tables** (additive; SQL drafted by the coder, not here):
|
||||
|
||||
- **`paliad.templates`** — `id`, `slug`, `name_de/en`, `kind` (`'submission'` | generic),
|
||||
`source_format` (`'docx'`), `firm`, `is_active`, `created/updated_by`, timestamps,
|
||||
`current_version_id` FK.
|
||||
- **`paliad.template_versions`** — immutable snapshots: `id`, `template_id` FK,
|
||||
`version` int, `carrier_blob` bytea (the `.docx`; or storage ref via `TemplateStore`),
|
||||
`created_at`, `created_by`. Editing a template inserts a new version row.
|
||||
- **`paliad.template_slots`** — `id`, `template_version_id` FK, `slot_key` (the variable
|
||||
key, e.g. `project.case_number`), `anchor` (position encoding — see flag below),
|
||||
`label`, `order_index`. Versioned alongside the carrier.
|
||||
|
||||
**Snapshot semantics (A3):** a draft pins `template_version_id`. Template edits create a
|
||||
new version; existing drafts keep their pinned version. *(Flag for coder: pin
|
||||
`template_version_id` on the draft vs. copy a `template_snapshot` jsonb onto the draft —
|
||||
both satisfy A3; the version-table approach is preferred for auditability but the coder
|
||||
picks based on query ergonomics.)*
|
||||
|
||||
**Touched existing tables:**
|
||||
|
||||
- `submission_drafts` — add nullable `template_version_id` for uploaded-template drafts;
|
||||
**legacy `base_id` path preserved** (extract-in-place ⇒ no data migration of the 11
|
||||
existing drafts; §0.5.8 fallback intact).
|
||||
- `submission_bases`, `submission_sections`, `submission_building_blocks` — **unchanged**.
|
||||
They remain paliad consumer-specific concepts that map onto docforge's neutral model at
|
||||
render time. submission_bases (Gitea-backed) coexists with the new uploaded-template
|
||||
tables during transition; convergence is a later, separate task.
|
||||
|
||||
**Slot anchor encoding (flag for coder):** how a `template_slots.anchor` records *where*
|
||||
in the carrier OOXML the slot sits (run index + offset, vs. a stable sentinel token
|
||||
injected into the carrier at authoring time). The sentinel-token approach is likely
|
||||
simpler and reuses the existing cross-run substitution machinery — resolve in
|
||||
implementation chat.
|
||||
|
||||
---
|
||||
|
||||
## §6 Migration plan (protects working code + the recent fixes)
|
||||
|
||||
**Principle:** extract-in-place (A4). Each step **compiles, passes the moved tests, and
|
||||
leaves observable behavior identical.** The recent fixes travel *with their files*:
|
||||
|
||||
- The **b78a984 underscore fix** → `pkg/docforge/docx/md_to_ooxml.go` (was
|
||||
`submission_md.go` `parseInlineSpans`), `submission_md_test.go` moves alongside.
|
||||
- **`placeholderRegex`** → `pkg/docforge/placeholder.go`; its tests move.
|
||||
- **`data-var` / `emitTextWithDraftVars`** → `pkg/docforge/render.go` (`RenderHTML`);
|
||||
wire test moves and is pinned in `types_wire_test.go`.
|
||||
- **Cross-run merge, `.dotm`→`.docx`, anchor splicing** → `pkg/docforge/docx/`; tests move.
|
||||
- **Building-block + section model, submission codes, the 7 concrete resolvers** stay in
|
||||
`internal/` (consumer-specific) — now calling into docforge.
|
||||
|
||||
**Safety rails per step:** (1) `go build ./...` green; (2) the moved test files green; (3)
|
||||
a golden-export check — generate a known draft before and after the step, assert byte-equal
|
||||
`.docx`; (4) the live preview HTML for a fixture draft is string-equal (the `data-var`
|
||||
contract). No step ships until all four hold.
|
||||
|
||||
**What is explicitly NOT migrated:** the 11 pre-Composer drafts (`base_id IS NULL`) keep
|
||||
the v1 fallback render path; no auto-upgrade (§0.5.8).
|
||||
|
||||
---
|
||||
|
||||
## §7 Slice train
|
||||
|
||||
Tracer-bullet vertical slices, each independently shippable. Slices 1–3 are pure
|
||||
behavior-preserving refactors (the risky-to-working-code part, front-loaded under golden
|
||||
checks); 4–7 build the new capability; 8 sets up the future.
|
||||
|
||||
1. **Extract the docx engine** — move MD→OOXML walker, OOXML merge/compose, placeholder
|
||||
grammar, `.dotm`→`.docx` into `pkg/docforge/{placeholder.go, render.go, docx/}`.
|
||||
paliad's `submission_*` services become thin adapters. Golden-export + preview checks
|
||||
green. *Protects b78a984, the regex, the data-var contract.*
|
||||
2. **Neutral model + binding** — introduce `Document`/`Block`/`Slot`/`Carrier` + `bind.go`;
|
||||
refactor the docx exporter to consume the neutral model (sections → blocks → OOXML
|
||||
spliced into carrier). Behavior identical (golden checks).
|
||||
3. **`VariableResolver` interface** — refactor the 7 `addXxxVars` into resolver types +
|
||||
`ResolverSet`; `BuildBag()` reproduces today's map (alias-parity tests pin it);
|
||||
`Catalogue()` exposed. Frontend form switched to consume `Catalogue()` (kills hardcoded
|
||||
`VARIABLE_GROUPS`).
|
||||
4. **Template store + schema** — `templates`/`template_versions`/`template_slots` +
|
||||
Postgres-bytea `TemplateStore` impl. No UI yet. Additive migrations.
|
||||
5. **UI package extraction** — pull generic plumbing (debounced autosave, data-var wiring,
|
||||
preview/export round-trip, focus preservation, sticky collapse) into
|
||||
`frontend/src/lib/docforge-editor/`; submission editor consumes it. Refactor, behavior
|
||||
identical.
|
||||
6. **Authoring page** — upload `.docx` → docforge docx-importer → WYSIWYG render → select
|
||||
text → pick variable from `Catalogue()` palette → inject slot (writes
|
||||
`template_slots` + new `template_version`). Reuses the UI package + docforge importer.
|
||||
*(v1: body-paragraph text slots only.)*
|
||||
7. **Generation on uploaded templates** — generation page picks an uploaded template
|
||||
(`template_version_id` path) alongside legacy bases; snapshot-at-create; data-bind +
|
||||
manual edit + export via docforge. Legacy base path still works.
|
||||
8. **Markdown importer + exporter-interface finalisation** — `docforge/markdown` importer
|
||||
as input; `Exporter` interface locked (docx-only impl). Sets up future formats +
|
||||
eventual upc-kommentar reuse.
|
||||
|
||||
**Flagged follow-ups (post-train, separate tasks):** slots in headers/footers/tables;
|
||||
foreign-docx import fidelity; the HTTP veneer + a TS consumer; submission_bases →
|
||||
templates convergence; auto-upgrade of pre-Composer drafts.
|
||||
|
||||
---
|
||||
|
||||
## §8 Out of scope
|
||||
|
||||
- **Implementation, migration SQL, code.** PRD only.
|
||||
- **upc-kommentar as a live consumer** — deferred; abstractions sized for it, nothing built.
|
||||
- **An HTTP service veneer** — addable later without engine rework; not now.
|
||||
- **Formats beyond `.docx`** — `Exporter` interface defined (B4), only the docx impl built.
|
||||
- **Lossless import of *foreign* `.docx`** — our own templates export losslessly via the
|
||||
carrier; importing an arbitrary third-party Word doc as input content is best-effort and
|
||||
inherently lossy. Distinct guarantee.
|
||||
- **Multi-user concurrent editing** of one draft.
|
||||
- **Re-proposing the current `submission_*.go` shape** — the point is to extract + clean it.
|
||||
- **Slots outside body paragraphs** (headers/footers/tables/text-boxes) in authoring v1.
|
||||
|
||||
---
|
||||
|
||||
## Appendix — open flags for the coder (resolve in implementation chat)
|
||||
|
||||
1. **Slot anchor encoding** — run-index+offset vs. injected sentinel token (§5). Lean
|
||||
sentinel.
|
||||
2. **Snapshot mechanism** — pinned `template_version_id` vs. `template_snapshot` jsonb on
|
||||
the draft (§5). Lean version-pin.
|
||||
3. **Authoring render fidelity** — reuse the existing lossy `docXMLToHTML` preview for the
|
||||
WYSIWYG surface, or invest in higher fidelity. Lean reuse for v1, accept that
|
||||
complex layouts render approximately while slots still anchor correctly.
|
||||
4. **Storage backend** — Postgres bytea now; Supabase Storage is a clean `TemplateStore`
|
||||
swap if template volume/size grows.
|
||||
354
docs/plans/prd-filename-generator-2026-06-01.md
Normal file
354
docs/plans/prd-filename-generator-2026-06-01.md
Normal file
@@ -0,0 +1,354 @@
|
||||
# PRD — Composable Name/Filename Generator Engine
|
||||
|
||||
**Task:** t-paliad-355 · **Author:** leibniz (inventor) · **Date:** 2026-06-01
|
||||
**Status:** DESIGN — awaiting head go/no-go on coder shift
|
||||
**Builds on:** t-paliad-352 / m/paliad#155 (draft title), t-paliad-354 (export filename, merged `94adeeb`)
|
||||
**Related:** `docs/plans/prd-docforge-2026-05-29.md` (doc-generation engine — a future naming consumer)
|
||||
|
||||
---
|
||||
|
||||
## § m's decisions (2026-06-01)
|
||||
|
||||
All eight grilling questions answered; every pick matched the inventor recommendation.
|
||||
|
||||
**Batch 1 — model:**
|
||||
- Q1 (Composition model): **Structured segments + string shorthand.** Canonical model is an ordered segment list with per-segment missing-rules; a token-template string is an optional authoring shorthand that compiles to segments.
|
||||
- Q2 (v1 precedence): **System → Firm → User → per-document.** Mirrors the existing dashboard-layout chain exactly. Project-level deferred to v1.1.
|
||||
- Q3 (Engine depth): **Reusable engine, wire 3 known consumers.** Real engine now; only draft-title, submission-.docx, and the non-project fix are wired. Other surfaces register as known artifacts but keep current code.
|
||||
- Q4 (Non-project name): **`<date> <keyword>`**, falling back to `Entwurf N` only when no type context exists.
|
||||
|
||||
**Batch 2 — concrete:**
|
||||
- Q5 (Missing-rule set): **omit + placeholder + literal**, per segment.
|
||||
- Q6 (Date semantics): **Render-time "today", Europe/Berlin, `YYYY-MM-DD`.**
|
||||
- Q7 (Settings UX): **Live-preview string field on `/settings`** + clickable `{token}` palette. Missing-rules use defaults (not user-editable in v1).
|
||||
- Q8 (Artifact scope): **2 submission artifacts (`submission_draft_title`, `submission_docx_filename`) + extensible registry.** docforge-export, data-zip, projection-slug registered as known artifacts but unwired in v1.
|
||||
|
||||
These are necessary for a coder shift, **not** sufficient — the head still gates whether/who/when to implement (inventor→coder rule).
|
||||
|
||||
---
|
||||
|
||||
## §0 Premises (verified against the live system, 2026-06-01)
|
||||
|
||||
| # | Premise | How verified |
|
||||
|---|---------|--------------|
|
||||
| P1 | Draft title = `<date> <client> ./. <forum> ./. <opponent>`, project-bound only, missing segments dropped-with-separator. | Read `internal/services/submission_autoname.go` (`AutoSubmissionTitle`). |
|
||||
| P2 | Non-project drafts fall back to `Entwurf N` / `Draft N` counter. | Read `submission_draft_service.go` `newDraftName`/`Create`. |
|
||||
| P3 | Export filename = `<date> <keyword> (<case | "Az. folgt">).docx`; keyword overridable per-draft via `composer_meta.filename_keyword`. | Read `internal/handlers/submissions.go` `submissionFileName` + `submissionFilenameKeyword`. |
|
||||
| P4 | Sanitiser `SanitiseSubmissionFileName` folds umlauts, maps `/\:*?<>|`→`_`, strips `"'`, **preserves spaces/parens**. Lives in `pkg/docforge/docx/dotm.go`, re-exported via `services.SanitiseSubmissionFileName` (`docforge_shims.go`). | Read `pkg/docforge/docx/dotm.go`. |
|
||||
| P5 | `DashboardLayoutSpec` is a production precedent for a validated jsonb spec: code `FactoryDefaultLayout` → admin `firm_dashboard_default` (db row id=1) → per-user `user_dashboard_layouts`, with `Validate()` (write) + `SanitizeForRead()` (read). | Read `dashboard_layout_spec.go`, `firm_dashboard_default_service.go`. |
|
||||
| P6 | `users.email_preferences jsonb` (per-user bag) and `projects.metadata jsonb` exist live. No dedicated `user_preferences` table — migration 017 only added the `email_preferences` column. | `information_schema.columns` query on live `paliad` schema. |
|
||||
| P7 | Draft titles are de-duplicated at create time via `uniqueDraftName` (appends a counter on collision). | Read `newDraftName`. |
|
||||
|
||||
**Doc-is-the-bug flags raised:** none. The two shipped behaviours are exactly as the task described; `projects.metadata` exists so a project-level override needs no new column when v1.1 arrives (only a documented sub-key).
|
||||
|
||||
---
|
||||
|
||||
## §1 The problem
|
||||
|
||||
Two one-off naming functions shipped in successive tasks (#155, 354). Each hardcodes: a date format, an ordered set of segments, a separator, and a missing-value policy. m wants to stop re-deriving this per feature — "we will need a filename generator more often later on" — and to expose **defaults / compositions** as a **user (and maybe project) setting**. Plus one immediate gap: non-project drafts get no date-led name.
|
||||
|
||||
The design must:
|
||||
1. Extract a **reusable composition engine** that renders a name from (template, variable-bag, render-target).
|
||||
2. Reproduce **both shipped schemes byte-for-byte** as seed defaults (no behaviour regression).
|
||||
3. Add **settings** with a clean precedence chain, built **on** the dashboard-spec pattern (P5), not beside it.
|
||||
4. Fix the **non-project** gap inside the engine, not as another special case.
|
||||
|
||||
---
|
||||
|
||||
## §2 The engine
|
||||
|
||||
A new package **`pkg/nomen`** (Latin *nomen* = "name"; firm-agnostic, sits beside `pkg/docforge`). Pure, dependency-light, table-testable. No DB, no HTTP — consumers resolve variables and hand them in, exactly as `AutoSubmissionTitle` is pure today.
|
||||
|
||||
> **FLAG (coder + m):** package name `nomen` is the inventor pick. Alternatives: `pkg/naming`, `internal/services/namegen`. Pick at implementation; nothing downstream depends on the name.
|
||||
|
||||
### 2.1 Core types (interface sketch — not final Go)
|
||||
|
||||
```go
|
||||
package nomen
|
||||
|
||||
// Segment is one piece of a composition: a variable reference, the
|
||||
// separator that precedes it, and what to do when the variable resolves
|
||||
// empty.
|
||||
type Segment struct {
|
||||
Var string // key into the variable catalog, e.g. "date", "keyword"
|
||||
Sep string // TRAILING separator: emitted AFTER this segment iff a
|
||||
// later segment also emits. The last emitted segment's
|
||||
// Sep is never used. (See Slice-1 note below.)
|
||||
Wrap [2]string // optional surrounding literals, e.g. {"(", ")"} for case-no.
|
||||
Missing MissingRule // omit | placeholder | literal
|
||||
}
|
||||
|
||||
type MissingRule struct {
|
||||
Kind MissingKind // KindOmit | KindPlaceholder | KindLiteral
|
||||
Value string // placeholder/literal text (e.g. "Az. folgt"); ignored for omit
|
||||
}
|
||||
|
||||
// Composition is the canonical, validated model.
|
||||
type Composition struct {
|
||||
Version int // schema version (start at 1)
|
||||
Segments []Segment
|
||||
}
|
||||
|
||||
// VarResolver yields a variable's value for one render. Returns ("", false)
|
||||
// when the variable is unavailable in this context (→ Missing rule applies).
|
||||
type VarResolver func(key string) (string, bool)
|
||||
|
||||
// RenderTarget post-processes the assembled string (sanitisation, suffix).
|
||||
type RenderTarget interface {
|
||||
Name() string // "title" | "filename"
|
||||
Transform(assembled string) string
|
||||
}
|
||||
|
||||
func (c Composition) Render(resolve VarResolver, target RenderTarget) string
|
||||
func (c Composition) Validate(catalog VarCatalog) error
|
||||
```
|
||||
|
||||
> **Implementation note (Slice 1, 2026-06-01 — `Sep` is trailing, not leading).**
|
||||
> This PRD originally sketched `Sep` as the separator emitted *before* a
|
||||
> segment. During Slice 1 that model proved unable to reproduce #155
|
||||
> byte-for-byte: the existing test `"no client — client segment omitted"`
|
||||
> requires `2026-05-31 UPC ./. Novartis Pharma` — the date must join the
|
||||
> *forum* with a single space when the client is absent, while the
|
||||
> forum-to-opponent join stays ` ./. `. A separator owned by the right-hand
|
||||
> segment would need two different values for the same segment depending on
|
||||
> what was omitted before it. Making the separator **trailing** (owned by
|
||||
> the left-hand segment) is the minimal faithful fix: the date's trailing
|
||||
> ` ` is used whenever any identity segment follows, and each party's
|
||||
> trailing ` ./. ` is used whenever another party follows. All shipped
|
||||
> #155/354 tests pass unchanged. Implemented in `pkg/nomen/nomen.go`; the
|
||||
> realised `RenderTarget` also splits `Transform` into `SanitiseValue`
|
||||
> (per-variable) + `Finalise` (whole-string + suffix) per §2.3.
|
||||
|
||||
### 2.2 Render algorithm (reproduces both shipped schemes)
|
||||
|
||||
For each segment, in order:
|
||||
1. `val, ok := resolve(seg.Var)`.
|
||||
2. If `!ok || strings.TrimSpace(val) == ""`, apply `seg.Missing`:
|
||||
- `KindOmit` → segment contributes nothing (and its `Sep` is suppressed).
|
||||
- `KindPlaceholder` → `val = seg.Missing.Value` (treated as present).
|
||||
- `KindLiteral` → `val = seg.Missing.Value` (same as placeholder; distinct *intent* in the model — "this is a fixed label", not "this is a stand-in for missing data" — so the settings UI can word them differently and future policy can diverge).
|
||||
3. If the segment emits, prepend `seg.Sep` **iff at least one segment already emitted** (kills the leading-separator problem the #155 code solves by hand), then wrap with `seg.Wrap`.
|
||||
4. Concatenate.
|
||||
5. `target.Transform(assembled)` runs once on the whole string.
|
||||
|
||||
**Separator suppression** is the generalisation of #155's "drop segment + its leading separator". **Placeholder** is the generalisation of 354's `(Az. folgt)`.
|
||||
|
||||
### 2.3 Render targets
|
||||
|
||||
The **same** `Composition` renders to different targets:
|
||||
|
||||
| Target | `Transform` | Used by |
|
||||
|--------|-------------|---------|
|
||||
| `TitleTarget` | identity (spaces, umlauts, ` ./. ` all valid in a human title) | `submission_draft_title` |
|
||||
| `FilenameTarget{ext: ".docx"}` | per-segment-aware: applies `services.SanitiseSubmissionFileName` to **variable values** (not the frame — preserve the spaces/parens/wrap), then appends `ext`. | `submission_docx_filename` |
|
||||
|
||||
> **Design note — where sanitisation runs.** 354 sanitises *each variable value* but keeps the assembled frame (`<date> <kw> (<case>)`) intact. To preserve that exactly, the `FilenameTarget` is **not** a dumb whole-string transform — the engine sanitises each resolved variable value *before* assembly when the target requests it, and the target only appends the extension at the end. So `RenderTarget` gains one more hook:
|
||||
|
||||
```go
|
||||
type RenderTarget interface {
|
||||
Name() string
|
||||
SanitiseValue(v string) string // per-variable; identity for TitleTarget
|
||||
Finalise(assembled string) string // whole-string; appends ".docx" for filename
|
||||
}
|
||||
```
|
||||
|
||||
This is the one subtlety that makes the engine faithful to 354. Both shipped schemes drop out of `(Composition, VarResolver, RenderTarget)` with no special-casing.
|
||||
|
||||
### 2.4 Variable catalog
|
||||
|
||||
A `VarCatalog` is an extensible registry: `key → VarDef{ Label, LabelEN, Description, Group }`. The catalog is **metadata only** (for validation + the settings palette); **values** come from the per-render `VarResolver` the consumer supplies. This keeps the engine pure — a consumer registers which keys it can resolve, the engine validates a composition only references known keys.
|
||||
|
||||
v1 catalog (the union of what the two schemes need + obvious near-neighbours):
|
||||
|
||||
| key | meaning | resolver source (submission consumer) |
|
||||
|-----|---------|----------------------------------------|
|
||||
| `date` | render-time today, Europe/Berlin, `YYYY-MM-DD` | engine-provided default resolver (see §2.5) |
|
||||
| `keyword` | document/submission type; user-overridable | `composer_meta.filename_keyword` → rule name (lang-aware) → "submission" |
|
||||
| `case_number` | project Aktenzeichen | `project.CaseNumber` |
|
||||
| `client` | root-ancestor client name | project-tree walk (existing `autoNameForProject`) |
|
||||
| `forum` | short forum label (UPC/EPA/LG/…) | `submissionForumShort(pt)` (existing) |
|
||||
| `opponent` | primary opposing party name | `submissionOpponentName(parties, ourSide)` (existing) |
|
||||
|
||||
Registered-but-deferred keys (declared so compositions can reference them, resolvers added when a consumer needs them): `proceeding`, `lang`, `client_matter`, `project_name`, `draft_counter`.
|
||||
|
||||
**Extensibility contract:** a new consumer (e.g. docforge export) builds its own `VarCatalog` subset + `VarResolver` and registers an artifact (§4). It never edits the engine.
|
||||
|
||||
### 2.5 The `date` resolver
|
||||
|
||||
The engine ships a default `date` resolver: `time.Now()` → `Europe/Berlin` → `Format("2006-01-02")`. This is the **one** variable the engine resolves itself (both shipped schemes compute it identically), so a consumer that only wants the standard date doesn't re-implement it. A consumer may override `date` in its resolver (e.g. a created-at date) — but v1 does not.
|
||||
|
||||
---
|
||||
|
||||
## §3 Settings & precedence
|
||||
|
||||
### 3.1 Precedence chain (v1)
|
||||
|
||||
Resolution order for a given artifact, **first hit wins**:
|
||||
|
||||
```
|
||||
per-document override → user override → firm default → system default
|
||||
(highest priority) (always present)
|
||||
```
|
||||
|
||||
- **System default** — code-resident, per artifact. The seed `Composition` literals (§5). Always exists; nothing can delete it.
|
||||
- **Firm default** — optional admin-set row, mirrors `firm_dashboard_default` (P5). A firm can mandate a house naming convention. Cleared → reverts to system.
|
||||
- **User override** — per-user, stored in a jsonb bag keyed by artifact id. Absent key → fall through.
|
||||
- **Per-document override** — the **already-shipped** `composer_meta.filename_keyword`, generalised to a `composer_meta.name_overrides` map of `{var → value}` (back-compat: `filename_keyword` reads as `name_overrides.keyword` for the filename artifact). This is a *variable-value* override, not a *composition* override — the user is replacing one token's value for one document, not redefining the template.
|
||||
|
||||
> **Why per-document is a value override, not a template override:** the shipped "Stichwort" editor lets a lawyer change *what the keyword is* for one draft, not *the shape of the name*. Keeping per-document as value-only avoids giving every draft its own editable template (scope creep) while preserving the shipped UX exactly.
|
||||
|
||||
### 3.2 Storage
|
||||
|
||||
| Level | Where | Shape |
|
||||
|-------|-------|-------|
|
||||
| System | Go code (`nomen` consumer package) | `Composition` literals |
|
||||
| Firm | **new** `paliad.firm_name_compositions` (id=1 singleton, mirrors `firm_dashboard_default`) | `jsonb`: `{ artifact_id: Composition }` map, validated |
|
||||
| User | **new column** `paliad.users.name_compositions jsonb NOT NULL DEFAULT '{}'` (mirrors `email_preferences`) | `{ artifact_id: Composition }` map |
|
||||
| Per-document | **existing** `submission_drafts.composer_meta` | `{ name_overrides: { var: value } }` (supersedes flat `filename_keyword`) |
|
||||
|
||||
A `NameCompositionSpec` type gets `Validate()` (write — references-known-vars, known-artifact, ≤ N segments) and `SanitizeForRead()` (read — drop segments referencing dropped vars, clamp version), exactly like `DashboardLayoutSpec`. This is the closest existing analog and the pattern is copy-shaped.
|
||||
|
||||
> **Project-level (v1.1, deferred):** when it lands, it slots between user and firm (`per-document → user → project → firm → system`) and stores under a documented `projects.metadata.name_compositions` sub-key — **no migration needed** (P6: column exists). The "project vs user, who wins?" call (Q2) is deferred with it; the v1.1 default is **user wins** (a lawyer's personal convention beats a matter's), but that's a v1.1 decision, flagged here so v1 storage doesn't preclude it.
|
||||
|
||||
---
|
||||
|
||||
## §4 Artifact registry
|
||||
|
||||
An **artifact** is a named thing that gets a name: it binds a default composition, an allowed-variable subset, and a render target.
|
||||
|
||||
```go
|
||||
type Artifact struct {
|
||||
ID string // "submission_draft_title", "submission_docx_filename"
|
||||
Label string // for the settings UI
|
||||
Catalog VarCatalog // which variables are available here
|
||||
Target RenderTarget // title vs filename
|
||||
SystemDefault Composition // the seed (§5)
|
||||
}
|
||||
```
|
||||
|
||||
v1 registry (`internal/services/namegen` — the paliad-side wiring; `pkg/nomen` stays pure):
|
||||
|
||||
| Artifact ID | Target | Wired in v1? |
|
||||
|-------------|--------|--------------|
|
||||
| `submission_draft_title` | title | **yes** |
|
||||
| `submission_docx_filename` | filename `.docx` | **yes** |
|
||||
| `docforge_export` | filename | registered, **unwired** (opts in when docforge ships) |
|
||||
| `data_zip_export` | filename `.zip` | registered, **unwired** (keeps `ExportFilename` shape) |
|
||||
| `projection_slug` | slug | registered, **unwired** |
|
||||
|
||||
Registering-but-not-wiring means: the artifact ID exists in the catalog so the settings UI *could* list it and a composition *could* be stored, but the consumer still calls its current code path until a follow-up task flips it. No dead behaviour, no speculative resolver code.
|
||||
|
||||
> **`data_zip_export` note:** `ExportFilename` (`paliad-export-project-<slug>-<short>-<ts>.zip`) is deliberately machine-shaped (UTC timestamp, uuid disambiguator) — it is **not** a legal title and should **not** inherit the legal-composition defaults. It is registered for *discoverability*, but its eventual opt-in would use a distinct catalog (slug/timestamp/uuid vars), confirming the engine generalises beyond the legal-title model without forcing that model on it.
|
||||
|
||||
---
|
||||
|
||||
## §5 Seed defaults (the two shipped schemes, as data)
|
||||
|
||||
### 5.1 `submission_draft_title` (reproduces `AutoSubmissionTitle`, #155)
|
||||
|
||||
```
|
||||
Segments:
|
||||
{ Var: "date", Sep: "", Missing: omit }
|
||||
{ Var: "client", Sep: " ", Missing: omit }
|
||||
{ Var: "forum", Sep: " ./. ", Missing: omit }
|
||||
{ Var: "opponent", Sep: " ./. ", Missing: omit }
|
||||
Target: TitleTarget
|
||||
```
|
||||
|
||||
- All-omit + separator-suppression reproduces "drop empty segment with its leading separator".
|
||||
- `date` with `Sep: ""` and the others' first-emitted-suppresses-Sep rule yields `2026-05-31 Bayer AG ./. UPC` when opponent is empty — identical to today.
|
||||
- Non-project draft: `client`/`forum`/`opponent` resolve `("", false)` → all omitted → renders bare `<date>`. **This is the non-project fix** (§6).
|
||||
|
||||
### 5.2 `submission_docx_filename` (reproduces `submissionFileName`, 354)
|
||||
|
||||
```
|
||||
Segments:
|
||||
{ Var: "date", Sep: "", Missing: omit }
|
||||
{ Var: "keyword", Sep: " ", Missing: literal("submission") }
|
||||
{ Var: "case_number", Sep: " ", Wrap: {"(", ")"},
|
||||
Missing: placeholder("Az. folgt") }
|
||||
Target: FilenameTarget{ext: ".docx"}
|
||||
```
|
||||
|
||||
- `keyword` missing → `literal("submission")` reproduces the `kw == "" → "submission"` fallback.
|
||||
- `case_number` missing → `placeholder("Az. folgt")`, wrapped in parens → `(Az. folgt)`.
|
||||
- `FilenameTarget` sanitises each value via `SanitiseSubmissionFileName`, preserves the frame, appends `.docx`. Output identical to 354.
|
||||
|
||||
**Faithfulness test (acceptance gate):** golden-file table tests assert the engine's output is byte-equal to the current `AutoSubmissionTitle` / `submissionFileName` across the existing test matrix (with/without opponent, with/without case-number, en/de, umlaut folding). The shipped funcs become thin wrappers over the engine, or are deleted once call-sites move.
|
||||
|
||||
---
|
||||
|
||||
## §6 The non-project fix
|
||||
|
||||
Currently `newDraftName` only calls `autoNameForProject` when `project != nil`; otherwise `nextDraftName` → `Entwurf N`. Under the engine:
|
||||
|
||||
- A non-project draft renders `submission_draft_title` with a resolver where `client/forum/opponent` are all `("", false)` → composition degrades to `<date>`.
|
||||
- Per Q4, the default gains a `keyword` segment so non-project drafts read **`<date> <keyword>`** where `keyword` = submission/document type if the draft has a `submission_code` that maps to a rule, else falls back.
|
||||
- **Fallback when no keyword context:** if `keyword` also resolves empty (project-less draft with no `submission_code`/rule), the title degrades to `<date> Entwurf N` — `Entwurf N` enters as the `keyword` segment's `literal` fallback **with** the existing counter, so uniqueness is preserved via `uniqueDraftName` (P7).
|
||||
|
||||
> **FLAG (coder):** confirm whether project-less drafts (t-paliad-243) carry a `submission_code`. If yes, `keyword` derives from the rule like the project path. If no, the `literal("Entwurf N")` fallback is the norm and non-project names read `<date> Entwurf N` — still satisfies "date first there" (m's ask). Resolve in implementation; both paths are handled by the same composition.
|
||||
|
||||
The non-project title is the **same** `submission_draft_title` artifact — not a separate composition. Degradation is data-driven, not a code branch. This is the payoff of the engine: the gap closes by *removing* the `project != nil` special-case, not adding another.
|
||||
|
||||
---
|
||||
|
||||
## §7 Settings UX (v1)
|
||||
|
||||
A section on the existing `/settings` page (017 surface):
|
||||
|
||||
- **Per artifact** (v1 lists the 2 wired ones): a single-line **token-template string** field, e.g. `{date} {keyword} ({case_number})`.
|
||||
- A **token palette**: clickable chips (`{date}` `{client}` `{forum}` `{opponent}` `{keyword}` `{case_number}`) insert at cursor. Chips show the localised label (DE primary / EN secondary).
|
||||
- A **live preview** rendered against a **sample project** (fixed fixture: client "Bayer AG", forum "UPC", opponent "Sandoz", case "UPC_CFI_123/2026", today's date) so the user sees the result instantly — and a second preview line with empties so they see the missing-rule behaviour.
|
||||
- **Reset to firm/system default** button (mirrors the dashboard "reset layout").
|
||||
|
||||
**String ⇄ segments:** the field is the *shorthand* (Q1). A small parser compiles `{var}` tokens + surrounding literals into `Segments` (separators = the literal runs between tokens; `(…)` around a token → `Wrap`). Missing-rules are **not** in the string (Q7) — they come from the system default for that var and are not user-editable in v1. So a user can reorder/drop/re-add tokens and change literals, but can't (yet) flip case-number from placeholder to omit. That's a deliberate v1 boundary; the structured model already supports it, the UI just doesn't expose it.
|
||||
|
||||
> Parser edge: a `{token}` the catalog doesn't know → inline validation error ("Unknown variable {foo}"), preview shows nothing, save disabled. Mirrors `DashboardLayoutSpec.Validate` rejecting unknown widget keys.
|
||||
|
||||
---
|
||||
|
||||
## §8 Slice train
|
||||
|
||||
Sliced so a **tracer bullet** ships value before the settings UI exists.
|
||||
|
||||
- **Slice 1 — Engine + faithful refactor (no behaviour change).**
|
||||
`pkg/nomen` (types, render, targets, catalog) + `internal/services/namegen` (artifact registry + the 2 seed compositions + resolvers built from existing `submission_autoname.go` helpers). Re-point `AutoSubmissionTitle` and `submissionFileName` call-sites at the engine. **Acceptance:** §5 golden-file byte-equality; all existing #155/354 tests green unchanged. *No user-visible change — this is the safety net.*
|
||||
- **Slice 2 — Non-project date-first (§6).**
|
||||
Remove the `project != nil` special-case in `newDraftName`; non-project drafts render `submission_draft_title`. **Acceptance:** project-less draft gets `<date> <keyword>` (or `<date> Entwurf N` fallback); existing project drafts unchanged. *First user-visible win, m's immediate ask.*
|
||||
- **Slice 3 — Precedence: system → user (per-document already shipped).**
|
||||
`users.name_compositions jsonb` column + `NameCompositionSpec` (`Validate`/`SanitizeForRead`) + resolution that prefers a user override over the system default. Generalise `composer_meta.filename_keyword` → `name_overrides.keyword` (back-compat read). *No UI yet — overrides settable via API/test.*
|
||||
- **Slice 4 — Settings UX (§7).**
|
||||
`/settings` token-template field + palette + live preview for the 2 wired artifacts. *User can now customise.*
|
||||
- **Slice 5 — Firm default.**
|
||||
`firm_name_compositions` singleton + admin surface, mirroring `firm_dashboard_default`. Slots into precedence below user. *Firm-wide convention.*
|
||||
|
||||
Slices 1–2 are the tracer bullet (engine proven on shipped behaviour + the gap closed). 3–5 layer settings without re-touching the engine.
|
||||
|
||||
---
|
||||
|
||||
## §9 Out of scope (this PRD)
|
||||
|
||||
- Implementation, migration SQL drafting, Go code.
|
||||
- Re-litigating #155 / 354 behaviour — they are the seed defaults, reproduced not redesigned.
|
||||
- **Project-level** compositions (v1.1; storage path reserved in §3.2, precedence call deferred).
|
||||
- Wiring `docforge_export`, `data_zip_export`, `projection_slug` — registered, not migrated (each is its own follow-up when the surface needs it).
|
||||
- Naming for non-doc-generation strings across the app.
|
||||
- User-editable **missing-rules** in the settings UI (model supports it; UI deferred past v1).
|
||||
|
||||
---
|
||||
|
||||
## §10 Open questions (historical record — resolved in § m's decisions)
|
||||
|
||||
1. Composition representation — token-string vs structured-segments vs both. → **Q1: structured + string shorthand.**
|
||||
2. v1 precedence levels. → **Q2: system → firm → user → per-document.**
|
||||
3. Generalisation depth (YAGNI vs engine-now). → **Q3: reusable engine, 3 consumers wired.**
|
||||
4. Non-project default name. → **Q4: `<date> <keyword>`.**
|
||||
5. Missing-rule policy set. → **Q5: omit + placeholder + literal.**
|
||||
6. Date semantics. → **Q6: render-time today, Europe/Berlin, `YYYY-MM-DD`.**
|
||||
7. Settings UX shape. → **Q7: live-preview string field + palette.**
|
||||
8. Artifact registry scope. → **Q8: 2 submission artifacts + extensible registry.**
|
||||
|
||||
**Remaining FLAGs for the coder (not blocking design approval):**
|
||||
- Package name `pkg/nomen` (vs `naming`/`namegen`) — implementation pick.
|
||||
- Whether project-less drafts carry a `submission_code` (decides `keyword` source in §6).
|
||||
- `name_overrides` back-compat read of the existing `filename_keyword` key — confirm the one shipped draft-keyword row migrates cleanly (live round-trip test, like t-paliad-354's).
|
||||
685
docs/plans/prd-procedures-litigation-planner-2026-05-27.md
Normal file
685
docs/plans/prd-procedures-litigation-planner-2026-05-27.md
Normal file
@@ -0,0 +1,685 @@
|
||||
# PRD — Procedures: Litigation Builder (m/paliad#153)
|
||||
|
||||
**Task:** t-paliad-339
|
||||
**Gitea:** m/paliad#153
|
||||
**Inventor:** edison (shift-1, Opus)
|
||||
**Date:** 2026-05-27
|
||||
**Branch:** `mai/edison/inventor-prd-columnar`
|
||||
**Status:** Draft — DESIGN READY FOR REVIEW. Coder gate held.
|
||||
|
||||
**Builds on (read before extending this PRD):**
|
||||
|
||||
- `docs/design-procedures-workflow-tracker-2026-05-27.md` — atlas's reverted tracker design (m/paliad#152). The anchor+scope idea did not land; understand *why* before re-proposing.
|
||||
- `docs/design-unified-procedural-events-tool-2026-05-27.md` — cronus's U0-U4 catalog, currently live on main @ `ed3c5d1` post-revert. Visual baseline for filter strip + tab control.
|
||||
- `docs/design-deadline-system-revision-2026-05-27.md` — atlas Phase 2 model layer (scenario_flags SSoT, view-mode toggle, per-rule selection chips). Model layer is locked; this PRD is purely surface + new persistence tables.
|
||||
- `docs/design-fristenrechner-overhaul-2026-05-26.md` — cronus 2026-05-26 inventor-pass (Mode A + B + result, shipped via t-paliad-322).
|
||||
|
||||
**Predecessor takeaway (atlas's debrief on #152):**
|
||||
|
||||
> "When the architecture is novel, default to grilling m in prose FIRST. The doc rewrites cost a commit; the bigger cost would have been wasting m's question-batch on the wrong architecture."
|
||||
|
||||
Followed here. This PRD captures the architecture m chose through **20 chip-picker decisions across 5 batches**, not an inventor-first strawman.
|
||||
|
||||
---
|
||||
|
||||
## §0 Premises
|
||||
|
||||
### §0.1 What is `/tools/procedures` today (live, post-revert)
|
||||
|
||||
The current page is cronus's 4-tab catalog (U0-U4, shipped via m/paliad#151):
|
||||
|
||||
- Sticky filter strip (search box + 4 chip rows: Forum / Verfahren / Ereignisart / Partei).
|
||||
- 4 solid tabs: `Verfahren wählen` / `Direkt suchen` / `Geführt` / `Aus Akte`.
|
||||
- Default-active tab = "Verfahren wählen" renders `VerfahrensablaufBody` (the legacy Verfahrensablauf wizard: proceeding picker → perspective + date → 3-step wizard → result in 3-column "Spalten" or single-column "Zeitstrahl").
|
||||
- Other 3 tab panels are stubs (search/wizard/akte never wired in U0-U3).
|
||||
|
||||
m's blocking feedback (verbatim, 2026-05-27 22:18):
|
||||
|
||||
> I like to keep our current columnar layout with proactive / court / reactive. And it is good if we can select which side we want to simulate. […] There are basically three main approaches I see to this: Get an overview over proceedings, play around with options, build Scenarios. Another one where something specific happened and we just want to know what deadlines we need to note […]. A third one from a specific proceeding / case file where things take place / have taken place.
|
||||
|
||||
And the architecture-shifting follow-ups (2026-05-27 22:35-22:36, mid-grilling):
|
||||
|
||||
> I would prefer to have an interface where not every constellation is in the URL by the way. That seems limiting.
|
||||
> We could just have a litigation builder. Sometimes we build a full scenario with multiple instances etc, sometimes we just want the next step.
|
||||
> we should have ways to save these "litigation constellations" where we save which proceedings we have and which state they are in, which submissions were or were not filed. A small Scenario DB could work, dont you think?
|
||||
|
||||
These three statements upgraded the brief from "redesign a catalog" to "build a Litigation Builder backed by a Scenario DB". The PRD below is shaped by them.
|
||||
|
||||
### §0.2 Locked constraints (m's words, brief in #153)
|
||||
|
||||
- Columnar layout: `proaktiv | court | reaktiv` (perspective-flippable).
|
||||
- Three approaches as entry modes: overview/scenarios, event-triggered, case-file driven.
|
||||
- Filtering across all dimensions + text search.
|
||||
- Optional follow-ups: toggleable, highlightable, with display-count setting.
|
||||
- Modular *where it actually helps* (m: "I don't know — generally does not super apply here." — drop modular as a load-bearing goal).
|
||||
- UPC v1, expand later.
|
||||
|
||||
### §0.3 Live data the builder works against
|
||||
|
||||
Verified 2026-05-27 against `paliad.sequencing_rules` (231 published / 242 total):
|
||||
|
||||
- 110 chained (`parent_id` not null).
|
||||
- 78 trigger-rooted, 4 spawns (cross-PT), 47 court-set, 18 conditional.
|
||||
- ~46 proceeding types total (UPC 35 / DE 5 / EPA 3 / DPMA 3). v1 focuses on UPC.
|
||||
- `paliad.proceeding_types.kind` discriminator (atlas's t-paliad-324) filters non-proceeding rows (phases/side_actions/meta) from the picker.
|
||||
- `paliad.deadlines` carries both `procedural_event_id` and `sequencing_rule_id` → Akte actuals overlay is a direct join.
|
||||
- `paliad.projects.scenario_flags` jsonb (atlas P0) is the SSoT for project-level scenario state; the new `paliad.scenario_proceedings.scenario_flags` mirrors this shape per-proceeding-per-scenario.
|
||||
|
||||
### §0.4 Scope (in / out)
|
||||
|
||||
**In:**
|
||||
|
||||
- Replace `/tools/procedures` with the Litigation Builder.
|
||||
- New `paliad.scenarios` + `paliad.scenario_proceedings` + `paliad.scenario_events` + `paliad.scenario_shares` tables.
|
||||
- Promote-to-project flow (scenario → `paliad.projects` row).
|
||||
- Bidirectional link from `/projects/{id}` (button: "Im Builder öffnen" — exports project state to a builder session).
|
||||
|
||||
**Out (deferred or owned elsewhere):**
|
||||
|
||||
- Calculator (`pkg/litigationplanner.CalculateRule`) — working.
|
||||
- Editorial backfill (curie's t-paliad-333 owns the 7 compound rules + R.109).
|
||||
- `/admin/procedural-events` (editor surface; different audience).
|
||||
- `/projects/{id}` Verlauf / SmartTimeline (per-Akte actuals; sister tool).
|
||||
- youpc.org / Outlook / PDF export.
|
||||
- Multi-jurisdiction expansion (DE/EPA/DPMA) — UPC v1 first.
|
||||
- Cross-proceeding peer triggers (UPC-inf judgment → EPA opp choice deadline) — v1.1.
|
||||
- Multi-user concurrent editing on the same scenario (out of scope; sharing is read-only).
|
||||
|
||||
---
|
||||
|
||||
## §1 Goals
|
||||
|
||||
1. **One canvas, three entry modes.** Unify the 3 approaches into a single Litigation Builder surface. The entry modes (`Übersicht / Ereignis / Aus Akte`) shape the *initial* state of the canvas; once the user is working, the canvas itself is what they interact with.
|
||||
2. **Persisted constellations.** A user can save a "litigation constellation" — multiple parallel proceedings with their flags, filed/skipped/planned event states, dates, and notes — as a named scenario. Scenarios live in the DB (not the URL).
|
||||
3. **Auto-save by default.** No "unsaved changes" modals. The active scenario auto-persists. Anonymous scratch scenarios convert to named ones when the user clicks "Benennen".
|
||||
4. **Promote-to-project.** A scenario can be turned into a real `paliad.projects` row via a 3-step wizard. Procedural shape, placeholder parties, notes, and filed-state all carry over; the user fleshes out client-bound metadata during the wizard.
|
||||
5. **Share read-only with the team.** Each scenario is private by default; explicit "An Team teilen" grants named HLC users read-only access. Original owner stays sole editor.
|
||||
6. **Columnar geometry restored.** The current "Spalten" view (claimant | court | defendant) returns as the canonical render — but now per-proceeding-triplet within a scenario, with perspective ("our side") flippable per proceeding so `proaktiv | court | reaktiv` reads correctly across multi-proceeding constellations.
|
||||
7. **Per-event-card optional horizon.** Each event card on the canvas can dial in how many optional follow-ups to surface. Cards are the unit of optional-display control.
|
||||
|
||||
---
|
||||
|
||||
## §2 User journeys
|
||||
|
||||
### §2.1 Journey A — Cold-open builder ("Übersicht / Scenarios")
|
||||
|
||||
**Persona:** Dr. Becker, senior partner. Friday afternoon. New UPC matter not yet committed; she's briefing a client on Monday on the full procedural shape.
|
||||
|
||||
1. Opens `/tools/procedures`. No `?scenario` param. Cold-open canvas: empty workbench with a "Neues Szenario starten" CTA and a short list of her 5 most-recent scenarios.
|
||||
2. Clicks the CTA → inline picker (Forum chip row → Verfahren chip row → `Hinzufügen`). Picks UPC + `upc.inf.cfi`.
|
||||
3. Canvas now renders one proceeding triplet (`proaktiv | court | reaktiv`). Default perspective is empty (no party selected) — both sides render equally; the perspective radio in the page header sits unset.
|
||||
4. She picks defendant perspective at the page header → triplet flips. The defendant column becomes `proaktiv` (her side); claimant becomes `reaktiv`.
|
||||
5. She adds a second proceeding via `+ Verfahren hinzufügen` at the bottom: EPA `epa.opp.opd`. New triplet stacks below the first. New triplet's perspective defaults to "patentee" inheriting from her client's role across the two; she flips per-proceeding via the triplet header.
|
||||
6. She turns on `with_ccr` on the UPC inf triplet's per-proceeding flag strip. The CCR child triplet auto-expands inline below the parent at the spawn node.
|
||||
7. Auto-save kicks in (debounced 500ms). The page header shows "Gespeichert in Scratch · Benennen".
|
||||
8. She clicks "Benennen", enters "Becker — UPC + EPA defensive". Side panel "Meine Szenarien" updates.
|
||||
9. On Monday she opens the scenario from her recent list, walks the client through it, hits "Als Projekt anlegen" (when the client commits). 3-step wizard fires (§5.4).
|
||||
|
||||
### §2.2 Journey B — Event-triggered lookup ("Ereignis")
|
||||
|
||||
**Persona:** Sandra, paralegal. Today: a Hinweisbeschluss arrived on a CMS queue. She doesn't know yet which Akte it belongs to.
|
||||
|
||||
1. Opens `/tools/procedures`. Picks "Ereignis" entry mode at the top.
|
||||
2. Page-header search box auto-focuses. She types "Hinweis" → universal search drops down: `5 Ereignisse · 1 Szenario · 0 Akten`. Picks the event `upc.inf.cfi.cmo_review` (Antrag CMO-Überprüfung).
|
||||
3. Canvas renders one triplet of `upc.inf.cfi` with the Hinweisbeschluss event card auto-anchored (lime band + `━━ DU BIST HIER ━━` divider above the next-coming events).
|
||||
4. She reads the follow-ups: "Antrag auf CMO-Überprüfung (claimant, R.333.2 · 1 Monat)" and 2 optional follow-ups. The Stichtag input in the page header defaults to today; she leaves it.
|
||||
5. She doesn't save anything — this was a quick lookup. Scratch scenario auto-persists but she doesn't name it; it'll fall off her recent list after a while.
|
||||
6. Later she identifies the matter (HL-2024-001), switches to "Aus Akte" mode, and continues there.
|
||||
|
||||
### §2.3 Journey C — Case-file driven ("Aus Akte")
|
||||
|
||||
**Persona:** Anna, senior associate. Working on HL-2024-001 (UPC infringement). The client just confirmed they want to file a CCR.
|
||||
|
||||
1. Opens `/tools/procedures`. Page-header Akte picker shows recent projects; she picks HL-2024-001.
|
||||
2. Page header auto-fills: proceeding = `upc.inf.cfi`, perspective = defendant (from `projects.our_side`), scenario_flags = `{with_ccr: false}` (current state).
|
||||
3. Builder loads: one `upc.inf.cfi` triplet, perspective-flipped. Event cards overlay actuals from `paliad.deadlines` — `Klageerhebung` is filed (2026-01-15), `Klageerwiderung` is planned (2026-04-01, computed), others are planned.
|
||||
4. She turns on `with_ccr` on the triplet's flag strip. The CCR child triplet expands inline. **Crucially:** the scenario is *project-backed* — the flag write also patches `projects.scenario_flags` (via existing `PATCH /api/projects/{id}/scenario-flags` from atlas P0). When she walks away, the project's deadlines + flags reflect the builder's state.
|
||||
5. She marks the `Widerklage auf Nichtigkeit` event card as "filed" with today's date. Builder writes a `paliad.deadlines` row with `status='done'` + `completed_at=today`, audit_reason "via Litigation Builder". Project's Verlauf reflects this.
|
||||
6. The CCR child triplet's `Antrag Patentänderung (R.30)` event card surfaces. She marks it "planned" and ticks the per-card optional horizon to "+2" → 2 more optional R.30-adjacent rules surface.
|
||||
7. Exit: she closes the tab. Project state persists in `paliad.projects` + `paliad.deadlines` as before; the scenario row tracks the builder-session view (so when she returns, the canvas state is restored — including her per-card optional-horizon picks).
|
||||
|
||||
### §2.4 Journey D — Promote scratch to a real project
|
||||
|
||||
**Persona:** Dr. Becker, follow-up from Journey A. The client committed; she wants to convert the scenario into a real matter.
|
||||
|
||||
1. With "Becker — UPC + EPA defensive" loaded, she clicks "Als Projekt anlegen" in the page header.
|
||||
2. **Wizard step 1: Bestätigen.** Read-only summary of what's about to be promoted: 2 proceedings (UPC inf + EPA opp), CCR child, 3 scenario flags set, 0 events filed, 5 events planned, 2 notes. "Weiter".
|
||||
3. **Wizard step 2: Parteien ergänzen.** Each proceeding's parties section shows whatever placeholder names she sketched in the scenario ("Klg X" / "Bekl Y"). She edits each into the real names. (Per m's Q11 pick — full carry — placeholder strings come in; the wizard's job is to clean them.)
|
||||
4. **Wizard step 3: Akte-Metadaten.** Case number, client, litigation parent project (optional), our_side (auto-set from the scenario's primary triplet), team selection. "Anlegen".
|
||||
5. New `paliad.projects` row written with `origin_scenario_id = <scenario.id>`. Scenario row's `status` flips to `promoted`, `promoted_project_id` points back. Builder navigates to `/projects/<new-id>`.
|
||||
6. The scenario stays read-only in her "Meine Szenarien" list under "Promoted", reachable for historical reference (cf. "this is what we planned at briefing time").
|
||||
|
||||
### §2.5 Journey E — Share a scenario with a colleague
|
||||
|
||||
**Persona:** Anna shares the HL-2024-001 builder session with Dr. Becker (her supervising partner) for review before committing to the CCR strategy.
|
||||
|
||||
1. Anna opens the scenario, clicks "Teilen" in the page header.
|
||||
2. Side panel slides in with a user-picker (HLC user search). She picks "Dr. Becker", clicks "Schreibgeschützt teilen".
|
||||
3. `paliad.scenario_shares` row written. Anna remains sole editor.
|
||||
4. Dr. Becker opens the tool. Her side panel "Meine Szenarien" has a new bucket "Geteilt mit mir"; Anna's scenario is listed. She opens it: canvas renders the same view but every mutating affordance (add proceeding, flag toggle, file/skip, promote, share) is disabled. Watermark: "Geteilt von Anna · schreibgeschützt".
|
||||
5. Becker reads, drops Anna a note via existing comment infrastructure (out of scope — separate ticket). Decision made out-of-band. Anna proceeds.
|
||||
|
||||
---
|
||||
|
||||
## §3 The canvas shape
|
||||
|
||||
### §3.1 ASCII sketch
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ Paliad · Verfahren & Fristen — Litigation Builder [Mein Konto ▾] │
|
||||
├─────────────────────────────────────────────────────────────────────────────────────┤
|
||||
│ Szenario: [Becker — UPC + EPA def. ▼] Gespeichert ✓ · [Benennen] [Teilen] [Als Projekt] │
|
||||
│ Akte: [— ohne — ▼] Stichtag: [2026-04-01] │
|
||||
├─────────────────────────────────────────────────────────────────────────────────────┤
|
||||
│ Filter: [🔍 Klageerwiderung, Hinweis, HL-2024… ] │
|
||||
│ Forum [● UPC] [DE] [EPA] [DPMA] Verfahren [● upc.inf.cfi …] │
|
||||
│ Partei [Klg] [● Bekl] Ereignisart [filing] [hearing] [decision] │
|
||||
├─────────────────────────────────────────────────────────────────────────────────────┤
|
||||
│ Einstieg: [ Übersicht ● ][ Ereignis ○ ][ Aus Akte ○ ] │
|
||||
├─────────────────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─ upc.inf.cfi · Verletzungsverfahren UPC Bekl-Sicht [▾] [Detailgrad: Gewählt ▾]│
|
||||
│ │ Optionen: ☑ with_ccr ☐ with_amend ☐ with_cci [─][×] │
|
||||
│ ├─────────────────┬────────────────────┬─────────────────────────────────────────┤
|
||||
│ │ Proaktiv (Bekl) │ Gericht │ Reaktiv (Klg) │
|
||||
│ │ ┌─────────────┐ │ │ ┌─────────────┐ │
|
||||
│ │ │ Klageerw. │ │ │ │ Klageerh. │ │
|
||||
│ │ │ R.23 │ │ │ │ R.13 │ │
|
||||
│ │ │ planned │ │ │ │ filed │ │
|
||||
│ │ │ 2026-04-01 │ │ │ │ 2026-01-15 │ │
|
||||
│ │ │ +3 Optionen ▾│ │ │ │ │ │
|
||||
│ │ └─────────────┘ │ │ └─────────────┘ │
|
||||
│ │ │ ┌──────────────┐ │ │
|
||||
│ │ │ │ Mündl. Verh. │ │ │
|
||||
│ │ │ │ planned │ │ │
|
||||
│ │ │ │ [Gericht] │ │ │
|
||||
│ │ │ └──────────────┘ │ │
|
||||
│ │ ━━━━━━━━━ DU BIST HIER (Klageerwiderung) ━━━━━━━━━ │
|
||||
│ └─────────────────┴────────────────────┴─────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌── (spawn child) upc.ccr.cfi · Widerklage auf Nichtigkeit Klg-Sicht [▾] ───────┐│
|
||||
│ │ Optionen: ☐ with_amend [─][×]││
|
||||
│ ├────────────────────┬─────────────────┬───────────────────────────────────────┐ ││
|
||||
│ │ Proaktiv (Klg) │ Gericht │ Reaktiv (Bekl) │ ││
|
||||
│ │ ┌─────────────┐ │ │ │ ││
|
||||
│ │ │ CCR-Antrag │ │ │ │ ││
|
||||
│ │ │ R.49 │ │ │ │ ││
|
||||
│ │ │ planned │ │ │ │ ││
|
||||
│ │ └─────────────┘ │ │ │ ││
|
||||
│ └────────────────────┴─────────────────┴───────────────────────────────────────┘ ││
|
||||
│ │
|
||||
│ ┌─ epa.opp.opd · Einspruchsverfahren EPA PatInh-Sicht [▾] [Detailgrad: Gewählt ▾]│
|
||||
│ │ Optionen: (keine flags für EPA Opp) [─][×] │
|
||||
│ ├─────────────────┬────────────────────┬─────────────────────────────────────────┤
|
||||
│ │ Proaktiv │ EPA │ Reaktiv (Einsprechende) │
|
||||
│ │ ┌─────────────┐ │ │ │
|
||||
│ │ │ Erwiderung │ │ │ │
|
||||
│ │ │ R.79(1) EPÜ │ │ │ │
|
||||
│ │ │ planned │ │ │ │
|
||||
│ │ └─────────────┘ │ │ │
|
||||
│ └─────────────────┴────────────────────┴─────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ [ + Verfahren hinzufügen ] │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
Side panel (collapsible, right-edge):
|
||||
┌──── Meine Szenarien ────┐
|
||||
│ ● Aktiv │
|
||||
│ ▸ Becker — UPC+EPA def │ ← current
|
||||
│ ▸ Test-CCR-Patent-X │
|
||||
│ ○ Geteilt mit mir │
|
||||
│ ▸ Becker UPC ply │
|
||||
│ ○ Promoted │
|
||||
│ ▸ HL-2023-118 │
|
||||
│ ○ Archiviert (3) │
|
||||
│ [+ Neues Szenario] │
|
||||
└──────────────────────────┘
|
||||
```
|
||||
|
||||
### §3.2 What each element does
|
||||
|
||||
| Element | Read | Write | Persists in |
|
||||
|---|---|---|---|
|
||||
| Page-header scenario picker | Current `scenarios.id` + `name` | Switch scenarios | URL `?scenario=<id>` + DB |
|
||||
| `[Benennen]` button | Anonymous → named | `scenarios.name`, `status='active'` | DB |
|
||||
| `[Teilen]` button | — | `scenario_shares` row(s) | DB |
|
||||
| `[Als Projekt]` button | — | Opens promote wizard | (wizard → DB on commit) |
|
||||
| Akte picker | User's projects | Loads project state into builder | URL `?project=<id>` + DB |
|
||||
| Stichtag input | Scenario-level default | `scenarios.stichtag` | DB |
|
||||
| Filter strip (search + chips) | Free-text + dimension filters | UI state | URL `?q`, `?forum`, … per-mode |
|
||||
| Einstieg mode radio | Current entry mode | Resets filter strip on change | URL `?mode=` |
|
||||
| Triplet header (jurisdiction badge + name + perspective + Detailgrad) | `scenario_proceedings.{primary_party, detailgrad}` | Edit | DB |
|
||||
| Triplet flag strip | `scenario_proceedings.scenario_flags` | Toggle flags | DB |
|
||||
| Event card (state, date, notes, optional-horizon) | `scenario_events.*` | Edit per-card | DB |
|
||||
| `+ Verfahren hinzufügen` | — | New `scenario_proceedings` row | DB |
|
||||
| Side panel | User's scenarios + shared scenarios | Switch + create + archive | DB |
|
||||
|
||||
### §3.3 Columns: `proaktiv | court | reaktiv`
|
||||
|
||||
The 3-column layout returns as the canonical desktop shape. Per m's locked constraint (and brief #153), it is a **stance grouping**, not a sequence anchor — time flows top-to-bottom (chronological), columns express *who is acting*.
|
||||
|
||||
- **Proaktiv**: the column for events the active perspective's party initiates (their `primary_party` matches the event's `primary_party`).
|
||||
- **Court**: court-set events (`is_court_set=true`), neutral column.
|
||||
- **Reaktiv**: the column for events the opposing party initiates.
|
||||
|
||||
The perspective is per-proceeding (per-triplet, via `scenario_proceedings.primary_party`). When no perspective is set (`null`), both party columns render equally with their natural party labels (Klg / Bekl), not Proaktiv / Reaktiv. This means kontextfrei browsing reads as "claimant column | court | defendant column" until the user picks a side.
|
||||
|
||||
This addresses m's reverted-design bug #3 verbatim: "Proaktiv/Gericht/Reaktiv columns are a stance grouping, not a sequence anchor." Time = vertical. Stance = horizontal. The triplet is the unit; multiple proceedings stack vertically.
|
||||
|
||||
### §3.4 Event card anatomy
|
||||
|
||||
```
|
||||
┌─────────────────────┐
|
||||
│ Klageerwiderung │ ← event name (procedural_event.name)
|
||||
│ R.23 │ ← rule code
|
||||
│ planned │ ← state: planned / filed / skipped
|
||||
│ 2026-04-01 │ ← date (computed for planned, actual for filed)
|
||||
│ +3 Optionen ▾ │ ← per-card optional horizon (only when card has optionals)
|
||||
└─────────────────────┘
|
||||
```
|
||||
|
||||
State machine (m's Q10 pick — 3-state):
|
||||
|
||||
- `planned` (default): future event, date is computed from anchor + duration_value + duration_unit. Click → choose `filed` or `skipped`.
|
||||
- `filed`: past event, `actual_date` is set (defaults to computed, user can override). Visual: ✓ checkmark, slightly muted "past" tone.
|
||||
- `skipped`: user chose not to file. Visual: strikethrough text + optional `skip_reason` (textarea). Optional rules are commonly skipped without rationale; mandatory rules with `skipped` state flag the scenario as "non-standard" but don't block.
|
||||
|
||||
No `overdue` state — user does the date arithmetic by eye against today. (Mandatory cards rendered in red when `actual_date < today AND state=planned` is a **render hint**, not a stored state.)
|
||||
|
||||
**Per-card optional horizon (m's Q4 pick).** Each card with children at `priority IN ('optional','recommended-skip-by-default')` carries a chip `+N Optionen ▾`. Default N=0 (hidden). Clicking opens an inline list of the optional children with `+`/`-` controls to surface/hide them on the canvas. Per-card horizon persists as `scenario_events.horizon_optional int`.
|
||||
|
||||
Filed-state cards persist the date in `scenario_events.actual_date date`. The card's notes field (textarea, lazy-loaded) lives in `scenario_events.notes text`.
|
||||
|
||||
### §3.5 Court-set events
|
||||
|
||||
`is_court_set=true` rules don't compute a date until the court picks one. Card renders with `[Gericht]` badge in place of the date and a small "Datum eintragen" affordance. Clicking `filed` opens a date picker (date is required for `filed` state when `is_court_set=true` — the user is asserting "the court set this date").
|
||||
|
||||
Downstream events that anchor on a court-set event render their dates as `[abhängig von <event>]` until the court date is filed, then auto-recompute.
|
||||
|
||||
### §3.6 Spawn (child) proceedings
|
||||
|
||||
When a triplet has a `with_<flag>` enabled and the flag's gating rule has `is_spawn=true`, the child proceeding (e.g. `upc.ccr.cfi` for `with_ccr` on `upc.inf.cfi`) renders inline as a child triplet **immediately below the parent triplet** in the canvas stack — visually nested via the spawn note in the parent triplet's header band.
|
||||
|
||||
`scenario_proceedings.parent_scenario_proceeding_id` FK self-references for the nesting; `scenario_proceedings.spawn_anchor_event_id` points at the gating sequencing_rule so the UI knows where in the parent the spawn happened.
|
||||
|
||||
The child triplet has its own perspective, scenario flags, Stichtag override, Detailgrad. It can itself spawn (depth N supported; today's data is 2-deep at most).
|
||||
|
||||
Cross-proceeding peer triggers (`upc.inf judgment → epa.opp choice deadline`) are **out of scope for v1** (m's Q14 pick). v1 ships independent triplets stacked vertically; the user mentally tracks cross-dependencies. A future `scenario_event_links` table is the path to peer triggers in v1.1.
|
||||
|
||||
---
|
||||
|
||||
## §4 Hard decisions table — m's 20 picks
|
||||
|
||||
| # | Topic | Pick | Locks |
|
||||
|---|---|---|---|
|
||||
| Q1 | Modular meaning | "doesn't super apply" — drop modular as a load-bearing goal | §0.2 |
|
||||
| Q2 | Tab state semantics | Shared anchor + Akte across modes; filters reset per mode | §3.1, §3.2, §6 |
|
||||
| Q3 | Case-file integration | Page-header Akte picker, persistent across modes | §3.1, §3.2, §2.3 |
|
||||
| Q4 | Optional-display horizon | Per-event-card | §3.4 |
|
||||
| Q5 | Builder shape | Unified builder, 3 entry modes (cold-open / event-triggered / Akte) | §0, §1, §2, §3 |
|
||||
| Q6 | Scenario↔project relationship | Separate `paliad.scenarios` table + promote-to-project action | §5, §2.4 |
|
||||
| Q7 | Scenario contents | Multi-proceeding constellation per scenario | §3, §5 |
|
||||
| Q8 | Save model | Auto-save active scenario + "Meine Szenarien" list | §1, §3, §6.4 |
|
||||
| Q9 | Multi-proceeding render | Vertical stacked column-triplets | §3 |
|
||||
| Q10 | Per-event state | 3-state: planned / filed / skipped (no `overdue` state) | §3.4 |
|
||||
| Q11 | Promote-to-project carry | Everything (incl. placeholder parties + free-form notes) | §2.4, §5.4 |
|
||||
| Q12 | Sharing model | Private by default + explicit team-share (read-only) | §1, §5, §2.5 |
|
||||
| Q13 | Scenario flags placement | Per-proceeding (each triplet owns its `scenario_flags`) | §5.1 |
|
||||
| Q14 | Cross-proceeding peer triggers | Out of scope for v1 (defer to v1.1) | §3.6, §7 |
|
||||
| Q15 | Perspective scope | Per-proceeding (each triplet has its own `primary_party`) | §3.3, §5.1 |
|
||||
| Q16 | Add-proceeding flow | `+ Verfahren hinzufügen` button below the last triplet, inline picker | §3, §3.1 |
|
||||
| Q17 | Cold-open canvas | Empty canvas + "Neues Szenario" CTA + recent-list | §2.1, §3 |
|
||||
| Q18 | Search scope | Universal: events + scenarios + Akten, scoped by result type | §3.1, §6 |
|
||||
| Q19 | Promote-to-project flow | 3-step wizard (Bestätigen → Parteien ergänzen → Akte-Metadaten) | §2.4, §5.4 |
|
||||
| Q20 | Mobile treatment | Desktop v1, mobile basic-read (mutating actions prompt "Auf größerem Bildschirm öffnen") | §3, §7 |
|
||||
|
||||
### §4.1 Divergences from inventor recommendations
|
||||
|
||||
Three picks diverged from my recommendation. Captured here so future readers (m, the coder) see the *current* design, not the strawman.
|
||||
|
||||
- **Q1 — Modular.** Inventor recommended "plug-in widgets". m: "I don't know — generally does not super apply here." Modular is dropped as a goal; the natural decomposition (BuilderCanvas → ProceedingTriplet → EventCard → ScenarioListPanel → PromoteWizard) is documented in §6.2 as build hygiene, not as a load-bearing constraint.
|
||||
- **Q10 — Event state.** Inventor recommended 4-state (planned / filed / skipped / overdue). m picked 3-state — no `overdue` enum. Rationale (interpreted): `overdue` is derived from `date < today AND state=planned`, not stored; this avoids stale state when the date is edited.
|
||||
- **Q11 — Promote carry.** Inventor recommended carrying procedural shape + flags + filed-state + notes but **not** placeholder parties/case_number/billing. m picked "everything carries" — placeholder parties come in. Mitigation: Q19's 3-step wizard's step 2 (Parteien ergänzen) gives the user a chance to clean placeholders before commit, so the safety net m wanted on Q11 is folded into Q19.
|
||||
|
||||
### §4.2 Inventor picks not formally asked
|
||||
|
||||
A few decisions are inventor-set because they're either: (a) implementation details that don't change the architecture, or (b) clean defaults that match existing patterns. Listed here so they're visible; m can flag any.
|
||||
|
||||
- **Detailgrad ("Gewählt" / "Alle Optionen") scope**: per-proceeding (matches today's Verfahrensablauf pattern). State in `scenario_proceedings.detailgrad`.
|
||||
- **Akte picker shape**: flat dropdown sorted by recently-viewed first, with a typeahead filter for case numbers/names. Same shape as today's project picker on /agenda.
|
||||
- **Notes**: per-event-card (textarea on each card, lazy-loaded). Scenario-level notes also exist (`scenarios.notes text`) for cross-cutting commentary.
|
||||
- **Read-only shared state UI**: every mutating affordance is disabled (greyed, no click handlers). Watermark "Geteilt von <X> · schreibgeschützt" at the top of the canvas. No "Fork to my workspace" affordance in v1.
|
||||
- **URL contract**: minimal, view-state only — `?scenario=<id>&mode=<entry>&event=<sequencing_rule_id>` (deep-link to a specific anchor). Filter pills + chip state get URL params *per active entry mode* but explicitly NOT the constellation data (per m's "not every constellation in URL" guidance). The constellation lives in `paliad.scenario_*` tables.
|
||||
- **Auto-save granularity**: debounced 500ms on every change. Indicator near scenario name: `Gespeichert ✓` (last successful save < 5s ago), `Speichert…` (in flight), `Letzte Speicherung fehlgeschlagen — erneut versuchen` (on error).
|
||||
- **Soft delete**: archived scenarios stay in DB with `status='archived'`. No hard delete in v1.
|
||||
- **Audit**: no audit log on scenario edits (they're exploratory). Audit on promote-to-project goes via the existing `projects.audit_log`.
|
||||
- **Concurrent editing**: single-editor model. Owner is sole editor; shares are read-only. No locking / merge conflict UI needed in v1.
|
||||
- **Bilingual**: German primary, English via existing `i18n.ts`. Scenario names: user-chosen, any language. Skip reasons + notes: free-text, any language.
|
||||
|
||||
---
|
||||
|
||||
## §5 Data model deltas
|
||||
|
||||
All new tables live in `paliad.*` schema, alongside existing `paliad.projects` / `paliad.deadlines` / `paliad.sequencing_rules`.
|
||||
|
||||
### §5.1 New tables
|
||||
|
||||
```sql
|
||||
-- Scenario header. One row per saved scenario (named or scratch).
|
||||
CREATE TABLE paliad.scenarios (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
owner_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
name text NOT NULL DEFAULT 'Unbenanntes Szenario',
|
||||
status text NOT NULL DEFAULT 'active'
|
||||
CHECK (status IN ('active','archived','promoted')),
|
||||
origin_project_id uuid NULL REFERENCES paliad.projects(id) ON DELETE SET NULL,
|
||||
-- set when scenario was exported from a project
|
||||
promoted_project_id uuid NULL REFERENCES paliad.projects(id) ON DELETE SET NULL,
|
||||
-- set when scenario was promoted to a project
|
||||
stichtag date NULL,
|
||||
-- scenario-level default Stichtag; per-triplet overrides take precedence
|
||||
notes text NULL,
|
||||
-- free-form scenario-level commentary
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX scenarios_owner_status_idx ON paliad.scenarios(owner_id, status);
|
||||
CREATE INDEX scenarios_updated_idx ON paliad.scenarios(owner_id, updated_at DESC);
|
||||
|
||||
-- One row per proceeding inside a scenario. Multiple per scenario for
|
||||
-- multi-proceeding constellations. parent_scenario_proceeding_id self-refs
|
||||
-- for spawned children (CCR child of UPC inf etc.).
|
||||
CREATE TABLE paliad.scenario_proceedings (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
scenario_id uuid NOT NULL REFERENCES paliad.scenarios(id) ON DELETE CASCADE,
|
||||
proceeding_type_id uuid NOT NULL REFERENCES paliad.proceeding_types(id),
|
||||
primary_party text NULL
|
||||
CHECK (primary_party IN ('claimant','defendant')),
|
||||
-- per-proceeding perspective; null = no perspective picked yet
|
||||
scenario_flags jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
-- per-proceeding flags: {with_ccr: true, with_amend: false, …}
|
||||
parent_scenario_proceeding_id uuid NULL REFERENCES paliad.scenario_proceedings(id) ON DELETE CASCADE,
|
||||
-- self-ref for spawned children (CCR child of UPC inf etc.)
|
||||
spawn_anchor_event_id uuid NULL REFERENCES paliad.sequencing_rules(id),
|
||||
-- which rule of the parent caused this spawn (for UI placement)
|
||||
ordinal int NOT NULL DEFAULT 0,
|
||||
-- stack order on canvas (top to bottom)
|
||||
stichtag date NULL,
|
||||
-- per-proceeding Stichtag override; falls back to scenarios.stichtag
|
||||
detailgrad text NOT NULL DEFAULT 'selected'
|
||||
CHECK (detailgrad IN ('selected','all_options')),
|
||||
appeal_target text NULL,
|
||||
-- applies_to_target for appeal proceedings; null for non-appeal triplets
|
||||
collapsed boolean NOT NULL DEFAULT false,
|
||||
-- user-collapsed triplet header (UI state)
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX scenario_proceedings_scenario_idx ON paliad.scenario_proceedings(scenario_id, ordinal);
|
||||
CREATE INDEX scenario_proceedings_parent_idx ON paliad.scenario_proceedings(parent_scenario_proceeding_id);
|
||||
|
||||
-- One row per event card on the canvas. Captures the card's state +
|
||||
-- per-card attributes (filed date, skip reason, notes, optional horizon).
|
||||
-- Most cards are sequencing-rule-backed; free-form events have a null
|
||||
-- sequencing_rule_id and a non-null procedural_event_id (or text label).
|
||||
CREATE TABLE paliad.scenario_events (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
scenario_proceeding_id uuid NOT NULL REFERENCES paliad.scenario_proceedings(id) ON DELETE CASCADE,
|
||||
sequencing_rule_id uuid NULL REFERENCES paliad.sequencing_rules(id),
|
||||
procedural_event_id uuid NULL REFERENCES paliad.procedural_events(id),
|
||||
-- one of {sequencing_rule_id, procedural_event_id, custom_label} must be set
|
||||
custom_label text NULL,
|
||||
-- free-form event name when neither sequencing_rule nor procedural_event apply
|
||||
state text NOT NULL DEFAULT 'planned'
|
||||
CHECK (state IN ('planned','filed','skipped')),
|
||||
actual_date date NULL,
|
||||
-- set when state='filed'; can also be set for state='planned' (court-set override)
|
||||
skip_reason text NULL,
|
||||
-- optional rationale when state='skipped'
|
||||
notes text NULL,
|
||||
-- per-card free-form
|
||||
horizon_optional int NOT NULL DEFAULT 0,
|
||||
-- per-card "show N more optionals" affordance
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (scenario_proceeding_id, sequencing_rule_id) WHERE sequencing_rule_id IS NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX scenario_events_proceeding_idx ON paliad.scenario_events(scenario_proceeding_id);
|
||||
|
||||
-- Read-only team shares. Owner is sole editor; shares grant view-only.
|
||||
CREATE TABLE paliad.scenario_shares (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
scenario_id uuid NOT NULL REFERENCES paliad.scenarios(id) ON DELETE CASCADE,
|
||||
shared_with_user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
created_by uuid NOT NULL REFERENCES auth.users(id),
|
||||
UNIQUE (scenario_id, shared_with_user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX scenario_shares_user_idx ON paliad.scenario_shares(shared_with_user_id);
|
||||
```
|
||||
|
||||
### §5.2 Additions to existing tables
|
||||
|
||||
```sql
|
||||
-- One nullable FK on paliad.projects to track which scenario spawned this
|
||||
-- project (set on promote-to-project). Auditable origin trail.
|
||||
ALTER TABLE paliad.projects
|
||||
ADD COLUMN origin_scenario_id uuid NULL
|
||||
REFERENCES paliad.scenarios(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX projects_origin_scenario_idx ON paliad.projects(origin_scenario_id)
|
||||
WHERE origin_scenario_id IS NOT NULL;
|
||||
```
|
||||
|
||||
No other changes to existing schema. `paliad.deadlines` continues to be the authoritative source for project-bound actuals; the builder writes to `paliad.deadlines` (not `scenario_events`) when working in Akte mode against a project-backed scenario.
|
||||
|
||||
### §5.3 RLS
|
||||
|
||||
Same pattern as existing `paliad.projects`:
|
||||
|
||||
- `scenarios` readable by `owner_id` OR by users with a matching `scenario_shares.shared_with_user_id` row.
|
||||
- `scenarios` writable only by `owner_id` (and only when `status != 'promoted'`).
|
||||
- `scenario_proceedings` + `scenario_events` cascade from scenario visibility.
|
||||
- `scenario_shares` readable by `shared_with_user_id` or `created_by`; writable only by the scenario owner.
|
||||
|
||||
Helper function `paliad.can_see_scenario(scenario_id)` mirrors the existing `paliad.can_see_project(project_id)` shape.
|
||||
|
||||
### §5.4 Promote-to-project: data flow
|
||||
|
||||
```
|
||||
[Wizard step 1: Bestätigen]
|
||||
Read: scenarios + scenario_proceedings + scenario_events
|
||||
Action: none (read-only summary)
|
||||
|
||||
[Wizard step 2: Parteien ergänzen]
|
||||
Read: scenario_proceedings.scenario_flags (for hints about placeholder party names)
|
||||
Action: builds an in-memory parties payload (per proceeding, per role)
|
||||
|
||||
[Wizard step 3: Akte-Metadaten]
|
||||
Read: user's clients + litigations + project tree (existing /projects API)
|
||||
Action: builds an in-memory project metadata payload
|
||||
|
||||
[Commit]
|
||||
Transaction:
|
||||
1. INSERT into paliad.projects (carrying step-2 + step-3 payloads, + scenario notes)
|
||||
SET origin_scenario_id = <scenario.id>
|
||||
2. INSERT into paliad.parties from step-2 payload
|
||||
3. For each scenario_proceeding (depth-first, parent before child):
|
||||
a. INSERT scenario_flags as projects.scenario_flags (parent-level only;
|
||||
children become sub-projects via parent_project_id)
|
||||
b. For each filed scenario_event: INSERT paliad.deadlines row with
|
||||
status='done', completed_at=actual_date, audit_reason='via Litigation Builder promotion'
|
||||
c. For each planned scenario_event: INSERT paliad.deadlines row with
|
||||
status='pending', due_date=computed (or actual_date override)
|
||||
d. Skipped events: not inserted (no deadline row)
|
||||
4. UPDATE paliad.scenarios SET status='promoted', promoted_project_id=<new>
|
||||
5. Navigate to /projects/<new>
|
||||
```
|
||||
|
||||
The deadlines write uses existing `POST /api/projects/{id}/deadlines/bulk` semantics under the hood — no new bulk-deadline-from-scenario endpoint needed.
|
||||
|
||||
---
|
||||
|
||||
## §6 Modular boundaries (light)
|
||||
|
||||
m said modular "doesn't super apply" — dropped as a load-bearing goal. The natural decomposition below is build-hygiene documentation, not a constraint the coder must enforce.
|
||||
|
||||
### §6.1 Front-end components
|
||||
|
||||
| Component | File | Responsibility |
|
||||
|---|---|---|
|
||||
| `BuilderCanvas` | `frontend/src/components/BuilderCanvas.tsx` | Root render of the builder. Receives the active scenario, renders triplet stack + cold-open empty state |
|
||||
| `ProceedingTriplet` | `frontend/src/components/ProceedingTriplet.tsx` | One proceeding's render: header strip (jurisdiction + name + perspective + Detailgrad + collapse + remove) + flag strip + 3 columns + spawn child triplets recursively |
|
||||
| `EventCard` | `frontend/src/components/EventCard.tsx` | One card in a column lane. State / date / optional-horizon / notes affordances |
|
||||
| `ScenarioFlagsStrip` | `frontend/src/components/ScenarioFlagsStrip.tsx` | Per-triplet flag toggles. Reads scenario_flag_catalog, applies to scenario_proceedings.scenario_flags |
|
||||
| `AddProceedingPicker` | `frontend/src/components/AddProceedingPicker.tsx` | Inline picker triggered by `+ Verfahren hinzufügen`. Forum chip row → Verfahren chip row → `Hinzufügen` |
|
||||
| `ScenarioListPanel` | `frontend/src/components/ScenarioListPanel.tsx` | Side panel: Aktiv / Geteilt / Promoted / Archiviert buckets + new-scenario CTA |
|
||||
| `PromoteToProjectWizard` | `frontend/src/components/PromoteToProjectWizard.tsx` | 3-step modal: Bestätigen / Parteien / Metadaten |
|
||||
| `PageHeaderControls` | `frontend/src/components/PageHeaderControls.tsx` | Scenario picker + Benennen/Teilen/Promote buttons + Akte picker + Stichtag input |
|
||||
| `EntryModeChrome` | `frontend/src/components/EntryModeChrome.tsx` | Cold-open / event-triggered / Akte mode radio; ephemeral UI affordance that fades into canvas state |
|
||||
|
||||
### §6.2 Client TS files
|
||||
|
||||
Mirror the React-ish component split:
|
||||
|
||||
- `frontend/src/client/builder.ts` — root orchestrator (auto-save loop, URL state, mode routing, scenario fetch)
|
||||
- `frontend/src/client/builder-scenario.ts` — scenario CRUD against `/api/scenarios`
|
||||
- `frontend/src/client/builder-event-card.ts` — per-card state machine + optional-horizon control
|
||||
- `frontend/src/client/builder-promote-wizard.ts` — 3-step wizard state machine
|
||||
- `frontend/src/client/builder-search.ts` — universal search (events + scenarios + Akten)
|
||||
- `frontend/src/client/builder-shares.ts` — share-with-team UI
|
||||
|
||||
### §6.3 Backend services + routes
|
||||
|
||||
| Service | File | Endpoints |
|
||||
|---|---|---|
|
||||
| `ScenarioService` | `internal/services/scenario_service.go` | List / Get / Create / Update / Archive / Promote |
|
||||
| `ScenarioProceedingService` | `internal/services/scenario_proceeding_service.go` | Add / Remove / Update (flags, perspective, ordinal, detailgrad) |
|
||||
| `ScenarioEventService` | `internal/services/scenario_event_service.go` | List / Update state / Set date / Set notes / Set horizon |
|
||||
| `ScenarioShareService` | `internal/services/scenario_share_service.go` | List / Add / Remove shares |
|
||||
| `ScenarioPromoteService` | `internal/services/scenario_promote_service.go` | Wizard-driven transactional promote |
|
||||
|
||||
Routes (added under existing API namespace):
|
||||
|
||||
```
|
||||
GET /api/scenarios — list user's scenarios (filtered by status)
|
||||
POST /api/scenarios — create new scenario
|
||||
GET /api/scenarios/{id} — get scenario + proceedings + events (deep)
|
||||
PATCH /api/scenarios/{id} — update name / stichtag / notes / status
|
||||
DELETE /api/scenarios/{id} — archive (soft delete; status='archived')
|
||||
POST /api/scenarios/{id}/proceedings — add proceeding to scenario
|
||||
PATCH /api/scenarios/{id}/proceedings/{pid} — update flags / perspective / ordinal / detailgrad
|
||||
DELETE /api/scenarios/{id}/proceedings/{pid} — remove proceeding (cascades to events)
|
||||
PATCH /api/scenarios/{id}/events/{eid} — update state / date / notes / horizon
|
||||
POST /api/scenarios/{id}/shares — share with user (read-only)
|
||||
DELETE /api/scenarios/{id}/shares/{sid} — revoke share
|
||||
POST /api/scenarios/{id}/promote — promote to project (3-step wizard payload)
|
||||
POST /api/scenarios/from-project/{project_id} — export project to a new scenario (what-if)
|
||||
GET /api/search — universal search (events + scenarios + Akten)
|
||||
```
|
||||
|
||||
Existing endpoints used unchanged:
|
||||
|
||||
- `GET /api/tools/fristenrechner/search?kind=events` — for the events corpus.
|
||||
- `GET /api/projects` — Akte picker source.
|
||||
- `POST /api/projects/{id}/deadlines/bulk` — promotion writes deadlines through this.
|
||||
- `PATCH /api/projects/{id}/scenario-flags` — Akte-mode flag sync.
|
||||
|
||||
---
|
||||
|
||||
## §7 Migration plan from current live shape
|
||||
|
||||
Current live (`/tools/procedures` on main @ `ed3c5d1`) = cronus's U0-U4 4-tab catalog. Migration is a 6-slice train, every slice ships visibly. No feature flag (m's pattern preference per #152 Q7).
|
||||
|
||||
### §7.1 Slice train
|
||||
|
||||
| Slice | What ships | DB | Visible to user |
|
||||
|---|---|---|---|
|
||||
| **B0 — Scenario DB foundation** | New tables (scenarios + scenario_proceedings + scenario_events + scenario_shares) + RLS + minimal API (list / create / get). Scenarios writable from a developer-only test route at first. | Mig #N (new tables + RLS + `paliad.projects.origin_scenario_id`) | No user-visible change. |
|
||||
| **B1 — Builder shell + cold-open mode** | New `/tools/procedures` page replaces the 4-tab catalog. Renders: page header (scenario picker + Akte picker + Stichtag + search), entry-mode radio (cold-open active), filter strip, empty canvas + "Neues Szenario starten" CTA + recent list. Add-proceeding picker works; first triplet renders with the existing Verfahrensablauf-core calc. Auto-save active scenario. Side panel "Meine Szenarien" with Aktiv bucket only. | — | New page visible. Single triplet works end-to-end. |
|
||||
| **B2 — Multi-triplet + spawn nesting + per-event state** | Vertical multi-triplet stack with `+ Verfahren hinzufügen`. Per-triplet perspective + flag strip. Spawn child triplets render inline. Event cards get the 3-state machine (planned/filed/skipped) + date editor + per-card optional horizon chip. Page-header Stichtag drives default dates. | — | Full scenario builder works without Akte integration. |
|
||||
| **B3 — Event-triggered mode + universal search** | "Ereignis" entry mode wires the search box to land on a single-triplet anchored view (scratch scenario). Universal search returns events + scenarios + Akten with type-scoped result groups. Filter pills (forum/proc/party/kind) reset on mode switch. | — | Event lookup works. |
|
||||
| **B4 — Akte mode + project-backed scenarios** | "Aus Akte" entry mode + page-header Akte picker. Loads project state into the builder (proceeding + perspective + scenario_flags + deadlines actuals). Akte-backed scenarios write through to `paliad.deadlines` + `paliad.projects.scenario_flags`; non-Akte scenarios write to `paliad.scenario_events`. Cross-surface scenario-flag-changed event listener reused from #152 T3. | — | Akte integration works end-to-end. |
|
||||
| **B5 — Share + Promote-to-project wizard** | "Teilen" button + user picker + share row. "Geteilt mit mir" bucket in side panel. "Als Projekt anlegen" opens the 3-step wizard (Bestätigen → Parteien ergänzen → Akte-Metadaten). Successful commit creates project + cascades deadlines + sets `origin_scenario_id`, navigates to /projects/{id}. "Promoted" bucket in side panel. | — | Sharing + promotion work. |
|
||||
| **B6 — Mobile basic-read + cleanup + i18n polish** | Mobile (<640px) shows scenarios + cards read-only; mutating affordances prompt "Auf größerem Bildschirm öffnen". Cleanup: delete dead U0-U4 catalog code (4-tab control, legacy `verfahrensablauf.ts`, etc.). All i18n keys finalised (DE + EN). | — | Mobile works; codebase cleaner. |
|
||||
|
||||
### §7.2 Why this train shape
|
||||
|
||||
- **B0 is DB-only**. The schema can land independently and be exercised via test routes / Supabase MCP before any UI sees it. Keeps mig risk isolated.
|
||||
- **B1-B2 are the MVP**. After B2, a user can build and save a multi-proceeding scenario fully kontextfrei. That alone replaces 60% of today's catalog use.
|
||||
- **B3 adds the lookup path**. After B3, "what's next after Klageerwiderung?" works without saving.
|
||||
- **B4 makes it real**. Akte integration is the load-bearing piece for daily use; ships once the foundation is stable.
|
||||
- **B5 unlocks team value**. Sharing + promotion are the difference between "personal tool" and "team tool". Ship after the core works.
|
||||
- **B6 is cleanup**. Mobile read + dead code removal land last to avoid coupling to in-flight features.
|
||||
|
||||
### §7.3 What stays unchanged
|
||||
|
||||
- URL `/tools/procedures` keeps it (the new builder lives there).
|
||||
- Sidebar entry "Verfahren & Fristen" keeps it.
|
||||
- cmd-K palette keeps it.
|
||||
- `/tools/fristenrechner` + `/tools/verfahrensablauf` legacy redirects (from cronus's U4) stay alive: 301 → `/tools/procedures` (the builder).
|
||||
- `pkg/litigationplanner.CalculateRule` — untouched.
|
||||
- `/admin/procedural-events` — untouched.
|
||||
- `/projects/{id}` Verlauf — untouched (new "Im Builder öffnen" button is the only addition).
|
||||
|
||||
### §7.4 Cleanup at B6
|
||||
|
||||
Dead code to delete (verify with grep before deletion):
|
||||
|
||||
- `frontend/src/components/VerfahrensablaufBody.tsx` (replaced by ProceedingTriplet)
|
||||
- `frontend/src/client/verfahrensablauf.ts` (replaced by builder.ts orchestration)
|
||||
- `frontend/src/client/views/verfahrensablauf-state.ts` (replaced by scenario-backed state)
|
||||
- `frontend/src/client/views/verfahrensablauf-state.test.ts`
|
||||
- ~~`frontend/src/client/verfahrensablauf-detail-mode.ts`~~ — KEEP. Builder imports `filterByDetailMode` from it; per-triplet Detailgrad reuses this module.
|
||||
- Existing scratch tab content in `frontend/src/client/procedures.ts` (4-tab toggling logic, mode routing)
|
||||
|
||||
**Kept**:
|
||||
|
||||
- `frontend/src/client/views/verfahrensablauf-core.ts` (calculation engine; reused by EventCard + ProceedingTriplet)
|
||||
- Legacy URL redirects in Go (`/tools/fristenrechner` + `/tools/verfahrensablauf` → `/tools/procedures`)
|
||||
|
||||
---
|
||||
|
||||
## §8 Open follow-ups (out of scope for v1)
|
||||
|
||||
Tracked for v1.1 / future tickets:
|
||||
|
||||
- **Cross-proceeding peer triggers** (UPC-inf judgment → EPA opp choice deadline). New `paliad.scenario_event_links` table. UI: trigger-picker chip on event cards.
|
||||
- **DE / EPA / DPMA full expansion**. v1 supports EPA + DPMA proceedings at the data layer (calc engine handles them), but the spawn flags and CCR-style nestings are UPC-specific. Other jurisdictions get proper coverage in v1.1.
|
||||
- **Scenario versioning / snapshots**. m's Q8 alternative ("versioned snapshots") deferred. Add when scenarios start driving client briefings.
|
||||
- **Multi-user concurrent editing**. Out of scope. Single-editor model with read-only shares is sufficient until usage shows otherwise.
|
||||
- **Fork-a-shared-scenario**. Read-only sharing in v1 doesn't expose "fork into my workspace". Add when team usage demands it.
|
||||
- **Comments on scenarios / event cards**. Out of scope (separate ticket).
|
||||
- **PDF export of a scenario for client briefings**. Out of scope.
|
||||
- **Mobile-parity edits**. v1.1 — full mobile interaction loop.
|
||||
- **Audit log on scenario edits**. Out of scope (exploratory data).
|
||||
- **Cross-scenario comparison view**. ("Compare planned vs actual" lives on the project page via promote-then-compare; explicit comparison tool is v2.)
|
||||
|
||||
---
|
||||
|
||||
## §9 Synthesis links
|
||||
|
||||
- **mBrian**: file as `[synthesis]` linked `triggered_by` t-paliad-339; `related_to` atlas's reverted tracker design, cronus's unified-procedural-events-tool design, atlas's deadline-system-revision.
|
||||
- **Cross-refs in this repo**: `docs/design-procedures-workflow-tracker-2026-05-27.md` (atlas, reverted), `docs/design-unified-procedural-events-tool-2026-05-27.md` (cronus, live), `docs/design-deadline-system-revision-2026-05-27.md` (atlas Phase 2), `docs/design-fristenrechner-overhaul-2026-05-26.md` (cronus 2026-05-26).
|
||||
- **Gitea**: m/paliad#153 (this PRD), m/paliad#152 (atlas's tracker, reverted), m/paliad#151 (cronus U0-U4 shipped), m/paliad#149 (atlas Phase 2 in flight).
|
||||
- **Coder phase** (deferred per inventor SKILL): runs after m ratifies this PRD. Slice ordering per §7.1. NOT edison (parked at DESIGN READY FOR REVIEW). NOT atlas (just-rejected tracker → framing bias). NOT cronus (parked on Fristenrechner inventor branch). A pattern-fluent Sonnet coder picks up B0 first.
|
||||
|
||||
---
|
||||
|
||||
## §10 Coder hand-off notes
|
||||
|
||||
(Pre-emptive — for whoever picks up B0.)
|
||||
|
||||
- **Migration number**: check `internal/db/migrations/` for the max slot at coder shift start. Two recent migrations (curie's t-paliad-336, ritchie's t-paliad-149 P0) are in flight; coordinate via paliadin/head before claiming a slot.
|
||||
- **Akte integration nuance**: when the builder is in Akte mode and the scenario is project-backed, writes flow to `paliad.deadlines` / `paliad.projects.scenario_flags` instead of `paliad.scenario_*` tables — the scenario row itself just records the canvas view-state (which triplets are visible, ordinal, collapsed state, per-card horizon). This dual-write rule is the load-bearing complexity of B4; design tests for it explicitly.
|
||||
- **Auto-save throttling**: 500ms debounce per change. Avoid PATCH-per-keystroke on notes textareas (use blur-trigger + 2s debounce there).
|
||||
- **Search performance**: universal search (events + scenarios + Akten) needs to stay snappy. Events corpus is ~3000 rows; scenarios/Akten are per-user. Use existing trgm indexes; avoid joining across all three for ranking.
|
||||
- **B5 transactional promotion**: do the wizard's commit in a single Postgres transaction. If any of (project insert / parties / deadlines / scenario status update) fails, roll back atomically. No partial promotions.
|
||||
- **Mobile rendering**: B6 is meant to be cheap. Column-triplet → CSS grid that collapses to single-column at `@media (max-width: 640px)`. Mutating affordances get `pointer-events: none` + a click-handler that surfaces the "Auf größerem Bildschirm öffnen" toast — keeps the desktop interaction code paths unchanged.
|
||||
- **i18n keys**: every user-facing string gets `data-i18n` from B1. Don't accumulate i18n debt across slices.
|
||||
280
exports/gen-deadline-list.py
Executable file
280
exports/gen-deadline-list.py
Executable file
@@ -0,0 +1,280 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate a markdown deadline-list export for UPC PA training (work/head delegation #2572).
|
||||
|
||||
Sorts by proceeding-type display_order then sequence_order. Sections by proceeding.
|
||||
|
||||
t-paliad-348 / yoUPC#178 update: matches the engine's `IncludeOptional=false`
|
||||
default (`pkg/litigationplanner/engine.go`). Optional rules (priority='optional')
|
||||
are SUPPRESSED by default so the manuscript shows the same "naked proceeding
|
||||
backbone" the UI now renders. Pass `--include-optional` to opt back in for an
|
||||
exhaustive catalog dump.
|
||||
|
||||
Usage:
|
||||
uv run exports/gen-deadline-list.py [--include-optional] [-o OUT]
|
||||
"""
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = ["psycopg2-binary"]
|
||||
# ///
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
import psycopg2
|
||||
import psycopg2.extras
|
||||
|
||||
DSN = os.environ.get(
|
||||
"PALIAD_DEADLINE_EXPORT_DSN",
|
||||
"postgres://postgres:rpsak3yf4lu1izgefx9p9xweg3qroojw@100.99.98.201:11833/postgres?sslmode=disable",
|
||||
)
|
||||
|
||||
# `priority` filter is wired at the SQL level (not post-filter in Python) so
|
||||
# the row counter in the markdown header reflects what's actually in the
|
||||
# manuscript — matching what the lawyer sees on /tools/procedures.
|
||||
SQL_TEMPLATE = """
|
||||
SELECT
|
||||
pt.code AS pt_code,
|
||||
pt.display_order,
|
||||
COALESCE(pt.name_en, pt.name) AS pt_label_en,
|
||||
pt.name AS pt_label_de,
|
||||
COALESCE(pe.name_en, pe.name) AS event_en,
|
||||
pe.name AS event_de,
|
||||
sr.duration_value,
|
||||
sr.duration_unit,
|
||||
sr.timing,
|
||||
sr.alt_duration_value,
|
||||
sr.alt_duration_unit,
|
||||
sr.combine_op,
|
||||
sr.rule_code,
|
||||
COALESCE(te.name, te.name_de) AS trigger_label,
|
||||
te.code AS trigger_code,
|
||||
sr.primary_party,
|
||||
sr.is_court_set,
|
||||
sr.is_spawn,
|
||||
sr.priority,
|
||||
sr.deadline_notes_en,
|
||||
sr.deadline_notes,
|
||||
sr.condition_expr,
|
||||
sr.sequence_order
|
||||
FROM paliad.sequencing_rules sr
|
||||
JOIN paliad.procedural_events pe ON pe.id = sr.procedural_event_id
|
||||
LEFT JOIN paliad.proceeding_types pt ON pt.id = sr.proceeding_type_id
|
||||
LEFT JOIN paliad.trigger_events te ON te.id = sr.trigger_event_id
|
||||
WHERE sr.lifecycle_state = 'published'
|
||||
AND sr.is_active = true
|
||||
AND pt.id IS NOT NULL
|
||||
{priority_filter}
|
||||
ORDER BY pt.display_order NULLS LAST, pt.code, sr.sequence_order NULLS LAST, sr.rule_code, pe.name;
|
||||
"""
|
||||
|
||||
|
||||
def format_frist(duration_value, duration_unit, timing, alt_value, alt_unit, combine_op):
|
||||
"""Format the deadline duration cleanly."""
|
||||
if duration_value is None or duration_unit is None:
|
||||
return ""
|
||||
unit_map = {
|
||||
"days": "d",
|
||||
"weeks": "w",
|
||||
"months": "M",
|
||||
"years": "y",
|
||||
"calendar_days": "CD",
|
||||
"working_days": "WD",
|
||||
}
|
||||
unit = unit_map.get(duration_unit, duration_unit)
|
||||
main = f"{duration_value} {unit}"
|
||||
if alt_value is not None and alt_unit is not None:
|
||||
alt_unit_short = unit_map.get(alt_unit, alt_unit)
|
||||
op = combine_op or "or"
|
||||
main = f"{main} {op} {alt_value} {alt_unit_short}"
|
||||
if timing == "before":
|
||||
main = f"{main} before"
|
||||
elif timing == "after":
|
||||
main = f"{main} after"
|
||||
return main
|
||||
|
||||
|
||||
def format_party(primary_party, is_court_set):
|
||||
if is_court_set:
|
||||
return "court-set"
|
||||
if primary_party == "claimant":
|
||||
return "claimant"
|
||||
if primary_party == "defendant":
|
||||
return "defendant"
|
||||
if primary_party == "both":
|
||||
return "either"
|
||||
if primary_party == "court":
|
||||
return "court"
|
||||
return primary_party or "—"
|
||||
|
||||
|
||||
def detect_r94(notes_en, notes_de):
|
||||
"""Flag R.9.4 non-extendable from notes text (heuristic — no DB field)."""
|
||||
blobs = " ".join(filter(None, [notes_en or "", notes_de or ""])).lower()
|
||||
if "r.9.4" in blobs or "r 9.4" in blobs or "r9.4" in blobs:
|
||||
return "✗"
|
||||
if "non-extendable" in blobs or "nicht verlängerbar" in blobs or "nicht verlaengerbar" in blobs:
|
||||
return "✗"
|
||||
return ""
|
||||
|
||||
|
||||
def conditional_marker(condition_expr):
|
||||
if condition_expr in (None, "", {}):
|
||||
return ""
|
||||
# condition_expr is JSONB → returns dict
|
||||
if isinstance(condition_expr, dict):
|
||||
if "flag" in condition_expr:
|
||||
return f"if `{condition_expr['flag']}`"
|
||||
if condition_expr.get("op") == "and" and "args" in condition_expr:
|
||||
flags = [a.get("flag", "?") for a in condition_expr["args"]]
|
||||
return "if " + " & ".join(f"`{f}`" for f in flags)
|
||||
if condition_expr.get("op") == "or" and "args" in condition_expr:
|
||||
flags = [a.get("flag", "?") for a in condition_expr["args"]]
|
||||
return "if " + " | ".join(f"`{f}`" for f in flags)
|
||||
return "cond"
|
||||
|
||||
|
||||
def md_escape(s):
|
||||
if s is None:
|
||||
return ""
|
||||
return str(s).replace("|", "\\|").replace("\n", " ")
|
||||
|
||||
|
||||
def render(rows, *, include_optional: bool, generated_for: str) -> str:
|
||||
by_pt = {}
|
||||
for r in rows:
|
||||
key = (r["display_order"] or 9999, r["pt_code"], r["pt_label_de"], r["pt_label_en"])
|
||||
by_pt.setdefault(key, []).append(r)
|
||||
|
||||
out = []
|
||||
today = date.today().isoformat()
|
||||
out.append(f"# UPC + DE/EP Deadline Catalog — Stand {today}")
|
||||
out.append("")
|
||||
out.append(f"Source: `paliad.sequencing_rules` (lifecycle_state=published, is_active=true).")
|
||||
out.append(f"Generated for {generated_for}. {len(rows)} rules across {len(by_pt)} proceedings.")
|
||||
if include_optional:
|
||||
out.append("")
|
||||
out.append(
|
||||
"**Mode:** `--include-optional` — every published rule, including "
|
||||
"`priority='optional'` rules suppressed by the engine's default "
|
||||
"(`IncludeOptional=false`). This is the exhaustive catalog dump."
|
||||
)
|
||||
else:
|
||||
out.append("")
|
||||
out.append(
|
||||
"**Mode:** default — matches the engine's `IncludeOptional=false` "
|
||||
"behaviour (pkg/litigationplanner/engine.go). `priority='optional'` "
|
||||
"rules are suppressed; the manuscript shows only the mandatory "
|
||||
"backbone the lawyer sees by default on /tools/procedures. "
|
||||
"Re-run with `--include-optional` for the full catalog. "
|
||||
"(t-paliad-348 / yoUPC#178)"
|
||||
)
|
||||
out.append("")
|
||||
out.append("**Spalten:**")
|
||||
out.append("- **Phase/Event** = procedural event (German primary)")
|
||||
out.append("- **Frist** = duration + timing (`d` days, `w` weeks, `M` months, `CD` calendar days, `WD` working days; `before` = relative to anchor)")
|
||||
out.append("- **Rule** = legal source (RoP / § ZPO / § PatG / Art. EPÜ)")
|
||||
out.append("- **Anchor** = trigger event the deadline runs from")
|
||||
out.append("- **Seite** = filing party (claimant / defendant / either / court-set)")
|
||||
out.append("- **Priorität** = mandatory / recommended / optional / informational (only when `--include-optional`)")
|
||||
out.append("- **R.9.4** = ✗ marked non-extendable in notes (heuristic — confirm against rule text)")
|
||||
out.append("- **Bedingung** = scenario flag(s) that must be set for the rule to fire (blank = always)")
|
||||
out.append("")
|
||||
out.append("---")
|
||||
out.append("")
|
||||
|
||||
for (order, pt_code, pt_de, pt_en) in sorted(by_pt.keys()):
|
||||
prules = by_pt[(order, pt_code, pt_de, pt_en)]
|
||||
out.append(f"## {pt_de} · `{pt_code}`")
|
||||
out.append("")
|
||||
if pt_en and pt_en != pt_de:
|
||||
out.append(f"*{pt_en}*")
|
||||
out.append("")
|
||||
if include_optional:
|
||||
out.append("| # | Phase / Event | Frist | Rule | Anchor | Seite | Priorität | R.9.4 | Bedingung |")
|
||||
out.append("|---:|---|---|---|---|---|---|:---:|---|")
|
||||
else:
|
||||
out.append("| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |")
|
||||
out.append("|---:|---|---|---|---|---|:---:|---|")
|
||||
for i, r in enumerate(prules, 1):
|
||||
event = md_escape(r["event_de"] or r["event_en"] or "")
|
||||
frist = md_escape(
|
||||
format_frist(
|
||||
r["duration_value"], r["duration_unit"], r["timing"],
|
||||
r["alt_duration_value"], r["alt_duration_unit"], r["combine_op"],
|
||||
)
|
||||
)
|
||||
rule = md_escape(r["rule_code"] or "")
|
||||
anchor = md_escape(r["trigger_label"] or "")
|
||||
party = format_party(r["primary_party"], r["is_court_set"])
|
||||
r94 = detect_r94(r["deadline_notes_en"], r["deadline_notes"])
|
||||
cond = md_escape(conditional_marker(r["condition_expr"]))
|
||||
spawn_marker = " ⤴" if r["is_spawn"] else ""
|
||||
if include_optional:
|
||||
priority = md_escape(r["priority"] or "")
|
||||
out.append(
|
||||
f"| {i} | {event}{spawn_marker} | {frist} | {rule} | {anchor} | {party} | {priority} | {r94} | {cond} |"
|
||||
)
|
||||
else:
|
||||
out.append(
|
||||
f"| {i} | {event}{spawn_marker} | {frist} | {rule} | {anchor} | {party} | {r94} | {cond} |"
|
||||
)
|
||||
out.append("")
|
||||
|
||||
out.append("---")
|
||||
out.append("")
|
||||
out.append("**Lesehilfe:**")
|
||||
out.append("- ⤴ Spawn-Marker: event opens a sub-proceeding (e.g. CCR forks revocation track)")
|
||||
out.append("- `with_ccr` = Widerklage auf Nichtigkeit gefilt | `with_amend` = Patentänderungsantrag | `with_cci` = Widerklage auf Verletzung (in rev.cfi)")
|
||||
out.append("- Catalog ist work-in-progress: 7 compound-name rules + Patentänderung-Duplikation noch in m's split-review backlog (m/paliad#149).")
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"--include-optional",
|
||||
action="store_true",
|
||||
help="Include priority='optional' rules. Default false matches the engine's IncludeOptional=false default.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-o",
|
||||
"--out",
|
||||
default="exports/upc-deadlines-2026-05-28.md",
|
||||
help="Output path (relative to repo root).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--generated-for",
|
||||
default="PA-Schulung 2026-05-28",
|
||||
help="Free-text label rendered in the markdown header.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
priority_filter = "" if args.include_optional else "AND sr.priority != 'optional'"
|
||||
sql = SQL_TEMPLATE.format(priority_filter=priority_filter)
|
||||
|
||||
conn = psycopg2.connect(DSN)
|
||||
try:
|
||||
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
cur.execute(sql)
|
||||
rows = cur.fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
md = render(rows, include_optional=args.include_optional, generated_for=args.generated_for)
|
||||
# Resolve out path relative to the repo root (= the script's grandparent).
|
||||
out_path = Path(args.out)
|
||||
if not out_path.is_absolute():
|
||||
out_path = Path(__file__).resolve().parent.parent / out_path
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
out_path.write_text(md)
|
||||
n_pt = len({(r["display_order"] or 9999, r["pt_code"]) for r in rows})
|
||||
print(
|
||||
f"WROTE {out_path} ({len(rows)} rules, {n_pt} proceedings, "
|
||||
f"include_optional={args.include_optional})"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
BIN
exports/screenshots/paliad-348-after-upc-inf-cfi.png
Normal file
BIN
exports/screenshots/paliad-348-after-upc-inf-cfi.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 156 KiB |
BIN
exports/screenshots/paliad-348-before-upc-inf-cfi.png
Normal file
BIN
exports/screenshots/paliad-348-before-upc-inf-cfi.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 490 KiB |
378
exports/upc-deadlines-2026-05-28.md
Normal file
378
exports/upc-deadlines-2026-05-28.md
Normal file
@@ -0,0 +1,378 @@
|
||||
# UPC + DE/EP Deadline Catalog — Stand 2026-05-28
|
||||
|
||||
Source: `paliad.sequencing_rules` (lifecycle_state=published, is_active=true).
|
||||
Generated for PA-Schulung 2026-05-28. 178 rules across 25 proceedings.
|
||||
|
||||
**Mode:** default — matches the engine's `IncludeOptional=false` behaviour (pkg/litigationplanner/engine.go). `priority='optional'` rules are suppressed; the manuscript shows only the mandatory backbone the lawyer sees by default on /tools/procedures. Re-run with `--include-optional` for the full catalog. (t-paliad-348 / yoUPC#178)
|
||||
|
||||
**Spalten:**
|
||||
- **Phase/Event** = procedural event (German primary)
|
||||
- **Frist** = duration + timing (`d` days, `w` weeks, `M` months, `CD` calendar days, `WD` working days; `before` = relative to anchor)
|
||||
- **Rule** = legal source (RoP / § ZPO / § PatG / Art. EPÜ)
|
||||
- **Anchor** = trigger event the deadline runs from
|
||||
- **Seite** = filing party (claimant / defendant / either / court-set)
|
||||
- **Priorität** = mandatory / recommended / optional / informational (only when `--include-optional`)
|
||||
- **R.9.4** = ✗ marked non-extendable in notes (heuristic — confirm against rule text)
|
||||
- **Bedingung** = scenario flag(s) that must be set for the rule to fire (blank = always)
|
||||
|
||||
---
|
||||
|
||||
## Verletzungsverfahren · `upc.inf.cfi`
|
||||
|
||||
*Infringement Action*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Klageerhebung | 0 M after | RoP.013.1 | | claimant | | |
|
||||
| 2 | Klageerwiderung | 3 M after | RoP.023 | | defendant | | |
|
||||
| 3 | Replik | 2 M or 2 M after | RoP.029.b | | claimant | | if `with_ccr` |
|
||||
| 4 | Duplik | 1 M or 2 M after | RoP.029.c | | defendant | | if `with_ccr` |
|
||||
| 5 | Erwiderung auf Nichtigkeitswiderklage | 2 M after | RoP.029.a | | claimant | | if `with_ccr` |
|
||||
| 6 | Replik auf Erwiderung zur Nichtigkeitswiderklage | 2 M after | RoP.029.d | | defendant | | if `with_ccr` |
|
||||
| 7 | Duplik auf Replik zur Erwiderung Nichtigkeitswiderklage | 1 M after | RoP.029.e | | claimant | | if `with_ccr` |
|
||||
| 8 | Antrag auf Patentänderung | 2 M after | RoP.030.1 | | claimant | | if `with_ccr` & `with_amend` |
|
||||
| 9 | Erwiderung auf Patentänderungsantrag | 2 M after | RoP.032.1 | | defendant | | if `with_ccr` & `with_amend` |
|
||||
| 10 | Replik auf Erwiderung zum Patentänderungsantrag | 1 M after | RoP.032.3 | | claimant | | if `with_ccr` & `with_amend` |
|
||||
| 11 | Duplik auf Replik zum Patentänderungsantrag | 1 M after | RoP.032.3 | | defendant | | if `with_ccr` & `with_amend` |
|
||||
| 12 | Zwischenanhörung | 0 M after | RoP.105 | | court-set | | |
|
||||
| 13 | Mitteilung Dolmetscherkosten | 2 w before | RoP.109.4 | Oral hearing | court | | |
|
||||
| 14 | Übersetzungen einreichen | 2 w after | RoP.109.5 | | either | | |
|
||||
| 15 | Mündliche Verhandlung | 0 M after | RoP.112 | | court-set | | |
|
||||
| 16 | Entscheidung | 0 M after | RoP.118.1 | | court-set | | |
|
||||
| 17 | Duplik zur Replik auf die Erwiderung zum Patentänderungsantrag | 1 M after | RoP.032.3 | Reply to the Defence to an Application to amend the patent | defendant | | if `with_ccr` & `with_amend` |
|
||||
| 18 | Einreichung von Übersetzungen von Schriftstücken | 1 M after | RoP.007.4 | Order of the judge-rapporteur to lodge translations | either | | |
|
||||
| 19 | Antrag auf Simultanübersetzung | 1 M before | RoP.109.5 | Oral hearing | either | | |
|
||||
| 20 | Antrag auf Folgemaßnahmen aus einer rechtskräftigen Validitätsentscheidung | 2 M after | RoP.118.4 | Final decision of the central division, Court of Appeal or EPO on the validity of the patent | either | | if `with_ccr` |
|
||||
| 21 | Antrag auf Überprüfung einer verfahrensleitenden Anordnung | 15 d after | RoP.333 | Case management order (Service) | either | | |
|
||||
| 22 | Mängelbeseitigung / Einreichung schriftlicher Stellungnahme | 14 d after | RoP.019 | Preliminary Objection | either | | |
|
||||
| 23 | Mängelbeseitigung / Zahlung | 14 d after | RoP.016 | Notification by the Registry to correct deficiencies | either | | |
|
||||
| 24 | Antrag auf Verweisung an die Zentralkammer | 10 d after | RoP.323 | Information by the Court not to approve Application to use the patent's language as language of the proceedings | either | | |
|
||||
| 25 | Mitteilung über Beauftragung eines Dolmetschers auf Kosten der Partei | 2 w before | RoP.109.5 | Oral hearing | either | | |
|
||||
| 26 | Klärung von Übersetzungsfragen | 2 w after | RoP.109 | Summons to Oral Hearing | court | | |
|
||||
| 27 | Antrag auf Vertraulichkeit gegenüber der Öffentlichkeit | 14 d after | RoP.262.2 | Opponent Submission | either | | |
|
||||
| 28 | Wiedereinsetzungsantrag (UPC R.320) | 2 M after | RoP.320 | Removal of obstacle (UPC R.320) | either | | |
|
||||
|
||||
## Verletzungsverfahren (LG) · `de.inf.lg`
|
||||
|
||||
*Infringement (Regional Court)*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Klageerhebung | 0 M after | § 253 ZPO | | claimant | | |
|
||||
| 2 | Anzeige der Verteidigungsbereitschaft | 2 w after | § 276 ZPO | | defendant | | |
|
||||
| 3 | Klageerwiderung | 6 w after | § 276 ZPO | | court-set | | |
|
||||
| 4 | Replik | 4 w after | § 282 ZPO | | court-set | | |
|
||||
| 5 | Duplik | 4 w after | § 282 ZPO | | court-set | | |
|
||||
| 6 | Haupttermin | 0 M after | § 279 ZPO | | court-set | | |
|
||||
| 7 | Urteil | 0 M after | § 300 ZPO | | court-set | | |
|
||||
| 8 | Berufungsfrist | 1 M after | § 517 ZPO | | either | | |
|
||||
| 9 | Berufungsbegründung | 2 M after | § 520 ZPO | | either | | |
|
||||
| 10 | Wiedereinsetzungsantrag (§ 233 ZPO) | 2 w after | § 233 ZPO | Removal of obstacle (ZPO §233) | — | | |
|
||||
| 11 | Einspruch gegen Versäumnisurteil (§ 339 ZPO) | 2 w after | § 339 ZPO | Service of default judgment | — | | |
|
||||
| 12 | Schriftsatznachreichung (§ 296a ZPO) | 3 w after | § 296a ZPO | End of oral hearing | — | | |
|
||||
|
||||
## Nichtigkeitsverfahren · `upc.rev.cfi`
|
||||
|
||||
*Revocation Action*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Nichtigkeitsklage | 0 M after | RoP.044 | | claimant | | |
|
||||
| 2 | Klageerwiderung | 2 M after | RoP.049.1 | | defendant | | |
|
||||
| 3 | Antrag auf Patentänderung | 0 M after | RoP.049.2.a | | defendant | | if `with_amend` |
|
||||
| 4 | Verletzungswiderklage | 0 M after | RoP.049.2.b | | defendant | | if `with_cci` |
|
||||
| 5 | Replik | 2 M after | RoP.051 | | claimant | | |
|
||||
| 6 | Erwiderung auf Patentänderungsantrag | 2 M after | RoP.043.3 | | claimant | | if `with_amend` |
|
||||
| 7 | Erwiderung auf Verletzungswiderklage | 2 M after | RoP.056.1 | | claimant | | if `with_cci` |
|
||||
| 8 | Duplik | 1 M after | RoP.052 | | defendant | | |
|
||||
| 9 | Replik auf Erwiderung zum Patentänderungsantrag | 1 M after | RoP.032.3 | | defendant | | if `with_amend` |
|
||||
| 10 | Replik auf Erwiderung zur Verletzungswiderklage | 1 M after | RoP.056.3 | | defendant | | if `with_cci` |
|
||||
| 11 | Duplik auf Replik zum Patentänderungsantrag | 1 M after | RoP.032.3 | | claimant | | if `with_amend` |
|
||||
| 12 | Duplik auf Replik zur Erwiderung Verletzungswiderklage | 1 M after | RoP.056.4 | | claimant | | if `with_cci` |
|
||||
| 13 | Zwischenanhörung | 0 M after | RoP.105 | | court-set | | |
|
||||
| 14 | Mündliche Verhandlung | 0 M after | RoP.112 | | court-set | | |
|
||||
| 15 | Entscheidung | 0 M after | RoP.118.3 | | court-set | | |
|
||||
| 16 | Duplik zur Replik auf die Erwiderung zur Nichtigkeitsklage | 1 M after | RoP.052 | Reply to the Defence to revocation | — | | |
|
||||
| 17 | Verletzungswiderklage | 2 M after | RoP.053 | Statement for Revocation | — | | |
|
||||
| 18 | Antrag auf Patentänderung | 2 M after | RoP.050 | Statement for Revocation | — | | |
|
||||
|
||||
## Nichtigkeitsverfahren (BPatG) · `de.null.bpatg`
|
||||
|
||||
*Nullity (Federal Patent Court)*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Nichtigkeitsklage | 0 M after | § 81 PatG | | claimant | | |
|
||||
| 2 | Klageerwiderung | 2 M after | § 82 Abs. 3 PatG | | defendant | | |
|
||||
| 3 | Replik | 2 M after | § 83 PatG | | claimant | | |
|
||||
| 4 | Hinweisbeschluss | 0 M after | § 83 PatG | | court-set | | |
|
||||
| 5 | Stellungnahme zum Hinweisbeschluss | 0 M after | § 83 PatG | | either | | |
|
||||
| 6 | Duplik | 1 M after | § 83 PatG | | defendant | | |
|
||||
| 7 | Mündliche Verhandlung | 0 M after | § 80 PatG | | court-set | | |
|
||||
| 8 | Urteil | 0 M after | § 84 PatG | | court-set | | |
|
||||
| 9 | Berufungsfrist | 1 M after | § 110 PatG | | either | | |
|
||||
| 10 | Berufungsbegründung | 3 M after | § 111 PatG | | either | | |
|
||||
| 11 | Wiedereinsetzungsantrag (§ 123 PatG) | 2 M after | § 123 PatG | Removal of obstacle (PatG §123) | — | | |
|
||||
|
||||
## Einspruchsverfahren · `epa.opp.opd`
|
||||
|
||||
*Opposition Proceedings*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Veröffentlichung der Erteilung | 0 M after | Art. 97 EPÜ | | either | | |
|
||||
| 2 | Einspruchsfrist | 9 M after | Art. 99 EPÜ | | either | | |
|
||||
| 3 | Erwiderung des Patentinhabers | 4 M after | R. 79(1) EPÜ | | court-set | | |
|
||||
| 4 | Entscheidung | 0 M after | Art. 102 EPÜ | | court-set | | |
|
||||
| 5 | Beschwerdefrist | 2 M after | Art. 108 EPÜ | | either | | |
|
||||
| 6 | Beschwerdebegründung | 4 M after | Art. 108 EPÜ | | either | | |
|
||||
| 7 | Stellungnahme weiterer Beteiligter | 0 M after | R. 79 EPÜ | | either | | |
|
||||
| 8 | Eingaben vor mündl. Verhandlung | 0 M after | R. 116 EPÜ | | either | | |
|
||||
| 9 | Wiedereinsetzungsantrag (Art. 122 EPÜ) | 2 M after | Art. 122 EPÜ | Removal of obstacle (EPC Art.122) | — | | |
|
||||
|
||||
## Beschwerdeverfahren · `epa.opp.boa`
|
||||
|
||||
*Appeal Proceedings*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Zustellung der Beschwerdeentscheidung | 0 M after | R. 124 EPÜ | | either | | |
|
||||
| 2 | Beschwerdeeinlegung | 2 M after | Art. 108 EPÜ | | either | | |
|
||||
| 3 | Beschwerdebegründung | 4 M after | Art. 108 EPÜ | | either | | |
|
||||
| 4 | Beschwerdeerwiderung | 4 M after | RPBA Art. 12 | | either | | |
|
||||
| 5 | Mündliche Verhandlung | 0 M after | Art. 116 EPÜ | | court-set | | |
|
||||
| 6 | Entscheidung | 0 M after | Art. 111 EPÜ | | court-set | | |
|
||||
| 7 | Eingaben vor mündl. Verhandlung | 0 M after | R. 116 EPÜ | | either | | |
|
||||
| 8 | Antrag auf Überprüfung | 2 M after | Art. 112a EPÜ | | either | | |
|
||||
|
||||
## Einspruchsverfahren DPMA · `dpma.opp.dpma`
|
||||
|
||||
*Opposition DPMA*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Veröffentlichung der Erteilung | 0 M after | § 58 PatG | | either | | |
|
||||
| 2 | Einspruchsfrist | 9 M after | § 59 PatG | | either | | |
|
||||
| 3 | Erwiderung des Patentinhabers | 4 M after | § 59(2) PatG | | court-set | | |
|
||||
| 4 | DPMA-Entscheidung | 0 M after | § 61 PatG | | court-set | | |
|
||||
| 5 | Wiedereinsetzungsantrag (DPMA) | 2 M after | § 123 PatG | Removal of obstacle (DPMA, PatG §123) | — | | |
|
||||
|
||||
## Berufungsverfahren · `upc.apl.merits`
|
||||
|
||||
*Appeal*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Berufungseinlegung | 2 M after | RoP.224.1.a | | either | | |
|
||||
| 2 | Berufungsbegründung | 4 M after | RoP.224.2.a | | either | | |
|
||||
| 3 | Berufungserwiderung | 3 M after | RoP.235.1 | | either | | |
|
||||
| 4 | Mündliche Verhandlung | 0 M after | RoP.240 | | court-set | | |
|
||||
| 5 | Entscheidung | 0 M after | RoP.235.4 | | court-set | | |
|
||||
| 6 | Anschlussberufung | 3 M after | RoP.237 | | either | | |
|
||||
| 7 | Erwiderung Anschlussberufung | 2 M after | RoP.238.1 | | either | | |
|
||||
| 8 | Berufungsschrift gegen eine in Regel 220.1(a) und (b) genannte Entscheidung | 2 M after | RoP.224.1(a) | Decision referred to in Rule 220.1(a) and (b) | — | | |
|
||||
| 9 | Berufungsbegründung gegen eine in Regel 220.1(a) und (b) genannte Entscheidung | 4 M after | RoP.224.1(a) | Decision referred to in Rule 220.1(a) and (b) | — | | |
|
||||
| 10 | Anfechtung einer Entscheidung über die Verwerfung der Berufung als unzulässig | 1 M after | RoP.245 | Decision to reject an appeal as inadmissible | — | | |
|
||||
| 11 | Antrag auf Wiederaufnahme (schwerwiegender Verfahrensmangel) | 2 M after | RoP.247.2 | Final decision (Service) / Discovery of the fundamental defect (whichever is later) | — | | |
|
||||
| 12 | Antrag auf Wiederaufnahme (Straftat) | 2 M after | RoP.247.1 | Final decision (Service) / Court decision on criminal offence (whichever is later) | — | | |
|
||||
|
||||
## Berufungsverfahren OLG (Verletzung) · `de.inf.olg`
|
||||
|
||||
*Appeal OLG (Infringement)*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Zustellung LG-Urteil | 0 M after | § 540 ZPO | | either | | |
|
||||
| 2 | Berufungsschrift | 1 M after | § 517 ZPO | | either | | |
|
||||
| 3 | Berufungsbegründung | 2 M after | § 520 ZPO | | either | | |
|
||||
| 4 | Berufungserwiderung | 1 M after | § 521 ZPO | | either | | |
|
||||
| 5 | Anschlussberufung | 0 M after | § 524 ZPO | | either | | |
|
||||
| 6 | Mündliche Verhandlung | 0 M after | § 540 ZPO | | court-set | | |
|
||||
| 7 | OLG-Urteil | 0 M after | § 540 ZPO | | court-set | | |
|
||||
|
||||
## Revisions-/NZB-Verfahren BGH (Verletzung) · `de.inf.bgh`
|
||||
|
||||
*Revision / Non-admission Appeal BGH (Infringement)*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Zustellung OLG-Urteil | 0 M after | § 555 ZPO | | either | | |
|
||||
| 2 | Nichtzulassungsbeschwerde | 1 M after | § 544 ZPO | | either | | |
|
||||
| 3 | Nichtzulassungsbeschwerde-Begründung | 2 M after | § 544 ZPO | | either | | |
|
||||
| 4 | Revisionsfrist | 1 M after | § 548 ZPO | | either | | |
|
||||
| 5 | Revisionsbegründung | 2 M after | § 551 ZPO | | either | | |
|
||||
| 6 | Revisionserwiderung | 1 M after | § 554 ZPO | | either | | |
|
||||
| 7 | Mündliche Verhandlung BGH | 0 M after | § 555 ZPO | | court-set | | |
|
||||
| 8 | BGH-Urteil | 0 M after | § 555 ZPO | | court-set | | |
|
||||
|
||||
## Berufungsverfahren BGH (Nichtigkeit) · `de.null.bgh`
|
||||
|
||||
*Appeal BGH (Nullity)*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Zustellung BPatG-Urteil | 0 M after | § 110 PatG | | either | | |
|
||||
| 2 | Berufungsschrift | 1 M after | § 110 PatG | | either | | |
|
||||
| 3 | Berufungsbegründung | 3 M after | § 520 Abs. 2 ZPO i.V.m. § 117 PatG | | either | | |
|
||||
| 4 | Berufungserwiderung | 2 M after | § 521 Abs. 2 ZPO i.V.m. § 117 PatG | | court-set | | |
|
||||
| 5 | Mündliche Verhandlung BGH | 0 M after | § 121 PatG | | court-set | | |
|
||||
| 6 | BGH-Urteil | 0 M after | § 122 PatG | | court-set | | |
|
||||
|
||||
## EP-Erteilungsverfahren · `epa.grant.exa`
|
||||
|
||||
*EP Grant Procedure*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Anmeldung | 0 M after | Art. 75 EPÜ | | claimant | | |
|
||||
| 2 | Recherchenbericht | 6 M after | Art. 92 EPÜ | | court-set | | |
|
||||
| 3 | Veröffentlichung (A1) | 18 M after | Art. 93 EPÜ | | court-set | | |
|
||||
| 4 | Prüfungsantrag | 6 M after | R. 70(1) EPÜ | | claimant | | |
|
||||
| 5 | Mitteilung nach R. 71(3) | 0 M after | R. 71(3) EPÜ | | court-set | | |
|
||||
| 6 | Zustimmung + Übersetzung | 4 M after | R. 71(3) EPÜ | | claimant | | |
|
||||
| 7 | Erteilung (B1) | 0 M after | Art. 97 EPÜ | | court-set | | |
|
||||
|
||||
## Beschwerdeverfahren BPatG (DPMA) · `dpma.appeal.bpatg`
|
||||
|
||||
*Appeal BPatG (against DPMA Decision)*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Zustellung DPMA-Entscheidung | 0 M after | § 65 PatG | | either | | |
|
||||
| 2 | Beschwerde | 1 M after | § 73 PatG | | either | | |
|
||||
| 3 | Beschwerdebegründung | 1 M after | § 75 PatG | | court-set | | |
|
||||
| 4 | Mündliche Verhandlung BPatG | 0 M after | § 78 PatG | | court-set | | |
|
||||
| 5 | BPatG-Entscheidung | 0 M after | § 78 PatG | | court-set | | |
|
||||
|
||||
## Rechtsbeschwerdeverfahren BGH · `dpma.appeal.bgh`
|
||||
|
||||
*Legal Appeal BGH*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Zustellung BPatG-Entscheidung | 0 M after | § 100 PatG | | either | | |
|
||||
| 2 | Rechtsbeschwerde | 1 M after | § 100 PatG | | either | | |
|
||||
| 3 | Rechtsbeschwerdebegründung | 1 M after | § 102 PatG | | either | | |
|
||||
| 4 | BGH-Entscheidung | 0 M after | § 100 PatG | | court-set | | |
|
||||
|
||||
## Berufungsverfahren Anordnungen · `upc.apl.order`
|
||||
|
||||
*Order Appeal (15-day track)*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Anordnung / angegriffene Entscheidung | 0 M after | RoP.220 | | court-set | | |
|
||||
| 2 | Berufung mit Zulassung | 15 d after | RoP.220.2 | | either | | |
|
||||
| 3 | Antrag auf Ermessensüberprüfung | 15 d after | RoP.220.3 | | either | | |
|
||||
| 4 | Berufungsbegründung (Orders Track) | 15 d after | RoP.224.2.b | | either | | |
|
||||
| 5 | Anschlussberufung | 15 d after | RoP.237 | | either | | |
|
||||
| 6 | Erwiderung Anschlussberufung | 15 d after | RoP.238.2 | | either | | |
|
||||
| 7 | Berufungsschrift gegen eine in Regel 220.1(c) genannte Anordnung oder eine in Regel 220.2 oder 221.3 genannte Entscheidung | 15 d after | RoP.224.1(b) | Order referred to in Rule 220.1(c) or a decision referred to in Rule 220.2 or 221.3 | — | | |
|
||||
| 8 | Berufungsbegründung gegen eine in Regel 220.1(c) genannte Anordnung oder eine in Regel 220.2 oder 221.3 genannte Entscheidung | 15 d after | RoP.224.1(b) | Order referred to in Rule 220.1(c) or a decision referred to in Rule 220.2 or 221.3 | — | | |
|
||||
|
||||
## Schadensbemessungsverfahren · `upc.dmgs.cfi`
|
||||
|
||||
*Damages Determination*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Antrag auf Schadensbemessung | 0 M after | RoP.125 | | claimant | | |
|
||||
| 2 | Klageerwiderung | 2 M after | RoP.137.2 | | defendant | | |
|
||||
| 3 | Replik | 1 M after | RoP.139 | | claimant | | |
|
||||
| 4 | Duplik | 1 M after | RoP.139 | | defendant | | |
|
||||
|
||||
## Bucheinsichtsverfahren · `upc.disc.cfi`
|
||||
|
||||
*Lay-open Books / Discovery*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Antrag auf Bucheinsicht | 0 M after | RoP.190 | | claimant | | |
|
||||
| 2 | Klageerwiderung | 2 M after | RoP.142.2 | | defendant | | |
|
||||
| 3 | Replik | 14 d after | RoP.142.3 | | claimant | | |
|
||||
| 4 | Duplik | 14 d after | RoP.142.3 | | defendant | | |
|
||||
|
||||
## Einstweilige Maßnahmen · `upc.pi.cfi`
|
||||
|
||||
*Provisional Measures*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Antrag | 0 M after | RoP.206 | | claimant | | |
|
||||
| 2 | Erwiderung | 0 M after | RoP.211.2 | | court-set | | |
|
||||
| 3 | Mündliche Verhandlung | 0 M after | RoP.195 | | court-set | | |
|
||||
| 4 | Mängelbeseitigung Antrag | 14 d after | RoP.207.6.a | | claimant | | |
|
||||
| 5 | Beschluss | 0 M after | RoP.211 | | court-set | | |
|
||||
| 6 | Klage in der Hauptsache erheben | 31 d max 20 WD after | RoP.213 | | claimant | | |
|
||||
|
||||
## Schutzschrift · `upc.pl.cfi`
|
||||
|
||||
*Protective Letter*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Einreichung der Schutzschrift | 0 M after | RoP.207 | | defendant | | |
|
||||
| 2 | Erneuerung der Schutzschrift | 6 M after | RoP.207.9 | Protective Letter | — | | |
|
||||
|
||||
## Berufungsverfahren Kosten · `upc.apl.cost`
|
||||
|
||||
*Cost-Decision Appeal*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Kostenfestsetzungsbeschluss | 0 M after | RoP.221.4 | | court-set | | |
|
||||
| 2 | Antrag auf Berufungszulassung | 15 d after | RoP.221.1 | | either | | |
|
||||
| 3 | Antrag auf Berufungszulassung gegen Kostenentscheidungen | 15 d after | RoP.220.2 | Decision on fixation of costs (Rule 157) | — | | |
|
||||
|
||||
## Negative Feststellungsklage · `upc.dni.cfi`
|
||||
|
||||
*Declaration of Non-Infringement*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Klage auf negative Feststellung der Nichtverletzung | 0 M after | RoP.063 | | claimant | | |
|
||||
| 2 | Erwiderung auf die negative Feststellungsklage | 2 M after | RoP.066 | Statement for a declaration of non-infringement | — | | |
|
||||
| 3 | Replik auf die Erwiderung zur negativen Feststellungsklage | 1 M after | RoP.067 | Defence to the Statement for a declaration of non-infringement | — | | |
|
||||
| 4 | Duplik zur Replik auf die Erwiderung zur negativen Feststellungsklage | 1 M after | RoP.068 | Reply to the Defence to the Statement for a declaration of non-infringement | — | | |
|
||||
|
||||
## Überprüfung von EPA-Entscheidungen · `upc.epo.review`
|
||||
|
||||
*Review of EPO decisions*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Antrag auf Überprüfung der EPA-Entscheidung | 0 M after | RoP.088 | | — | | |
|
||||
| 2 | Antrag auf Aufhebung einer Entscheidung des EPA, mit der ein Antrag auf einheitliche Wirkung zurückgewiesen wurde | 3 w after | RoP.097 | Decision of the EPO not to grant unitary effect | — | | |
|
||||
|
||||
## Separate Kostenentscheidung · `upc.costs.cfi`
|
||||
|
||||
*Separate Cost Decision*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Antrag auf Kostenfestsetzung | 1 M after | RoP.151 | | claimant | | |
|
||||
|
||||
## Beweissicherung / saisie · `upc.bsv.cfi`
|
||||
|
||||
*Evidence Preservation*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Antrag auf Beweissicherung | 0 M after | RoP.192 | | court-set | | |
|
||||
| 2 | Antrag auf Überprüfung der Beweissicherungsanordnung | 30 d after | RoP.197.3 | Execution of measures to preserve evidence | — | | |
|
||||
| 3 | Beginn des Hauptsacheverfahrens | 31 d max 20 WD after | RoP.198 | Date specified in the Court's order to preserve evidence | — | | |
|
||||
|
||||
## Widerklage auf Nichtigkeit · `upc.ccr.cfi`
|
||||
|
||||
*Counterclaim for Revocation*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Widerklage auf Nichtigkeit | 3 M after | RoP.025 | | defendant | | |
|
||||
|
||||
---
|
||||
|
||||
**Lesehilfe:**
|
||||
- ⤴ Spawn-Marker: event opens a sub-proceeding (e.g. CCR forks revocation track)
|
||||
- `with_ccr` = Widerklage auf Nichtigkeit gefilt | `with_amend` = Patentänderungsantrag | `with_cci` = Widerklage auf Verletzung (in rev.cfi)
|
||||
- Catalog ist work-in-progress: 7 compound-name rules + Patentänderung-Duplikation noch in m's split-review backlog (m/paliad#149).
|
||||
@@ -18,6 +18,7 @@ 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 { renderTemplatesAuthoring } from "./src/templates-authoring";
|
||||
import { renderSubmissionsIndex } from "./src/submissions-index";
|
||||
import { renderSubmissionsNew } from "./src/submissions-new";
|
||||
import { renderEvents } from "./src/events";
|
||||
@@ -255,6 +256,7 @@ async function build() {
|
||||
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/templates-authoring.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"),
|
||||
@@ -382,6 +384,7 @@ async function build() {
|
||||
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, "templates-authoring.html"), renderTemplatesAuthoring());
|
||||
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.
|
||||
|
||||
Binary file not shown.
BIN
frontend/public/patentstyle/HLC-Patents-Style.dotm
Normal file
BIN
frontend/public/patentstyle/HLC-Patents-Style.dotm
Normal file
Binary file not shown.
@@ -1,9 +1,9 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>HL Patents Style</title>
|
||||
<title>HLC Patents Style</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #002236;
|
||||
@@ -81,31 +81,35 @@
|
||||
<body>
|
||||
<main>
|
||||
|
||||
<h1>HL <span class="accent">Patents Style</span></h1>
|
||||
<p class="lead">Das Word-Template fuer Patentschriftsaetze bei Hogan Lovells.</p>
|
||||
<h1>HLC <span class="accent">Patents Style</span></h1>
|
||||
<!-- Lead line: provisional pending m's final de-brand wording (work/head delegation #2681).
|
||||
"at HLC" matches the confirmed rebrand; swap when the final copy lands. -->
|
||||
<p class="lead">The Word template for patent submissions at HLC.</p>
|
||||
|
||||
<h2>Was es kann</h2>
|
||||
<h2>What it does</h2>
|
||||
<ul>
|
||||
<li>Vorlagen-Stile fuer alle gaengigen Schriftsatz-Bausteine (Headings, Randnummern, Antraege, Exhibits)</li>
|
||||
<li>BuildingBlocks: ueber das Ribbon vorgefertigte Abschnitte einfuegen</li>
|
||||
<li>Sprachumschaltung DE / EN per Ribbon-Toggle</li>
|
||||
<li>Scaffolding: kompletter Schriftsatz-Aufbau mit einem Klick</li>
|
||||
<li>Margin Numbers, Exhibit-Nummerierung, SEQ-Felder</li>
|
||||
<li>Auto-Update ueber das Ribbon (siehe unten)</li>
|
||||
<li>Document styles for every common submission building block (headings, margin numbers, motions, exhibits)</li>
|
||||
<li>BuildingBlocks: insert ready-made sections straight from the ribbon</li>
|
||||
<li>DE / EN language switch via a ribbon toggle</li>
|
||||
<li>Scaffolding: build a complete submission with one click</li>
|
||||
<li>Margin numbers, exhibit numbering, SEQ fields</li>
|
||||
<li>Auto-update from the ribbon (see below)</li>
|
||||
</ul>
|
||||
|
||||
<h2>Aktualisierungen</h2>
|
||||
<p>Im Ribbon-Tab <em>HL Patent</em> → Gruppe <em>Manage</em> → <kbd>Check for Updates</kbd>. Holt das aktuelle Manifest von diesem Server, prueft die Version, laedt die neue <code>.dotm</code> nur bei Bedarf, verifiziert per SHA256, installiert. Nach dem Update Word neu starten.</p>
|
||||
<h2>Updates</h2>
|
||||
<p>In the ribbon tab <em>HLC Patent</em> → group <em>Manage</em> → <kbd>Check for Updates</kbd>. It fetches the current manifest from this server, checks the version, downloads the new <code>.dotm</code> only when needed, verifies it via SHA256, and installs it. Restart Word after updating.</p>
|
||||
|
||||
<h2>Frische Installation</h2>
|
||||
<p>Wer das Template noch nicht installiert hat, laedt einmal manuell die aktuelle Version und kopiert sie in den Word-Startup-Ordner. Den Rest macht die <code>InstallTemplate</code>-Routine im Template selbst.</p>
|
||||
<p><a class="download" href="HL-Patents-Style.dotm" download>HL Patents Style.dotm herunterladen</a></p>
|
||||
<h2>Fresh install</h2>
|
||||
<p>If you haven’t installed the template yet, download the current version once manually and copy it into the Word startup folder. The <code>InstallTemplate</code> routine inside the template handles the rest.</p>
|
||||
<!-- Download href stays on the current HL-Patents-Style.dotm until work/head confirms
|
||||
HLC-Patents-Style.dotm is published (zero-downtime swap, delegation #2681). -->
|
||||
<p><a class="download" href="HL-Patents-Style.dotm" download>Download HLC Patents Style</a></p>
|
||||
|
||||
<h2>Hilfe & Feedback</h2>
|
||||
<p>Fehler, Wuensche, Stilfragen, Build-Probleme: <a href="mailto:matthias.siebels@hoganlovells.com?subject=HL%20Patents%20Style">matthias.siebels@hoganlovells.com</a></p>
|
||||
<h2>Help & feedback</h2>
|
||||
<p>Bugs, requests, style questions, build problems: <a href="mailto:matthias.siebels@hoganlovells.com?subject=HLC%20Patents%20Style">matthias.siebels@hoganlovells.com</a></p>
|
||||
|
||||
<footer>
|
||||
<p>Update-Endpoint: <code>paliad.msbls.de/patentstyle/</code> · Mirror: <code>hihlc.msbls.de/patentstyle/</code></p>
|
||||
<p>Update endpoint: <code>paliad.msbls.de/patentstyle/</code> · Mirror: <code>hihlc.msbls.de/patentstyle/</code></p>
|
||||
<p id="ver"></p>
|
||||
</footer>
|
||||
|
||||
@@ -115,7 +119,7 @@
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(j => {
|
||||
if (j && j.version) {
|
||||
document.getElementById('ver').textContent = 'Aktuell ausgeliefert: ' + j.version;
|
||||
document.getElementById('ver').textContent = 'Currently served: ' + j.version;
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "v0.260518",
|
||||
"dotm_url": "https://paliad.msbls.de/patentstyle/HL-Patents-Style.dotm",
|
||||
"sha256": "5CEA98A29D2FD6D9970B9A2499054DF52685A1116459E07F9290B0D0ADD521F4"
|
||||
}
|
||||
"version": "v0.260601",
|
||||
"dotm_url": "https://paliad.msbls.de/patentstyle/HLC-Patents-Style.dotm",
|
||||
"sha256": "DE6B6A17AC603FF4A9B3893CD2A7EF8263C9E2D4224A0A5E28E2FABF5E27A798"
|
||||
}
|
||||
262
frontend/src/client/builder-akte.ts
Normal file
262
frontend/src/client/builder-akte.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
// Akte-mode wiring for the Litigation Builder (m/paliad#153 B4,
|
||||
// t-paliad-347).
|
||||
//
|
||||
// PRD §2.3 + §3.1 + §3.2: the page-header Akte picker lists every
|
||||
// project (`type='case'`) the user can see. Picking one POSTs to
|
||||
// /api/builder/scenarios/from-project, which mints a project-backed
|
||||
// scenario (origin_project_id pinned) seeded with the project's
|
||||
// proceeding + scenario_flags + completed deadlines. Subsequent
|
||||
// builder edits dual-write through to paliad.deadlines + projects.
|
||||
// scenario_flags via the server-side dual-write hooks.
|
||||
//
|
||||
// The picker is its own module so the builder.ts orchestrator only
|
||||
// has to expose two hooks:
|
||||
//
|
||||
// - `onProjectChosen(projectId)` — called when the user picks a
|
||||
// project. Builder calls the from-project endpoint and loads the
|
||||
// returned scenario.
|
||||
// - `setSelectedProject(scenario)` — called after a scenario loads
|
||||
// so the picker reflects the current Akte (or "— ohne —" for
|
||||
// kontextfrei scenarios).
|
||||
//
|
||||
// Cross-surface scenario-flag-changed (mig 154 ssoT, m/paliad#149):
|
||||
// the builder listens to the existing CustomEvent so any peer surface
|
||||
// that PATCHes /api/projects/{id}/scenario-flags triggers a re-fetch
|
||||
// on the builder's active proceeding when the projectId matches the
|
||||
// scenario's origin_project_id. The dispatch direction is already
|
||||
// covered by patchScenarioFlags inside scenario-flags.ts — the
|
||||
// builder's own PATCH /api/projects/.../scenario-flags goes through
|
||||
// that helper so peer surfaces stay in sync without a separate dispatch.
|
||||
|
||||
import { t } from "./i18n";
|
||||
|
||||
export interface AkteProjectMeta {
|
||||
id: string;
|
||||
title: string;
|
||||
reference?: string | null;
|
||||
case_number?: string | null;
|
||||
proceeding_type_id?: number | null;
|
||||
our_side?: string | null;
|
||||
}
|
||||
|
||||
export type OnProjectChosen = (projectId: string) => void | Promise<void>;
|
||||
|
||||
interface State {
|
||||
projects: AkteProjectMeta[];
|
||||
loaded: boolean;
|
||||
}
|
||||
|
||||
const state: State = {
|
||||
projects: [],
|
||||
loaded: false,
|
||||
};
|
||||
|
||||
// fetchAkteProjects pulls every type=case project the caller can see.
|
||||
// Visibility is enforced by /api/projects via the project_teams /
|
||||
// can_see_project predicate. We filter client-side to projects with a
|
||||
// proceeding_type_id — those are the ones the builder can render. We
|
||||
// don't filter server-side because /api/projects' filter param doesn't
|
||||
// accept proceeding_type_id_not_null and round-tripping for that one
|
||||
// reason isn't worth a new endpoint.
|
||||
export async function fetchAkteProjects(): Promise<AkteProjectMeta[]> {
|
||||
try {
|
||||
const resp = await fetch("/api/projects?type=case", {
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
if (!resp.ok) {
|
||||
console.warn("builder-akte: /api/projects", resp.status);
|
||||
return [];
|
||||
}
|
||||
const rows = (await resp.json()) as Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
reference?: string | null;
|
||||
case_number?: string | null;
|
||||
proceeding_type_id?: number | null;
|
||||
our_side?: string | null;
|
||||
status?: string;
|
||||
}>;
|
||||
return rows
|
||||
.filter((r) => r.proceeding_type_id != null && (r.status ?? "active") === "active")
|
||||
.map((r) => ({
|
||||
id: r.id,
|
||||
title: r.title,
|
||||
reference: r.reference ?? null,
|
||||
case_number: r.case_number ?? null,
|
||||
proceeding_type_id: r.proceeding_type_id ?? null,
|
||||
our_side: r.our_side ?? null,
|
||||
}));
|
||||
} catch (e) {
|
||||
console.error("builder-akte: fetch projects failed", e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// formatProjectLabel renders the dropdown row for a project. Reference
|
||||
// + title are the primary anchors; the case_number tail disambiguates
|
||||
// when two cases share a reference family.
|
||||
function formatProjectLabel(p: AkteProjectMeta): string {
|
||||
const parts: string[] = [];
|
||||
if (p.reference) parts.push(p.reference);
|
||||
parts.push(p.title);
|
||||
if (p.case_number) parts.push("(" + p.case_number + ")");
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
// renderAktePicker fills the existing <select id="builder-akte-picker">
|
||||
// with the project list + a "— ohne —" sentinel. Idempotent.
|
||||
function renderAktePicker(selectedId: string | null): void {
|
||||
const sel = document.getElementById("builder-akte-picker") as HTMLSelectElement | null;
|
||||
if (!sel) return;
|
||||
const none = t("builder.akte.none");
|
||||
const opts: string[] = [`<option value="" data-i18n="builder.akte.none">${escHtml(none)}</option>`];
|
||||
for (const p of state.projects) {
|
||||
const selected = p.id === selectedId ? " selected" : "";
|
||||
opts.push(
|
||||
`<option value="${escAttr(p.id)}"${selected}>${escHtml(formatProjectLabel(p))}</option>`,
|
||||
);
|
||||
}
|
||||
sel.innerHTML = opts.join("");
|
||||
}
|
||||
|
||||
// mountAktePicker is the entry point. It fetches the project list once,
|
||||
// wires the dropdown change event to the supplied callback, and
|
||||
// returns a controller exposing setSelectedProject so the builder can
|
||||
// keep the picker reflective of the active scenario's Akte.
|
||||
//
|
||||
// The picker re-enables itself the moment projects load. While
|
||||
// loading, the existing `disabled` attribute (set in procedures.tsx)
|
||||
// stays so users don't pick during the fetch — but if the user lands
|
||||
// on the page after the catalog is cached this is essentially
|
||||
// instantaneous.
|
||||
export interface AktePickerHandle {
|
||||
setSelectedProject: (projectId: string | null) => void;
|
||||
isAkteMode: () => boolean;
|
||||
reload: () => Promise<void>;
|
||||
}
|
||||
|
||||
export async function mountAktePicker(onChosen: OnProjectChosen): Promise<AktePickerHandle> {
|
||||
const sel = document.getElementById("builder-akte-picker") as HTMLSelectElement | null;
|
||||
if (!sel) {
|
||||
return {
|
||||
setSelectedProject: () => {},
|
||||
isAkteMode: () => false,
|
||||
reload: async () => {},
|
||||
};
|
||||
}
|
||||
|
||||
// First load — fill the dropdown, enable it, wire change.
|
||||
state.projects = await fetchAkteProjects();
|
||||
state.loaded = true;
|
||||
renderAktePicker(null);
|
||||
sel.disabled = false;
|
||||
|
||||
sel.addEventListener("change", () => {
|
||||
const id = sel.value;
|
||||
if (!id) {
|
||||
// "— ohne —" reset is intentional; the builder treats this as
|
||||
// "leave the current scenario alone, just clear the picker".
|
||||
// Switching the active scenario to a non-Akte one happens via
|
||||
// the scenario picker, not by clicking the empty Akte option.
|
||||
return;
|
||||
}
|
||||
void onChosen(id);
|
||||
});
|
||||
|
||||
return {
|
||||
setSelectedProject: (projectId: string | null) => {
|
||||
const next = projectId ?? "";
|
||||
// Renderless quick-sync when the option is present; otherwise
|
||||
// re-render so the option appears (covers freshly created
|
||||
// projects since this picker last loaded).
|
||||
const optEl = sel.querySelector<HTMLOptionElement>(`option[value="${cssEscape(next)}"]`);
|
||||
if (next && !optEl) {
|
||||
renderAktePicker(next);
|
||||
} else {
|
||||
sel.value = next;
|
||||
}
|
||||
},
|
||||
isAkteMode: () => sel.value !== "",
|
||||
reload: async () => {
|
||||
state.projects = await fetchAkteProjects();
|
||||
renderAktePicker(sel.value || null);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// createScenarioFromProject posts to the B4 entry point. Returns the
|
||||
// new scenario's deep payload on success (id + proceedings + events),
|
||||
// null on failure. Caller is expected to load the returned scenario
|
||||
// via the builder's existing fetchScenarioDeep / state.active path.
|
||||
export async function createScenarioFromProject(projectId: string): Promise<{ id: string } | null> {
|
||||
try {
|
||||
const resp = await fetch("/api/builder/scenarios/from-project", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||
body: JSON.stringify({ project_id: projectId }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
console.warn("builder-akte: from-project", resp.status, await resp.text().catch(() => ""));
|
||||
return null;
|
||||
}
|
||||
const out = await resp.json();
|
||||
return out && typeof out.id === "string" ? { id: out.id } : null;
|
||||
} catch (e) {
|
||||
console.error("builder-akte: from-project failed", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// renderAkteBanner toggles the "Aus Akte: <code>" badge next to the
|
||||
// scenario picker. The badge is a <span class="builder-akte-banner">
|
||||
// inserted/removed by this helper; CSS gives it a lime tint to match
|
||||
// the Akte affordance throughout the app. Pass `null` (or omit
|
||||
// projectId) to hide.
|
||||
export function renderAkteBanner(projectId: string | null): void {
|
||||
const host = document.querySelector(".builder-pageheader") as HTMLElement | null;
|
||||
if (!host) return;
|
||||
let badge = document.getElementById("builder-akte-banner");
|
||||
if (!projectId) {
|
||||
if (badge) badge.remove();
|
||||
return;
|
||||
}
|
||||
const meta = state.projects.find((p) => p.id === projectId);
|
||||
const label = meta ? formatProjectLabel(meta) : projectId.slice(0, 8);
|
||||
const text =
|
||||
t("builder.akte.banner.prefix") + " " + label;
|
||||
if (!badge) {
|
||||
badge = document.createElement("span");
|
||||
badge.id = "builder-akte-banner";
|
||||
badge.className = "builder-akte-banner";
|
||||
badge.setAttribute("role", "note");
|
||||
host.appendChild(badge);
|
||||
}
|
||||
badge.textContent = text;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// helpers
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function escAttr(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/"/g, """);
|
||||
}
|
||||
|
||||
function escHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
// cssEscape is a small fallback for browsers that don't yet expose
|
||||
// CSS.escape. UUIDs only contain [0-9a-f-] so even the naïve replacer
|
||||
// keeps us safe; the function exists to make intent obvious.
|
||||
function cssEscape(s: string): string {
|
||||
if (typeof CSS !== "undefined" && typeof (CSS as { escape?: (s: string) => string }).escape === "function") {
|
||||
return (CSS as { escape: (s: string) => string }).escape(s);
|
||||
}
|
||||
return s.replace(/[^a-zA-Z0-9_-]/g, "\\$&");
|
||||
}
|
||||
147
frontend/src/client/builder-picker.ts
Normal file
147
frontend/src/client/builder-picker.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
// Add-proceeding inline picker for the Litigation Builder.
|
||||
//
|
||||
// PRD §3 + §3.1: "+ Verfahren hinzufügen" button at the bottom of the
|
||||
// triplet stack opens an inline picker. Forum chip row (UPC for v1)
|
||||
// gates the Verfahren chip row, click → callback. Designed for B1's
|
||||
// single-triplet flow and B2's multi-triplet stacking with no shape
|
||||
// change between slices.
|
||||
|
||||
import { t } from "./i18n";
|
||||
|
||||
export interface ProceedingTypeMeta {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
nameEN: string;
|
||||
// group / jurisdiction. The proceeding-types API returns "UPC" /
|
||||
// "DE" / etc. as the canonical jurisdiction; for v1 the picker
|
||||
// only renders UPC.
|
||||
group?: string;
|
||||
jurisdiction?: string;
|
||||
}
|
||||
|
||||
type OnPick = (meta: ProceedingTypeMeta) => void | Promise<void>;
|
||||
|
||||
let activePopover: HTMLElement | null = null;
|
||||
|
||||
export function mountAddProceedingPicker(
|
||||
anchor: HTMLElement,
|
||||
types: ProceedingTypeMeta[],
|
||||
onPick: OnPick,
|
||||
): void {
|
||||
closeActive();
|
||||
const pop = document.createElement("div");
|
||||
pop.className = "builder-picker-popover";
|
||||
pop.setAttribute("role", "dialog");
|
||||
pop.setAttribute("aria-label", t("builder.picker.aria"));
|
||||
|
||||
const header = document.createElement("div");
|
||||
header.className = "builder-picker-header";
|
||||
header.innerHTML = `
|
||||
<strong class="builder-picker-title">${escHtml(t("builder.picker.title"))}</strong>
|
||||
<button type="button" class="builder-picker-close" aria-label="${escAttr(t("builder.picker.close"))}">×</button>
|
||||
`;
|
||||
pop.appendChild(header);
|
||||
|
||||
// Forum row — UPC only for v1. Disabled chips render greyed.
|
||||
const forumRow = document.createElement("div");
|
||||
forumRow.className = "builder-picker-row";
|
||||
forumRow.innerHTML = `
|
||||
<span class="builder-picker-axis-label">${escHtml(t("builder.picker.axis.forum"))}</span>
|
||||
<div class="builder-picker-chips">
|
||||
<button type="button" class="builder-picker-chip is-active" data-forum="UPC">UPC</button>
|
||||
<button type="button" class="builder-picker-chip" data-forum="DE" disabled title="${escAttr(t("builder.picker.future_jurisdiction"))}">DE</button>
|
||||
<button type="button" class="builder-picker-chip" data-forum="EPA" disabled title="${escAttr(t("builder.picker.future_jurisdiction"))}">EPA</button>
|
||||
<button type="button" class="builder-picker-chip" data-forum="DPMA" disabled title="${escAttr(t("builder.picker.future_jurisdiction"))}">DPMA</button>
|
||||
</div>
|
||||
`;
|
||||
pop.appendChild(forumRow);
|
||||
|
||||
const procRow = document.createElement("div");
|
||||
procRow.className = "builder-picker-row";
|
||||
procRow.innerHTML = `
|
||||
<span class="builder-picker-axis-label">${escHtml(t("builder.picker.axis.proc"))}</span>
|
||||
<div class="builder-picker-chips builder-picker-chips--wrap" id="builder-picker-proc-chips"></div>
|
||||
`;
|
||||
pop.appendChild(procRow);
|
||||
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "builder-picker-empty";
|
||||
empty.hidden = true;
|
||||
empty.textContent = t("builder.picker.empty");
|
||||
pop.appendChild(empty);
|
||||
|
||||
const procHost = pop.querySelector("#builder-picker-proc-chips") as HTMLElement;
|
||||
const lang = document.documentElement.lang === "en" ? "en" : "de";
|
||||
for (const meta of types) {
|
||||
const label = lang === "en" ? (meta.nameEN || meta.name) : meta.name;
|
||||
const chip = document.createElement("button");
|
||||
chip.type = "button";
|
||||
chip.className = "builder-picker-chip builder-picker-chip--proc";
|
||||
chip.setAttribute("data-code", meta.code);
|
||||
chip.innerHTML = `<span class="builder-picker-chip-code">${escHtml(meta.code)}</span>
|
||||
<span class="builder-picker-chip-name">${escHtml(label)}</span>`;
|
||||
chip.addEventListener("click", () => {
|
||||
closeActive();
|
||||
void onPick(meta);
|
||||
});
|
||||
procHost.appendChild(chip);
|
||||
}
|
||||
if (types.length === 0) empty.hidden = false;
|
||||
|
||||
header.querySelector(".builder-picker-close")?.addEventListener("click", () => {
|
||||
closeActive();
|
||||
});
|
||||
|
||||
// Position the popover under the anchor button.
|
||||
positionUnder(pop, anchor);
|
||||
document.body.appendChild(pop);
|
||||
activePopover = pop;
|
||||
document.addEventListener("click", onOutsideClick, true);
|
||||
document.addEventListener("keydown", onEscape, true);
|
||||
}
|
||||
|
||||
function positionUnder(pop: HTMLElement, anchor: HTMLElement): void {
|
||||
const rect = anchor.getBoundingClientRect();
|
||||
pop.style.position = "absolute";
|
||||
const top = rect.bottom + window.scrollY + 6;
|
||||
// Default left = anchor's left; clamp so popover stays in viewport.
|
||||
const left = Math.max(8, rect.left + window.scrollX);
|
||||
pop.style.top = `${top}px`;
|
||||
pop.style.left = `${left}px`;
|
||||
pop.style.maxWidth = "min(640px, calc(100vw - 24px))";
|
||||
pop.style.zIndex = "60";
|
||||
}
|
||||
|
||||
function onOutsideClick(ev: Event): void {
|
||||
if (!activePopover) return;
|
||||
const target = ev.target as Node;
|
||||
if (activePopover.contains(target)) return;
|
||||
closeActive();
|
||||
}
|
||||
|
||||
function onEscape(ev: KeyboardEvent): void {
|
||||
if (ev.key === "Escape") closeActive();
|
||||
}
|
||||
|
||||
function closeActive(): void {
|
||||
if (activePopover) {
|
||||
activePopover.remove();
|
||||
activePopover = null;
|
||||
}
|
||||
document.removeEventListener("click", onOutsideClick, true);
|
||||
document.removeEventListener("keydown", onEscape, true);
|
||||
}
|
||||
|
||||
function escHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function escAttr(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/"/g, """);
|
||||
}
|
||||
370
frontend/src/client/builder-promote.ts
Normal file
370
frontend/src/client/builder-promote.ts
Normal file
@@ -0,0 +1,370 @@
|
||||
// Litigation Builder — promote-to-project wizard (m/paliad#153 PRD §2.4
|
||||
// + §5.4, B5).
|
||||
//
|
||||
// 3 steps: Bestätigen (read-only summary) → Parteien ergänzen (party
|
||||
// names) → Akte-Metadaten (title, reference, case number, our_side,
|
||||
// litigation parent, team). Commit POSTs the merged payload to
|
||||
// /api/builder/scenarios/{id}/promote — a single server-side transaction
|
||||
// (no partial promotions) that creates the paliad.projects 'case' row,
|
||||
// cascades deadlines, and flips the scenario to 'promoted'. On success
|
||||
// the wizard navigates to /projects/{new-id}.
|
||||
|
||||
import { t } from "./i18n";
|
||||
|
||||
interface ProjectOption {
|
||||
id: string;
|
||||
title: string;
|
||||
type: string;
|
||||
reference?: string;
|
||||
}
|
||||
|
||||
interface UserOption {
|
||||
id: string;
|
||||
email: string;
|
||||
display_name?: string;
|
||||
office?: string;
|
||||
}
|
||||
|
||||
interface PartyRow {
|
||||
name: string;
|
||||
role: string;
|
||||
representative: string;
|
||||
}
|
||||
|
||||
export interface PromoteContext {
|
||||
scenarioId: string;
|
||||
ownerId?: string;
|
||||
proceedingLabel: string;
|
||||
filedCount: number;
|
||||
plannedCount: number;
|
||||
flagCount: number;
|
||||
extraTopLevel: number;
|
||||
defaultOurSide: "claimant" | "defendant" | null;
|
||||
defaultTitle: string;
|
||||
onSuccess: (projectId: string) => void;
|
||||
}
|
||||
|
||||
export async function openPromoteWizard(ctx: PromoteContext): Promise<void> {
|
||||
// Parallel fetch: litigation parents + HLC users (both optional pickers).
|
||||
const [parents, users] = await Promise.all([
|
||||
fetchProjects("litigation"),
|
||||
fetchUsers(),
|
||||
]);
|
||||
|
||||
let step = 1;
|
||||
const parties: PartyRow[] = [];
|
||||
const meta = {
|
||||
title: ctx.defaultTitle || "",
|
||||
reference: "",
|
||||
caseNumber: "",
|
||||
clientNumber: "",
|
||||
ourSide: (ctx.defaultOurSide ?? "") as "" | "claimant" | "defendant",
|
||||
parentId: "",
|
||||
teamIds: new Set<string>(),
|
||||
};
|
||||
|
||||
const backdrop = document.createElement("div");
|
||||
backdrop.className = "builder-modal-backdrop";
|
||||
const modal = document.createElement("div");
|
||||
modal.className = "builder-modal builder-promote-modal";
|
||||
modal.setAttribute("role", "dialog");
|
||||
modal.setAttribute("aria-modal", "true");
|
||||
modal.setAttribute("aria-label", t("builder.promote.title"));
|
||||
backdrop.appendChild(modal);
|
||||
|
||||
const close = () => {
|
||||
document.removeEventListener("keydown", onEsc, true);
|
||||
backdrop.remove();
|
||||
};
|
||||
const onEsc = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") close();
|
||||
};
|
||||
backdrop.addEventListener("click", (e) => {
|
||||
if (e.target === backdrop) close();
|
||||
});
|
||||
document.addEventListener("keydown", onEsc, true);
|
||||
|
||||
function stepHeader(): string {
|
||||
const steps = [
|
||||
t("builder.promote.step1"),
|
||||
t("builder.promote.step2"),
|
||||
t("builder.promote.step3"),
|
||||
];
|
||||
const dots = steps.map((label, i) => {
|
||||
const n = i + 1;
|
||||
const cls = n === step ? " is-active" : n < step ? " is-done" : "";
|
||||
return `<li class="builder-promote-step${cls}"><span class="builder-promote-step-n">${n}</span>` +
|
||||
`<span class="builder-promote-step-label">${escHtml(label)}</span></li>`;
|
||||
}).join("");
|
||||
return `<ol class="builder-promote-steps">${dots}</ol>`;
|
||||
}
|
||||
|
||||
function renderStep1(): string {
|
||||
const rows = [
|
||||
`<li><span>${escHtml(t("builder.promote.summary.proceeding"))}</span><strong>${escHtml(ctx.proceedingLabel)}</strong></li>`,
|
||||
`<li><span>${escHtml(t("builder.promote.summary.events_filed"))}</span><strong>${ctx.filedCount}</strong></li>`,
|
||||
`<li><span>${escHtml(t("builder.promote.summary.events_planned"))}</span><strong>${ctx.plannedCount}</strong></li>`,
|
||||
`<li><span>${escHtml(t("builder.promote.summary.flags"))}</span><strong>${ctx.flagCount}</strong></li>`,
|
||||
].join("");
|
||||
const extra = ctx.extraTopLevel > 0
|
||||
? `<p class="builder-promote-note">${escHtml(
|
||||
t("builder.promote.summary.note_extra").replace("{n}", String(ctx.extraTopLevel)),
|
||||
)}</p>`
|
||||
: "";
|
||||
return (
|
||||
`<h3 class="builder-promote-section-title">${escHtml(t("builder.promote.summary.heading"))}</h3>` +
|
||||
`<ul class="builder-promote-summary">${rows}</ul>${extra}`
|
||||
);
|
||||
}
|
||||
|
||||
function renderStep2(): string {
|
||||
const list = parties.length === 0
|
||||
? `<p class="builder-promote-empty">${escHtml(t("builder.promote.parties.empty"))}</p>`
|
||||
: parties.map((p, i) => (
|
||||
`<div class="builder-promote-party" data-idx="${i}">` +
|
||||
`<input class="builder-promote-party-name" placeholder="${escAttr(t("builder.promote.parties.name"))}" value="${escAttr(p.name)}" />` +
|
||||
`<input class="builder-promote-party-role" placeholder="${escAttr(t("builder.promote.parties.role"))}" value="${escAttr(p.role)}" />` +
|
||||
`<input class="builder-promote-party-rep" placeholder="${escAttr(t("builder.promote.parties.representative"))}" value="${escAttr(p.representative)}" />` +
|
||||
`<button type="button" class="builder-promote-party-remove" aria-label="${escAttr(t("builder.promote.parties.remove"))}">×</button>` +
|
||||
`</div>`
|
||||
)).join("");
|
||||
return (
|
||||
`<p class="builder-promote-hint">${escHtml(t("builder.promote.parties.hint"))}</p>` +
|
||||
`<div class="builder-promote-parties">${list}</div>` +
|
||||
`<button type="button" class="builder-promote-party-add">${escHtml(t("builder.promote.parties.add"))}</button>`
|
||||
);
|
||||
}
|
||||
|
||||
function renderStep3(): string {
|
||||
const parentOpts = [`<option value="">${escHtml(t("builder.promote.meta.parent.none"))}</option>`]
|
||||
.concat(parents.map((p) => {
|
||||
const sel = p.id === meta.parentId ? " selected" : "";
|
||||
const label = p.reference ? `${p.title} (${p.reference})` : p.title;
|
||||
return `<option value="${escAttr(p.id)}"${sel}>${escHtml(label)}</option>`;
|
||||
})).join("");
|
||||
const sideSel = (v: string) => (meta.ourSide === v ? " selected" : "");
|
||||
const team = users
|
||||
.filter((u) => u.id !== ctx.ownerId)
|
||||
.slice(0, 40)
|
||||
.map((u) => {
|
||||
const checked = meta.teamIds.has(u.id) ? " checked" : "";
|
||||
const label = (u.display_name || "").trim()
|
||||
? ((u.office ? `${u.display_name} · ${u.office}` : u.display_name) as string)
|
||||
: u.email;
|
||||
return (
|
||||
`<label class="builder-promote-team-item">` +
|
||||
`<input type="checkbox" class="builder-promote-team-cb" data-user-id="${escAttr(u.id)}"${checked} />` +
|
||||
`<span>${escHtml(label)}</span></label>`
|
||||
);
|
||||
}).join("");
|
||||
return (
|
||||
`<label class="builder-promote-field"><span>${escHtml(t("builder.promote.meta.title"))}</span>` +
|
||||
`<input class="builder-promote-title" placeholder="${escAttr(t("builder.promote.meta.title.placeholder"))}" value="${escAttr(meta.title)}" /></label>` +
|
||||
`<div class="builder-promote-field-row">` +
|
||||
`<label class="builder-promote-field"><span>${escHtml(t("builder.promote.meta.reference"))}</span>` +
|
||||
`<input class="builder-promote-reference" value="${escAttr(meta.reference)}" /></label>` +
|
||||
`<label class="builder-promote-field"><span>${escHtml(t("builder.promote.meta.case_number"))}</span>` +
|
||||
`<input class="builder-promote-casenumber" value="${escAttr(meta.caseNumber)}" /></label>` +
|
||||
`</div>` +
|
||||
`<div class="builder-promote-field-row">` +
|
||||
`<label class="builder-promote-field"><span>${escHtml(t("builder.promote.meta.client_number"))}</span>` +
|
||||
`<input class="builder-promote-clientnumber" value="${escAttr(meta.clientNumber)}" /></label>` +
|
||||
`<label class="builder-promote-field"><span>${escHtml(t("builder.promote.meta.our_side"))}</span>` +
|
||||
`<select class="builder-promote-ourside">` +
|
||||
`<option value=""${sideSel("")}>${escHtml(t("builder.promote.meta.our_side.none"))}</option>` +
|
||||
`<option value="claimant"${sideSel("claimant")}>${escHtml(t("builder.promote.meta.our_side.claimant"))}</option>` +
|
||||
`<option value="defendant"${sideSel("defendant")}>${escHtml(t("builder.promote.meta.our_side.defendant"))}</option>` +
|
||||
`</select></label>` +
|
||||
`</div>` +
|
||||
`<label class="builder-promote-field"><span>${escHtml(t("builder.promote.meta.parent"))}</span>` +
|
||||
`<select class="builder-promote-parent">${parentOpts}</select></label>` +
|
||||
`<div class="builder-promote-field"><span>${escHtml(t("builder.promote.meta.team"))}</span>` +
|
||||
`<p class="builder-promote-team-hint">${escHtml(t("builder.promote.meta.team.hint"))}</p>` +
|
||||
`<div class="builder-promote-team">${team}</div></div>` +
|
||||
`<p class="builder-promote-error" hidden></p>`
|
||||
);
|
||||
}
|
||||
|
||||
function render(): void {
|
||||
let body = "";
|
||||
if (step === 1) body = renderStep1();
|
||||
else if (step === 2) body = renderStep2();
|
||||
else body = renderStep3();
|
||||
|
||||
const backLabel = t("builder.promote.back");
|
||||
const cancelLabel = t("builder.promote.cancel");
|
||||
const nextLabel = step < 3 ? t("builder.promote.next") : t("builder.promote.commit");
|
||||
|
||||
modal.innerHTML = `
|
||||
<header class="builder-modal-header">
|
||||
<h2 class="builder-modal-title">${escHtml(t("builder.promote.title"))}</h2>
|
||||
<button type="button" class="builder-modal-close" aria-label="${escAttr(cancelLabel)}">×</button>
|
||||
</header>
|
||||
${stepHeader()}
|
||||
<div class="builder-promote-body">${body}</div>
|
||||
<footer class="builder-promote-footer">
|
||||
<button type="button" class="builder-promote-cancel">${escHtml(cancelLabel)}</button>
|
||||
<span class="builder-promote-footer-spacer"></span>
|
||||
${step > 1 ? `<button type="button" class="builder-promote-backbtn">${escHtml(backLabel)}</button>` : ""}
|
||||
<button type="button" class="builder-promote-nextbtn builder-action-btn--primary">${escHtml(nextLabel)}</button>
|
||||
</footer>`;
|
||||
wire();
|
||||
}
|
||||
|
||||
function captureStep2(): void {
|
||||
modal.querySelectorAll<HTMLElement>(".builder-promote-party").forEach((row) => {
|
||||
const idx = Number(row.getAttribute("data-idx"));
|
||||
if (Number.isNaN(idx) || !parties[idx]) return;
|
||||
parties[idx].name = (row.querySelector(".builder-promote-party-name") as HTMLInputElement).value;
|
||||
parties[idx].role = (row.querySelector(".builder-promote-party-role") as HTMLInputElement).value;
|
||||
parties[idx].representative = (row.querySelector(".builder-promote-party-rep") as HTMLInputElement).value;
|
||||
});
|
||||
}
|
||||
|
||||
function captureStep3(): void {
|
||||
const get = (sel: string) => (modal.querySelector(sel) as HTMLInputElement | null)?.value ?? "";
|
||||
meta.title = get(".builder-promote-title");
|
||||
meta.reference = get(".builder-promote-reference");
|
||||
meta.caseNumber = get(".builder-promote-casenumber");
|
||||
meta.clientNumber = get(".builder-promote-clientnumber");
|
||||
meta.ourSide = ((modal.querySelector(".builder-promote-ourside") as HTMLSelectElement)?.value || "") as typeof meta.ourSide;
|
||||
meta.parentId = (modal.querySelector(".builder-promote-parent") as HTMLSelectElement)?.value || "";
|
||||
meta.teamIds = new Set(
|
||||
Array.from(modal.querySelectorAll<HTMLInputElement>(".builder-promote-team-cb:checked"))
|
||||
.map((cb) => cb.getAttribute("data-user-id") || "")
|
||||
.filter(Boolean),
|
||||
);
|
||||
}
|
||||
|
||||
function wire(): void {
|
||||
modal.querySelector(".builder-modal-close")?.addEventListener("click", close);
|
||||
modal.querySelector(".builder-promote-cancel")?.addEventListener("click", close);
|
||||
modal.querySelector(".builder-promote-backbtn")?.addEventListener("click", () => {
|
||||
if (step === 2) captureStep2();
|
||||
if (step === 3) captureStep3();
|
||||
step = Math.max(1, step - 1);
|
||||
render();
|
||||
});
|
||||
modal.querySelector(".builder-promote-nextbtn")?.addEventListener("click", () => {
|
||||
if (step === 2) captureStep2();
|
||||
if (step < 3) {
|
||||
step += 1;
|
||||
render();
|
||||
return;
|
||||
}
|
||||
captureStep3();
|
||||
void commit();
|
||||
});
|
||||
if (step === 2) {
|
||||
modal.querySelector(".builder-promote-party-add")?.addEventListener("click", () => {
|
||||
captureStep2();
|
||||
parties.push({ name: "", role: "", representative: "" });
|
||||
render();
|
||||
});
|
||||
modal.querySelectorAll<HTMLElement>(".builder-promote-party-remove").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
captureStep2();
|
||||
const row = btn.closest(".builder-promote-party") as HTMLElement;
|
||||
const idx = Number(row?.getAttribute("data-idx"));
|
||||
if (!Number.isNaN(idx)) parties.splice(idx, 1);
|
||||
render();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function commit(): Promise<void> {
|
||||
const errEl = modal.querySelector(".builder-promote-error") as HTMLElement | null;
|
||||
const showErr = (msg: string) => {
|
||||
if (errEl) {
|
||||
errEl.textContent = msg;
|
||||
errEl.hidden = false;
|
||||
}
|
||||
};
|
||||
if (!meta.title.trim()) {
|
||||
showErr(t("builder.promote.error.title_required"));
|
||||
return;
|
||||
}
|
||||
const nextBtn = modal.querySelector(".builder-promote-nextbtn") as HTMLButtonElement | null;
|
||||
if (nextBtn) nextBtn.disabled = true;
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
title: meta.title.trim(),
|
||||
reference: meta.reference.trim() || undefined,
|
||||
case_number: meta.caseNumber.trim() || undefined,
|
||||
client_number: meta.clientNumber.trim() || undefined,
|
||||
our_side: meta.ourSide || undefined,
|
||||
parent_id: meta.parentId || undefined,
|
||||
parties: parties
|
||||
.filter((p) => p.name.trim())
|
||||
.map((p) => ({
|
||||
name: p.name.trim(),
|
||||
role: p.role.trim() || undefined,
|
||||
representative: p.representative.trim() || undefined,
|
||||
})),
|
||||
team_members: Array.from(meta.teamIds).map((id) => ({ user_id: id })),
|
||||
};
|
||||
|
||||
try {
|
||||
const resp = await fetch(
|
||||
"/api/builder/scenarios/" + encodeURIComponent(ctx.scenarioId) + "/promote",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
);
|
||||
if (!resp.ok) {
|
||||
if (nextBtn) nextBtn.disabled = false;
|
||||
showErr(t("builder.promote.error.generic"));
|
||||
return;
|
||||
}
|
||||
const out = (await resp.json()) as { project_id: string };
|
||||
const body = modal.querySelector(".builder-promote-body") as HTMLElement;
|
||||
if (body) body.innerHTML = `<p class="builder-promote-success">${escHtml(t("builder.promote.success"))}</p>`;
|
||||
ctx.onSuccess(out.project_id);
|
||||
} catch {
|
||||
if (nextBtn) nextBtn.disabled = false;
|
||||
showErr(t("builder.promote.error.generic"));
|
||||
}
|
||||
}
|
||||
|
||||
render();
|
||||
document.body.appendChild(backdrop);
|
||||
(modal.querySelector(".builder-promote-nextbtn") as HTMLElement | null)?.focus();
|
||||
}
|
||||
|
||||
async function fetchProjects(type: string): Promise<ProjectOption[]> {
|
||||
try {
|
||||
const resp = await fetch("/api/projects?type=" + encodeURIComponent(type));
|
||||
if (!resp.ok) return [];
|
||||
const data = (await resp.json()) as ProjectOption[];
|
||||
return Array.isArray(data) ? data : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchUsers(): Promise<UserOption[]> {
|
||||
try {
|
||||
const resp = await fetch("/api/users");
|
||||
if (!resp.ok) return [];
|
||||
const data = (await resp.json()) as UserOption[];
|
||||
return Array.isArray(data) ? data : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function escHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function escAttr(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/"/g, """);
|
||||
}
|
||||
412
frontend/src/client/builder-search.ts
Normal file
412
frontend/src/client/builder-search.ts
Normal file
@@ -0,0 +1,412 @@
|
||||
// Universal search dropdown for the Litigation Builder (m/paliad#153 B3).
|
||||
//
|
||||
// PRD §2.2 + §3.1 + §6.3: the page-header search box ("Suche") drives
|
||||
// a typed dropdown returning grouped event / scenario / project hits.
|
||||
// Picking an event lands the user on a scratch scenario with one
|
||||
// triplet anchored on that event's proceeding type. Picking a scenario
|
||||
// loads it; picking a project (Akte) is deferred to B4 (the dropdown
|
||||
// row renders but pick falls through to a console hint until B4 wires
|
||||
// project-backed scenarios).
|
||||
//
|
||||
// The controller is owned by builder.ts; this module exports
|
||||
// `mountBuilderSearch` which wires the input + dropdown lifecycle and
|
||||
// invokes the supplied callbacks. No module-level state — re-mounting
|
||||
// is safe.
|
||||
|
||||
import { t } from "./i18n";
|
||||
|
||||
export interface EventSearchHit {
|
||||
id: string;
|
||||
code: string;
|
||||
name_de: string;
|
||||
name_en: string;
|
||||
event_kind?: string | null;
|
||||
primary_party?: string | null;
|
||||
anchor_rule_id: string;
|
||||
follow_up_count: number;
|
||||
proceeding_type: {
|
||||
id: number;
|
||||
code: string;
|
||||
name_de: string;
|
||||
name_en: string;
|
||||
jurisdiction?: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ScenarioSearchHit {
|
||||
id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ProjectSearchHit {
|
||||
id: string;
|
||||
type: string;
|
||||
title: string;
|
||||
reference?: string | null;
|
||||
case_number?: string | null;
|
||||
matter_number?: string | null;
|
||||
client_number?: string | null;
|
||||
}
|
||||
|
||||
export interface UniversalSearchResponse {
|
||||
query: string;
|
||||
events: EventSearchHit[];
|
||||
scenarios: ScenarioSearchHit[];
|
||||
projects: ProjectSearchHit[];
|
||||
counts: { events: number; scenarios: number; projects: number };
|
||||
}
|
||||
|
||||
export interface BuilderSearchCallbacks {
|
||||
onPickEvent: (hit: EventSearchHit) => void | Promise<void>;
|
||||
onPickScenario: (hit: ScenarioSearchHit) => void | Promise<void>;
|
||||
onPickProject?: (hit: ProjectSearchHit) => void | Promise<void>;
|
||||
}
|
||||
|
||||
interface Controller {
|
||||
input: HTMLInputElement;
|
||||
dropdown: HTMLElement;
|
||||
open: boolean;
|
||||
abort: AbortController | null;
|
||||
debounceTimer: number | null;
|
||||
lang: "de" | "en";
|
||||
}
|
||||
|
||||
let active: Controller | null = null;
|
||||
|
||||
// mountBuilderSearch wires the universal search behavior onto an
|
||||
// existing <input>. Idempotent — re-calling tears down the previous
|
||||
// dropdown and rebinds. Returns a controller exposing focus() so the
|
||||
// entry-mode toggle in builder.ts can land on the search input.
|
||||
export function mountBuilderSearch(
|
||||
input: HTMLInputElement,
|
||||
cb: BuilderSearchCallbacks,
|
||||
): { focus: () => void; close: () => void } {
|
||||
teardown();
|
||||
|
||||
const lang: "de" | "en" = document.documentElement.lang === "en" ? "en" : "de";
|
||||
|
||||
// Single dropdown container, anchored under the input. Positioned
|
||||
// absolutely so it floats above the canvas without reflowing layout.
|
||||
const dropdown = document.createElement("div");
|
||||
dropdown.className = "builder-search-dropdown";
|
||||
dropdown.setAttribute("role", "listbox");
|
||||
dropdown.hidden = true;
|
||||
document.body.appendChild(dropdown);
|
||||
|
||||
active = {
|
||||
input,
|
||||
dropdown,
|
||||
open: false,
|
||||
abort: null,
|
||||
debounceTimer: null,
|
||||
lang,
|
||||
};
|
||||
|
||||
input.addEventListener("input", onInput);
|
||||
input.addEventListener("focus", onFocus);
|
||||
input.addEventListener("keydown", onKeydown);
|
||||
document.addEventListener("click", onOutsideClick, true);
|
||||
window.addEventListener("resize", reposition);
|
||||
window.addEventListener("scroll", reposition, true);
|
||||
|
||||
// Click handler is wired once on the dropdown root via event
|
||||
// delegation; per-row data attributes identify the hit type.
|
||||
dropdown.addEventListener("click", (ev) => {
|
||||
const row = (ev.target as HTMLElement).closest<HTMLElement>(".builder-search-row");
|
||||
if (!row) return;
|
||||
const kind = row.getAttribute("data-hit-kind");
|
||||
const payload = row.getAttribute("data-hit-payload");
|
||||
if (!kind || !payload) return;
|
||||
try {
|
||||
const hit = JSON.parse(payload);
|
||||
ev.stopPropagation();
|
||||
closeDropdown();
|
||||
if (kind === "event") void cb.onPickEvent(hit);
|
||||
else if (kind === "scenario") void cb.onPickScenario(hit);
|
||||
else if (kind === "project" && cb.onPickProject) void cb.onPickProject(hit);
|
||||
} catch (err) {
|
||||
console.error("builder-search: bad payload", err);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
focus: () => {
|
||||
input.focus();
|
||||
// Open the dropdown on focus even when input is empty — show the
|
||||
// "start typing" hint per PRD §2.2 (search box auto-focuses).
|
||||
openDropdown();
|
||||
renderHint(t("builder.search.hint.start"));
|
||||
},
|
||||
close: closeDropdown,
|
||||
};
|
||||
}
|
||||
|
||||
function teardown(): void {
|
||||
if (!active) return;
|
||||
if (active.abort) active.abort.abort();
|
||||
if (active.debounceTimer !== null) window.clearTimeout(active.debounceTimer);
|
||||
active.dropdown.remove();
|
||||
active.input.removeEventListener("input", onInput);
|
||||
active.input.removeEventListener("focus", onFocus);
|
||||
active.input.removeEventListener("keydown", onKeydown);
|
||||
document.removeEventListener("click", onOutsideClick, true);
|
||||
window.removeEventListener("resize", reposition);
|
||||
window.removeEventListener("scroll", reposition, true);
|
||||
active = null;
|
||||
}
|
||||
|
||||
function onInput(): void {
|
||||
if (!active) return;
|
||||
const q = active.input.value.trim();
|
||||
if (active.debounceTimer !== null) window.clearTimeout(active.debounceTimer);
|
||||
if (q.length === 0) {
|
||||
openDropdown();
|
||||
renderHint(t("builder.search.hint.start"));
|
||||
return;
|
||||
}
|
||||
if (q.length < 2) {
|
||||
openDropdown();
|
||||
renderHint(t("builder.search.hint.short"));
|
||||
return;
|
||||
}
|
||||
active.debounceTimer = window.setTimeout(() => {
|
||||
void runSearch(q);
|
||||
}, 180);
|
||||
}
|
||||
|
||||
function onFocus(): void {
|
||||
if (!active) return;
|
||||
const q = active.input.value.trim();
|
||||
if (q.length === 0) {
|
||||
openDropdown();
|
||||
renderHint(t("builder.search.hint.start"));
|
||||
} else if (q.length >= 2) {
|
||||
void runSearch(q);
|
||||
}
|
||||
}
|
||||
|
||||
function onKeydown(ev: KeyboardEvent): void {
|
||||
if (!active) return;
|
||||
if (ev.key === "Escape") {
|
||||
closeDropdown();
|
||||
return;
|
||||
}
|
||||
if (ev.key === "ArrowDown" || ev.key === "ArrowUp") {
|
||||
const rows = Array.from(active.dropdown.querySelectorAll<HTMLElement>(".builder-search-row"));
|
||||
if (rows.length === 0) return;
|
||||
ev.preventDefault();
|
||||
const current = active.dropdown.querySelector<HTMLElement>(".builder-search-row.is-focus");
|
||||
let idx = current ? rows.indexOf(current) : -1;
|
||||
idx = ev.key === "ArrowDown"
|
||||
? Math.min(rows.length - 1, idx + 1)
|
||||
: Math.max(0, idx - 1);
|
||||
rows.forEach((r) => r.classList.remove("is-focus"));
|
||||
rows[idx].classList.add("is-focus");
|
||||
rows[idx].scrollIntoView({ block: "nearest" });
|
||||
return;
|
||||
}
|
||||
if (ev.key === "Enter") {
|
||||
const focused = active.dropdown.querySelector<HTMLElement>(".builder-search-row.is-focus");
|
||||
if (focused) {
|
||||
ev.preventDefault();
|
||||
focused.click();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onOutsideClick(ev: Event): void {
|
||||
if (!active) return;
|
||||
const target = ev.target as Node;
|
||||
if (active.input.contains(target)) return;
|
||||
if (active.dropdown.contains(target)) return;
|
||||
closeDropdown();
|
||||
}
|
||||
|
||||
async function runSearch(q: string): Promise<void> {
|
||||
if (!active) return;
|
||||
// Cancel any in-flight request so a slow earlier query can't clobber
|
||||
// a faster newer one.
|
||||
if (active.abort) active.abort.abort();
|
||||
const ctl = new AbortController();
|
||||
active.abort = ctl;
|
||||
|
||||
openDropdown();
|
||||
renderHint(t("builder.search.hint.loading"));
|
||||
|
||||
try {
|
||||
const url = "/api/builder/search?q=" + encodeURIComponent(q);
|
||||
const resp = await fetch(url, { signal: ctl.signal });
|
||||
if (!resp.ok) {
|
||||
renderHint(t("builder.search.hint.error"));
|
||||
return;
|
||||
}
|
||||
const data = (await resp.json()) as UniversalSearchResponse;
|
||||
if (active.abort !== ctl) return;
|
||||
renderResults(data);
|
||||
} catch (err) {
|
||||
if ((err as { name?: string })?.name === "AbortError") return;
|
||||
console.error("builder-search error:", err);
|
||||
renderHint(t("builder.search.hint.error"));
|
||||
}
|
||||
}
|
||||
|
||||
function renderHint(message: string): void {
|
||||
if (!active) return;
|
||||
active.dropdown.innerHTML = `<div class="builder-search-hint">${escHtml(message)}</div>`;
|
||||
reposition();
|
||||
}
|
||||
|
||||
function renderResults(data: UniversalSearchResponse): void {
|
||||
if (!active) return;
|
||||
const lang = active.lang;
|
||||
|
||||
const total = data.events.length + data.scenarios.length + data.projects.length;
|
||||
if (total === 0) {
|
||||
renderHint(t("builder.search.hint.empty"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Result-count summary per PRD §2.2: "N Ereignisse · M Szenarios · K Akten"
|
||||
const counts = `<div class="builder-search-summary">` +
|
||||
escHtml(tCount("builder.search.summary.events", data.events.length)) +
|
||||
` · ` +
|
||||
escHtml(tCount("builder.search.summary.scenarios", data.scenarios.length)) +
|
||||
` · ` +
|
||||
escHtml(tCount("builder.search.summary.projects", data.projects.length)) +
|
||||
`</div>`;
|
||||
|
||||
const sections: string[] = [counts];
|
||||
|
||||
if (data.events.length > 0) {
|
||||
sections.push(renderGroup(
|
||||
t("builder.search.group.events"),
|
||||
data.events.map((e) => renderEventRow(e, lang)).join(""),
|
||||
));
|
||||
}
|
||||
if (data.scenarios.length > 0) {
|
||||
sections.push(renderGroup(
|
||||
t("builder.search.group.scenarios"),
|
||||
data.scenarios.map((s) => renderScenarioRow(s)).join(""),
|
||||
));
|
||||
}
|
||||
if (data.projects.length > 0) {
|
||||
sections.push(renderGroup(
|
||||
t("builder.search.group.projects"),
|
||||
data.projects.map((p) => renderProjectRow(p, lang)).join(""),
|
||||
));
|
||||
}
|
||||
|
||||
active.dropdown.innerHTML = sections.join("");
|
||||
reposition();
|
||||
}
|
||||
|
||||
function renderGroup(label: string, rowsHtml: string): string {
|
||||
return `<section class="builder-search-group">` +
|
||||
`<header class="builder-search-group-label">${escHtml(label)}</header>` +
|
||||
rowsHtml +
|
||||
`</section>`;
|
||||
}
|
||||
|
||||
function renderEventRow(hit: EventSearchHit, lang: "de" | "en"): string {
|
||||
const name = lang === "en" ? (hit.name_en || hit.name_de) : (hit.name_de || hit.name_en);
|
||||
const ptName = lang === "en"
|
||||
? (hit.proceeding_type.name_en || hit.proceeding_type.name_de)
|
||||
: (hit.proceeding_type.name_de || hit.proceeding_type.name_en);
|
||||
const party = hit.primary_party ? `<span class="builder-search-party">${escHtml(hit.primary_party)}</span>` : "";
|
||||
const kind = hit.event_kind ? `<span class="builder-search-kind">${escHtml(hit.event_kind)}</span>` : "";
|
||||
// Payload for the click handler — we embed the full hit so builder.ts
|
||||
// doesn't need a second lookup. JSON-encoded into a data attribute,
|
||||
// attr-escaped on the way in.
|
||||
const payload = escAttr(JSON.stringify(hit));
|
||||
return `<div class="builder-search-row" data-hit-kind="event" data-hit-payload="${payload}" tabindex="-1" role="option">` +
|
||||
`<div class="builder-search-row-main">` +
|
||||
`<span class="builder-search-pt-code">${escHtml(hit.proceeding_type.code)}</span>` +
|
||||
`<span class="builder-search-event-name">${escHtml(name)}</span>` +
|
||||
`</div>` +
|
||||
`<div class="builder-search-row-meta">` +
|
||||
`<span class="builder-search-pt-name">${escHtml(ptName)}</span>` +
|
||||
kind + party +
|
||||
`</div>` +
|
||||
`</div>`;
|
||||
}
|
||||
|
||||
function renderScenarioRow(hit: ScenarioSearchHit): string {
|
||||
const payload = escAttr(JSON.stringify(hit));
|
||||
return `<div class="builder-search-row" data-hit-kind="scenario" data-hit-payload="${payload}" tabindex="-1" role="option">` +
|
||||
`<div class="builder-search-row-main">` +
|
||||
`<span class="builder-search-scenario-name">${escHtml(hit.name)}</span>` +
|
||||
`</div>` +
|
||||
`<div class="builder-search-row-meta">` +
|
||||
`<span class="builder-search-status">${escHtml(hit.status)}</span>` +
|
||||
`</div>` +
|
||||
`</div>`;
|
||||
}
|
||||
|
||||
function renderProjectRow(hit: ProjectSearchHit, _lang: "de" | "en"): string {
|
||||
const meta: string[] = [];
|
||||
if (hit.case_number) meta.push(hit.case_number);
|
||||
if (hit.matter_number) meta.push(hit.matter_number);
|
||||
if (hit.client_number) meta.push(hit.client_number);
|
||||
if (hit.reference) meta.push(hit.reference);
|
||||
const metaText = meta.length > 0 ? meta.join(" · ") : "";
|
||||
const payload = escAttr(JSON.stringify(hit));
|
||||
return `<div class="builder-search-row" data-hit-kind="project" data-hit-payload="${payload}" tabindex="-1" role="option">` +
|
||||
`<div class="builder-search-row-main">` +
|
||||
`<span class="builder-search-project-type">${escHtml(hit.type)}</span>` +
|
||||
`<span class="builder-search-project-title">${escHtml(hit.title)}</span>` +
|
||||
`</div>` +
|
||||
`<div class="builder-search-row-meta">${escHtml(metaText)}</div>` +
|
||||
`</div>`;
|
||||
}
|
||||
|
||||
function openDropdown(): void {
|
||||
if (!active) return;
|
||||
active.dropdown.hidden = false;
|
||||
active.open = true;
|
||||
reposition();
|
||||
}
|
||||
|
||||
function closeDropdown(): void {
|
||||
if (!active) return;
|
||||
active.dropdown.hidden = true;
|
||||
active.open = false;
|
||||
if (active.abort) {
|
||||
active.abort.abort();
|
||||
active.abort = null;
|
||||
}
|
||||
}
|
||||
|
||||
function reposition(): void {
|
||||
if (!active || !active.open) return;
|
||||
const rect = active.input.getBoundingClientRect();
|
||||
const top = rect.bottom + window.scrollY + 4;
|
||||
const left = rect.left + window.scrollX;
|
||||
const width = Math.max(rect.width, 380);
|
||||
active.dropdown.style.position = "absolute";
|
||||
active.dropdown.style.top = `${top}px`;
|
||||
active.dropdown.style.left = `${left}px`;
|
||||
active.dropdown.style.width = `${width}px`;
|
||||
active.dropdown.style.zIndex = "60";
|
||||
}
|
||||
|
||||
// tCount applies a simple plural pick: keys ".one" / ".other" carry
|
||||
// the singular/plural variants; the caller's key is the bare stem.
|
||||
function tCount(key: string, n: number): string {
|
||||
const variant = n === 1 ? `${key}.one` : `${key}.other`;
|
||||
return t(variant).replace("{n}", String(n));
|
||||
}
|
||||
|
||||
function escHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function escAttr(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/"/g, """);
|
||||
}
|
||||
229
frontend/src/client/builder-shares.ts
Normal file
229
frontend/src/client/builder-shares.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
// Litigation Builder — share-with-team UI (m/paliad#153 PRD §2.5, B5).
|
||||
//
|
||||
// "Teilen" opens a modal with an HLC user picker. Picking a colleague +
|
||||
// "Schreibgeschützt teilen" POSTs a paliad.scenario_shares row; the owner
|
||||
// stays sole editor. Existing shares are listed with a revoke affordance.
|
||||
// The sharee sees the scenario in their "Geteilt mit mir" bucket (read-
|
||||
// only) — that side is handled by builder.ts.
|
||||
|
||||
import { t } from "./i18n";
|
||||
|
||||
export interface ShareUser {
|
||||
id: string;
|
||||
email: string;
|
||||
display_name?: string;
|
||||
office?: string;
|
||||
}
|
||||
|
||||
export interface BuilderShareRow {
|
||||
id: string;
|
||||
scenario_id: string;
|
||||
shared_with_user_id: string;
|
||||
created_by: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface ShareModalOpts {
|
||||
scenarioId: string;
|
||||
ownerId?: string;
|
||||
currentShares: BuilderShareRow[];
|
||||
// Called after a successful add/revoke with the fresh share list so the
|
||||
// caller can update state.active.shares + re-render side panel buckets.
|
||||
onChanged: (shares: BuilderShareRow[]) => void;
|
||||
}
|
||||
|
||||
let allUsers: ShareUser[] | null = null;
|
||||
|
||||
async function fetchUsers(): Promise<ShareUser[]> {
|
||||
if (allUsers) return allUsers;
|
||||
try {
|
||||
const resp = await fetch("/api/users");
|
||||
if (!resp.ok) return [];
|
||||
const data = (await resp.json()) as ShareUser[];
|
||||
allUsers = Array.isArray(data) ? data : [];
|
||||
return allUsers;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function userLabel(u: ShareUser): string {
|
||||
const name = (u.display_name || "").trim();
|
||||
if (name) return u.office ? `${name} · ${u.office}` : name;
|
||||
return u.email;
|
||||
}
|
||||
|
||||
export async function openShareModal(opts: ShareModalOpts): Promise<void> {
|
||||
const users = await fetchUsers();
|
||||
let shares = [...opts.currentShares];
|
||||
|
||||
const backdrop = document.createElement("div");
|
||||
backdrop.className = "builder-modal-backdrop";
|
||||
backdrop.innerHTML = `
|
||||
<div class="builder-modal builder-share-modal" role="dialog" aria-modal="true"
|
||||
aria-label="${escAttr(t("builder.share.title"))}">
|
||||
<header class="builder-modal-header">
|
||||
<h2 class="builder-modal-title">${escHtml(t("builder.share.title"))}</h2>
|
||||
<button type="button" class="builder-modal-close" aria-label="${escAttr(t("builder.share.close"))}">×</button>
|
||||
</header>
|
||||
<p class="builder-modal-subtitle">${escHtml(t("builder.share.subtitle"))}</p>
|
||||
<div class="builder-share-pickerbox">
|
||||
<input type="search" class="builder-share-search" autocomplete="off" spellcheck="false"
|
||||
placeholder="${escAttr(t("builder.share.search.placeholder"))}" />
|
||||
<ul class="builder-share-results" aria-label="${escAttr(t("builder.share.title"))}"></ul>
|
||||
</div>
|
||||
<div class="builder-share-current">
|
||||
<h3 class="builder-share-current-title">${escHtml(t("builder.share.current.title"))}</h3>
|
||||
<ul class="builder-share-current-list"></ul>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
const close = () => {
|
||||
document.removeEventListener("keydown", onEsc, true);
|
||||
backdrop.remove();
|
||||
};
|
||||
const onEsc = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") close();
|
||||
};
|
||||
backdrop.addEventListener("click", (e) => {
|
||||
if (e.target === backdrop) close();
|
||||
});
|
||||
backdrop.querySelector(".builder-modal-close")?.addEventListener("click", close);
|
||||
document.addEventListener("keydown", onEsc, true);
|
||||
|
||||
const searchEl = backdrop.querySelector(".builder-share-search") as HTMLInputElement;
|
||||
const resultsEl = backdrop.querySelector(".builder-share-results") as HTMLElement;
|
||||
const currentEl = backdrop.querySelector(".builder-share-current-list") as HTMLElement;
|
||||
|
||||
function renderCurrent(): void {
|
||||
if (shares.length === 0) {
|
||||
currentEl.innerHTML = `<li class="builder-share-current-empty">${escHtml(t("builder.share.current.empty"))}</li>`;
|
||||
return;
|
||||
}
|
||||
currentEl.innerHTML = shares.map((sh) => {
|
||||
const u = users.find((x) => x.id === sh.shared_with_user_id);
|
||||
const label = u ? userLabel(u) : sh.shared_with_user_id;
|
||||
return (
|
||||
`<li class="builder-share-current-item" data-share-id="${escAttr(sh.id)}">` +
|
||||
`<span class="builder-share-current-name">${escHtml(label)}</span>` +
|
||||
`<button type="button" class="builder-share-revoke">${escHtml(t("builder.share.revoke"))}</button>` +
|
||||
`</li>`
|
||||
);
|
||||
}).join("");
|
||||
currentEl.querySelectorAll<HTMLElement>(".builder-share-current-item").forEach((li) => {
|
||||
const id = li.getAttribute("data-share-id");
|
||||
if (!id) return;
|
||||
li.querySelector(".builder-share-revoke")?.addEventListener("click", () => {
|
||||
void revoke(id);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderResults(): void {
|
||||
const q = searchEl.value.trim().toLowerCase();
|
||||
const sharedIds = new Set(shares.map((s) => s.shared_with_user_id));
|
||||
const matches = users
|
||||
.filter((u) => u.id !== opts.ownerId && !sharedIds.has(u.id))
|
||||
.filter((u) => {
|
||||
if (!q) return true;
|
||||
return (
|
||||
(u.display_name || "").toLowerCase().includes(q) ||
|
||||
u.email.toLowerCase().includes(q) ||
|
||||
(u.office || "").toLowerCase().includes(q)
|
||||
);
|
||||
})
|
||||
.slice(0, 12);
|
||||
if (matches.length === 0) {
|
||||
resultsEl.innerHTML = `<li class="builder-share-result-empty">${escHtml(t("builder.share.no_results"))}</li>`;
|
||||
return;
|
||||
}
|
||||
resultsEl.innerHTML = matches.map((u) => (
|
||||
`<li class="builder-share-result" data-user-id="${escAttr(u.id)}">` +
|
||||
`<span class="builder-share-result-name">${escHtml(userLabel(u))}</span>` +
|
||||
`<button type="button" class="builder-share-add">${escHtml(t("builder.share.button"))}</button>` +
|
||||
`</li>`
|
||||
)).join("");
|
||||
resultsEl.querySelectorAll<HTMLElement>(".builder-share-result").forEach((li) => {
|
||||
const uid = li.getAttribute("data-user-id");
|
||||
if (!uid) return;
|
||||
li.querySelector(".builder-share-add")?.addEventListener("click", () => {
|
||||
void add(uid);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function add(userId: string): Promise<void> {
|
||||
try {
|
||||
const resp = await fetch(
|
||||
"/api/builder/scenarios/" + encodeURIComponent(opts.scenarioId) + "/shares",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ shared_with_user_id: userId }),
|
||||
},
|
||||
);
|
||||
if (!resp.ok) {
|
||||
flashError();
|
||||
return;
|
||||
}
|
||||
const row = (await resp.json()) as BuilderShareRow;
|
||||
shares = [...shares.filter((s) => s.id !== row.id), row];
|
||||
searchEl.value = "";
|
||||
renderResults();
|
||||
renderCurrent();
|
||||
opts.onChanged(shares);
|
||||
} catch {
|
||||
flashError();
|
||||
}
|
||||
}
|
||||
|
||||
async function revoke(shareId: string): Promise<void> {
|
||||
try {
|
||||
const resp = await fetch(
|
||||
"/api/builder/scenarios/" + encodeURIComponent(opts.scenarioId) +
|
||||
"/shares/" + encodeURIComponent(shareId),
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
if (!resp.ok && resp.status !== 204) {
|
||||
flashError();
|
||||
return;
|
||||
}
|
||||
shares = shares.filter((s) => s.id !== shareId);
|
||||
renderResults();
|
||||
renderCurrent();
|
||||
opts.onChanged(shares);
|
||||
} catch {
|
||||
flashError();
|
||||
}
|
||||
}
|
||||
|
||||
function flashError(): void {
|
||||
const box = backdrop.querySelector(".builder-share-pickerbox") as HTMLElement;
|
||||
let err = box.querySelector(".builder-share-error") as HTMLElement | null;
|
||||
if (!err) {
|
||||
err = document.createElement("p");
|
||||
err.className = "builder-share-error";
|
||||
box.appendChild(err);
|
||||
}
|
||||
err.textContent = t("builder.share.error");
|
||||
}
|
||||
|
||||
searchEl.addEventListener("input", renderResults);
|
||||
renderResults();
|
||||
renderCurrent();
|
||||
document.body.appendChild(backdrop);
|
||||
searchEl.focus();
|
||||
}
|
||||
|
||||
function escHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function escAttr(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/"/g, """);
|
||||
}
|
||||
271
frontend/src/client/builder-triplet.ts
Normal file
271
frontend/src/client/builder-triplet.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
// ProceedingTriplet renderer for the Litigation Builder.
|
||||
//
|
||||
// PRD §3.3 + §3.4 + §6.1: one triplet = jurisdiction badge + name +
|
||||
// perspective + Detailgrad + columnar `proaktiv | court | reaktiv`
|
||||
// body.
|
||||
//
|
||||
// B2 wires the live controls — perspective radio, scenario-flag strip,
|
||||
// remove button, collapse — and the per-event-card overlays (3-state
|
||||
// machine, action buttons, optional-horizon chip). The 3-column body
|
||||
// itself is still produced by verfahrensablauf-core.renderColumnsBody;
|
||||
// per-card overlays are layered on top after innerHTML write via the
|
||||
// data-rule-id hooks added in the same slice.
|
||||
|
||||
import { t, tDyn, getLang } from "./i18n";
|
||||
import type { DeadlineResponse, Side } from "./views/verfahrensablauf-core";
|
||||
import type { BuilderProceeding, BuilderEvent } from "./builder";
|
||||
import type { ProceedingTypeMeta } from "./builder-picker";
|
||||
|
||||
export interface ScenarioFlagCatalogEntry {
|
||||
flag_key: string;
|
||||
label_de: string;
|
||||
label_en: string;
|
||||
description?: string;
|
||||
hidden_unless_set: boolean;
|
||||
}
|
||||
|
||||
export interface TripletViewInput {
|
||||
proceeding: BuilderProceeding;
|
||||
meta: ProceedingTypeMeta;
|
||||
data: DeadlineResponse | null;
|
||||
side: Side;
|
||||
// Flag catalog filtered to the keys the active proceeding actually
|
||||
// references via its rules' condition_expr. B2 passes the global
|
||||
// catalog and lets the user toggle any — flags that don't gate any
|
||||
// rule are simply no-ops on this triplet.
|
||||
flagCatalog: ScenarioFlagCatalogEntry[];
|
||||
// Map keyed by sequencing_rule_id (lowercased UUID) → BuilderEvent
|
||||
// for the per-card state machine. Cards whose rule is absent default
|
||||
// to "planned".
|
||||
eventsByRule: Map<string, BuilderEvent>;
|
||||
// Per-card optional-horizon registry. Each rule with optional
|
||||
// children carries a `+N Optionen` chip; the chip's count comes from
|
||||
// here (defaults to scenario_events.horizon_optional, falls back to
|
||||
// proceeding-level when not stored per-card).
|
||||
columnsHtml: string;
|
||||
isChild: boolean;
|
||||
}
|
||||
|
||||
// Triplet header + controls + columns body. Pure-string render; the
|
||||
// caller (builder.ts) wires click handlers on top.
|
||||
export function renderTriplet(input: TripletViewInput): string {
|
||||
const lang = getLang();
|
||||
const procLabel = lang === "en"
|
||||
? (input.meta.nameEN || input.meta.name)
|
||||
: (input.meta.name || input.meta.nameEN);
|
||||
const flagsBadge = activeFlagsBadge(input.proceeding.scenario_flags);
|
||||
|
||||
const body = input.data
|
||||
? input.columnsHtml
|
||||
: `<div class="builder-triplet-loading">${escHtml(t("builder.triplet.loading"))}</div>`;
|
||||
|
||||
const controls = renderControls(input);
|
||||
const flagStrip = renderFlagStrip(input);
|
||||
|
||||
return `
|
||||
<header class="builder-triplet-header">
|
||||
<span class="builder-triplet-jurisdiction">${escHtml(jurisdictionFor(input.meta))}</span>
|
||||
<span class="builder-triplet-code">${escHtml(input.meta.code)}</span>
|
||||
<span class="builder-triplet-name">${escHtml(procLabel)}</span>
|
||||
${flagsBadge}
|
||||
</header>
|
||||
${controls}
|
||||
${flagStrip}
|
||||
<div class="builder-triplet-body">
|
||||
${body}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderControls(input: TripletViewInput): string {
|
||||
const perspective = input.side ?? "";
|
||||
const detailgrad = input.proceeding.detailgrad || "selected";
|
||||
|
||||
const radio = (value: string, key: string, current: string): string => {
|
||||
const active = value === current ? " is-active" : "";
|
||||
return `<button type="button" class="builder-triplet-perspective-btn${active}"
|
||||
data-action="perspective" data-value="${escAttr(value)}">${escHtml(tDyn(key))}</button>`;
|
||||
};
|
||||
const detailBtn = (value: string, key: string, current: string): string => {
|
||||
const active = value === current ? " is-active" : "";
|
||||
return `<button type="button" class="builder-triplet-detailgrad-btn${active}"
|
||||
data-action="detailgrad" data-value="${escAttr(value)}">${escHtml(tDyn(key))}</button>`;
|
||||
};
|
||||
|
||||
return `<div class="builder-triplet-controls">
|
||||
<span class="builder-triplet-controls-label">${escHtml(t("builder.triplet.perspective.label"))}</span>
|
||||
<div class="builder-triplet-perspective">
|
||||
${radio("", "builder.triplet.perspective.none", perspective)}
|
||||
${radio("claimant", "builder.triplet.perspective.claimant", perspective)}
|
||||
${radio("defendant", "builder.triplet.perspective.defendant", perspective)}
|
||||
</div>
|
||||
<span class="builder-triplet-controls-label">${escHtml(t("builder.triplet.detailgrad.label"))}</span>
|
||||
<div class="builder-triplet-detailgrad">
|
||||
${detailBtn("selected", "builder.triplet.detailgrad.selected", detailgrad)}
|
||||
${detailBtn("all_options", "builder.triplet.detailgrad.all_options", detailgrad)}
|
||||
</div>
|
||||
<button type="button" class="builder-triplet-remove" data-action="remove">
|
||||
${escHtml(t("builder.triplet.remove"))}
|
||||
</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderFlagStrip(input: TripletViewInput): string {
|
||||
// B2 ships the full global catalog. Flags that don't gate any of the
|
||||
// active proceeding's rules are still toggle-able but have no effect
|
||||
// on the calc result (the engine simply doesn't read them).
|
||||
const lang = getLang();
|
||||
const flags = input.proceeding.scenario_flags || {};
|
||||
if (input.flagCatalog.length === 0) {
|
||||
return `<div class="builder-triplet-flagstrip">
|
||||
<span class="builder-triplet-flag-empty">${escHtml(t("builder.triplet.no_flags"))}</span>
|
||||
</div>`;
|
||||
}
|
||||
const toggles = input.flagCatalog.map((entry) => {
|
||||
const label = lang === "en" ? entry.label_en : entry.label_de;
|
||||
const isOn = flags[entry.flag_key] === true;
|
||||
return `<label class="builder-triplet-flag-toggle">
|
||||
<input type="checkbox"
|
||||
data-action="flag"
|
||||
data-flag-key="${escAttr(entry.flag_key)}"
|
||||
${isOn ? "checked" : ""} />
|
||||
<span>${escHtml(label)}</span>
|
||||
</label>`;
|
||||
}).join("");
|
||||
return `<div class="builder-triplet-flagstrip">${toggles}</div>`;
|
||||
}
|
||||
|
||||
function jurisdictionFor(meta: ProceedingTypeMeta): string {
|
||||
if (meta.jurisdiction) return meta.jurisdiction;
|
||||
if (meta.group) return meta.group;
|
||||
const dot = meta.code.indexOf(".");
|
||||
if (dot > 0) return meta.code.slice(0, dot).toUpperCase();
|
||||
return meta.code.toUpperCase();
|
||||
}
|
||||
|
||||
function activeFlagsBadge(flags: Record<string, unknown>): string {
|
||||
const active = Object.entries(flags).filter(([, v]) => v === true).map(([k]) => k);
|
||||
if (active.length === 0) return "";
|
||||
const label = t("builder.triplet.flags.label");
|
||||
const chips = active.map((f) =>
|
||||
`<span class="builder-triplet-flag-chip">${escHtml(f)}</span>`,
|
||||
).join("");
|
||||
return `<span class="builder-triplet-flags">${escHtml(label)} ${chips}</span>`;
|
||||
}
|
||||
|
||||
// overlayEventStates walks the rendered .fr-col-item nodes and:
|
||||
// - sets data-builder-state from eventsByRule lookup;
|
||||
// - appends a per-card action row (file / skip / reset);
|
||||
// - shows a +N Optionen chip when the rule has optional children
|
||||
// (the chip placeholder; B2 ships the per-card horizon control —
|
||||
// the actual horizon-count→render expansion lands when the calc
|
||||
// engine surfaces "available optionals" for a parent rule, which
|
||||
// pasteur's Options.IncludeOptional flag already exposes server-
|
||||
// side; full wiring is a follow-up). Cards without optional
|
||||
// children get no chip.
|
||||
export function overlayEventStates(
|
||||
root: HTMLElement,
|
||||
eventsByRule: Map<string, BuilderEvent>,
|
||||
on: {
|
||||
onAction: (ruleId: string, action: "file" | "skip" | "reset", payload?: { date?: string; reason?: string }) => void;
|
||||
onHorizon: (ruleId: string, delta: 1 | -1) => void;
|
||||
},
|
||||
): void {
|
||||
const items = root.querySelectorAll<HTMLElement>(".fr-col-item[data-rule-id]");
|
||||
items.forEach((item) => {
|
||||
const ruleId = item.getAttribute("data-rule-id");
|
||||
if (!ruleId) return;
|
||||
const ev = eventsByRule.get(ruleId.toLowerCase());
|
||||
const state = ev?.state || "planned";
|
||||
item.setAttribute("data-builder-state", state);
|
||||
|
||||
// Append actions (idempotent: clear any prior overlay first).
|
||||
item.querySelectorAll(".builder-event-actions, .builder-event-horizon-chip").forEach((n) => n.remove());
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "builder-event-actions";
|
||||
actions.innerHTML = actionButtonsHtml(state);
|
||||
item.appendChild(actions);
|
||||
|
||||
actions.addEventListener("click", (ev) => {
|
||||
const btn = (ev.target as HTMLElement).closest<HTMLElement>(".builder-event-action");
|
||||
if (!btn) return;
|
||||
const action = btn.getAttribute("data-action") as "file" | "skip" | "reset" | null;
|
||||
if (!action) return;
|
||||
ev.stopPropagation();
|
||||
if (action === "file") {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const v = window.prompt(t("builder.event.actual_date.prompt"), today);
|
||||
if (v === null) return;
|
||||
on.onAction(ruleId, "file", { date: v.trim() || today });
|
||||
} else if (action === "skip") {
|
||||
const reason = window.prompt(t("builder.event.skip_reason.prompt"), "");
|
||||
if (reason === null) return;
|
||||
on.onAction(ruleId, "skip", { reason: reason.trim() });
|
||||
} else {
|
||||
on.onAction(ruleId, "reset");
|
||||
}
|
||||
});
|
||||
|
||||
// Per-card optional horizon chip. The PRD §3.4 places the chip on
|
||||
// every card with optional children; until the calc surface exposes
|
||||
// an "optionals available count" on each parent rule, the chip is
|
||||
// shown only when the card has a stored non-zero horizon (so the
|
||||
// user can see and reduce a previously-set horizon). This is the
|
||||
// graceful B2 baseline; the full surface lands once the engine
|
||||
// emits an optionalsAvailable counter (PRD §3.4 follow-up).
|
||||
const horizonCount = ev?.horizon_optional ?? 0;
|
||||
if (horizonCount > 0) {
|
||||
const chip = document.createElement("button");
|
||||
chip.type = "button";
|
||||
chip.className = "builder-event-horizon-chip";
|
||||
chip.setAttribute("data-action", "horizon-toggle");
|
||||
chip.textContent = t("builder.event.horizon.label").replace("{n}", String(horizonCount));
|
||||
chip.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
on.onHorizon(ruleId, -1);
|
||||
});
|
||||
item.appendChild(chip);
|
||||
} else {
|
||||
// Inline "+ Optionen" affordance — adds a horizon entry when
|
||||
// first clicked. Tagged as data-builder-feature so the cleanup
|
||||
// sweep can rip it out if the calc surface lands a counter.
|
||||
const chip = document.createElement("button");
|
||||
chip.type = "button";
|
||||
chip.className = "builder-event-horizon-chip";
|
||||
chip.setAttribute("data-action", "horizon-add");
|
||||
chip.setAttribute("data-builder-feature", "horizon-add");
|
||||
chip.textContent = "+ " + t("builder.event.horizon.label").replace("+{n} ", "");
|
||||
chip.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
on.onHorizon(ruleId, 1);
|
||||
});
|
||||
item.appendChild(chip);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function actionButtonsHtml(state: BuilderEvent["state"]): string {
|
||||
// Re-render the action row per state. Cards in the planned state
|
||||
// show "File / Skip"; filed/skipped cards show "Reset to planned".
|
||||
if (state === "planned") {
|
||||
return `
|
||||
<button type="button" class="builder-event-action" data-action="file">${escHtml(t("builder.event.action.file"))}</button>
|
||||
<button type="button" class="builder-event-action" data-action="skip">${escHtml(t("builder.event.action.skip"))}</button>
|
||||
`;
|
||||
}
|
||||
return `<button type="button" class="builder-event-action" data-action="reset">${escHtml(t("builder.event.action.reset"))}</button>`;
|
||||
}
|
||||
|
||||
function escHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function escAttr(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/"/g, """);
|
||||
}
|
||||
1571
frontend/src/client/builder.ts
Normal file
1571
frontend/src/client/builder.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -212,45 +212,144 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"procedures.tab.wizard": "Gef\u00fchrt",
|
||||
"procedures.tab.akte": "Aus Akte",
|
||||
"procedures.panel.akte.placeholder": "Akten-Einstieg folgt in einem sp\u00e4teren Slice.",
|
||||
|
||||
// Workflow-tracker shell (m/paliad#152 T1+) \u2014 keys for the new
|
||||
// /tools/procedures shape (find header + per-proceeding cards).
|
||||
"procedures.filter.axis.date": "Stichtag:",
|
||||
"procedures.filter.forum.all": "Alle",
|
||||
"procedures.filter.party.all": "Alle",
|
||||
"procedures.timelines.loading": "Verfahren werden geladen\u2026",
|
||||
"procedures.timelines.empty": "Keine Verfahren passen. Filter zur\u00fccksetzen.",
|
||||
"procedures.timelines.error": "Fehler beim Laden dieses Verfahrens.",
|
||||
"procedures.timelines.options": "Optionen:",
|
||||
"procedures.timelines.court_set": "vom Gericht bestimmt",
|
||||
"procedures.cold_open.hint": "Suchen oder filtern, um andere Verfahren einzublenden.",
|
||||
"procedures.find.summary.empty": "Keine Treffer.",
|
||||
"procedures.find.summary.one": "{n} Verfahren",
|
||||
"procedures.find.summary.many": "{n} Verfahren",
|
||||
"procedures.find.summary.anchor": "Anker: {name}",
|
||||
"procedures.find.summary.akte": "Akte: {name}",
|
||||
"procedures.node.actual.done": "Erledigt",
|
||||
"procedures.node.actual.overdue": "Überfällig",
|
||||
"procedures.node.actual.open": "Offen",
|
||||
"procedures.node.cross": "Gegenseitige Handlung",
|
||||
"procedures.node.cross.short": "Gegen.",
|
||||
"procedures.proceeding.detail.title": "Detailgrad umschalten",
|
||||
"procedures.proceeding.detail.selected": "· Gewählt ·",
|
||||
"procedures.proceeding.detail.all": "Alle Optionen",
|
||||
"procedures.appeal_target.label": "Berufung gegen:",
|
||||
"procedures.node.pin": "An dieses Ereignis anheften",
|
||||
"procedures.node.fokus": "Fokus \u2014 andere Zweige ausblenden",
|
||||
"procedures.node.here": "\u2500\u2500 DU BIST HIER \u2500\u2500",
|
||||
"procedures.zoom.breadcrumb": "Pfad",
|
||||
"procedures.zoom.hidden": "{n} weitere Schritte verborgen \u2014 Fokus aufheben f\u00fcr volle Ansicht",
|
||||
"procedures.proceeding.toggle": "Verfahren ein-/ausblenden",
|
||||
"procedures.proceeding.show": "zeigen",
|
||||
"procedures.proceeding.hide": "ausblenden",
|
||||
"deadlines.flag.amend": "Mit Antrag auf Patent\u00e4nderung",
|
||||
"deadlines.flag.cci": "Mit Verletzungswiderklage",
|
||||
|
||||
"nav.procedures": "Verfahren & Fristen",
|
||||
|
||||
// Litigation Builder (m/paliad#153 B1+B2)
|
||||
"builder.subtitle": "Litigation Builder \u2014 Szenarien bauen, Verfahren stapeln, Fristen behalten.",
|
||||
"builder.header.scenario": "Szenario:",
|
||||
"builder.header.akte": "Akte:",
|
||||
"builder.header.stichtag": "Stichtag:",
|
||||
"builder.header.search": "Suche:",
|
||||
"builder.akte.none": "\u2014 ohne \u2014",
|
||||
"builder.akte.banner.prefix": "Aus Akte:",
|
||||
"builder.search.placeholder": "Ereignis, Szenario, Akte \u2026",
|
||||
"builder.action.rename": "Benennen",
|
||||
"builder.action.rename.prompt": "Name f\u00fcr dieses Szenario:",
|
||||
"builder.action.share": "Teilen",
|
||||
"builder.action.promote": "Als Projekt anlegen",
|
||||
"builder.mode.cold": "\u00dcbersicht",
|
||||
"builder.mode.event": "Ereignis",
|
||||
"builder.mode.akte": "Aus Akte",
|
||||
"builder.panel.title": "Meine Szenarien",
|
||||
"builder.panel.new": "+ Neues Szenario",
|
||||
"builder.panel.empty": "Noch keine Szenarien.",
|
||||
"builder.bucket.active": "Aktiv",
|
||||
"builder.empty.headline": "Noch kein Szenario ge\u00f6ffnet.",
|
||||
"builder.empty.hint": "Starte ein neues Szenario, w\u00e4hle aus deiner Liste oder \u00fcbernimm eine Akte (B4).",
|
||||
"builder.empty.cta": "Neues Szenario starten",
|
||||
"builder.empty.recent": "Zuletzt bearbeitet",
|
||||
"builder.picker.placeholder": "\u2014 Szenario w\u00e4hlen \u2014",
|
||||
"builder.picker.title": "Verfahren hinzuf\u00fcgen",
|
||||
"builder.picker.close": "Schlie\u00dfen",
|
||||
"builder.picker.aria": "Verfahren ausw\u00e4hlen",
|
||||
"builder.picker.axis.forum": "Forum:",
|
||||
"builder.picker.axis.proc": "Verfahren:",
|
||||
"builder.picker.empty": "Keine Verfahren verf\u00fcgbar.",
|
||||
"builder.picker.future_jurisdiction": "Andere Foren folgen sp\u00e4ter.",
|
||||
"builder.canvas.add_proceeding": "+ Verfahren hinzuf\u00fcgen",
|
||||
"builder.triplet.loading": "Berechne Fristen \u2026",
|
||||
"builder.triplet.unknown_proceeding": "Unbekannter Verfahrenstyp.",
|
||||
"builder.triplet.side.claimant": "Kl\u00e4ger-Sicht",
|
||||
"builder.triplet.side.defendant": "Beklagten-Sicht",
|
||||
"builder.triplet.flags.label": "Optionen:",
|
||||
"builder.triplet.perspective.label": "Perspektive:",
|
||||
"builder.triplet.perspective.none": "keine",
|
||||
"builder.triplet.perspective.claimant": "Kl\u00e4ger",
|
||||
"builder.triplet.perspective.defendant": "Beklagter",
|
||||
"builder.triplet.detailgrad.label": "Detailgrad:",
|
||||
"builder.triplet.detailgrad.selected": "Gew\u00e4hlt",
|
||||
"builder.triplet.detailgrad.all_options": "Alle Optionen",
|
||||
"builder.triplet.remove": "Entfernen",
|
||||
"builder.triplet.collapse": "Einklappen",
|
||||
"builder.triplet.expand": "Ausklappen",
|
||||
"builder.triplet.no_flags": "(keine Flags f\u00fcr diesen Verfahrenstyp)",
|
||||
"builder.event.state.planned": "geplant",
|
||||
"builder.event.state.filed": "eingereicht",
|
||||
"builder.event.state.skipped": "ausgelassen",
|
||||
"builder.event.action.file": "Einreichen",
|
||||
"builder.event.action.skip": "Auslassen",
|
||||
"builder.event.action.reset": "Zur\u00fcck zu geplant",
|
||||
"builder.event.actual_date.prompt": "Datum der Einreichung:",
|
||||
"builder.event.skip_reason.prompt": "Grund (optional):",
|
||||
"builder.event.horizon.label": "+{n} Optionen \u25be",
|
||||
"builder.event.horizon.hide": "Optionen ausblenden",
|
||||
"builder.save.idle": "\u00a0",
|
||||
"builder.save.saving": "Speichert \u2026",
|
||||
"builder.save.saved": "Gespeichert \u2713",
|
||||
"builder.save.error": "Speichern fehlgeschlagen",
|
||||
"builder.search.hint.start": "Tippe \u2026 z.\u202fB. \u201eKlageerwiderung\u201c, \u201eHinweis\u201c, \u201eHL-2024\u201c",
|
||||
"builder.search.hint.short": "Mindestens 2 Zeichen.",
|
||||
"builder.search.hint.loading": "Suche \u2026",
|
||||
"builder.search.hint.empty": "Keine Treffer.",
|
||||
"builder.search.hint.error": "Suche fehlgeschlagen. Erneut versuchen.",
|
||||
"builder.search.hint.akte_b4": "Akten-Modus folgt in B4.",
|
||||
"builder.search.group.events": "Ereignisse",
|
||||
"builder.search.group.scenarios": "Szenarien",
|
||||
"builder.search.group.projects": "Akten",
|
||||
"builder.search.summary.events.one": "{n} Ereignis",
|
||||
"builder.search.summary.events.other": "{n} Ereignisse",
|
||||
"builder.search.summary.scenarios.one": "{n} Szenario",
|
||||
"builder.search.summary.scenarios.other": "{n} Szenarien",
|
||||
"builder.search.summary.projects.one": "{n} Akte",
|
||||
"builder.search.summary.projects.other": "{n} Akten",
|
||||
"builder.search.anchor.divider": "\u2501\u2501\u2501\u2501 DU BIST HIER \u2501\u2501\u2501\u2501",
|
||||
|
||||
// B5 \u2014 side-panel buckets, sharing, promote-to-project wizard.
|
||||
"builder.bucket.shared": "Geteilt mit mir",
|
||||
"builder.bucket.promoted": "Als Projekt angelegt",
|
||||
"builder.bucket.archived": "Archiviert",
|
||||
"builder.bucket.empty": "\u2014",
|
||||
"builder.readonly.watermark": "Geteilt von {owner} \u00b7 schreibgesch\u00fctzt",
|
||||
"builder.readonly.blocked": "Schreibgesch\u00fctzt \u2014 Bearbeiten ist nur f\u00fcr die Eigent\u00fcmer:in m\u00f6glich.",
|
||||
"builder.share.title": "Szenario teilen",
|
||||
"builder.share.subtitle": "Schreibgesch\u00fctzt mit HLC-Kolleg:innen teilen. Du bleibst alleinige Bearbeiter:in.",
|
||||
"builder.share.search.placeholder": "Name oder E-Mail suchen \u2026",
|
||||
"builder.share.button": "Schreibgesch\u00fctzt teilen",
|
||||
"builder.share.current.title": "Bereits geteilt mit:",
|
||||
"builder.share.current.empty": "Noch mit niemandem geteilt.",
|
||||
"builder.share.revoke": "Entfernen",
|
||||
"builder.share.close": "Schlie\u00dfen",
|
||||
"builder.share.no_results": "Keine Nutzer:innen gefunden.",
|
||||
"builder.share.error": "Teilen fehlgeschlagen. Erneut versuchen.",
|
||||
"builder.promote.title": "Als Projekt anlegen",
|
||||
"builder.promote.step1": "Best\u00e4tigen",
|
||||
"builder.promote.step2": "Parteien erg\u00e4nzen",
|
||||
"builder.promote.step3": "Akte-Metadaten",
|
||||
"builder.promote.next": "Weiter",
|
||||
"builder.promote.back": "Zur\u00fcck",
|
||||
"builder.promote.commit": "Anlegen",
|
||||
"builder.promote.cancel": "Abbrechen",
|
||||
"builder.promote.summary.heading": "Das wird angelegt:",
|
||||
"builder.promote.summary.proceeding": "Hauptverfahren",
|
||||
"builder.promote.summary.events_filed": "eingereichte Ereignisse",
|
||||
"builder.promote.summary.events_planned": "geplante Ereignisse",
|
||||
"builder.promote.summary.flags": "aktive Optionen",
|
||||
"builder.promote.summary.note_extra": "{n} weitere(s) eigenst\u00e4ndige(s) Verfahren bleibt im Szenario und wird nicht automatisch \u00fcbernommen.",
|
||||
"builder.promote.parties.hint": "Trage die echten Parteinamen ein \u2014 oder erg\u00e4nze sie sp\u00e4ter in der Akte.",
|
||||
"builder.promote.parties.add": "+ Partei hinzuf\u00fcgen",
|
||||
"builder.promote.parties.name": "Name",
|
||||
"builder.promote.parties.role": "Rolle (z. B. Kl\u00e4ger)",
|
||||
"builder.promote.parties.representative": "Vertreter:in",
|
||||
"builder.promote.parties.remove": "Entfernen",
|
||||
"builder.promote.parties.empty": "Noch keine Parteien.",
|
||||
"builder.promote.meta.title": "Aktentitel / Mandat",
|
||||
"builder.promote.meta.title.placeholder": "z. B. Becker ./. X \u2014 UPC Verletzung",
|
||||
"builder.promote.meta.reference": "Referenz (optional)",
|
||||
"builder.promote.meta.case_number": "Aktenzeichen (optional)",
|
||||
"builder.promote.meta.client_number": "Mandantennummer (optional)",
|
||||
"builder.promote.meta.our_side": "Unsere Seite",
|
||||
"builder.promote.meta.our_side.claimant": "Kl\u00e4ger",
|
||||
"builder.promote.meta.our_side.defendant": "Beklagter",
|
||||
"builder.promote.meta.our_side.none": "\u2014 offen \u2014",
|
||||
"builder.promote.meta.parent": "\u00dcbergeordnetes Verfahren (optional)",
|
||||
"builder.promote.meta.parent.none": "\u2014 keines \u2014",
|
||||
"builder.promote.meta.team": "Team (optional)",
|
||||
"builder.promote.meta.team.hint": "Du wirst automatisch als Lead hinzugef\u00fcgt.",
|
||||
"builder.promote.error.title_required": "Bitte einen Aktentitel eingeben.",
|
||||
"builder.promote.error.generic": "Anlegen fehlgeschlagen. Erneut versuchen.",
|
||||
"builder.promote.success": "Akte angelegt \u2014 Weiterleitung \u2026",
|
||||
"builder.mobile.blocked": "Auf gr\u00f6\u00dferem Bildschirm \u00f6ffnen, um zu bearbeiten.",
|
||||
|
||||
"deadlines.step1": "Verfahrensart w\u00e4hlen",
|
||||
"deadlines.step2": "Ausgangsdatum eingeben",
|
||||
"deadlines.step2.perspective": "Perspektive und Datum",
|
||||
@@ -300,10 +399,6 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.epa.opp.opd": "Einspruchsverfahren",
|
||||
"deadlines.epa.opp.boa": "Beschwerdeverfahren",
|
||||
"deadlines.epa.grant.exa": "EP-Erteilungsverfahren",
|
||||
"deadlines.party.claimant": "Kl\u00e4ger",
|
||||
"deadlines.party.defendant": "Beklagter",
|
||||
"deadlines.party.court": "Gericht",
|
||||
"deadlines.party.both": "Beide",
|
||||
"deadlines.party.both.label": "beide Seiten",
|
||||
"deadlines.court.set": "vom Gericht bestimmt",
|
||||
"deadlines.court.indirect": "unbestimmt",
|
||||
@@ -1029,6 +1124,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"cal.view.month": "Monat",
|
||||
"cal.view.week": "Woche",
|
||||
"cal.view.day": "Tag",
|
||||
"cal.today": "Heute",
|
||||
"cal.month.prev": "Vorheriger Monat",
|
||||
"cal.month.next": "Nächster Monat",
|
||||
"cal.week.prev": "Vorherige Woche",
|
||||
@@ -1454,7 +1550,25 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"einstellungen.tab.profil": "Profil",
|
||||
"einstellungen.tab.benachrichtigungen": "Benachrichtigungen",
|
||||
"einstellungen.tab.caldav": "CalDAV",
|
||||
"einstellungen.tab.names": "Namensschemata",
|
||||
"einstellungen.tab.export": "Datenexport",
|
||||
"einstellungen.names.subtitle": "Legen Sie fest, wie Paliad Entwurfstitel und Dateinamen aus Projektdaten zusammensetzt. Klicken Sie auf einen Platzhalter, um ihn einzuf\u00fcgen; die Vorschau zeigt das Ergebnis sofort.",
|
||||
"einstellungen.names.preview.sample": "Beispiel:",
|
||||
"einstellungen.names.preview.empty": "Ohne Projektdaten:",
|
||||
"einstellungen.names.reset": "Auf Standard zur\u00fccksetzen",
|
||||
"einstellungen.names.saved": "Gespeichert.",
|
||||
"einstellungen.names.reset_done": "Auf Standard zur\u00fcckgesetzt.",
|
||||
"einstellungen.names.override_badge": "Angepasst",
|
||||
"einstellungen.names.firm_badge": "Firmenstandard",
|
||||
"einstellungen.names.firm.heading": "Firmenstandard (f\u00fcr alle)",
|
||||
"einstellungen.names.firm.status_set": "Aktiver Firmenstandard:",
|
||||
"einstellungen.names.firm.status_unset": "Kein Firmenstandard gesetzt \u2014 es gilt der Systemstandard.",
|
||||
"einstellungen.names.firm.set": "Als Firmenstandard festlegen",
|
||||
"einstellungen.names.firm.clear": "Firmenstandard l\u00f6schen",
|
||||
"einstellungen.names.firm.saved": "Firmenstandard gespeichert.",
|
||||
"einstellungen.names.firm.cleared": "Firmenstandard gel\u00f6scht \u2014 Systemstandard gilt wieder.",
|
||||
"einstellungen.names.error.load": "Namensschemata konnten nicht geladen werden.",
|
||||
"einstellungen.names.error.invalid": "Ung\u00fcltige Vorlage \u2014 bitte pr\u00fcfen Sie die Platzhalter.",
|
||||
"einstellungen.export.subtitle": "Laden Sie Ihre pers\u00f6nlichen Paliad-Daten als Excel- + JSON- + CSV-Paket herunter. Enthalten ist alles, was Sie aktuell sehen k\u00f6nnen \u2014 Ihre Projekte, Fristen, Termine, Notizen, Genehmigungen und Einstellungen.",
|
||||
"einstellungen.export.heading": "Pers\u00f6nlicher Datenexport",
|
||||
"einstellungen.export.what": "Das Paket enth\u00e4lt Ihre sichtbaren Daten in drei Formaten in einem .zip:",
|
||||
@@ -1649,11 +1763,28 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"submissions.draft.language.de": "DE",
|
||||
"submissions.draft.language.en": "EN",
|
||||
"submissions.draft.language.fallback_notice": "Fallback: universelles Skelett (keine sprachspezifische Vorlage).",
|
||||
// t-paliad-354 — Dateiname-Stichwort (führt den Namen des exportierten Dokuments an).
|
||||
"submissions.draft.keyword.label": "Stichwort (Dateiname)",
|
||||
"submissions.draft.keyword.placeholder": "Automatisch aus dem Schriftsatztyp",
|
||||
"submissions.draft.keyword.hint": "Führt den Dateinamen an: <Datum> <Stichwort> (<Aktenzeichen>).",
|
||||
// t-paliad-313 (m/paliad#141) Composer Slice A — base picker + section list.
|
||||
"submissions.draft.base.label": "Vorlagenbasis",
|
||||
"submissions.draft.base.hint": "Steuert Schriftarten, Briefkopf und Abschnitts-Defaults.",
|
||||
"submissions.draft.sections.title": "Abschnitte",
|
||||
"submissions.draft.sections.hint": "Inhalt pro Abschnitt — Autosave nach 500 ms. Letztes Layout in Word.",
|
||||
// t-paliad-349 (m/paliad#157) docforge slice 6 — template authoring page.
|
||||
"templates.authoring.title": "Vorlagen — Paliad",
|
||||
"templates.authoring.heading": "Vorlagen",
|
||||
"templates.authoring.intro": "Lade eine Word-Vorlage hoch, markiere Stellen und setze Variablen ein.",
|
||||
"templates.authoring.upload.title": "Neue Vorlage hochladen",
|
||||
"templates.authoring.upload.file": "Word-Datei (.docx)",
|
||||
"templates.authoring.upload.name_de": "Name (DE)",
|
||||
"templates.authoring.upload.name_en": "Name (EN)",
|
||||
"templates.authoring.upload.firm": "Kanzlei (optional)",
|
||||
"templates.authoring.upload.submit": "Hochladen",
|
||||
"templates.authoring.list.title": "Vorhandene Vorlagen",
|
||||
"templates.authoring.workspace.hint": "Text markieren, dann eine Variable wählen, um einen Platzhalter zu setzen.",
|
||||
"templates.authoring.slots.title": "Platzhalter",
|
||||
// t-paliad-315 (m/paliad#141) Composer Slice C — building blocks admin.
|
||||
"admin.building_blocks.title": "Bausteine — Paliad",
|
||||
"admin.building_blocks.heading": "Bausteine",
|
||||
@@ -3453,44 +3584,144 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"procedures.tab.wizard": "Guided",
|
||||
"procedures.tab.akte": "From matter",
|
||||
"procedures.panel.akte.placeholder": "Matter entry ships in a later slice.",
|
||||
|
||||
// Workflow-tracker shell (m/paliad#152 T1+).
|
||||
"procedures.filter.axis.date": "As of:",
|
||||
"procedures.filter.forum.all": "All",
|
||||
"procedures.filter.party.all": "All",
|
||||
"procedures.timelines.loading": "Loading proceedings…",
|
||||
"procedures.timelines.empty": "No proceedings match. Reset filters.",
|
||||
"procedures.timelines.error": "Failed to load this proceeding.",
|
||||
"procedures.timelines.options": "Options:",
|
||||
"procedures.timelines.court_set": "court-set",
|
||||
"procedures.cold_open.hint": "Search or filter to surface other proceedings.",
|
||||
"procedures.find.summary.empty": "No matches.",
|
||||
"procedures.find.summary.one": "{n} proceeding",
|
||||
"procedures.find.summary.many": "{n} proceedings",
|
||||
"procedures.find.summary.anchor": "Anchor: {name}",
|
||||
"procedures.find.summary.akte": "Matter: {name}",
|
||||
"procedures.node.actual.done": "Done",
|
||||
"procedures.node.actual.overdue": "Overdue",
|
||||
"procedures.node.actual.open": "Open",
|
||||
"procedures.node.cross": "Opposing-side action",
|
||||
"procedures.node.cross.short": "Opp.",
|
||||
"procedures.proceeding.detail.title": "Toggle detail level",
|
||||
"procedures.proceeding.detail.selected": "· Selected ·",
|
||||
"procedures.proceeding.detail.all": "All options",
|
||||
"procedures.appeal_target.label": "Appeal target:",
|
||||
"procedures.node.pin": "Pin this event as the anchor",
|
||||
"procedures.node.fokus": "Focus — hide sibling branches",
|
||||
"procedures.node.here": "── YOU ARE HERE ──",
|
||||
"procedures.zoom.breadcrumb": "Path",
|
||||
"procedures.zoom.hidden": "{n} more steps hidden — unfocus to see all",
|
||||
"procedures.proceeding.toggle": "Toggle proceeding",
|
||||
"procedures.proceeding.show": "show",
|
||||
"procedures.proceeding.hide": "hide",
|
||||
"deadlines.flag.amend": "With patent amendment request",
|
||||
"deadlines.flag.cci": "With infringement counterclaim",
|
||||
|
||||
"nav.procedures": "Procedures & Deadlines",
|
||||
|
||||
// Litigation Builder (m/paliad#153 B1+B2)
|
||||
"builder.subtitle": "Litigation Builder — build scenarios, stack proceedings, track deadlines.",
|
||||
"builder.header.scenario": "Scenario:",
|
||||
"builder.header.akte": "Matter:",
|
||||
"builder.header.stichtag": "Anchor:",
|
||||
"builder.header.search": "Search:",
|
||||
"builder.akte.none": "— none —",
|
||||
"builder.akte.banner.prefix": "From matter:",
|
||||
"builder.search.placeholder": "Event, scenario, matter …",
|
||||
"builder.action.rename": "Name it",
|
||||
"builder.action.rename.prompt": "Name for this scenario:",
|
||||
"builder.action.share": "Share",
|
||||
"builder.action.promote": "Create as project",
|
||||
"builder.mode.cold": "Overview",
|
||||
"builder.mode.event": "Event",
|
||||
"builder.mode.akte": "From matter",
|
||||
"builder.panel.title": "My scenarios",
|
||||
"builder.panel.new": "+ New scenario",
|
||||
"builder.panel.empty": "No scenarios yet.",
|
||||
"builder.bucket.active": "Active",
|
||||
"builder.empty.headline": "No scenario open.",
|
||||
"builder.empty.hint": "Start a new scenario, pick one from your list, or load a matter (B4).",
|
||||
"builder.empty.cta": "Start a new scenario",
|
||||
"builder.empty.recent": "Recent",
|
||||
"builder.picker.placeholder": "— pick a scenario —",
|
||||
"builder.picker.title": "Add proceeding",
|
||||
"builder.picker.close": "Close",
|
||||
"builder.picker.aria": "Pick a proceeding",
|
||||
"builder.picker.axis.forum": "Forum:",
|
||||
"builder.picker.axis.proc": "Proceeding:",
|
||||
"builder.picker.empty": "No proceedings available.",
|
||||
"builder.picker.future_jurisdiction": "Other forums coming later.",
|
||||
"builder.canvas.add_proceeding": "+ Add proceeding",
|
||||
"builder.triplet.loading": "Calculating deadlines …",
|
||||
"builder.triplet.unknown_proceeding": "Unknown proceeding type.",
|
||||
"builder.triplet.side.claimant": "Claimant view",
|
||||
"builder.triplet.side.defendant": "Defendant view",
|
||||
"builder.triplet.flags.label": "Options:",
|
||||
"builder.triplet.perspective.label": "Perspective:",
|
||||
"builder.triplet.perspective.none": "none",
|
||||
"builder.triplet.perspective.claimant": "Claimant",
|
||||
"builder.triplet.perspective.defendant": "Defendant",
|
||||
"builder.triplet.detailgrad.label": "Detail:",
|
||||
"builder.triplet.detailgrad.selected": "Selected",
|
||||
"builder.triplet.detailgrad.all_options": "All options",
|
||||
"builder.triplet.remove": "Remove",
|
||||
"builder.triplet.collapse": "Collapse",
|
||||
"builder.triplet.expand": "Expand",
|
||||
"builder.triplet.no_flags": "(no flags for this proceeding type)",
|
||||
"builder.event.state.planned": "planned",
|
||||
"builder.event.state.filed": "filed",
|
||||
"builder.event.state.skipped": "skipped",
|
||||
"builder.event.action.file": "File",
|
||||
"builder.event.action.skip": "Skip",
|
||||
"builder.event.action.reset": "Reset to planned",
|
||||
"builder.event.actual_date.prompt": "Date of filing:",
|
||||
"builder.event.skip_reason.prompt": "Reason (optional):",
|
||||
"builder.event.horizon.label": "+{n} optional ▾",
|
||||
"builder.event.horizon.hide": "Hide optional",
|
||||
"builder.save.idle": " ",
|
||||
"builder.save.saving": "Saving …",
|
||||
"builder.save.saved": "Saved ✓",
|
||||
"builder.save.error": "Save failed",
|
||||
"builder.search.hint.start": "Type … e.g. \"defence\", \"hearing\", \"HL-2024\"",
|
||||
"builder.search.hint.short": "At least 2 characters.",
|
||||
"builder.search.hint.loading": "Searching …",
|
||||
"builder.search.hint.empty": "No matches.",
|
||||
"builder.search.hint.error": "Search failed. Try again.",
|
||||
"builder.search.hint.akte_b4": "Matter mode coming in B4.",
|
||||
"builder.search.group.events": "Events",
|
||||
"builder.search.group.scenarios": "Scenarios",
|
||||
"builder.search.group.projects": "Matters",
|
||||
"builder.search.summary.events.one": "{n} event",
|
||||
"builder.search.summary.events.other": "{n} events",
|
||||
"builder.search.summary.scenarios.one": "{n} scenario",
|
||||
"builder.search.summary.scenarios.other": "{n} scenarios",
|
||||
"builder.search.summary.projects.one": "{n} matter",
|
||||
"builder.search.summary.projects.other": "{n} matters",
|
||||
"builder.search.anchor.divider": "━━━━ YOU ARE HERE ━━━━",
|
||||
|
||||
// B5 — side-panel buckets, sharing, promote-to-project wizard.
|
||||
"builder.bucket.shared": "Shared with me",
|
||||
"builder.bucket.promoted": "Promoted to project",
|
||||
"builder.bucket.archived": "Archived",
|
||||
"builder.bucket.empty": "—",
|
||||
"builder.readonly.watermark": "Shared by {owner} · read-only",
|
||||
"builder.readonly.blocked": "Read-only — only the owner can edit.",
|
||||
"builder.share.title": "Share scenario",
|
||||
"builder.share.subtitle": "Share read-only with HLC colleagues. You remain the sole editor.",
|
||||
"builder.share.search.placeholder": "Search name or email …",
|
||||
"builder.share.button": "Share read-only",
|
||||
"builder.share.current.title": "Already shared with:",
|
||||
"builder.share.current.empty": "Not shared with anyone yet.",
|
||||
"builder.share.revoke": "Remove",
|
||||
"builder.share.close": "Close",
|
||||
"builder.share.no_results": "No users found.",
|
||||
"builder.share.error": "Sharing failed. Please try again.",
|
||||
"builder.promote.title": "Create as project",
|
||||
"builder.promote.step1": "Confirm",
|
||||
"builder.promote.step2": "Add parties",
|
||||
"builder.promote.step3": "Case metadata",
|
||||
"builder.promote.next": "Next",
|
||||
"builder.promote.back": "Back",
|
||||
"builder.promote.commit": "Create",
|
||||
"builder.promote.cancel": "Cancel",
|
||||
"builder.promote.summary.heading": "What will be created:",
|
||||
"builder.promote.summary.proceeding": "Primary proceeding",
|
||||
"builder.promote.summary.events_filed": "filed events",
|
||||
"builder.promote.summary.events_planned": "planned events",
|
||||
"builder.promote.summary.flags": "active options",
|
||||
"builder.promote.summary.note_extra": "{n} further standalone proceeding(s) stay in the scenario and are not carried over automatically.",
|
||||
"builder.promote.parties.hint": "Enter the real party names — or add them later in the case file.",
|
||||
"builder.promote.parties.add": "+ Add party",
|
||||
"builder.promote.parties.name": "Name",
|
||||
"builder.promote.parties.role": "Role (e.g. claimant)",
|
||||
"builder.promote.parties.representative": "Representative",
|
||||
"builder.promote.parties.remove": "Remove",
|
||||
"builder.promote.parties.empty": "No parties yet.",
|
||||
"builder.promote.meta.title": "Case title / matter",
|
||||
"builder.promote.meta.title.placeholder": "e.g. Becker v. X — UPC infringement",
|
||||
"builder.promote.meta.reference": "Reference (optional)",
|
||||
"builder.promote.meta.case_number": "Case number (optional)",
|
||||
"builder.promote.meta.client_number": "Client number (optional)",
|
||||
"builder.promote.meta.our_side": "Our side",
|
||||
"builder.promote.meta.our_side.claimant": "Claimant",
|
||||
"builder.promote.meta.our_side.defendant": "Defendant",
|
||||
"builder.promote.meta.our_side.none": "— open —",
|
||||
"builder.promote.meta.parent": "Parent litigation (optional)",
|
||||
"builder.promote.meta.parent.none": "— none —",
|
||||
"builder.promote.meta.team": "Team (optional)",
|
||||
"builder.promote.meta.team.hint": "You are added as lead automatically.",
|
||||
"builder.promote.error.title_required": "Please enter a case title.",
|
||||
"builder.promote.error.generic": "Creation failed. Please try again.",
|
||||
"builder.promote.success": "Case created — redirecting …",
|
||||
"builder.mobile.blocked": "Open on a larger screen to edit.",
|
||||
|
||||
"deadlines.step1": "Select Proceeding Type",
|
||||
"deadlines.step2": "Enter Trigger Date",
|
||||
"deadlines.step2.perspective": "Perspective and Date",
|
||||
@@ -3540,10 +3771,6 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.epa.opp.opd": "Opposition",
|
||||
"deadlines.epa.opp.boa": "Appeal",
|
||||
"deadlines.epa.grant.exa": "Grant Procedure",
|
||||
"deadlines.party.claimant": "Claimant",
|
||||
"deadlines.party.defendant": "Defendant",
|
||||
"deadlines.party.court": "Court",
|
||||
"deadlines.party.both": "Both",
|
||||
"deadlines.party.both.label": "both parties",
|
||||
"deadlines.court.set": "set by court",
|
||||
"deadlines.court.indirect": "tbd",
|
||||
@@ -4675,7 +4902,25 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"einstellungen.tab.profil": "Profile",
|
||||
"einstellungen.tab.benachrichtigungen": "Notifications",
|
||||
"einstellungen.tab.caldav": "CalDAV",
|
||||
"einstellungen.tab.names": "Naming",
|
||||
"einstellungen.tab.export": "Data export",
|
||||
"einstellungen.names.subtitle": "Define how Paliad composes draft titles and file names from project data. Click a placeholder to insert it; the preview updates instantly.",
|
||||
"einstellungen.names.preview.sample": "Sample:",
|
||||
"einstellungen.names.preview.empty": "Without project data:",
|
||||
"einstellungen.names.reset": "Reset to default",
|
||||
"einstellungen.names.saved": "Saved.",
|
||||
"einstellungen.names.reset_done": "Reset to default.",
|
||||
"einstellungen.names.override_badge": "Customised",
|
||||
"einstellungen.names.firm_badge": "Firm default",
|
||||
"einstellungen.names.firm.heading": "Firm default (for everyone)",
|
||||
"einstellungen.names.firm.status_set": "Active firm default:",
|
||||
"einstellungen.names.firm.status_unset": "No firm default set \u2014 the system default applies.",
|
||||
"einstellungen.names.firm.set": "Set as firm default",
|
||||
"einstellungen.names.firm.clear": "Clear firm default",
|
||||
"einstellungen.names.firm.saved": "Firm default saved.",
|
||||
"einstellungen.names.firm.cleared": "Firm default cleared \u2014 system default applies again.",
|
||||
"einstellungen.names.error.load": "Could not load naming schemes.",
|
||||
"einstellungen.names.error.invalid": "Invalid template \u2014 please check the placeholders.",
|
||||
"einstellungen.export.subtitle": "Download your personal Paliad data as an Excel + JSON + CSV bundle. The package contains everything you can currently see \u2014 your projects, deadlines, appointments, notes, approvals and settings.",
|
||||
"einstellungen.export.heading": "Personal data export",
|
||||
"einstellungen.export.what": "The package contains your visible data in three formats in one .zip:",
|
||||
@@ -4865,6 +5110,10 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"submissions.draft.language.de": "DE",
|
||||
"submissions.draft.language.en": "EN",
|
||||
"submissions.draft.language.fallback_notice": "Fallback: universal skeleton (no language-matched template).",
|
||||
// t-paliad-354 — filename keyword (leads the exported document name).
|
||||
"submissions.draft.keyword.label": "Keyword (filename)",
|
||||
"submissions.draft.keyword.placeholder": "Auto-derived from the submission type",
|
||||
"submissions.draft.keyword.hint": "Leads the filename: <date> <keyword> (<case number>).",
|
||||
"submissions.draft.preview.hint": "Read-only preview — final formatting in Word.",
|
||||
// t-paliad-277 — import-from-project + party-picker.
|
||||
"submissions.draft.import.button": "Import from project",
|
||||
@@ -4875,6 +5124,19 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"submissions.draft.base.hint": "Drives fonts, letterhead, and section defaults.",
|
||||
"submissions.draft.sections.title": "Sections",
|
||||
"submissions.draft.sections.hint": "Edit per section — autosaves after 500ms. Final layout in Word.",
|
||||
// t-paliad-349 (m/paliad#157) docforge slice 6 — template authoring page.
|
||||
"templates.authoring.title": "Templates — Paliad",
|
||||
"templates.authoring.heading": "Templates",
|
||||
"templates.authoring.intro": "Upload a Word template, highlight spots and insert variables.",
|
||||
"templates.authoring.upload.title": "Upload a new template",
|
||||
"templates.authoring.upload.file": "Word file (.docx)",
|
||||
"templates.authoring.upload.name_de": "Name (DE)",
|
||||
"templates.authoring.upload.name_en": "Name (EN)",
|
||||
"templates.authoring.upload.firm": "Firm (optional)",
|
||||
"templates.authoring.upload.submit": "Upload",
|
||||
"templates.authoring.list.title": "Existing templates",
|
||||
"templates.authoring.workspace.hint": "Highlight text, then pick a variable to place a placeholder.",
|
||||
"templates.authoring.slots.title": "Placeholders",
|
||||
// t-paliad-315 (m/paliad#141) Composer Slice C — building blocks admin.
|
||||
"admin.building_blocks.title": "Building blocks — Paliad",
|
||||
"admin.building_blocks.heading": "Building blocks",
|
||||
|
||||
@@ -1,704 +0,0 @@
|
||||
// procedures-tracker — render module for /tools/procedures (m/paliad#152
|
||||
// T1 + onwards, docs/design-procedures-workflow-tracker-2026-05-27.md).
|
||||
//
|
||||
// Responsibilities:
|
||||
// - Per-proceeding card render: header + chained tree by parent_id +
|
||||
// priority-styled bullets + scenario-flag fork checkboxes.
|
||||
// - Tree layout: children grouped under their parentRuleCode in
|
||||
// deadlines-list order, root rules surface at depth 0.
|
||||
// - Default detail mode = "selected" (mandatory + recommended +
|
||||
// scenario-flag-enabled). Conditional rules whose gate is OFF are
|
||||
// filtered out by the calculator and don't surface; the
|
||||
// corresponding fork checkbox on the gating node reveals them when
|
||||
// toggled ON.
|
||||
//
|
||||
// T1 floor: card-level "Optionen" strip carries the scenario-flag
|
||||
// forks at the top of each proceeding card. Per-node inline placement
|
||||
// (the design's stated final shape — fork checkbox on the actual
|
||||
// gating node) is a T2 refinement; T1 keeps forks discoverable but
|
||||
// scoped per proceeding so they're not the global-page strip m's bug
|
||||
// #5 flagged.
|
||||
|
||||
import { t, tDyn, getLang } from "./i18n";
|
||||
import {
|
||||
type CalculatedDeadline,
|
||||
type DeadlineResponse,
|
||||
calculateDeadlines,
|
||||
escHtml,
|
||||
formatDate,
|
||||
} from "./views/verfahrensablauf-core";
|
||||
import { filterByDetailMode } from "./verfahrensablauf-detail-mode";
|
||||
|
||||
// ProceedingDef — the catalog of proceedings the find header pills and
|
||||
// the cold-open default surface against. Kept in sync with paliad's
|
||||
// proceeding_types catalog as of 2026-05-27 (matches
|
||||
// VerfahrensablaufBody.tsx's listing).
|
||||
export interface ProceedingDef {
|
||||
code: string;
|
||||
forum: "upc" | "de" | "epa" | "dpma";
|
||||
i18nKey: string;
|
||||
nameDE: string;
|
||||
nameEN: string;
|
||||
}
|
||||
|
||||
export const PROCEEDINGS: ProceedingDef[] = [
|
||||
{ code: "upc.inf.cfi", forum: "upc", i18nKey: "deadlines.upc.inf.cfi", nameDE: "Verletzungsverfahren", nameEN: "Infringement (CFI)" },
|
||||
{ code: "upc.rev.cfi", forum: "upc", i18nKey: "deadlines.upc.rev.cfi", nameDE: "Nichtigkeitsklage", nameEN: "Revocation (CFI)" },
|
||||
{ code: "upc.ccr.cfi", forum: "upc", i18nKey: "deadlines.upc.ccr.cfi", nameDE: "Widerklage auf Nichtigkeit", nameEN: "Counterclaim for revocation" },
|
||||
{ code: "upc.pi.cfi", forum: "upc", i18nKey: "deadlines.upc.pi.cfi", nameDE: "Einstw. Maßnahmen", nameEN: "Provisional measures" },
|
||||
{ code: "upc.apl.unified", forum: "upc", i18nKey: "deadlines.upc.apl.unified", nameDE: "Berufung UPC", nameEN: "Appeal UPC" },
|
||||
{ code: "upc.dmgs.cfi", forum: "upc", i18nKey: "deadlines.upc.dmgs.cfi", nameDE: "Schadensbemessung", nameEN: "Damages" },
|
||||
{ code: "upc.disc.cfi", forum: "upc", i18nKey: "deadlines.upc.disc.cfi", nameDE: "Bucheinsicht", nameEN: "Inspection of accounts" },
|
||||
{ code: "de.inf.lg", forum: "de", i18nKey: "deadlines.de.inf.lg", nameDE: "LG Verletzungsklage", nameEN: "LG infringement (DE 1st inst.)" },
|
||||
{ code: "de.inf.olg", forum: "de", i18nKey: "deadlines.de.inf.olg", nameDE: "OLG Berufung", nameEN: "OLG appeal" },
|
||||
{ code: "de.inf.bgh", forum: "de", i18nKey: "deadlines.de.inf.bgh", nameDE: "BGH Revision / NZB", nameEN: "BGH revision / NZB" },
|
||||
{ code: "de.null.bpatg", forum: "de", i18nKey: "deadlines.de.null.bpatg", nameDE: "BPatG Nichtigkeit", nameEN: "BPatG revocation" },
|
||||
{ code: "de.null.bgh", forum: "de", i18nKey: "deadlines.de.null.bgh", nameDE: "BGH Berufung (Nichtigkeit)", nameEN: "BGH revocation appeal" },
|
||||
{ code: "epa.opp.opd", forum: "epa", i18nKey: "deadlines.epa.opp.opd", nameDE: "Einspruchsverfahren EPA", nameEN: "EPO opposition" },
|
||||
{ code: "epa.opp.boa", forum: "epa", i18nKey: "deadlines.epa.opp.boa", nameDE: "Beschwerdeverfahren EPA", nameEN: "EPO appeal" },
|
||||
{ code: "epa.grant.exa", forum: "epa", i18nKey: "deadlines.epa.grant.exa", nameDE: "EP-Erteilungsverfahren", nameEN: "EP grant" },
|
||||
{ code: "dpma.opp.dpma", forum: "dpma", i18nKey: "deadlines.dpma.opp.dpma", nameDE: "Einspruch DPMA", nameEN: "DPMA opposition" },
|
||||
{ code: "dpma.appeal.bpatg", forum: "dpma", i18nKey: "deadlines.dpma.appeal.bpatg", nameDE: "Beschwerde BPatG (DPMA)", nameEN: "BPatG appeal (DPMA)" },
|
||||
{ code: "dpma.appeal.bgh", forum: "dpma", i18nKey: "deadlines.dpma.appeal.bgh", nameDE: "Rechtsbeschwerde BGH", nameEN: "BGH legal appeal" },
|
||||
];
|
||||
|
||||
// COLD_OPEN_DEFAULTS — design §8 / §11.Q4. When no URL params and no
|
||||
// Akte context, the page renders these 6 proceedings stacked. Hint
|
||||
// text above the timelines invites the user to filter for more.
|
||||
export const COLD_OPEN_DEFAULTS: string[] = [
|
||||
"upc.inf.cfi",
|
||||
"upc.rev.cfi",
|
||||
"upc.apl.unified",
|
||||
"de.inf.lg",
|
||||
"epa.opp.opd",
|
||||
"dpma.opp.dpma",
|
||||
];
|
||||
|
||||
// FORUM_LABEL mirrors the forum-pill label and the proceeding card
|
||||
// header jurisdiction prefix. Same slugs the proceeding-types catalog
|
||||
// carries.
|
||||
const FORUM_LABEL: Record<string, string> = {
|
||||
upc: "UPC",
|
||||
de: "DE",
|
||||
epa: "EPA",
|
||||
dpma: "DPMA",
|
||||
};
|
||||
|
||||
export function lookupProceeding(code: string): ProceedingDef | undefined {
|
||||
return PROCEEDINGS.find((p) => p.code === code);
|
||||
}
|
||||
|
||||
export function proceedingDisplayName(code: string): string {
|
||||
const def = lookupProceeding(code);
|
||||
if (!def) return code;
|
||||
const lang = getLang();
|
||||
return lang === "en" ? def.nameEN : def.nameDE;
|
||||
}
|
||||
|
||||
// ─── condition_expr flag extraction ─────────────────────────────────────────
|
||||
//
|
||||
// Walks the jsonb tree shape documented in pkg/litigationplanner/expr.go:
|
||||
// {"flag": "<name>"} — leaf
|
||||
// {"op": "and|or|not", "args":[…]} — composite
|
||||
// Returns the set of flag names mentioned in the expression. The page
|
||||
// uses this to decide which scenario_flag forks apply to a given
|
||||
// proceeding (the union over all conditional rules' expressions).
|
||||
|
||||
function collectFlagsFromExpr(node: unknown, out: Set<string>): void {
|
||||
if (!node || typeof node !== "object") return;
|
||||
const n = node as Record<string, unknown>;
|
||||
if (typeof n.flag === "string" && n.flag) {
|
||||
out.add(n.flag);
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(n.args)) {
|
||||
for (const arg of n.args) collectFlagsFromExpr(arg, out);
|
||||
}
|
||||
}
|
||||
|
||||
export function gatingFlagsForProceeding(deadlines: CalculatedDeadline[]): string[] {
|
||||
const set = new Set<string>();
|
||||
for (const dl of deadlines) {
|
||||
if (!dl.conditionExpr) continue;
|
||||
collectFlagsFromExpr(dl.conditionExpr, set);
|
||||
}
|
||||
return Array.from(set).sort();
|
||||
}
|
||||
|
||||
// Hard-coded fallback set: when the calc was run without a flag, the
|
||||
// conditional rules gated by that flag are filtered out server-side
|
||||
// (selected mode doesn't surface their condition_expr). The fallback
|
||||
// lists the flags each proceeding *can* gate so the per-card Optionen
|
||||
// strip surfaces them even on first render with the flag off.
|
||||
//
|
||||
// Mirrors mig 084's backfill: each scenario_flag → the proceedings
|
||||
// where it's referenced by at least one rule. Today's catalog: 18
|
||||
// conditional rules across upc.inf.cfi (with_ccr / with_amend) and
|
||||
// upc.rev.cfi (with_amend / with_cci). Keep in sync with
|
||||
// paliad.sequencing_rules.condition_expr.
|
||||
const FALLBACK_FLAGS: Record<string, string[]> = {
|
||||
"upc.inf.cfi": ["with_ccr", "with_amend"],
|
||||
"upc.rev.cfi": ["with_amend", "with_cci"],
|
||||
};
|
||||
|
||||
// Appeal-target slugs the engine accepts for the upc.apl.unified
|
||||
// proceeding (mig 137 / B1). Each chip filters the appeal timeline to
|
||||
// the rule subset whose applies_to_target jsonb contains the slug.
|
||||
// Same vocabulary the VerfahrensablaufBody chip group exposes.
|
||||
export const APPEAL_TARGETS = [
|
||||
"endentscheidung",
|
||||
"kostenentscheidung",
|
||||
"anordnung",
|
||||
"schadensbemessung",
|
||||
"bucheinsicht",
|
||||
] as const;
|
||||
|
||||
const APPEAL_TARGET_PROCEEDINGS = new Set(["upc.apl.unified"]);
|
||||
|
||||
function hasAppealTarget(code: string): boolean {
|
||||
return APPEAL_TARGET_PROCEEDINGS.has(code);
|
||||
}
|
||||
|
||||
// Per-proceeding detail mode persistence (T4 §3.4). State is keyed by
|
||||
// proceeding code so a page with 3 proceedings can have one in "Alle
|
||||
// Optionen" without affecting the others.
|
||||
const DETAIL_MODE_PREFIX = "procedures.tracker.detail_mode:";
|
||||
|
||||
export function readDetailMode(code: string): "selected" | "all_options" {
|
||||
try {
|
||||
const raw = localStorage.getItem(DETAIL_MODE_PREFIX + code);
|
||||
if (raw === "all_options") return "all_options";
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
return "selected";
|
||||
}
|
||||
|
||||
export function writeDetailMode(code: string, mode: "selected" | "all_options"): void {
|
||||
try {
|
||||
if (mode === "selected") localStorage.removeItem(DETAIL_MODE_PREFIX + code);
|
||||
else localStorage.setItem(DETAIL_MODE_PREFIX + code, mode);
|
||||
} catch {
|
||||
// localStorage unavailable — runtime state stays in memory only;
|
||||
// toggle still works for this session.
|
||||
}
|
||||
}
|
||||
|
||||
export function applicableFlagsForProceeding(
|
||||
code: string,
|
||||
deadlines: CalculatedDeadline[],
|
||||
): string[] {
|
||||
const fromExpr = gatingFlagsForProceeding(deadlines);
|
||||
if (fromExpr.length > 0) return fromExpr;
|
||||
return FALLBACK_FLAGS[code] || [];
|
||||
}
|
||||
|
||||
// flagLabel maps scenario_flag keys to i18n keys (matches the
|
||||
// existing deadlines.flag.* keys used by VerfahrensablaufBody).
|
||||
const FLAG_I18N: Record<string, string> = {
|
||||
with_ccr: "deadlines.flag.ccr",
|
||||
with_amend: "deadlines.flag.amend",
|
||||
with_cci: "deadlines.flag.cci",
|
||||
};
|
||||
|
||||
function labelForFlag(flagKey: string, proceeding: string): string {
|
||||
// upc.inf.cfi with_amend = R.30 amendment request; upc.rev.cfi
|
||||
// with_amend = R.49.2(a) amendment. Different labels even though the
|
||||
// flag key is the same. Honour the inf vs rev distinction.
|
||||
if (flagKey === "with_amend" && proceeding === "upc.inf.cfi") return t("deadlines.flag.inf_amend");
|
||||
if (flagKey === "with_amend" && proceeding === "upc.rev.cfi") return t("deadlines.flag.rev_amend");
|
||||
if (flagKey === "with_cci" && proceeding === "upc.rev.cfi") return t("deadlines.flag.rev_cci");
|
||||
const key = FLAG_I18N[flagKey];
|
||||
return key ? t(key) : flagKey;
|
||||
}
|
||||
|
||||
// ─── per-proceeding render ──────────────────────────────────────────────────
|
||||
|
||||
// ActualStatus is the per-rule overlay derived from paliad.deadlines /
|
||||
// paliad.appointments for an Akte (§6.4). The tracker reads this map
|
||||
// when ?project= is set and stamps a status badge on each node.
|
||||
export interface ActualStatus {
|
||||
status: "done" | "open" | "overdue" | "court_set";
|
||||
// dueDate / completedAt fall back to the calculator's projected date
|
||||
// when not set (open / future). Format is ISO date.
|
||||
dueDate?: string;
|
||||
completedAt?: string;
|
||||
// deadlineId lets the renderer deep-link to /projects/{p}/deadlines/{id}.
|
||||
deadlineId?: string;
|
||||
appointmentId?: string;
|
||||
}
|
||||
|
||||
export type ActualsMap = Map<string, ActualStatus>;
|
||||
|
||||
export interface TimelineRenderParams {
|
||||
proceedingType: string;
|
||||
triggerDate: string;
|
||||
flags: string[];
|
||||
anchorRuleId?: string;
|
||||
zoom?: boolean;
|
||||
// collapsed=true renders the card as a one-line header only with a
|
||||
// [zeigen] link. Used by §6.5: when an anchor is pinned + >1
|
||||
// proceeding visible, non-anchored proceedings collapse so the
|
||||
// anchor's full context owns the page.
|
||||
collapsed?: boolean;
|
||||
// actuals carries the per-rule overlay when the page is bound to an
|
||||
// Akte via ?project=. Empty map / undefined = template render.
|
||||
actuals?: ActualsMap;
|
||||
// projectId carries through to deep-links and write-back paths.
|
||||
projectId?: string;
|
||||
// T4 — Verfahren-card detail mode toggle. "selected" (default) shows
|
||||
// mandatory + recommended + active-flag-gated; "all_options" reveals
|
||||
// conditional rules whose flag is off and unselected optionals,
|
||||
// muted. Per-proceeding state lives in localStorage keyed by code.
|
||||
detailMode?: "selected" | "all_options";
|
||||
// T4 — appeal-target slug for proceedings with `applies_to_target`
|
||||
// (upc.apl.unified). Drives the chip group at the appeal root. Empty
|
||||
// = use the engine's default (endentscheidung for upc.apl).
|
||||
appealTarget?: string;
|
||||
// T4 — perspective for the cross-party muted treatment (§3.6). Rows
|
||||
// whose party doesn't match are rendered with a "Gegenseitig" badge
|
||||
// and a muted style. Empty = no perspective applied, render all
|
||||
// rows at full saturation.
|
||||
party?: "claimant" | "defendant" | "";
|
||||
}
|
||||
|
||||
export interface RenderedTimeline {
|
||||
card: HTMLElement;
|
||||
data: DeadlineResponse | null;
|
||||
// hasAnchor true iff the rendered card contains the active
|
||||
// anchorRuleId. Drives the multi-proceeding auto-collapse decision
|
||||
// back in procedures.ts.
|
||||
hasAnchor: boolean;
|
||||
}
|
||||
|
||||
// renderCard takes a proceeding code + flag set + trigger date and
|
||||
// returns a fully-wired card element ready to mount. Re-running with a
|
||||
// new flag set requires a fresh fetch (calc applies the flag set
|
||||
// server-side).
|
||||
export async function renderCard(params: TimelineRenderParams): Promise<RenderedTimeline> {
|
||||
const card = document.createElement("article");
|
||||
card.className = "tracker-proceeding";
|
||||
card.dataset.proceeding = params.proceedingType;
|
||||
|
||||
// Header — proceeding name + jurisdiction badge + detail-mode
|
||||
// toggle + collapse toggle. Detail-mode renders only on non-
|
||||
// collapsed cards; collapse toggle renders always.
|
||||
const def = lookupProceeding(params.proceedingType);
|
||||
const jur = def ? (FORUM_LABEL[def.forum] || "") : "";
|
||||
const procName = proceedingDisplayName(params.proceedingType);
|
||||
const detailMode = params.detailMode || "selected";
|
||||
const detailToggle = params.collapsed
|
||||
? ""
|
||||
: `<button type="button" class="tracker-proceeding-detail" data-action="detail-toggle"
|
||||
data-code="${escHtml(params.proceedingType)}"
|
||||
aria-pressed="${detailMode === "all_options" ? "true" : "false"}"
|
||||
title="${escHtml(t("procedures.proceeding.detail.title"))}">
|
||||
${escHtml(detailMode === "all_options" ? t("procedures.proceeding.detail.all") : t("procedures.proceeding.detail.selected"))}
|
||||
</button>`;
|
||||
const header = document.createElement("header");
|
||||
header.className = "tracker-proceeding-header";
|
||||
header.innerHTML = `
|
||||
<span class="tracker-proceeding-jur">${escHtml(jur)}</span>
|
||||
<h3 class="tracker-proceeding-name">${escHtml(procName)}</h3>
|
||||
<span class="tracker-proceeding-code" title="${escHtml(params.proceedingType)}">${escHtml(params.proceedingType)}</span>
|
||||
${detailToggle}
|
||||
<button type="button" class="tracker-proceeding-toggle" data-action="proc-toggle"
|
||||
data-code="${escHtml(params.proceedingType)}"
|
||||
aria-label="${escHtml(t("procedures.proceeding.toggle"))}">
|
||||
${escHtml(params.collapsed ? t("procedures.proceeding.show") : t("procedures.proceeding.hide"))}
|
||||
</button>
|
||||
`;
|
||||
card.appendChild(header);
|
||||
|
||||
// Collapsed state — header-only render (§6.5). Bail before issuing
|
||||
// the calc fetch; the card has no body. hasAnchor stays false here
|
||||
// because we never resolved the data.
|
||||
if (params.collapsed) {
|
||||
card.classList.add("tracker-proceeding--collapsed");
|
||||
return { card, data: null, hasAnchor: false };
|
||||
}
|
||||
|
||||
// Appeal-target chip group — visible only on proceedings with
|
||||
// applies_to_target rules (today: upc.apl.unified). The picked slug
|
||||
// feeds the calc's appealTarget param so the timeline narrows to the
|
||||
// rule subset (Endentscheidung / Kostenentscheidung / Anordnung /
|
||||
// Schadensbemessung / Bucheinsicht).
|
||||
if (hasAppealTarget(params.proceedingType)) {
|
||||
const targetGroup = document.createElement("div");
|
||||
targetGroup.className = "tracker-proceeding-targets";
|
||||
const lbl = document.createElement("span");
|
||||
lbl.className = "tracker-proceeding-options-label";
|
||||
lbl.textContent = t("procedures.appeal_target.label");
|
||||
targetGroup.appendChild(lbl);
|
||||
const active = params.appealTarget || "endentscheidung";
|
||||
for (const slug of APPEAL_TARGETS) {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "tracker-pill tracker-pill--sm";
|
||||
btn.dataset.action = "appeal-target";
|
||||
btn.dataset.code = params.proceedingType;
|
||||
btn.dataset.target = slug;
|
||||
btn.textContent = t(`deadlines.appeal_target.${slug}` as never);
|
||||
if (slug === active) btn.classList.add("is-active");
|
||||
targetGroup.appendChild(btn);
|
||||
}
|
||||
card.appendChild(targetGroup);
|
||||
}
|
||||
|
||||
// Optionen strip — scenario flag checkboxes scoped to this card.
|
||||
// Hydrated after the calc response so the applicable flag set is
|
||||
// known. T1 floor: card-level placement; T2+ may move these to
|
||||
// inline on the actual gating node per the design.
|
||||
const optionsStrip = document.createElement("div");
|
||||
optionsStrip.className = "tracker-proceeding-options";
|
||||
optionsStrip.hidden = true;
|
||||
card.appendChild(optionsStrip);
|
||||
|
||||
// Body — chained tree mounts here once the calc returns.
|
||||
const body = document.createElement("div");
|
||||
body.className = "tracker-proceeding-body";
|
||||
body.innerHTML = `<div class="tracker-proceeding-loading">${escHtml(t("procedures.timelines.loading"))}</div>`;
|
||||
card.appendChild(body);
|
||||
|
||||
// Fetch + render.
|
||||
const data = await calculateDeadlines({
|
||||
proceedingType: params.proceedingType,
|
||||
triggerDate: params.triggerDate,
|
||||
flags: params.flags,
|
||||
appealTarget: hasAppealTarget(params.proceedingType)
|
||||
? (params.appealTarget || "endentscheidung")
|
||||
: undefined,
|
||||
// includeHidden=true under "all_options" so the calculator
|
||||
// re-surfaces previously-skipped conditional rules with isHidden
|
||||
// set; the tracker mutes them.
|
||||
includeHidden: params.detailMode === "all_options" || undefined,
|
||||
});
|
||||
|
||||
if (!data) {
|
||||
body.innerHTML = `<div class="tracker-proceeding-error">${escHtml(t("procedures.timelines.error"))}</div>`;
|
||||
return { card, data: null, hasAnchor: false };
|
||||
}
|
||||
|
||||
// Hydrate the Optionen strip with the applicable scenario flags.
|
||||
const applicable = applicableFlagsForProceeding(params.proceedingType, data.deadlines);
|
||||
if (applicable.length > 0) {
|
||||
const labelSpan = document.createElement("span");
|
||||
labelSpan.className = "tracker-proceeding-options-label";
|
||||
labelSpan.textContent = t("procedures.timelines.options");
|
||||
optionsStrip.appendChild(labelSpan);
|
||||
|
||||
for (const flag of applicable) {
|
||||
const label = document.createElement("label");
|
||||
label.className = "tracker-proceeding-option";
|
||||
const cb = document.createElement("input");
|
||||
cb.type = "checkbox";
|
||||
cb.value = flag;
|
||||
cb.checked = params.flags.includes(flag);
|
||||
cb.dataset.flag = flag;
|
||||
label.appendChild(cb);
|
||||
const text = document.createElement("span");
|
||||
text.textContent = labelForFlag(flag, params.proceedingType);
|
||||
label.appendChild(text);
|
||||
optionsStrip.appendChild(label);
|
||||
}
|
||||
optionsStrip.hidden = false;
|
||||
}
|
||||
|
||||
// Filter per detail mode (§3.4).
|
||||
// selected → mandatory + recommended + active-flag-gated
|
||||
// all_options → everything; unselected optionals + conditional-off
|
||||
// rules render muted (the renderer stamps the
|
||||
// __detailUnselected flag via filterByDetailMode).
|
||||
const filtered = filterByDetailMode(data.deadlines, detailMode, null);
|
||||
|
||||
// Anchor-present detection: does this card's rule set contain the
|
||||
// active anchor rule id? Drives the multi-proceeding scope logic
|
||||
// and the zoom branch below.
|
||||
const hasAnchor = !!(params.anchorRuleId && filtered.some((d) => d.ruleId === params.anchorRuleId));
|
||||
|
||||
if (params.zoom && hasAnchor && params.anchorRuleId) {
|
||||
body.innerHTML = renderZoomedBody(filtered, params.anchorRuleId, params.actuals, params.party);
|
||||
} else {
|
||||
body.innerHTML = renderTreeBody(filtered, params.anchorRuleId, params.actuals, params.party);
|
||||
}
|
||||
|
||||
if (hasAnchor) card.classList.add("tracker-proceeding--anchored");
|
||||
|
||||
return { card, data, hasAnchor };
|
||||
}
|
||||
|
||||
// ─── tree builder ──────────────────────────────────────────────────────────
|
||||
//
|
||||
// Builds a `parentRuleCode → child[]` map, then walks from each root
|
||||
// (no parent) emitting nested <ul class="tracker-tree-…"> nodes. The
|
||||
// calculator already sorts the deadlines into a sensible chain (root
|
||||
// → linear-deepest-first); the tree builder preserves that order.
|
||||
|
||||
function renderTreeBody(
|
||||
deadlines: CalculatedDeadline[],
|
||||
anchorRuleId?: string,
|
||||
actuals?: ActualsMap,
|
||||
party?: "claimant" | "defendant" | "",
|
||||
): string {
|
||||
if (deadlines.length === 0) {
|
||||
return `<div class="tracker-proceeding-empty">${escHtml(t("procedures.timelines.empty"))}</div>`;
|
||||
}
|
||||
|
||||
// Index by code so children can find their parent visually. Track
|
||||
// every code that appears in the filtered set — children whose
|
||||
// parent isn't in the set surface as orphan roots.
|
||||
const present = new Set(deadlines.map((d) => d.code));
|
||||
const childrenOf: Record<string, CalculatedDeadline[]> = {};
|
||||
const roots: CalculatedDeadline[] = [];
|
||||
|
||||
for (const dl of deadlines) {
|
||||
const parent = dl.parentRuleCode || "";
|
||||
if (parent && present.has(parent)) {
|
||||
(childrenOf[parent] ||= []).push(dl);
|
||||
} else {
|
||||
roots.push(dl);
|
||||
}
|
||||
}
|
||||
|
||||
const parts: string[] = [`<ul class="tracker-tree tracker-tree-root">`];
|
||||
for (const root of roots) {
|
||||
parts.push(renderTreeNode(root, childrenOf, 0, anchorRuleId, actuals, party));
|
||||
}
|
||||
parts.push(`</ul>`);
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
// isCrossParty — §3.6. When perspective is set, rows whose primary_party
|
||||
// is the OPPOSITE side render with a "Gegenseitig" badge and a muted
|
||||
// style. court / both / informational rows are never cross-party.
|
||||
function isCrossParty(dl: CalculatedDeadline, party: "claimant" | "defendant" | ""): boolean {
|
||||
if (!party) return false;
|
||||
if (!dl.party) return false;
|
||||
if (dl.party === "court" || dl.party === "both") return false;
|
||||
return dl.party !== party;
|
||||
}
|
||||
|
||||
function renderTreeNode(
|
||||
dl: CalculatedDeadline,
|
||||
childrenOf: Record<string, CalculatedDeadline[]>,
|
||||
depth: number,
|
||||
anchorRuleId?: string,
|
||||
actuals?: ActualsMap,
|
||||
party?: "claimant" | "defendant" | "",
|
||||
): string {
|
||||
const children = childrenOf[dl.code] || [];
|
||||
const isAnchored = !!(anchorRuleId && dl.ruleId === anchorRuleId);
|
||||
const lang = getLang();
|
||||
const crossParty = party ? isCrossParty(dl, party) : false;
|
||||
// __detailUnselected is stamped by filterByDetailMode under
|
||||
// all_options mode (verfahrensablauf-detail-mode.ts). Read via
|
||||
// unknown-prop cast so we don't pollute the public CalculatedDeadline
|
||||
// type for one transient ui hint.
|
||||
const unselected = !!(dl as unknown as { __detailUnselected?: boolean }).__detailUnselected;
|
||||
const isHidden = !!dl.isHidden;
|
||||
|
||||
// Priority-driven bullet style.
|
||||
const priorityClass = `tracker-node--${dl.priority || "mandatory"}`;
|
||||
const anchorClass = isAnchored ? " tracker-node--anchored" : "";
|
||||
const courtClass = dl.isCourtSet ? " tracker-node--court" : "";
|
||||
const crossClass = crossParty ? " tracker-node--cross" : "";
|
||||
const unselectedClass = unselected ? " tracker-node--unselected" : "";
|
||||
const hiddenClass = isHidden ? " tracker-node--hidden" : "";
|
||||
|
||||
const name = lang === "en" ? (dl.nameEN || dl.name) : (dl.name || dl.nameEN);
|
||||
const ref = dl.legalSourceDisplay || dl.ruleRef || "";
|
||||
|
||||
// Actuals overlay (§6.4). When the page is Akte-bound and this rule
|
||||
// has an actuals row, the badge replaces the priority bullet's
|
||||
// status — done = ✓, overdue = ⚠, open ≠ projected = 📅, open ≡
|
||||
// projected = ◇. Date column shows the actual date when present.
|
||||
const actual = (actuals && dl.ruleId) ? actuals.get(dl.ruleId) : undefined;
|
||||
let dateLabel = "";
|
||||
let statusBadge = "";
|
||||
let actualClass = "";
|
||||
if (actual) {
|
||||
actualClass = ` tracker-node--actual-${actual.status}`;
|
||||
if (actual.status === "done") {
|
||||
statusBadge = `<span class="tracker-node-actual tracker-node-actual--done" title="${escHtml(t("procedures.node.actual.done"))}">✓</span>`;
|
||||
dateLabel = actual.completedAt ? formatDate(actual.completedAt) : (actual.dueDate ? formatDate(actual.dueDate) : "");
|
||||
} else if (actual.status === "overdue") {
|
||||
statusBadge = `<span class="tracker-node-actual tracker-node-actual--overdue" title="${escHtml(t("procedures.node.actual.overdue"))}">⚠</span>`;
|
||||
dateLabel = actual.dueDate ? formatDate(actual.dueDate) : "";
|
||||
} else if (actual.status === "open") {
|
||||
// Open + actual due differs from projected = 📅, else ◇.
|
||||
const projectedDate = dl.dueDate || "";
|
||||
const actualDate = actual.dueDate || "";
|
||||
const differs = projectedDate && actualDate && projectedDate !== actualDate;
|
||||
statusBadge = `<span class="tracker-node-actual tracker-node-actual--open" title="${escHtml(t("procedures.node.actual.open"))}">${differs ? "📅" : "◇"}</span>`;
|
||||
dateLabel = actualDate ? formatDate(actualDate) : (projectedDate ? formatDate(projectedDate) : "");
|
||||
}
|
||||
}
|
||||
if (!dateLabel) {
|
||||
dateLabel = dl.isCourtSet
|
||||
? t("procedures.timelines.court_set")
|
||||
: (dl.dueDate ? formatDate(dl.dueDate) : "");
|
||||
}
|
||||
|
||||
// Party badge — one-letter affordance to the right.
|
||||
const partyBadge = dl.party === "court"
|
||||
? "G"
|
||||
: dl.party === "claimant"
|
||||
? "K"
|
||||
: dl.party === "defendant"
|
||||
? "B"
|
||||
: dl.party === "both" ? "K+B" : "";
|
||||
|
||||
const ruleIdAttr = dl.ruleId ? ` data-rule-id="${escHtml(dl.ruleId)}"` : "";
|
||||
const codeAttr = ` data-code="${escHtml(dl.code)}"`;
|
||||
|
||||
// Pin + Fokus affordances. Pin is always available on nodes with a
|
||||
// real ruleId (synthetic appeal-trigger markers carry no id). Fokus
|
||||
// only on the currently anchored node.
|
||||
const pinBtn = dl.ruleId
|
||||
? `<button type="button" class="tracker-node-pin" data-action="pin"
|
||||
data-rule-id="${escHtml(dl.ruleId)}"
|
||||
aria-label="${escHtml(t("procedures.node.pin"))}"
|
||||
title="${escHtml(t("procedures.node.pin"))}">📌</button>`
|
||||
: "";
|
||||
const fokusBtn = isAnchored
|
||||
? `<button type="button" class="tracker-node-fokus" data-action="fokus"
|
||||
aria-label="${escHtml(t("procedures.node.fokus"))}"
|
||||
title="${escHtml(t("procedures.node.fokus"))}">🔍</button>`
|
||||
: "";
|
||||
|
||||
const crossBadge = crossParty
|
||||
? `<span class="tracker-node-cross" title="${escHtml(t("procedures.node.cross"))}">${escHtml(t("procedures.node.cross.short"))}</span>`
|
||||
: "";
|
||||
|
||||
const meta = `
|
||||
<div class="tracker-node-line">
|
||||
<span class="tracker-node-bullet" aria-hidden="true"></span>
|
||||
${statusBadge}
|
||||
<span class="tracker-node-name">${escHtml(name)}</span>
|
||||
${crossBadge}
|
||||
${ref ? `<span class="tracker-node-ref">${escHtml(ref)}</span>` : ""}
|
||||
${dateLabel ? `<span class="tracker-node-date">${escHtml(dateLabel)}</span>` : ""}
|
||||
${partyBadge ? `<span class="tracker-node-party tracker-node-party--${escHtml(dl.party || "")}">${escHtml(partyBadge)}</span>` : ""}
|
||||
${pinBtn}
|
||||
${fokusBtn}
|
||||
</div>
|
||||
${isAnchored ? `<div class="tracker-node-here" role="note">${escHtml(t("procedures.node.here"))}</div>` : ""}
|
||||
`;
|
||||
|
||||
let inner = meta;
|
||||
if (children.length > 0) {
|
||||
const kids = children
|
||||
.map((c) => renderTreeNode(c, childrenOf, depth + 1, anchorRuleId, actuals, party))
|
||||
.join("");
|
||||
inner += `<ul class="tracker-tree tracker-tree--depth-${depth + 1}">${kids}</ul>`;
|
||||
}
|
||||
|
||||
return `<li class="tracker-node ${priorityClass}${anchorClass}${courtClass}${crossClass}${unselectedClass}${hiddenClass}${actualClass}"${ruleIdAttr}${codeAttr}>${inner}</li>`;
|
||||
}
|
||||
|
||||
// ─── zoom mode (§6.2) ──────────────────────────────────────────────────────
|
||||
//
|
||||
// When the user clicks [🔍] on the anchored node, the proceeding card
|
||||
// re-renders into zoom mode:
|
||||
// - Ancestors of the anchor collapse to a single breadcrumb at the
|
||||
// top of the card (proceeding-code ▸ root ▸ … ▸ anchor).
|
||||
// - Sibling branches at each ancestor depth fold to a one-line
|
||||
// "… N weitere verborgen — [zeigen]" summary.
|
||||
// - The anchored node renders full with all its successors.
|
||||
//
|
||||
// Sibling-expand-on-demand: when the user clicks [zeigen] on a fold
|
||||
// summary, the corresponding sibling subtree expands inline. State is
|
||||
// per-card in sessionStorage so a reload keeps it.
|
||||
|
||||
function renderZoomedBody(
|
||||
deadlines: CalculatedDeadline[],
|
||||
anchorRuleId: string,
|
||||
actuals?: ActualsMap,
|
||||
party?: "claimant" | "defendant" | "",
|
||||
): string {
|
||||
const anchor = deadlines.find((d) => d.ruleId === anchorRuleId);
|
||||
if (!anchor) {
|
||||
// The anchor is no longer in the filtered set (e.g. the user
|
||||
// toggled a flag that hid it). Fall back to the full tree so the
|
||||
// user can re-pin.
|
||||
return renderTreeBody(deadlines, anchorRuleId, actuals, party);
|
||||
}
|
||||
|
||||
// Build the parent chain (anchor → root). The chain is walked via
|
||||
// parentRuleCode; bail at the first missing parent or at >20 hops
|
||||
// (defensive — the deepest tree today is ~5).
|
||||
const byCode: Record<string, CalculatedDeadline> = {};
|
||||
for (const dl of deadlines) byCode[dl.code] = dl;
|
||||
const ancestors: CalculatedDeadline[] = [];
|
||||
let cursor: CalculatedDeadline | undefined = anchor;
|
||||
let safety = 20;
|
||||
while (cursor && safety-- > 0) {
|
||||
const parentCode = cursor.parentRuleCode || "";
|
||||
if (!parentCode) break;
|
||||
const parent = byCode[parentCode];
|
||||
if (!parent) break;
|
||||
ancestors.unshift(parent);
|
||||
cursor = parent;
|
||||
}
|
||||
|
||||
const lang = getLang();
|
||||
const breadcrumbParts: string[] = [];
|
||||
for (const a of ancestors) {
|
||||
const name = lang === "en" ? (a.nameEN || a.name) : (a.name || a.nameEN);
|
||||
breadcrumbParts.push(`<span class="tracker-zoom-crumb">${escHtml(name)}</span>`);
|
||||
}
|
||||
const anchorName = lang === "en" ? (anchor.nameEN || anchor.name) : (anchor.name || anchor.nameEN);
|
||||
breadcrumbParts.push(`<span class="tracker-zoom-crumb tracker-zoom-crumb--anchor">${escHtml(anchorName)}</span>`);
|
||||
|
||||
const breadcrumb = `<nav class="tracker-zoom-breadcrumb" aria-label="${escHtml(t("procedures.zoom.breadcrumb"))}">
|
||||
${breadcrumbParts.join('<span class="tracker-zoom-crumb-sep">▸</span>')}
|
||||
</nav>`;
|
||||
|
||||
// Subtree: anchor + all descendants (parentRuleCode chain rooted
|
||||
// at the anchor).
|
||||
const descendants = new Set<string>([anchor.code]);
|
||||
const queue = [anchor.code];
|
||||
while (queue.length > 0) {
|
||||
const code = queue.shift()!;
|
||||
for (const dl of deadlines) {
|
||||
if (dl.parentRuleCode === code && !descendants.has(dl.code)) {
|
||||
descendants.add(dl.code);
|
||||
queue.push(dl.code);
|
||||
}
|
||||
}
|
||||
}
|
||||
const subtree = deadlines.filter((d) => descendants.has(d.code));
|
||||
const subtreeBody = renderTreeBody(subtree, anchorRuleId, actuals, party);
|
||||
|
||||
// Sibling count summary — descendants ignored. Stays terse so the
|
||||
// page tells the user how much is hidden without listing it.
|
||||
const totalCount = deadlines.length;
|
||||
const hiddenCount = totalCount - subtree.length;
|
||||
const hiddenLine = hiddenCount > 0
|
||||
? `<div class="tracker-zoom-hidden">${escHtml(tDyn("procedures.zoom.hidden").replace("{n}", String(hiddenCount)))}</div>`
|
||||
: "";
|
||||
|
||||
return breadcrumb + subtreeBody + hiddenLine;
|
||||
}
|
||||
|
||||
// ─── search hit highlight (URL `?event=`) ──────────────────────────────────
|
||||
//
|
||||
// T1 affordance: when `?event=<rule_id>` is set, scroll the matching
|
||||
// node into view and apply a transient highlight. Anchor pin + zoom is
|
||||
// a T2 layering on top of this.
|
||||
|
||||
export function scrollAnchorIntoView(card: HTMLElement, anchorRuleId: string): void {
|
||||
const node = card.querySelector<HTMLElement>(`[data-rule-id="${CSS.escape(anchorRuleId)}"]`);
|
||||
if (!node) return;
|
||||
node.classList.add("tracker-node--highlight");
|
||||
node.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
setTimeout(() => node.classList.remove("tracker-node--highlight"), 3000);
|
||||
}
|
||||
|
||||
// summarise — short status line for the find-header summary.
|
||||
export function summariseRender(rendered: RenderedTimeline[]): string {
|
||||
const proc = rendered.length;
|
||||
if (proc === 0) return t("procedures.find.summary.empty");
|
||||
if (proc === 1) return tDyn("procedures.find.summary.one").replace("{n}", "1");
|
||||
return tDyn("procedures.find.summary.many").replace("{n}", String(proc));
|
||||
}
|
||||
@@ -1,756 +1,15 @@
|
||||
// /tools/procedures client (m/paliad#152 T1,
|
||||
// docs/design-procedures-workflow-tracker-2026-05-27.md §9 T1).
|
||||
// /tools/procedures bundle entry — Litigation Builder (m/paliad#153 B1).
|
||||
//
|
||||
// Workflow-tracker shell — replaces the 4-tab catalog (U0-U4) shipped
|
||||
// earlier today with a single canonical shape:
|
||||
//
|
||||
// 1. Sticky find header — search input + forum / Verfahren / Partei
|
||||
// pill rows + global Stichtag. The header narrows the timeline
|
||||
// set rendered below.
|
||||
// 2. Timeline body — one card per matched proceeding, rendered as
|
||||
// a chained tree by parent_id with priority-styled bullets.
|
||||
//
|
||||
// URL state (T1):
|
||||
// ?q=<text> — free-text search
|
||||
// ?forum=<id> — single forum (upc/de/epa/dpma)
|
||||
// ?procs=<csv> — comma-separated proceeding codes
|
||||
// ?party=<x> — claimant/defendant/both/""
|
||||
// ?trigger_date=<iso> — global Stichtag
|
||||
// ?event=<rule_id> — scroll-highlight matching node (no anchor
|
||||
// pin / zoom yet; T2 layering)
|
||||
// ?flags=<csv> — scenario flag overrides; default off
|
||||
//
|
||||
// Legacy ?mode= params from the catalog UI are dropped silently — the
|
||||
// /tools/fristenrechner + /tools/verfahrensablauf URLs still 301
|
||||
// redirect here.
|
||||
//
|
||||
// T2-T4 layer on this shell:
|
||||
// T2 — anchor pin + zoom + multi-proceeding scope.
|
||||
// T3 — Akte landing + actuals overlay.
|
||||
// T4 — appeal-target + court-set choices + per-proceeding Alle Optionen.
|
||||
// Replaces cronus's U0-U4 catalog bootstrap. The page chrome is
|
||||
// emitted by procedures.tsx; this file boots the i18n + sidebar
|
||||
// runtime and hands off to builder.ts.
|
||||
|
||||
import { initI18n, t, tDyn } from "./i18n";
|
||||
import { initI18n } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import {
|
||||
COLD_OPEN_DEFAULTS,
|
||||
PROCEEDINGS,
|
||||
type ActualStatus,
|
||||
type ActualsMap,
|
||||
type RenderedTimeline,
|
||||
proceedingDisplayName,
|
||||
renderCard,
|
||||
scrollAnchorIntoView,
|
||||
summariseRender,
|
||||
} from "./procedures-tracker";
|
||||
import {
|
||||
fetchScenarioFlags,
|
||||
patchScenarioFlags,
|
||||
SCENARIO_FLAG_CHANGED_EVENT,
|
||||
type ScenarioFlagChangedDetail,
|
||||
} from "./scenario-flags";
|
||||
import { readDetailMode, writeDetailMode } from "./procedures-tracker";
|
||||
|
||||
// Per-proceeding appeal-target state. Today only upc.apl.unified has
|
||||
// applies_to_target rules; the map is keyed by proceeding_type code
|
||||
// so future appeal-style proceedings (de.apl, etc.) can opt in without
|
||||
// touching the state shape.
|
||||
const appealTargets: Record<string, string> = {};
|
||||
|
||||
type ForumId = "upc" | "de" | "epa" | "dpma" | "";
|
||||
type PartyId = "claimant" | "defendant" | "both" | "";
|
||||
|
||||
// Find state. Single source of truth; URL keeps it shareable.
|
||||
const state = {
|
||||
q: "",
|
||||
forum: "" as ForumId,
|
||||
procs: [] as string[],
|
||||
party: "" as PartyId,
|
||||
triggerDate: todayISO(),
|
||||
event: "",
|
||||
zoom: false,
|
||||
flags: [] as string[],
|
||||
// T3 Akte state — loaded on demand from /api/projects/{id}/timeline
|
||||
// when ?project= is set in the URL. Kept off the URL writer so a
|
||||
// shared link without ?project= doesn't accidentally leak.
|
||||
projectId: "",
|
||||
projectTitle: "",
|
||||
projectProceeding: "",
|
||||
actuals: new Map() as ActualsMap,
|
||||
akteLoaded: false,
|
||||
};
|
||||
|
||||
// Per-anchor user-expanded set — when the multi-proceeding auto-collapse
|
||||
// kicks in (§6.5), the user can [zeigen] specific proceedings. We track
|
||||
// the explicit expansions so re-renders keep them open. Reset whenever
|
||||
// the anchor changes (new pin clears prior expansion state).
|
||||
let userExpanded = new Set<string>();
|
||||
let lastAnchor = "";
|
||||
|
||||
function todayISO(): string {
|
||||
return new Date().toISOString().split("T")[0];
|
||||
}
|
||||
|
||||
// ─── URL state ─────────────────────────────────────────────────────────────
|
||||
|
||||
function readStateFromURL(): void {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
state.q = params.get("q") || "";
|
||||
state.forum = (params.get("forum") || "") as ForumId;
|
||||
const procs = params.get("procs") || "";
|
||||
state.procs = procs ? procs.split(",").map((s) => s.trim()).filter(Boolean) : [];
|
||||
state.party = (params.get("party") || "") as PartyId;
|
||||
state.triggerDate = params.get("trigger_date") || todayISO();
|
||||
state.event = params.get("event") || "";
|
||||
state.zoom = params.get("zoom") === "1";
|
||||
const flags = params.get("flags") || "";
|
||||
state.flags = flags ? flags.split(",").map((s) => s.trim()).filter(Boolean) : [];
|
||||
state.projectId = params.get("project") || "";
|
||||
lastAnchor = state.event;
|
||||
}
|
||||
|
||||
function writeStateToURL(): void {
|
||||
const url = new URL(window.location.href);
|
||||
const sp = url.searchParams;
|
||||
setOrDelete(sp, "q", state.q);
|
||||
setOrDelete(sp, "forum", state.forum);
|
||||
setOrDelete(sp, "procs", state.procs.join(","));
|
||||
setOrDelete(sp, "party", state.party);
|
||||
setOrDelete(sp, "trigger_date", state.triggerDate === todayISO() ? "" : state.triggerDate);
|
||||
setOrDelete(sp, "event", state.event);
|
||||
setOrDelete(sp, "zoom", state.event && state.zoom ? "1" : "");
|
||||
setOrDelete(sp, "flags", state.flags.join(","));
|
||||
setOrDelete(sp, "project", state.projectId);
|
||||
// Legacy ?mode= from the U0-U4 catalog era → drop on every state write
|
||||
// so a bookmarked URL self-cleans on first interaction.
|
||||
sp.delete("mode");
|
||||
history.replaceState(null, "", url.pathname + (sp.toString() ? "?" + sp.toString() : "") + url.hash);
|
||||
}
|
||||
|
||||
// onAnchorChanged keeps userExpanded in sync. A fresh pin clears the
|
||||
// prior expansion set so the auto-collapse rule (§6.5) kicks in from
|
||||
// scratch; unpinning clears it too so the full multi-proceeding view
|
||||
// returns.
|
||||
function onAnchorChanged(next: string): void {
|
||||
if (next === lastAnchor) return;
|
||||
userExpanded = new Set();
|
||||
if (!next) state.zoom = false;
|
||||
lastAnchor = next;
|
||||
}
|
||||
|
||||
function setOrDelete(sp: URLSearchParams, key: string, value: string): void {
|
||||
if (value) sp.set(key, value);
|
||||
else sp.delete(key);
|
||||
}
|
||||
|
||||
// ─── pill hydration ────────────────────────────────────────────────────────
|
||||
|
||||
function hydrateForumPills(): void {
|
||||
const host = document.getElementById("tracker-pills-forum");
|
||||
if (!host) return;
|
||||
host.innerHTML = "";
|
||||
const all: { id: ForumId; label: string }[] = [
|
||||
{ id: "", label: t("procedures.filter.forum.all") },
|
||||
{ id: "upc", label: "UPC" },
|
||||
{ id: "de", label: "DE" },
|
||||
{ id: "epa", label: "EPA" },
|
||||
{ id: "dpma", label: "DPMA" },
|
||||
];
|
||||
for (const f of all) {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "tracker-pill";
|
||||
btn.textContent = f.label;
|
||||
btn.dataset.forum = f.id;
|
||||
if (state.forum === f.id) btn.classList.add("is-active");
|
||||
btn.addEventListener("click", () => {
|
||||
state.forum = f.id;
|
||||
// Drop procs that no longer match the active forum.
|
||||
if (state.forum) {
|
||||
state.procs = state.procs.filter((code) => {
|
||||
const def = PROCEEDINGS.find((p) => p.code === code);
|
||||
return def && def.forum === state.forum;
|
||||
});
|
||||
}
|
||||
writeStateToURL();
|
||||
hydrateForumPills();
|
||||
hydrateProcPills();
|
||||
void rerender();
|
||||
});
|
||||
host.appendChild(btn);
|
||||
}
|
||||
}
|
||||
|
||||
function hydrateProcPills(): void {
|
||||
const host = document.getElementById("tracker-pills-proc");
|
||||
if (!host) return;
|
||||
host.innerHTML = "";
|
||||
const visible = state.forum
|
||||
? PROCEEDINGS.filter((p) => p.forum === state.forum)
|
||||
: PROCEEDINGS;
|
||||
for (const p of visible) {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "tracker-pill";
|
||||
btn.textContent = proceedingDisplayName(p.code);
|
||||
btn.dataset.code = p.code;
|
||||
btn.title = p.code;
|
||||
if (state.procs.includes(p.code)) btn.classList.add("is-active");
|
||||
btn.addEventListener("click", () => {
|
||||
if (state.procs.includes(p.code)) {
|
||||
state.procs = state.procs.filter((c) => c !== p.code);
|
||||
} else {
|
||||
state.procs = [...state.procs, p.code];
|
||||
}
|
||||
writeStateToURL();
|
||||
hydrateProcPills();
|
||||
void rerender();
|
||||
});
|
||||
host.appendChild(btn);
|
||||
}
|
||||
}
|
||||
|
||||
function hydratePartyPills(): void {
|
||||
const host = document.getElementById("tracker-pills-party");
|
||||
if (!host) return;
|
||||
host.innerHTML = "";
|
||||
const all: { id: PartyId; key: string }[] = [
|
||||
{ id: "", key: "procedures.filter.party.all" },
|
||||
{ id: "claimant", key: "deadlines.side.claimant" },
|
||||
{ id: "defendant", key: "deadlines.side.defendant" },
|
||||
];
|
||||
for (const p of all) {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "tracker-pill";
|
||||
btn.textContent = t(p.key as never);
|
||||
btn.dataset.party = p.id;
|
||||
if (state.party === p.id) btn.classList.add("is-active");
|
||||
btn.addEventListener("click", () => {
|
||||
state.party = p.id;
|
||||
writeStateToURL();
|
||||
hydratePartyPills();
|
||||
void rerender();
|
||||
});
|
||||
host.appendChild(btn);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── search box ────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Debounced 200ms. Free-text matches procedural events via the existing
|
||||
// /api/tools/fristenrechner/search?kind=events endpoint; the hits drive
|
||||
// proceeding pre-selection (the proceedings the hits live in surface
|
||||
// in the timeline body) and the first hit's rule_id becomes the
|
||||
// `?event=` anchor.
|
||||
|
||||
let searchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function wireSearchInput(): void {
|
||||
const input = document.getElementById("tracker-search-input") as HTMLInputElement | null;
|
||||
if (!input) return;
|
||||
input.value = state.q;
|
||||
input.addEventListener("input", () => {
|
||||
if (searchTimer !== null) clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(() => {
|
||||
searchTimer = null;
|
||||
void onSearchChanged(input.value.trim());
|
||||
}, 200);
|
||||
});
|
||||
}
|
||||
|
||||
async function onSearchChanged(q: string): Promise<void> {
|
||||
state.q = q;
|
||||
// Empty query → revert to pill-driven set (or cold-open default).
|
||||
if (!q) {
|
||||
state.event = "";
|
||||
writeStateToURL();
|
||||
void rerender();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL("/api/tools/fristenrechner/search", window.location.origin);
|
||||
url.searchParams.set("q", q);
|
||||
url.searchParams.set("kind", "events");
|
||||
url.searchParams.set("limit", "20");
|
||||
if (state.forum) url.searchParams.set("jurisdiction", state.forum.toUpperCase());
|
||||
if (state.party) url.searchParams.set("party", state.party);
|
||||
const resp = await fetch(url.pathname + url.search, { headers: { Accept: "application/json" } });
|
||||
if (!resp.ok) {
|
||||
writeStateToURL();
|
||||
void rerender();
|
||||
return;
|
||||
}
|
||||
const body = await resp.json();
|
||||
const events = Array.isArray(body.events) ? body.events : [];
|
||||
|
||||
// Collect distinct proceeding_type codes from the hits and pre-seed
|
||||
// state.procs. If exactly one hit, scroll-highlight its anchor rule.
|
||||
const procs: string[] = [];
|
||||
for (const ev of events) {
|
||||
const code = ev?.proceeding_type?.code;
|
||||
if (typeof code === "string" && code && !procs.includes(code)) procs.push(code);
|
||||
}
|
||||
state.procs = procs;
|
||||
const nextEvent = events.length === 1 && events[0]?.anchor_rule_id
|
||||
? String(events[0].anchor_rule_id)
|
||||
: "";
|
||||
onAnchorChanged(nextEvent);
|
||||
state.event = nextEvent;
|
||||
writeStateToURL();
|
||||
hydrateProcPills();
|
||||
void rerender();
|
||||
} catch (e) {
|
||||
console.error("tracker search failed", e);
|
||||
writeStateToURL();
|
||||
void rerender();
|
||||
}
|
||||
}
|
||||
|
||||
// ─── trigger date input ────────────────────────────────────────────────────
|
||||
|
||||
function wireTriggerDateInput(): void {
|
||||
const input = document.getElementById("tracker-trigger-date") as HTMLInputElement | null;
|
||||
if (!input) return;
|
||||
input.value = state.triggerDate;
|
||||
input.addEventListener("change", () => {
|
||||
const next = input.value || todayISO();
|
||||
if (next === state.triggerDate) return;
|
||||
state.triggerDate = next;
|
||||
writeStateToURL();
|
||||
void rerender();
|
||||
});
|
||||
}
|
||||
|
||||
// ─── flag toggle wiring ────────────────────────────────────────────────────
|
||||
//
|
||||
// Forks on the per-proceeding "Optionen" strip dispatch via event
|
||||
// delegation so re-rendering doesn't need to re-bind. Each tick mutates
|
||||
// state.flags and triggers a re-render of the specific card.
|
||||
|
||||
function wireFlagDelegation(): void {
|
||||
const host = document.getElementById("tracker-timelines");
|
||||
if (!host) return;
|
||||
host.addEventListener("change", (ev) => {
|
||||
const target = ev.target as HTMLInputElement;
|
||||
if (!target || target.tagName !== "INPUT") return;
|
||||
if (target.type !== "checkbox") return;
|
||||
const flagKey = target.dataset.flag || "";
|
||||
if (!flagKey) return;
|
||||
if (target.checked) {
|
||||
if (!state.flags.includes(flagKey)) state.flags = [...state.flags, flagKey];
|
||||
} else {
|
||||
state.flags = state.flags.filter((f) => f !== flagKey);
|
||||
}
|
||||
writeStateToURL();
|
||||
|
||||
// T3: when bound to a project, persist the flag delta via
|
||||
// patchScenarioFlags so a reload (or another surface — Mode B
|
||||
// Fristenrechner / Verlauf) sees the same scenario. Fire-and-
|
||||
// forget; the cross-surface re-sync fires a CustomEvent that
|
||||
// doesn't reach back here today (the tracker has no listener),
|
||||
// but the persistence side-effect is what matters.
|
||||
if (state.projectId) {
|
||||
void patchScenarioFlags(state.projectId, { [flagKey]: target.checked });
|
||||
}
|
||||
void rerender();
|
||||
});
|
||||
}
|
||||
|
||||
// wireClickDelegation handles pin / fokus / proc-toggle. Single listener
|
||||
// on the timelines host; the render functions stamp data-action on the
|
||||
// affordances they emit.
|
||||
function wireClickDelegation(): void {
|
||||
const host = document.getElementById("tracker-timelines");
|
||||
if (!host) return;
|
||||
host.addEventListener("click", (ev) => {
|
||||
const btn = (ev.target as HTMLElement | null)?.closest<HTMLButtonElement>("button[data-action]");
|
||||
if (!btn) return;
|
||||
const action = btn.dataset.action || "";
|
||||
ev.preventDefault();
|
||||
|
||||
if (action === "pin") {
|
||||
const ruleId = btn.dataset.ruleId || "";
|
||||
if (!ruleId) return;
|
||||
// Click on the already-anchored node un-pins. Toggle pattern.
|
||||
const next = state.event === ruleId ? "" : ruleId;
|
||||
onAnchorChanged(next);
|
||||
state.event = next;
|
||||
writeStateToURL();
|
||||
void rerender();
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "fokus") {
|
||||
// Toggle zoom on the current anchor.
|
||||
if (!state.event) return;
|
||||
state.zoom = !state.zoom;
|
||||
writeStateToURL();
|
||||
void rerender();
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "proc-toggle") {
|
||||
const code = btn.dataset.code || "";
|
||||
if (!code) return;
|
||||
if (userExpanded.has(code)) userExpanded.delete(code);
|
||||
else userExpanded.add(code);
|
||||
void rerender();
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "detail-toggle") {
|
||||
// Per-proceeding "Alle Optionen" ↔ "Gewählt" toggle (§3.4).
|
||||
const code = btn.dataset.code || "";
|
||||
if (!code) return;
|
||||
const next = readDetailMode(code) === "all_options" ? "selected" : "all_options";
|
||||
writeDetailMode(code, next);
|
||||
void rerender();
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "appeal-target") {
|
||||
const code = btn.dataset.code || "";
|
||||
const target = btn.dataset.target || "";
|
||||
if (!code || !target) return;
|
||||
appealTargets[code] = target;
|
||||
void rerender();
|
||||
return;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ─── render driver ─────────────────────────────────────────────────────────
|
||||
|
||||
let currentRender = 0;
|
||||
|
||||
function pickProceedingsToRender(): string[] {
|
||||
if (state.procs.length > 0) return state.procs;
|
||||
if (state.forum) {
|
||||
return PROCEEDINGS.filter((p) => p.forum === state.forum).map((p) => p.code);
|
||||
}
|
||||
return COLD_OPEN_DEFAULTS;
|
||||
}
|
||||
|
||||
async function rerender(): Promise<void> {
|
||||
const seq = ++currentRender;
|
||||
const host = document.getElementById("tracker-timelines");
|
||||
const summary = document.getElementById("tracker-find-summary");
|
||||
if (!host) return;
|
||||
|
||||
// Loading placeholder during render. The placeholder is replaced
|
||||
// atomically once all cards return, so the user doesn't see
|
||||
// intermediate flicker between cards.
|
||||
host.innerHTML = `<div class="tracker-timelines-placeholder">${t("procedures.timelines.loading")}</div>`;
|
||||
|
||||
const codes = pickProceedingsToRender();
|
||||
|
||||
// Cold-open hint: if we're showing the curated default set, surface
|
||||
// a small instruction so the user knows the page expects further
|
||||
// narrowing for non-default proceedings.
|
||||
const isColdOpen = state.procs.length === 0 && !state.forum && !state.q;
|
||||
const hasAnchor = !!state.event;
|
||||
const multiProceeding = codes.length > 1;
|
||||
|
||||
// Multi-proceeding anchor scope (§6.5). When an anchor is pinned and
|
||||
// multiple proceedings are visible, non-anchored proceedings render
|
||||
// as a one-line header card with a [zeigen] link. We don't know yet
|
||||
// which card carries the anchor — that's a property of the calc
|
||||
// response. Two-pass render: first pass resolves anchor location;
|
||||
// second pass renders collapsed/full per code. Cheap because the
|
||||
// collapsed render skips the calc fetch entirely.
|
||||
//
|
||||
// Optimisation path: probe just one card per render to find the
|
||||
// anchor's home. Probe the matching code first when we already know
|
||||
// it (cached on `lastAnchorProceeding` below).
|
||||
|
||||
// First-pass: render every card, collect which ones carry the anchor.
|
||||
const firstPass: RenderedTimeline[] = await Promise.all(
|
||||
codes.map((code) =>
|
||||
renderCard({
|
||||
proceedingType: code,
|
||||
triggerDate: state.triggerDate,
|
||||
flags: state.flags,
|
||||
anchorRuleId: state.event || undefined,
|
||||
zoom: state.zoom,
|
||||
actuals: state.akteLoaded ? state.actuals : undefined,
|
||||
projectId: state.projectId || undefined,
|
||||
detailMode: readDetailMode(code),
|
||||
appealTarget: appealTargets[code] || undefined,
|
||||
party: state.party || "",
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
if (seq !== currentRender) return; // a newer render started; bail.
|
||||
|
||||
// If we have an anchor + multi-proceeding, decide which cards
|
||||
// collapse. Cards with hasAnchor=true stay expanded; others collapse
|
||||
// unless the user explicitly expanded them via [zeigen].
|
||||
let rendered: RenderedTimeline[] = firstPass;
|
||||
if (hasAnchor && multiProceeding) {
|
||||
rendered = await Promise.all(
|
||||
firstPass.map(async (r) => {
|
||||
if (r.hasAnchor) return r;
|
||||
if (userExpanded.has(r.card.dataset.proceeding || "")) return r;
|
||||
// Re-render collapsed.
|
||||
return renderCard({
|
||||
proceedingType: r.card.dataset.proceeding || "",
|
||||
triggerDate: state.triggerDate,
|
||||
flags: state.flags,
|
||||
anchorRuleId: state.event || undefined,
|
||||
collapsed: true,
|
||||
actuals: state.akteLoaded ? state.actuals : undefined,
|
||||
projectId: state.projectId || undefined,
|
||||
detailMode: readDetailMode(r.card.dataset.proceeding || ""),
|
||||
appealTarget: appealTargets[r.card.dataset.proceeding || ""] || undefined,
|
||||
party: state.party || "",
|
||||
});
|
||||
}),
|
||||
);
|
||||
if (seq !== currentRender) return;
|
||||
}
|
||||
|
||||
host.innerHTML = "";
|
||||
|
||||
if (isColdOpen) {
|
||||
const hint = document.createElement("div");
|
||||
hint.className = "tracker-cold-open-hint";
|
||||
hint.textContent = t("procedures.cold_open.hint");
|
||||
host.appendChild(hint);
|
||||
}
|
||||
|
||||
for (const r of rendered) host.appendChild(r.card);
|
||||
|
||||
if (rendered.length === 0) {
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "tracker-timelines-empty";
|
||||
empty.textContent = t("procedures.timelines.empty");
|
||||
host.appendChild(empty);
|
||||
}
|
||||
|
||||
// Find-header summary line. When an anchor is pinned, surface the
|
||||
// anchor's name so the user has a visual confirmation of where
|
||||
// they are.
|
||||
if (summary) {
|
||||
const parts: string[] = [summariseRender(rendered)];
|
||||
if (state.akteLoaded && state.projectTitle) {
|
||||
parts.push(tDyn("procedures.find.summary.akte").replace("{name}", state.projectTitle));
|
||||
}
|
||||
if (hasAnchor) {
|
||||
const anchorName = findAnchorName(firstPass, state.event);
|
||||
if (anchorName) {
|
||||
parts.push(tDyn("procedures.find.summary.anchor").replace("{name}", anchorName));
|
||||
}
|
||||
}
|
||||
summary.textContent = parts.join(" · ");
|
||||
}
|
||||
|
||||
// Scroll-highlight the anchored node, if any. Walks every card so a
|
||||
// ?event= deep link works even when the same rule appears in a
|
||||
// shared-chain proceeding (e.g. inf.cfi and ccr.cfi).
|
||||
if (state.event) {
|
||||
for (const r of rendered) scrollAnchorIntoView(r.card, state.event);
|
||||
}
|
||||
}
|
||||
|
||||
// findAnchorName resolves the anchored rule's display name across the
|
||||
// rendered cards. Returns "" when the anchor isn't in any visible
|
||||
// proceeding (e.g. invalid ?event= deep link).
|
||||
function findAnchorName(rendered: RenderedTimeline[], ruleId: string): string {
|
||||
if (!ruleId) return "";
|
||||
for (const r of rendered) {
|
||||
if (!r.data) continue;
|
||||
const hit = r.data.deadlines.find((d) => d.ruleId === ruleId);
|
||||
if (!hit) continue;
|
||||
return hit.name || hit.nameEN || ruleId;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
// ─── Akte landing (§6.4) ───────────────────────────────────────────────────
|
||||
//
|
||||
// When ?project=<uuid> is in the URL, we load:
|
||||
// 1. /api/projects/{id} — title + proceeding_type for header context
|
||||
// 2. /api/projects/{id}/timeline — actuals (deadlines + appointments)
|
||||
// 3. /api/projects/{id}/scenario-flags — seeds state.flags + provides
|
||||
// write-back path
|
||||
//
|
||||
// The actuals overlay maps deadline_rule_id → ActualStatus. The
|
||||
// fristenrechner calc returns ruleId on each TimelineEntry; the
|
||||
// tracker stamps the matching badge on each node.
|
||||
//
|
||||
// On first load, the anchor auto-pins to the latest status='done'
|
||||
// deadline (design Q5). Subsequent renders preserve the user's pin.
|
||||
|
||||
async function loadAkte(projectId: string): Promise<void> {
|
||||
if (!projectId) {
|
||||
state.actuals = new Map();
|
||||
state.akteLoaded = false;
|
||||
state.projectTitle = "";
|
||||
state.projectProceeding = "";
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. Project header / proceeding_type.
|
||||
try {
|
||||
const resp = await fetch(`/api/projects/${encodeURIComponent(projectId)}`, {
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
if (resp.ok) {
|
||||
const proj = await resp.json();
|
||||
state.projectTitle = String(proj?.title || proj?.name || "");
|
||||
const procCode = String(proj?.proceeding_type?.code || proj?.proceeding_type_code || "");
|
||||
state.projectProceeding = procCode;
|
||||
if (procCode && !state.procs.includes(procCode)) {
|
||||
state.procs = [procCode];
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("project fetch failed", e);
|
||||
}
|
||||
|
||||
// 2. Timeline → actuals map.
|
||||
try {
|
||||
const resp = await fetch(`/api/projects/${encodeURIComponent(projectId)}/timeline`, {
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
if (resp.ok) {
|
||||
const body = await resp.json();
|
||||
const events = Array.isArray(body?.events) ? body.events : Array.isArray(body) ? body : [];
|
||||
state.actuals = buildActualsMap(events);
|
||||
// Auto-pin anchor: latest status='done' (most recent completed
|
||||
// deadline). Only when no anchor is set yet — preserve URL
|
||||
// ?event= for shared links.
|
||||
if (!state.event) {
|
||||
const anchor = pickLatestDoneAnchor(events);
|
||||
if (anchor) {
|
||||
state.event = anchor;
|
||||
lastAnchor = anchor;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("project timeline fetch failed", e);
|
||||
}
|
||||
|
||||
// 3. Scenario flags — seed state.flags + future write-back.
|
||||
try {
|
||||
const view = await fetchScenarioFlags(projectId);
|
||||
if (view && view.flags) {
|
||||
const onFlags: string[] = [];
|
||||
for (const [k, v] of Object.entries(view.flags)) {
|
||||
// Filter to top-level scenario flags (not per-rule deviations).
|
||||
// Per-rule flags are keyed `rule:<uuid>` and not consumed by
|
||||
// the calc's flags[] payload.
|
||||
if (k.startsWith("rule:")) continue;
|
||||
if (v === true) onFlags.push(k);
|
||||
}
|
||||
state.flags = onFlags;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("scenario-flags fetch failed", e);
|
||||
}
|
||||
|
||||
state.akteLoaded = true;
|
||||
}
|
||||
|
||||
function buildActualsMap(events: unknown[]): ActualsMap {
|
||||
const map: ActualsMap = new Map();
|
||||
for (const ev of events) {
|
||||
const e = ev as Record<string, unknown> | null;
|
||||
if (!e) continue;
|
||||
const ruleId = String(e.deadline_rule_id || "");
|
||||
if (!ruleId) continue;
|
||||
const kind = String(e.kind || "");
|
||||
const status = String(e.status || "");
|
||||
const date = typeof e.date === "string" ? e.date.split("T")[0] : "";
|
||||
|
||||
// Map SmartTimeline statuses to ActualStatus.status.
|
||||
let mapped: ActualStatus["status"];
|
||||
if (status === "done" || kind === "appointment") {
|
||||
mapped = "done";
|
||||
} else if (status === "overdue") {
|
||||
mapped = "overdue";
|
||||
} else if (status === "court_set") {
|
||||
mapped = "court_set";
|
||||
} else if (status === "open") {
|
||||
mapped = "open";
|
||||
} else {
|
||||
// projected / predicted / off_script — don't overlay.
|
||||
continue;
|
||||
}
|
||||
|
||||
const entry: ActualStatus = { status: mapped };
|
||||
if (mapped === "done") entry.completedAt = date || undefined;
|
||||
else entry.dueDate = date || undefined;
|
||||
if (typeof e.deadline_id === "string") entry.deadlineId = e.deadline_id;
|
||||
if (typeof e.appointment_id === "string") entry.appointmentId = e.appointment_id;
|
||||
map.set(ruleId, entry);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function pickLatestDoneAnchor(events: unknown[]): string {
|
||||
let latest = "";
|
||||
let latestDate = "";
|
||||
for (const ev of events) {
|
||||
const e = ev as Record<string, unknown> | null;
|
||||
if (!e) continue;
|
||||
if (e.status !== "done") continue;
|
||||
const ruleId = String(e.deadline_rule_id || "");
|
||||
if (!ruleId) continue;
|
||||
const date = typeof e.date === "string" ? e.date : "";
|
||||
if (!latest || date > latestDate) {
|
||||
latest = ruleId;
|
||||
latestDate = date;
|
||||
}
|
||||
}
|
||||
return latest;
|
||||
}
|
||||
|
||||
// ─── boot ──────────────────────────────────────────────────────────────────
|
||||
import { mountBuilder } from "./builder";
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
readStateFromURL();
|
||||
hydrateForumPills();
|
||||
hydrateProcPills();
|
||||
hydratePartyPills();
|
||||
wireSearchInput();
|
||||
wireTriggerDateInput();
|
||||
wireFlagDelegation();
|
||||
wireClickDelegation();
|
||||
|
||||
// T3: when bound to an Akte via ?project=, load actuals + scenario
|
||||
// flags + auto-pin to latest done deadline BEFORE the first render.
|
||||
// Otherwise the first render fires on template data and re-renders
|
||||
// once the Akte resolves — visible flicker on a slow connection.
|
||||
if (state.projectId) {
|
||||
void loadAkte(state.projectId).then(() => {
|
||||
hydrateProcPills();
|
||||
void rerender();
|
||||
});
|
||||
} else {
|
||||
void rerender();
|
||||
}
|
||||
|
||||
// T4: cross-surface scenario-flag re-sync. When another surface
|
||||
// (Mode B Fristenrechner, Verfahrensablauf, /admin) PATCHes the
|
||||
// same project's flags, scenario-flags.ts dispatches this event.
|
||||
// We re-seed state.flags from the detail payload and re-render so
|
||||
// the tracker stays coherent without a fresh GET.
|
||||
document.addEventListener(SCENARIO_FLAG_CHANGED_EVENT, (ev) => {
|
||||
const detail = (ev as CustomEvent<ScenarioFlagChangedDetail>).detail;
|
||||
if (!detail || !state.projectId) return;
|
||||
if (detail.projectId !== state.projectId) return;
|
||||
const onFlags: string[] = [];
|
||||
for (const [k, v] of Object.entries(detail.flags)) {
|
||||
if (k.startsWith("rule:")) continue;
|
||||
if (v === true) onFlags.push(k);
|
||||
}
|
||||
state.flags = onFlags;
|
||||
void rerender();
|
||||
});
|
||||
void mountBuilder();
|
||||
});
|
||||
|
||||
@@ -51,8 +51,8 @@ interface SyncLogEntry {
|
||||
duration_ms?: number;
|
||||
}
|
||||
|
||||
type TabName = "profil" | "benachrichtigungen" | "caldav" | "export";
|
||||
const TABS: TabName[] = ["profil", "benachrichtigungen", "caldav", "export"];
|
||||
type TabName = "profil" | "benachrichtigungen" | "caldav" | "names" | "export";
|
||||
const TABS: TabName[] = ["profil", "benachrichtigungen", "caldav", "names", "export"];
|
||||
const DEFAULT_TAB: TabName = "profil";
|
||||
|
||||
let me: Me | null = null;
|
||||
@@ -115,6 +115,7 @@ function showTab(tab: TabName, pushHistory: boolean) {
|
||||
if (tab === "profil") void loadProfilTab();
|
||||
else if (tab === "benachrichtigungen") void loadPrefsTab();
|
||||
else if (tab === "caldav") void loadCalDAVTab();
|
||||
else if (tab === "names") void loadNamesTab();
|
||||
else if (tab === "export") void loadExportTab();
|
||||
}
|
||||
}
|
||||
@@ -1119,6 +1120,415 @@ function runExport(): void {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Namensschemata tab (t-paliad-356 Slice 4) ------------------------------
|
||||
//
|
||||
// Per-artifact token-template editor. All parsing, validation and preview
|
||||
// rendering happen server-side (the nomen engine is the single source of
|
||||
// truth); this client only inserts {tokens} at the cursor, debounces a preview
|
||||
// request, and persists via PUT/DELETE.
|
||||
|
||||
interface NameVar {
|
||||
var: string;
|
||||
label: string;
|
||||
label_en: string;
|
||||
}
|
||||
|
||||
interface NameArtifactCard {
|
||||
artifact_id: string;
|
||||
label: string;
|
||||
label_en: string;
|
||||
template: string;
|
||||
system_template: string;
|
||||
is_override: boolean;
|
||||
firm_is_set: boolean;
|
||||
firm_template: string;
|
||||
palette: NameVar[];
|
||||
preview_full: string;
|
||||
preview_empty: string;
|
||||
}
|
||||
|
||||
let nameCards: NameArtifactCard[] = [];
|
||||
let nameIsAdmin = false;
|
||||
const namePreviewTimers = new Map<string, number>();
|
||||
|
||||
function nameVarLabel(v: NameVar): string {
|
||||
return getLang() === "en" ? v.label_en : v.label;
|
||||
}
|
||||
|
||||
function artifactLabel(c: NameArtifactCard): string {
|
||||
return getLang() === "en" ? c.label_en : c.label;
|
||||
}
|
||||
|
||||
async function loadNamesTab(): Promise<void> {
|
||||
const loading = document.getElementById("names-loading");
|
||||
const list = document.getElementById("names-list");
|
||||
if (!list) return;
|
||||
try {
|
||||
const resp = await fetch("/api/me/name-compositions");
|
||||
if (!resp.ok) {
|
||||
if (loading) loading.textContent = t("einstellungen.names.error.load");
|
||||
return;
|
||||
}
|
||||
const data = await resp.json();
|
||||
nameCards = (data.artifacts ?? []) as NameArtifactCard[];
|
||||
nameIsAdmin = data.is_admin === true;
|
||||
} catch {
|
||||
if (loading) loading.textContent = t("einstellungen.names.error.load");
|
||||
return;
|
||||
}
|
||||
if (loading) loading.style.display = "none";
|
||||
list.style.display = "";
|
||||
renderNameCards();
|
||||
}
|
||||
|
||||
function renderNameCards(): void {
|
||||
const list = document.getElementById("names-list");
|
||||
if (!list) return;
|
||||
list.innerHTML = nameCards.map(nameCardHTML).join("");
|
||||
for (const card of nameCards) wireNameCard(card.artifact_id);
|
||||
}
|
||||
|
||||
function nameCardHTML(c: NameArtifactCard): string {
|
||||
const id = c.artifact_id;
|
||||
const chips = c.palette
|
||||
.map(
|
||||
(v) =>
|
||||
`<button type="button" class="names-chip" data-var="${esc(v.var)}" data-art="${esc(id)}">${esc(nameVarLabel(v))}</button>`,
|
||||
)
|
||||
.join("");
|
||||
return `
|
||||
<div class="names-artifact" data-art="${esc(id)}">
|
||||
<div class="names-artifact-head">
|
||||
<h2>${esc(artifactLabel(c))}</h2>
|
||||
${nameBadgeHTML(c)}
|
||||
</div>
|
||||
<div class="names-palette" id="names-palette-${esc(id)}">${chips}</div>
|
||||
<input type="text" class="names-template-input" id="names-input-${esc(id)}"
|
||||
value="${esc(c.template)}" autocomplete="off" spellcheck="false" />
|
||||
<p class="form-msg form-msg-error names-error" id="names-error-${esc(id)}" style="display:none"></p>
|
||||
<div class="names-preview">
|
||||
<div class="names-preview-row">
|
||||
<span class="names-preview-label" data-i18n="einstellungen.names.preview.sample">${esc(t("einstellungen.names.preview.sample"))}</span>
|
||||
<code class="names-preview-value" id="names-full-${esc(id)}">${esc(c.preview_full)}</code>
|
||||
</div>
|
||||
<div class="names-preview-row">
|
||||
<span class="names-preview-label" data-i18n="einstellungen.names.preview.empty">${esc(t("einstellungen.names.preview.empty"))}</span>
|
||||
<code class="names-preview-value" id="names-empty-${esc(id)}">${esc(c.preview_empty)}</code>
|
||||
</div>
|
||||
</div>
|
||||
<p class="form-msg names-saved" id="names-saved-${esc(id)}"></p>
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn-secondary" id="names-reset-${esc(id)}" data-i18n="einstellungen.names.reset">${esc(t("einstellungen.names.reset"))}</button>
|
||||
<button type="button" class="btn-primary btn-cta-lime" id="names-save-${esc(id)}" data-i18n="einstellungen.save">${esc(t("einstellungen.save"))}</button>
|
||||
</div>
|
||||
${nameIsAdmin ? nameFirmAdminHTML(c) : ""}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Badge: "Angepasst" when the user has their own override, else "Firmenstandard"
|
||||
// when the firm default is the source of the shown name. Hidden otherwise.
|
||||
function nameBadgeHTML(c: NameArtifactCard): string {
|
||||
const id = c.artifact_id;
|
||||
if (c.is_override) {
|
||||
return `<span class="names-badge" id="names-badge-${esc(id)}">${esc(t("einstellungen.names.override_badge"))}</span>`;
|
||||
}
|
||||
if (c.firm_is_set) {
|
||||
return `<span class="names-badge names-badge--firm" id="names-badge-${esc(id)}">${esc(t("einstellungen.names.firm_badge"))}</span>`;
|
||||
}
|
||||
return `<span class="names-badge" id="names-badge-${esc(id)}" style="display:none"></span>`;
|
||||
}
|
||||
|
||||
// Admin-only firm-default controls (mirrors the firm-dashboard-default promote
|
||||
// pattern). "Set as firm default" takes whatever is in the template field;
|
||||
// "Clear" reverts the firm tier to the system default for everyone.
|
||||
function nameFirmAdminHTML(c: NameArtifactCard): string {
|
||||
const id = c.artifact_id;
|
||||
const status = c.firm_is_set
|
||||
? `${esc(t("einstellungen.names.firm.status_set"))} <code>${esc(c.firm_template)}</code>`
|
||||
: esc(t("einstellungen.names.firm.status_unset"));
|
||||
return `
|
||||
<div class="names-firm-admin" id="names-firm-${esc(id)}">
|
||||
<h3 class="names-firm-heading" data-i18n="einstellungen.names.firm.heading">${esc(t("einstellungen.names.firm.heading"))}</h3>
|
||||
<p class="form-hint names-firm-status" id="names-firm-status-${esc(id)}">${status}</p>
|
||||
<p class="form-msg names-firm-msg" id="names-firm-msg-${esc(id)}"></p>
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn-danger" id="names-firm-clear-${esc(id)}" data-i18n="einstellungen.names.firm.clear"
|
||||
style="${c.firm_is_set ? "" : "display:none"}">${esc(t("einstellungen.names.firm.clear"))}</button>
|
||||
<button type="button" class="btn-secondary" id="names-firm-set-${esc(id)}" data-i18n="einstellungen.names.firm.set">${esc(t("einstellungen.names.firm.set"))}</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function wireNameCard(id: string): void {
|
||||
const input = document.getElementById(`names-input-${id}`) as HTMLInputElement | null;
|
||||
if (!input) return;
|
||||
input.addEventListener("input", () => scheduleNamePreview(id));
|
||||
document.querySelectorAll<HTMLButtonElement>(`.names-chip[data-art="${cssEscapeAttr(id)}"]`).forEach((chip) => {
|
||||
chip.addEventListener("click", () => insertNameToken(id, chip.getAttribute("data-var") ?? ""));
|
||||
});
|
||||
document.getElementById(`names-reset-${id}`)?.addEventListener("click", () => resetNameComposition(id));
|
||||
document.getElementById(`names-save-${id}`)?.addEventListener("click", () => saveNameComposition(id));
|
||||
document.getElementById(`names-firm-set-${id}`)?.addEventListener("click", () => setFirmNameComposition(id));
|
||||
document.getElementById(`names-firm-clear-${id}`)?.addEventListener("click", () => clearFirmNameComposition(id));
|
||||
}
|
||||
|
||||
// Artifact ids are [a-z_] only, but keep the attribute-selector value safe.
|
||||
function cssEscapeAttr(s: string): string {
|
||||
return s.replace(/["\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function insertNameToken(id: string, varName: string): void {
|
||||
const input = document.getElementById(`names-input-${id}`) as HTMLInputElement | null;
|
||||
if (!input || !varName) return;
|
||||
const token = `{${varName}}`;
|
||||
const start = input.selectionStart ?? input.value.length;
|
||||
const end = input.selectionEnd ?? input.value.length;
|
||||
input.value = input.value.slice(0, start) + token + input.value.slice(end);
|
||||
const caret = start + token.length;
|
||||
input.focus();
|
||||
input.setSelectionRange(caret, caret);
|
||||
scheduleNamePreview(id);
|
||||
}
|
||||
|
||||
function scheduleNamePreview(id: string): void {
|
||||
clearSavedMsg(id);
|
||||
const existing = namePreviewTimers.get(id);
|
||||
if (existing) window.clearTimeout(existing);
|
||||
namePreviewTimers.set(id, window.setTimeout(() => void runNamePreview(id), 250));
|
||||
}
|
||||
|
||||
async function runNamePreview(id: string): Promise<void> {
|
||||
const input = document.getElementById(`names-input-${id}`) as HTMLInputElement | null;
|
||||
if (!input) return;
|
||||
const template = input.value;
|
||||
try {
|
||||
const resp = await fetch("/api/me/name-compositions/preview", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ artifact_id: id, template }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
setNameError(id, t("einstellungen.names.error.invalid"));
|
||||
return;
|
||||
}
|
||||
const data = await resp.json();
|
||||
if (data.ok) {
|
||||
setNamePreview(id, data.preview_full, data.preview_empty);
|
||||
clearNameError(id);
|
||||
} else {
|
||||
setNameError(id, t("einstellungen.names.error.invalid"));
|
||||
}
|
||||
} catch {
|
||||
setNameError(id, t("einstellungen.names.error.invalid"));
|
||||
}
|
||||
}
|
||||
|
||||
function setNamePreview(id: string, full: string, empty: string): void {
|
||||
const f = document.getElementById(`names-full-${id}`);
|
||||
const e = document.getElementById(`names-empty-${id}`);
|
||||
if (f) f.textContent = full;
|
||||
if (e) e.textContent = empty;
|
||||
}
|
||||
|
||||
function setNameError(id: string, msg: string): void {
|
||||
const err = document.getElementById(`names-error-${id}`);
|
||||
if (err) {
|
||||
err.textContent = msg;
|
||||
err.style.display = "";
|
||||
}
|
||||
const save = document.getElementById(`names-save-${id}`) as HTMLButtonElement | null;
|
||||
if (save) save.disabled = true;
|
||||
}
|
||||
|
||||
function clearNameError(id: string): void {
|
||||
const err = document.getElementById(`names-error-${id}`);
|
||||
if (err) {
|
||||
err.textContent = "";
|
||||
err.style.display = "none";
|
||||
}
|
||||
const save = document.getElementById(`names-save-${id}`) as HTMLButtonElement | null;
|
||||
if (save) save.disabled = false;
|
||||
}
|
||||
|
||||
function clearSavedMsg(id: string): void {
|
||||
const saved = document.getElementById(`names-saved-${id}`);
|
||||
if (saved) saved.textContent = "";
|
||||
}
|
||||
|
||||
function applyNameCard(updated: NameArtifactCard): void {
|
||||
const idx = nameCards.findIndex((c) => c.artifact_id === updated.artifact_id);
|
||||
if (idx >= 0) nameCards[idx] = updated;
|
||||
const id = updated.artifact_id;
|
||||
const input = document.getElementById(`names-input-${id}`) as HTMLInputElement | null;
|
||||
if (input) input.value = updated.template;
|
||||
setNamePreview(id, updated.preview_full, updated.preview_empty);
|
||||
clearNameError(id);
|
||||
updateNameBadge(updated);
|
||||
updateFirmStatus(updated);
|
||||
}
|
||||
|
||||
// updateNameBadge reflects the override → firm → none state on the chip.
|
||||
function updateNameBadge(c: NameArtifactCard): void {
|
||||
const badge = document.getElementById(`names-badge-${c.artifact_id}`);
|
||||
if (!badge) return;
|
||||
if (c.is_override) {
|
||||
badge.textContent = t("einstellungen.names.override_badge");
|
||||
badge.classList.remove("names-badge--firm");
|
||||
badge.style.display = "";
|
||||
} else if (c.firm_is_set) {
|
||||
badge.textContent = t("einstellungen.names.firm_badge");
|
||||
badge.classList.add("names-badge--firm");
|
||||
badge.style.display = "";
|
||||
} else {
|
||||
badge.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
// updateFirmStatus refreshes the admin firm-default status line + clear button.
|
||||
function updateFirmStatus(c: NameArtifactCard): void {
|
||||
const status = document.getElementById(`names-firm-status-${c.artifact_id}`);
|
||||
if (status) {
|
||||
if (c.firm_is_set) {
|
||||
status.innerHTML = `${esc(t("einstellungen.names.firm.status_set"))} <code>${esc(c.firm_template)}</code>`;
|
||||
} else {
|
||||
status.textContent = t("einstellungen.names.firm.status_unset");
|
||||
}
|
||||
}
|
||||
const clearBtn = document.getElementById(`names-firm-clear-${c.artifact_id}`);
|
||||
if (clearBtn) clearBtn.style.display = c.firm_is_set ? "" : "none";
|
||||
}
|
||||
|
||||
async function setFirmNameComposition(id: string): Promise<void> {
|
||||
const input = document.getElementById(`names-input-${id}`) as HTMLInputElement | null;
|
||||
const msg = document.getElementById(`names-firm-msg-${id}`);
|
||||
if (!input) return;
|
||||
try {
|
||||
const resp = await fetch(`/api/admin/name-compositions/${encodeURIComponent(id)}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ template: input.value }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
if (msg) {
|
||||
msg.textContent = t("einstellungen.names.error.invalid");
|
||||
msg.className = "form-msg form-msg-error names-firm-msg";
|
||||
}
|
||||
return;
|
||||
}
|
||||
const updated = (await resp.json()) as NameArtifactCard;
|
||||
// The admin PUT response carries no user override; preserve the caller's
|
||||
// own is_override/template view by merging only the firm fields.
|
||||
mergeFirmFields(id, updated);
|
||||
if (msg) {
|
||||
msg.textContent = t("einstellungen.names.firm.saved");
|
||||
msg.className = "form-msg form-msg-success names-firm-msg";
|
||||
}
|
||||
} catch {
|
||||
if (msg) {
|
||||
msg.textContent = t("einstellungen.names.error.invalid");
|
||||
msg.className = "form-msg form-msg-error names-firm-msg";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function clearFirmNameComposition(id: string): Promise<void> {
|
||||
const msg = document.getElementById(`names-firm-msg-${id}`);
|
||||
try {
|
||||
const resp = await fetch(`/api/admin/name-compositions/${encodeURIComponent(id)}`, { method: "DELETE" });
|
||||
if (!resp.ok) {
|
||||
if (msg) {
|
||||
msg.textContent = t("einstellungen.names.error.invalid");
|
||||
msg.className = "form-msg form-msg-error names-firm-msg";
|
||||
}
|
||||
return;
|
||||
}
|
||||
const updated = (await resp.json()) as NameArtifactCard;
|
||||
mergeFirmFields(id, updated);
|
||||
if (msg) {
|
||||
msg.textContent = t("einstellungen.names.firm.cleared");
|
||||
msg.className = "form-msg form-msg-success names-firm-msg";
|
||||
}
|
||||
} catch {
|
||||
if (msg) {
|
||||
msg.textContent = t("einstellungen.names.error.invalid");
|
||||
msg.className = "form-msg form-msg-error names-firm-msg";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// mergeFirmFields applies the firm-tier fields from an admin PUT/DELETE
|
||||
// response onto the stored card without disturbing the caller's own
|
||||
// user-override view, then refreshes the badge + firm status.
|
||||
function mergeFirmFields(id: string, fromAdmin: NameArtifactCard): void {
|
||||
const idx = nameCards.findIndex((c) => c.artifact_id === id);
|
||||
if (idx < 0) return;
|
||||
nameCards[idx].firm_is_set = fromAdmin.firm_is_set;
|
||||
nameCards[idx].firm_template = fromAdmin.firm_template;
|
||||
updateNameBadge(nameCards[idx]);
|
||||
updateFirmStatus(nameCards[idx]);
|
||||
}
|
||||
|
||||
async function saveNameComposition(id: string): Promise<void> {
|
||||
const input = document.getElementById(`names-input-${id}`) as HTMLInputElement | null;
|
||||
if (!input) return;
|
||||
try {
|
||||
const resp = await fetch(`/api/me/name-compositions/${encodeURIComponent(id)}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ template: input.value }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
setNameError(id, t("einstellungen.names.error.invalid"));
|
||||
return;
|
||||
}
|
||||
const updated = (await resp.json()) as NameArtifactCard;
|
||||
applyNameCard(updated);
|
||||
const saved = document.getElementById(`names-saved-${id}`);
|
||||
if (saved) {
|
||||
saved.textContent = t("einstellungen.names.saved");
|
||||
saved.className = "form-msg form-msg-success names-saved";
|
||||
}
|
||||
} catch {
|
||||
setNameError(id, t("einstellungen.names.error.invalid"));
|
||||
}
|
||||
}
|
||||
|
||||
async function resetNameComposition(id: string): Promise<void> {
|
||||
try {
|
||||
const resp = await fetch(`/api/me/name-compositions/${encodeURIComponent(id)}`, { method: "DELETE" });
|
||||
if (!resp.ok) {
|
||||
setNameError(id, t("einstellungen.names.error.invalid"));
|
||||
return;
|
||||
}
|
||||
const updated = (await resp.json()) as NameArtifactCard;
|
||||
applyNameCard(updated);
|
||||
const saved = document.getElementById(`names-saved-${id}`);
|
||||
if (saved) {
|
||||
saved.textContent = t("einstellungen.names.reset_done");
|
||||
saved.className = "form-msg form-msg-success names-saved";
|
||||
}
|
||||
} catch {
|
||||
setNameError(id, t("einstellungen.names.error.invalid"));
|
||||
}
|
||||
}
|
||||
|
||||
// Re-localise palette chips + artifact headings on language change without
|
||||
// rebuilding the cards (which would discard in-progress edits).
|
||||
function relocaliseNameCards(): void {
|
||||
for (const card of nameCards) {
|
||||
const head = document.querySelector(`.names-artifact[data-art="${cssEscapeAttr(card.artifact_id)}"] h2`);
|
||||
if (head) head.textContent = artifactLabel(card);
|
||||
const badge = document.getElementById(`names-badge-${card.artifact_id}`);
|
||||
if (badge && badge.style.display !== "none") badge.textContent = t("einstellungen.names.override_badge");
|
||||
for (const v of card.palette) {
|
||||
const chip = document.querySelector(
|
||||
`.names-chip[data-art="${cssEscapeAttr(card.artifact_id)}"][data-var="${cssEscapeAttr(v.var)}"]`,
|
||||
);
|
||||
if (chip) chip.textContent = nameVarLabel(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Init -------------------------------------------------------------------
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
@@ -1152,6 +1562,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
renderCalDAVStatus();
|
||||
void loadCalDAVLog();
|
||||
}
|
||||
if (loadedTabs.has("names")) relocaliseNameCards();
|
||||
});
|
||||
|
||||
showTab(parseTab(), false);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { initI18n, t } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import { escapeHtml, cssEscape } from "../lib/docforge-editor/dom";
|
||||
import { fetchVariableCatalogue, labelMap } from "../lib/docforge-editor/catalogue";
|
||||
|
||||
// t-paliad-238 Slice A — client bundle for the dedicated
|
||||
// Submissions/Schriftsätze editor at
|
||||
@@ -33,6 +35,9 @@ interface SubmissionDraftJSON {
|
||||
// path stays the fallback). composer_meta carries the seed-time
|
||||
// section order in later slices.
|
||||
base_id?: string | null;
|
||||
// t-paliad-349 slice 7 — pinned uploaded docforge template version.
|
||||
// Mutually exclusive with base_id in practice (export checks this first).
|
||||
template_version_id?: string | null;
|
||||
composer_meta?: Record<string, unknown>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
@@ -69,6 +74,17 @@ interface SubmissionBaseRow {
|
||||
section_count: number;
|
||||
}
|
||||
|
||||
// t-paliad-349 slice 7 — an uploaded docforge template offered in the
|
||||
// picker for generation. version_id is what a draft pins.
|
||||
interface PickerTemplate {
|
||||
id: string;
|
||||
name_de: string;
|
||||
name_en: string;
|
||||
firm?: string | null;
|
||||
version: number;
|
||||
version_id?: string;
|
||||
}
|
||||
|
||||
interface AvailablePartyJSON {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -153,19 +169,16 @@ 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, "'");
|
||||
}
|
||||
// escapeHtml + cssEscape now come from ../lib/docforge-editor/dom (the
|
||||
// shared editor utilities); the local copies were removed in slice 5.
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// 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.
|
||||
// Labels come from the Go-side catalogue (GET /api/docforge/variables),
|
||||
// fetched once on boot into state.varLabels. The frontend keeps only the
|
||||
// presentation grouping (VARIABLE_GROUPS) — which keys to show and how to
|
||||
// section them — not the label data itself, so labels can't drift from the
|
||||
// resolvers that produce the values (t-paliad-349 slice 5).
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface VariableLabel {
|
||||
@@ -186,71 +199,6 @@ interface VariableGroup {
|
||||
collapsedByDefault?: boolean;
|
||||
}
|
||||
|
||||
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" },
|
||||
// Procedural-event namespace (t-paliad-262 Slice A, design doc
|
||||
// docs/design-procedural-events-model-2026-05-25.md). The canonical
|
||||
// placeholder names are below; the `rule.*` aliases that follow are
|
||||
// @deprecated but kept forever per m's Q7 lock — existing Word
|
||||
// templates and saved drafts authored with the old names keep
|
||||
// merging identically.
|
||||
"procedural_event.code": { de: "Code (Verfahrensschritt)", en: "Code (procedural event)" },
|
||||
"procedural_event.name": { de: "Verfahrensschritt", en: "Procedural event" },
|
||||
"procedural_event.name_de": { de: "Verfahrensschritt (DE)", en: "Procedural event (DE)" },
|
||||
"procedural_event.name_en": { de: "Verfahrensschritt (EN)", en: "Procedural event (EN)" },
|
||||
"procedural_event.legal_source": { de: "Rechtsgrundlage (Code)", en: "Legal source (code)" },
|
||||
"procedural_event.legal_source_pretty":{ de: "Rechtsgrundlage", en: "Legal source" },
|
||||
"procedural_event.primary_party": { de: "Partei (typisch)", en: "Primary party" },
|
||||
"procedural_event.event_kind": { de: "Art des Verfahrensschritts", en: "Procedural-event kind" },
|
||||
// Legacy aliases — @deprecated, kept forever (m/paliad#93 Q7).
|
||||
"rule.submission_code": { de: "Schriftsatz-Code (legacy)", en: "Submission code (legacy)" },
|
||||
"rule.name": { de: "Schriftsatz (legacy)", en: "Submission (legacy)" },
|
||||
"rule.name_de": { de: "Schriftsatz (DE, legacy)", en: "Submission (DE, legacy)" },
|
||||
"rule.name_en": { de: "Schriftsatz (EN, legacy)", en: "Submission (EN, legacy)" },
|
||||
"rule.legal_source": { de: "Rechtsgrundlage (Code, legacy)", en: "Legal source (code, legacy)" },
|
||||
"rule.legal_source_pretty": { de: "Rechtsgrundlage (legacy)", en: "Legal source (legacy)" },
|
||||
"rule.primary_party": { de: "Partei (typisch, legacy)", en: "Primary party (legacy)" },
|
||||
"rule.event_type": { de: "Schriftsatz-Typ (legacy)", en: "Event type (legacy)" },
|
||||
"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" },
|
||||
};
|
||||
|
||||
// t-paliad-287 — variable groups restructured into four lawyer-facing
|
||||
// sections: Mandant/Verfahren up top (the case identity), then Parteien
|
||||
@@ -341,7 +289,7 @@ const VARIABLE_GROUPS: VariableGroup[] = [
|
||||
];
|
||||
|
||||
function labelFor(key: string): string {
|
||||
const entry = VARIABLE_LABELS[key];
|
||||
const entry = state.varLabels[key];
|
||||
if (!entry) return key;
|
||||
return isEN() ? entry.en : entry.de;
|
||||
}
|
||||
@@ -373,6 +321,15 @@ interface State {
|
||||
// completes) keeps the picker hidden permanently for this load.
|
||||
bases: SubmissionBaseRow[];
|
||||
basesLoaded: boolean;
|
||||
// t-paliad-349 slice 7 — uploaded templates offered in the picker.
|
||||
templates: PickerTemplate[];
|
||||
templatesLoaded: boolean;
|
||||
// t-paliad-349 slice 5 — variable labels fetched once on boot from the
|
||||
// Go catalogue (GET /api/docforge/variables), the single source of
|
||||
// truth. Empty until the fetch lands; labelFor falls back to the raw
|
||||
// key, so a failed fetch degrades gracefully rather than breaking the
|
||||
// form.
|
||||
varLabels: Record<string, VariableLabel>;
|
||||
}
|
||||
|
||||
type PartySide = "claimant" | "defendant" | "other";
|
||||
@@ -401,6 +358,9 @@ const state: State = {
|
||||
addPartyBusy: false,
|
||||
bases: [],
|
||||
basesLoaded: false,
|
||||
templates: [],
|
||||
templatesLoaded: false,
|
||||
varLabels: {},
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
@@ -425,6 +385,21 @@ async function boot(): Promise<void> {
|
||||
console.warn("submission-draft: base catalog fetch failed", err);
|
||||
state.basesLoaded = true;
|
||||
});
|
||||
// t-paliad-349 slice 7 — uploaded-template catalog for the picker.
|
||||
loadTemplates().catch(err => {
|
||||
console.warn("submission-draft: template catalog fetch failed", err);
|
||||
state.templatesLoaded = true;
|
||||
});
|
||||
|
||||
// t-paliad-349 slice 5 — load the variable-label catalogue (Go SSOT)
|
||||
// before the first paint so the sidebar form labels render. Awaited
|
||||
// because labelFor needs it at paint time; a failure leaves varLabels
|
||||
// empty and labelFor falls back to the raw key (degraded but usable).
|
||||
try {
|
||||
state.varLabels = labelMap(await fetchVariableCatalogue());
|
||||
} catch (err) {
|
||||
console.warn("submission-draft: variable catalogue fetch failed", err);
|
||||
}
|
||||
|
||||
try {
|
||||
if (parsed.mode === "global") {
|
||||
@@ -528,7 +503,7 @@ async function fetchGlobalView(draftID: string): Promise<SubmissionDraftView> {
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
async function patchDraft(payload: { name?: string; variables?: Record<string, string>; project_id?: string | null; selected_parties?: string[]; language?: string }): Promise<SubmissionDraftView> {
|
||||
async function patchDraft(payload: { name?: string; variables?: Record<string, string>; project_id?: string | null; selected_parties?: string[]; language?: string; filename_keyword?: string }): Promise<SubmissionDraftView> {
|
||||
const p = state.parsed;
|
||||
if (!p.draftID) throw new Error("no draft id");
|
||||
if (state.inFlight) {
|
||||
@@ -583,6 +558,7 @@ function paint(): void {
|
||||
paintPartyPicker();
|
||||
paintLanguageRow();
|
||||
paintLanguageFallback();
|
||||
paintKeywordRow();
|
||||
paintVariables();
|
||||
paintSectionList();
|
||||
paintPreview();
|
||||
@@ -1059,6 +1035,53 @@ function paintLanguageFallback(): void {
|
||||
el.style.display = fallback ? "" : "none";
|
||||
}
|
||||
|
||||
// autoKeyword returns the lang-aware rule name that leads the exported
|
||||
// filename when the user sets no override — shown as the keyword input's
|
||||
// placeholder so the lawyer sees the default without it being forced.
|
||||
// t-paliad-354.
|
||||
function autoKeyword(): string {
|
||||
const view = state.view;
|
||||
if (!view?.rule) return "";
|
||||
const en = (view.draft.language || view.lang || "de").toLowerCase() === "en";
|
||||
const name = en && view.rule.name_en ? view.rule.name_en : view.rule.name;
|
||||
return (name || "").trim();
|
||||
}
|
||||
|
||||
// paintKeywordRow syncs the "Stichwort (Dateiname)" input with the
|
||||
// draft's stored override (composer_meta.filename_keyword) and shows the
|
||||
// auto-derived rule name as the placeholder. Editing PATCHes the draft on
|
||||
// blur (change), persisting under composer_meta.filename_keyword.
|
||||
// t-paliad-354.
|
||||
function paintKeywordRow(): void {
|
||||
const input = document.getElementById("submission-draft-keyword") as HTMLInputElement | null;
|
||||
if (!input || !state.view) return;
|
||||
const stored = state.view.draft.composer_meta?.["filename_keyword"];
|
||||
input.value = typeof stored === "string" ? stored : "";
|
||||
const auto = autoKeyword();
|
||||
if (auto) input.placeholder = auto;
|
||||
input.onchange = () => { void onKeywordChange(input.value.trim()); };
|
||||
}
|
||||
|
||||
async function onKeywordChange(keyword: string): Promise<void> {
|
||||
if (!state.view) return;
|
||||
const stored = state.view.draft.composer_meta?.["filename_keyword"];
|
||||
const current = typeof stored === "string" ? stored.trim() : "";
|
||||
if (keyword === current) return;
|
||||
setSaveStatus(isEN() ? "Saving…" : "Speichert…");
|
||||
try {
|
||||
const view = await patchDraft({ filename_keyword: keyword });
|
||||
state.view = view;
|
||||
paintKeywordRow();
|
||||
setSaveStatus(isEN() ? "Saved" : "Gespeichert");
|
||||
} catch (err) {
|
||||
if ((err as Error).name === "AbortError") return;
|
||||
console.error("submission-draft keyword save:", err);
|
||||
setSaveStatus(isEN() ? "Save failed" : "Speichern fehlgeschlagen", true);
|
||||
// Revert to the persisted value so the field doesn't lie.
|
||||
paintKeywordRow();
|
||||
}
|
||||
}
|
||||
|
||||
async function onLanguageChange(lang: "de" | "en"): Promise<void> {
|
||||
if (!state.view) return;
|
||||
if ((state.view.draft.language || "de").toLowerCase() === lang) return;
|
||||
@@ -1217,29 +1240,46 @@ async function loadBases(): Promise<void> {
|
||||
if (state.view) paintBasePicker();
|
||||
}
|
||||
|
||||
// loadTemplates fetches the firm-shared uploaded-template catalog
|
||||
// (t-paliad-349 slice 7). Failure leaves the list empty — the picker
|
||||
// simply offers no uploaded templates, the editor stays usable.
|
||||
async function loadTemplates(): Promise<void> {
|
||||
const res = await fetch("/api/templates", { credentials: "include" });
|
||||
if (!res.ok) {
|
||||
throw new Error("template list HTTP " + res.status);
|
||||
}
|
||||
const body = await res.json() as { templates?: PickerTemplate[] };
|
||||
state.templates = (body.templates ?? []).filter(t => !!t.version_id);
|
||||
state.templatesLoaded = true;
|
||||
if (state.view) paintBasePicker();
|
||||
}
|
||||
|
||||
function paintBasePicker(): void {
|
||||
const row = document.getElementById("submission-draft-base-row") as HTMLDivElement | null;
|
||||
const sel = document.getElementById("submission-draft-base") as HTMLSelectElement | null;
|
||||
if (!row || !sel || !state.view) return;
|
||||
|
||||
// Hide the picker until the catalog has loaded AND the catalog has
|
||||
// at least one entry. A failed fetch (basesLoaded=true, bases empty)
|
||||
// keeps the picker hidden indefinitely so the editor stays usable.
|
||||
if (!state.basesLoaded || state.bases.length === 0) {
|
||||
// Hide the picker only when BOTH catalogs are loaded-but-empty. As long
|
||||
// as bases OR uploaded templates exist, the picker is useful. A failed
|
||||
// fetch leaves the respective list empty; the editor stays usable.
|
||||
const hasBases = state.basesLoaded && state.bases.length > 0;
|
||||
const hasTemplates = state.templatesLoaded && state.templates.length > 0;
|
||||
if (!hasBases && !hasTemplates) {
|
||||
row.style.display = "none";
|
||||
return;
|
||||
}
|
||||
row.style.display = "";
|
||||
|
||||
// Rebuild the <option> list each paint so language toggles + base
|
||||
// catalog updates flow through.
|
||||
// Rebuild the <option> list each paint so language toggles + catalog
|
||||
// updates flow through.
|
||||
sel.innerHTML = "";
|
||||
const currentBaseID = state.view.draft.base_id ?? "";
|
||||
const currentTplVersion = state.view.draft.template_version_id ?? "";
|
||||
|
||||
// "Keine Vorlagenbasis" only listed when the draft is currently in
|
||||
// that state (pre-Composer / cleared). Avoids tempting the lawyer
|
||||
// to clear after they've already picked one.
|
||||
if (!currentBaseID) {
|
||||
// that state (no base, no template). Avoids tempting the lawyer to
|
||||
// clear after they've already picked one.
|
||||
if (!currentBaseID && !currentTplVersion) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = "";
|
||||
opt.textContent = isEN() ? "— no base —" : "— keine Vorlagenbasis —";
|
||||
@@ -1252,6 +1292,21 @@ function paintBasePicker(): void {
|
||||
if (b.id === currentBaseID) opt.selected = true;
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
// t-paliad-349 slice 7 — uploaded templates as a separate optgroup.
|
||||
// The value is "tpl:<version_id>" so onBaseChange can route it to the
|
||||
// template_version_id PATCH instead of base_id.
|
||||
if (hasTemplates) {
|
||||
const group = document.createElement("optgroup");
|
||||
group.label = isEN() ? "Uploaded templates" : "Hochgeladene Vorlagen";
|
||||
for (const tmpl of state.templates) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = "tpl:" + tmpl.version_id;
|
||||
opt.textContent = isEN() ? tmpl.name_en : tmpl.name_de;
|
||||
if (tmpl.version_id === currentTplVersion) opt.selected = true;
|
||||
group.appendChild(opt);
|
||||
}
|
||||
sel.appendChild(group);
|
||||
}
|
||||
|
||||
// Wire change handler once per paint. Removing then re-adding
|
||||
// keeps the binding consistent across repaints (e.g. after
|
||||
@@ -1259,12 +1314,17 @@ function paintBasePicker(): void {
|
||||
sel.onchange = () => { onBaseChange(sel.value); };
|
||||
}
|
||||
|
||||
async function onBaseChange(newBaseID: string): Promise<void> {
|
||||
async function onBaseChange(newValue: string): Promise<void> {
|
||||
if (!state.view) return;
|
||||
const payload: Record<string, unknown> = {
|
||||
// Empty string in the picker maps to null = clear.
|
||||
base_id: newBaseID === "" ? null : newBaseID,
|
||||
};
|
||||
// The picker mixes legacy bases (plain uuid) and uploaded templates
|
||||
// ("tpl:<version_id>"). Route to the matching field and clear the other
|
||||
// so the two render paths stay mutually exclusive. Empty = clear both.
|
||||
let payload: Record<string, unknown>;
|
||||
if (newValue.startsWith("tpl:")) {
|
||||
payload = { template_version_id: newValue.slice(4), base_id: null };
|
||||
} else {
|
||||
payload = { base_id: newValue === "" ? null : newValue, template_version_id: null };
|
||||
}
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/submission-drafts/${state.view.draft.id}`,
|
||||
@@ -1985,11 +2045,11 @@ function paintPickerList(host: HTMLElement, blocks: BuildingBlockPickJSON[], sec
|
||||
const preview = ((lang === "en" ? b.content_md_en : b.content_md_de) || "").slice(0, 200);
|
||||
row.innerHTML = `
|
||||
<div class="submission-bb-picker-row-head">
|
||||
<strong>${escapeHTML(title)}</strong>
|
||||
<span class="submission-bb-picker-vis submission-bb-picker-vis--${escapeHTML(b.visibility)}">${escapeHTML(b.visibility)}</span>
|
||||
<strong>${escapeHtml(title)}</strong>
|
||||
<span class="submission-bb-picker-vis submission-bb-picker-vis--${escapeHtml(b.visibility)}">${escapeHtml(b.visibility)}</span>
|
||||
</div>
|
||||
${desc ? `<div class="submission-bb-picker-row-desc">${escapeHTML(desc)}</div>` : ""}
|
||||
<pre class="submission-bb-picker-row-preview">${escapeHTML(preview)}${preview.length === 200 ? "…" : ""}</pre>`;
|
||||
${desc ? `<div class="submission-bb-picker-row-desc">${escapeHtml(desc)}</div>` : ""}
|
||||
<pre class="submission-bb-picker-row-preview">${escapeHtml(preview)}${preview.length === 200 ? "…" : ""}</pre>`;
|
||||
row.addEventListener("click", () => {
|
||||
void insertBlockIntoSection(b.id, sec.id, overlay);
|
||||
});
|
||||
@@ -2019,15 +2079,6 @@ async function insertBlockIntoSection(blockID: string, sectionID: string, overla
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHTML(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
async function patchSection(sectionID: string, payload: Record<string, unknown>): Promise<void> {
|
||||
try {
|
||||
const draftID = state.view?.draft.id;
|
||||
@@ -2104,17 +2155,6 @@ function findVarInput(key: string): HTMLInputElement | null {
|
||||
);
|
||||
}
|
||||
|
||||
function cssEscape(s: string): string {
|
||||
// CSS.escape covers our placeholder keys ([A-Za-z][A-Za-z0-9_.]*) but
|
||||
// older browsers may lack it; defensive fallback escapes characters
|
||||
// CSS treats as special. Placeholder keys never carry whitespace or
|
||||
// quotes so escaping is straightforward.
|
||||
if (typeof CSS !== "undefined" && typeof CSS.escape === "function") {
|
||||
return CSS.escape(s);
|
||||
}
|
||||
return s.replace(/([!"#$%&'()*+,./:;<=>?@[\\\]^`{|}~])/g, "\\$1");
|
||||
}
|
||||
|
||||
function onDraftVarClick(key: string, ev: Event): void {
|
||||
const input = findVarInput(key);
|
||||
if (!input) return;
|
||||
|
||||
314
frontend/src/client/templates-authoring.ts
Normal file
314
frontend/src/client/templates-authoring.ts
Normal file
@@ -0,0 +1,314 @@
|
||||
import { initI18n } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import { escapeHtml } from "../lib/docforge-editor/dom";
|
||||
import { fetchVariableCatalogue, type VariableEntry } from "../lib/docforge-editor/catalogue";
|
||||
|
||||
// t-paliad-349 docforge slice 6 — client for the template authoring page.
|
||||
//
|
||||
// Flow: list templates → upload a .docx (or open one) → the carrier renders
|
||||
// as run spans (<span class="docforge-run" data-run="N">) → the admin
|
||||
// selects text within one run, then clicks a variable in the palette → the
|
||||
// server injects {{slot}} at the selection and returns the updated view.
|
||||
//
|
||||
// The select-then-pick gesture keys on the run index (data-run) + the
|
||||
// selected text, matching the server's text-based InjectSlot so umlauts
|
||||
// can't desync the selection from the slice. Selections that span more than
|
||||
// one run are rejected with a hint (v1 scope: single-run text slots).
|
||||
|
||||
interface TemplateMeta {
|
||||
id: string;
|
||||
slug?: string;
|
||||
name_de: string;
|
||||
name_en: string;
|
||||
kind: string;
|
||||
source_format: string;
|
||||
firm?: string;
|
||||
is_active: boolean;
|
||||
version: number;
|
||||
}
|
||||
|
||||
interface TemplateSlot {
|
||||
key: string;
|
||||
anchor: string;
|
||||
label?: string;
|
||||
order_index: number;
|
||||
}
|
||||
|
||||
interface AuthoringView {
|
||||
template: TemplateMeta;
|
||||
preview_html: string;
|
||||
slots: TemplateSlot[];
|
||||
}
|
||||
|
||||
interface Selection1Run {
|
||||
runIndex: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface State {
|
||||
catalogue: VariableEntry[];
|
||||
openID: string | null;
|
||||
activeSlotKey: string | null;
|
||||
selection: Selection1Run | null;
|
||||
}
|
||||
|
||||
const state: State = {
|
||||
catalogue: [],
|
||||
openID: null,
|
||||
activeSlotKey: null,
|
||||
selection: null,
|
||||
};
|
||||
|
||||
function isEN(): boolean {
|
||||
return (document.documentElement.lang || "de").toLowerCase().startsWith("en");
|
||||
}
|
||||
|
||||
function labelOf(e: VariableEntry): string {
|
||||
return isEN() ? e.label_en : e.label_de;
|
||||
}
|
||||
|
||||
async function boot(): Promise<void> {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
|
||||
try {
|
||||
state.catalogue = await fetchVariableCatalogue();
|
||||
} catch (err) {
|
||||
console.warn("templates-authoring: catalogue fetch failed", err);
|
||||
}
|
||||
|
||||
wireUploadForm();
|
||||
await loadList();
|
||||
}
|
||||
|
||||
async function loadList(): Promise<void> {
|
||||
const host = document.getElementById("docforge-template-list");
|
||||
if (!host) return;
|
||||
let metas: TemplateMeta[] = [];
|
||||
try {
|
||||
const res = await fetch("/api/admin/templates", { headers: { Accept: "application/json" } });
|
||||
if (res.ok) {
|
||||
const body = (await res.json()) as { templates: TemplateMeta[] };
|
||||
metas = body.templates ?? [];
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("templates-authoring: list fetch failed", err);
|
||||
}
|
||||
if (metas.length === 0) {
|
||||
host.innerHTML = `<li class="docforge-template-empty">${escapeHtml(isEN() ? "No templates yet." : "Noch keine Vorlagen.")}</li>`;
|
||||
return;
|
||||
}
|
||||
host.innerHTML = metas
|
||||
.map((m) => {
|
||||
const name = isEN() ? m.name_en : m.name_de;
|
||||
const firm = m.firm ? ` · ${escapeHtml(m.firm)}` : "";
|
||||
return `<li class="docforge-template-row" data-template-id="${escapeHtml(m.id)}">
|
||||
<span class="docforge-template-name">${escapeHtml(name)}</span>
|
||||
<span class="docforge-template-meta">v${m.version}${firm}</span>
|
||||
</li>`;
|
||||
})
|
||||
.join("");
|
||||
host.querySelectorAll<HTMLLIElement>(".docforge-template-row").forEach((li) => {
|
||||
li.addEventListener("click", () => {
|
||||
const id = li.dataset.templateId;
|
||||
if (id) void openTemplate(id);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function wireUploadForm(): void {
|
||||
const form = document.getElementById("docforge-upload-form") as HTMLFormElement | null;
|
||||
if (!form) return;
|
||||
form.addEventListener("submit", async (ev) => {
|
||||
ev.preventDefault();
|
||||
const status = document.getElementById("docforge-upload-status");
|
||||
const data = new FormData(form);
|
||||
setText(status, isEN() ? "Uploading…" : "Lädt hoch…");
|
||||
try {
|
||||
const res = await fetch("/api/admin/templates", { method: "POST", body: data });
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({ error: `HTTP ${res.status}` }));
|
||||
setText(status, (isEN() ? "Error: " : "Fehler: ") + (body.error ?? res.status));
|
||||
return;
|
||||
}
|
||||
const view = (await res.json()) as AuthoringView;
|
||||
setText(status, "");
|
||||
form.reset();
|
||||
await loadList();
|
||||
openView(view);
|
||||
} catch (err) {
|
||||
setText(status, (isEN() ? "Error: " : "Fehler: ") + String(err));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function openTemplate(id: string): Promise<void> {
|
||||
try {
|
||||
const res = await fetch(`/api/admin/templates/${encodeURIComponent(id)}`, {
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
if (!res.ok) return;
|
||||
openView((await res.json()) as AuthoringView);
|
||||
} catch (err) {
|
||||
console.warn("templates-authoring: open failed", err);
|
||||
}
|
||||
}
|
||||
|
||||
function openView(view: AuthoringView): void {
|
||||
state.openID = view.template.id;
|
||||
state.activeSlotKey = null;
|
||||
state.selection = null;
|
||||
|
||||
const workspace = document.getElementById("docforge-workspace");
|
||||
if (workspace) workspace.hidden = false;
|
||||
|
||||
const title = document.getElementById("docforge-workspace-title");
|
||||
if (title) {
|
||||
const name = isEN() ? view.template.name_en : view.template.name_de;
|
||||
title.textContent = `${name} · v${view.template.version}`;
|
||||
}
|
||||
|
||||
renderPreview(view.preview_html);
|
||||
renderSlots(view.slots);
|
||||
renderPalette();
|
||||
setWorkspaceStatus("");
|
||||
}
|
||||
|
||||
function renderPreview(html: string): void {
|
||||
const host = document.getElementById("docforge-preview");
|
||||
if (!host) return;
|
||||
host.innerHTML = html;
|
||||
host.addEventListener("mouseup", onPreviewSelect);
|
||||
}
|
||||
|
||||
// onPreviewSelect captures a selection that lies entirely within one run
|
||||
// span; otherwise it clears the pending selection and hints.
|
||||
function onPreviewSelect(): void {
|
||||
const sel = window.getSelection();
|
||||
if (!sel || sel.isCollapsed || sel.rangeCount === 0) {
|
||||
state.selection = null;
|
||||
return;
|
||||
}
|
||||
const text = sel.toString();
|
||||
if (text === "") {
|
||||
state.selection = null;
|
||||
return;
|
||||
}
|
||||
const anchorRun = closestRun(sel.anchorNode);
|
||||
const focusRun = closestRun(sel.focusNode);
|
||||
if (!anchorRun || anchorRun !== focusRun) {
|
||||
state.selection = null;
|
||||
setWorkspaceStatus(isEN()
|
||||
? "Select within a single text span."
|
||||
: "Bitte innerhalb einer Textstelle markieren.");
|
||||
return;
|
||||
}
|
||||
const runIndex = Number(anchorRun.dataset.run);
|
||||
if (Number.isNaN(runIndex)) {
|
||||
state.selection = null;
|
||||
return;
|
||||
}
|
||||
state.selection = { runIndex, text };
|
||||
setWorkspaceStatus(state.activeSlotKey
|
||||
? (isEN() ? `Click to bind “${text}” → ${state.activeSlotKey}` : `Variable wählen, um „${text}“ zu setzen`)
|
||||
: (isEN() ? `Selected “${text}” — now pick a variable.` : `„${text}" markiert — jetzt Variable wählen.`));
|
||||
}
|
||||
|
||||
function closestRun(node: Node | null): HTMLElement | null {
|
||||
let el: Node | null = node;
|
||||
while (el && el !== document.body) {
|
||||
if (el instanceof HTMLElement && el.classList.contains("docforge-run")) return el;
|
||||
el = el.parentNode;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// renderPalette groups catalogue entries by their namespace group and wires
|
||||
// each as a click-to-place control.
|
||||
function renderPalette(): void {
|
||||
const host = document.getElementById("docforge-palette");
|
||||
if (!host) return;
|
||||
if (state.catalogue.length === 0) {
|
||||
host.innerHTML = `<p class="docforge-palette-empty">${escapeHtml(isEN() ? "No variables." : "Keine Variablen.")}</p>`;
|
||||
return;
|
||||
}
|
||||
const groups = new Map<string, VariableEntry[]>();
|
||||
for (const e of state.catalogue) {
|
||||
const arr = groups.get(e.group) ?? [];
|
||||
arr.push(e);
|
||||
groups.set(e.group, arr);
|
||||
}
|
||||
let html = `<h3>${escapeHtml(isEN() ? "Variables" : "Variablen")}</h3>`;
|
||||
for (const [group, entries] of groups) {
|
||||
html += `<div class="docforge-palette-group"><h4>${escapeHtml(group)}</h4>`;
|
||||
for (const e of entries) {
|
||||
html += `<button type="button" class="docforge-palette-var" data-slot-key="${escapeHtml(e.key)}" title="{{${escapeHtml(e.key)}}}">${escapeHtml(labelOf(e))}</button>`;
|
||||
}
|
||||
html += `</div>`;
|
||||
}
|
||||
host.innerHTML = html;
|
||||
host.querySelectorAll<HTMLButtonElement>(".docforge-palette-var").forEach((btn) => {
|
||||
btn.addEventListener("click", () => onPaletteClick(btn.dataset.slotKey ?? "", btn));
|
||||
});
|
||||
}
|
||||
|
||||
function onPaletteClick(slotKey: string, btn: HTMLButtonElement): void {
|
||||
state.activeSlotKey = slotKey;
|
||||
const host = document.getElementById("docforge-palette");
|
||||
host?.querySelectorAll(".docforge-palette-var--active").forEach((el) => el.classList.remove("docforge-palette-var--active"));
|
||||
btn.classList.add("docforge-palette-var--active");
|
||||
|
||||
if (state.selection) {
|
||||
void placeSlot(state.selection.runIndex, state.selection.text, slotKey);
|
||||
} else {
|
||||
setWorkspaceStatus(isEN()
|
||||
? `${slotKey} selected — now highlight the text to replace.`
|
||||
: `${slotKey} gewählt — jetzt den zu ersetzenden Text markieren.`);
|
||||
}
|
||||
}
|
||||
|
||||
async function placeSlot(runIndex: number, selectedText: string, slotKey: string): Promise<void> {
|
||||
if (!state.openID) return;
|
||||
setWorkspaceStatus(isEN() ? "Placing…" : "Setze…");
|
||||
try {
|
||||
const res = await fetch(`/api/admin/templates/${encodeURIComponent(state.openID)}/slots`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ run_index: runIndex, selected_text: selectedText, slot_key: slotKey }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({ error: `HTTP ${res.status}` }));
|
||||
setWorkspaceStatus((isEN() ? "Error: " : "Fehler: ") + (body.error ?? res.status));
|
||||
return;
|
||||
}
|
||||
openView((await res.json()) as AuthoringView);
|
||||
} catch (err) {
|
||||
setWorkspaceStatus((isEN() ? "Error: " : "Fehler: ") + String(err));
|
||||
}
|
||||
}
|
||||
|
||||
function renderSlots(slots: TemplateSlot[]): void {
|
||||
const host = document.getElementById("docforge-slot-list");
|
||||
if (!host) return;
|
||||
if (slots.length === 0) {
|
||||
host.innerHTML = `<li class="docforge-slot-empty">${escapeHtml(isEN() ? "No slots yet." : "Noch keine Platzhalter.")}</li>`;
|
||||
return;
|
||||
}
|
||||
host.innerHTML = slots
|
||||
.map((s) => `<li class="docforge-slot-row" data-slot="${escapeHtml(s.key)}"><code>{{${escapeHtml(s.key)}}}</code></li>`)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function setWorkspaceStatus(msg: string): void {
|
||||
setText(document.getElementById("docforge-workspace-status"), msg);
|
||||
}
|
||||
|
||||
function setText(el: Element | null, msg: string): void {
|
||||
if (el) el.textContent = msg;
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", () => void boot());
|
||||
} else {
|
||||
void boot();
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import {
|
||||
type CalculatedDeadline,
|
||||
type DeadlineResponse,
|
||||
bucketDeadlinesIntoColumns,
|
||||
calculateDeadlines,
|
||||
deadlineCardHtml,
|
||||
formatDurationLabel,
|
||||
renderColumnsBody,
|
||||
@@ -773,3 +774,81 @@ describe("stripLeadingDurationFromNotes — render-side dedup (t-paliad-307)", (
|
||||
.toBe("Time limit set by the court");
|
||||
});
|
||||
});
|
||||
|
||||
// Pin the engine-options plumbing surface (t-paliad-348 / yoUPC#178).
|
||||
// calculateDeadlines must forward `includeOptional` and
|
||||
// `triggerEventAnchors` straight into the POST body so the Go handler
|
||||
// (handleFristenrechnerAPI) can pass them into lp.CalcOptions. If a
|
||||
// future refactor drops the fields, the Builder triplet silently
|
||||
// reverts to "engine emits optional rules" and the unified
|
||||
// /tools/procedures page loses its naked-proceeding default.
|
||||
describe("calculateDeadlines — forwards engine options into request body", () => {
|
||||
type CapturedRequest = { url: string; body: Record<string, unknown> };
|
||||
let captured: CapturedRequest | null;
|
||||
let originalFetch: typeof globalThis.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
captured = null;
|
||||
originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const body = typeof init?.body === "string" ? JSON.parse(init.body) : {};
|
||||
captured = { url: String(input), body };
|
||||
return new Response(JSON.stringify({
|
||||
proceedingType: "x", proceedingName: "x", triggerDate: "2026-01-01", deadlines: [],
|
||||
}), { status: 200, headers: { "Content-Type": "application/json" } });
|
||||
}) as typeof globalThis.fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
test("default call omits includeOptional and triggerEventAnchors", async () => {
|
||||
await calculateDeadlines({ proceedingType: "upc.inf.cfi", triggerDate: "2026-05-26" });
|
||||
expect(captured).not.toBeNull();
|
||||
expect(captured!.body.includeOptional).toBeUndefined();
|
||||
expect(captured!.body.triggerEventAnchors).toBeUndefined();
|
||||
});
|
||||
|
||||
test("includeOptional=true sends includeOptional: true", async () => {
|
||||
await calculateDeadlines({
|
||||
proceedingType: "upc.inf.cfi",
|
||||
triggerDate: "2026-05-26",
|
||||
includeOptional: true,
|
||||
});
|
||||
expect(captured!.body.includeOptional).toBe(true);
|
||||
});
|
||||
|
||||
test("includeOptional=false is omitted (matches engine default)", async () => {
|
||||
await calculateDeadlines({
|
||||
proceedingType: "upc.inf.cfi",
|
||||
triggerDate: "2026-05-26",
|
||||
includeOptional: false,
|
||||
});
|
||||
expect(captured!.body.includeOptional).toBeUndefined();
|
||||
});
|
||||
|
||||
test("triggerEventAnchors forwarded as object", async () => {
|
||||
await calculateDeadlines({
|
||||
proceedingType: "upc.inf.cfi",
|
||||
triggerDate: "2026-05-26",
|
||||
triggerEventAnchors: {
|
||||
"upc.inf.cfi.oral": "2026-09-01",
|
||||
"upc.inf.cfi.decision": "2026-12-15",
|
||||
},
|
||||
});
|
||||
expect(captured!.body.triggerEventAnchors).toEqual({
|
||||
"upc.inf.cfi.oral": "2026-09-01",
|
||||
"upc.inf.cfi.decision": "2026-12-15",
|
||||
});
|
||||
});
|
||||
|
||||
test("empty triggerEventAnchors is omitted", async () => {
|
||||
await calculateDeadlines({
|
||||
proceedingType: "upc.inf.cfi",
|
||||
triggerDate: "2026-05-26",
|
||||
triggerEventAnchors: {},
|
||||
});
|
||||
expect(captured!.body.triggerEventAnchors).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -271,6 +271,12 @@ export interface DeadlineResponse {
|
||||
// when the toggle is OFF — so users know there's something to
|
||||
// re-surface.
|
||||
hiddenCount?: number;
|
||||
// rulesAwaitingAnchor (t-paliad-348 / yoUPC#178): number of rules the
|
||||
// engine suppressed because their `trigger_event_id` anchor wasn't
|
||||
// supplied via CalcParams.triggerEventAnchors. Mirrors the Go
|
||||
// Timeline.RulesAwaitingAnchor counter — a single integer surface for
|
||||
// "N rules waiting on an anchor" UI affordances.
|
||||
rulesAwaitingAnchor?: number;
|
||||
}
|
||||
|
||||
export interface CourtRow {
|
||||
@@ -311,6 +317,20 @@ export interface CalcParams {
|
||||
// endentscheidung | kostenentscheidung | anordnung |
|
||||
// schadensbemessung | bucheinsicht.
|
||||
appealTarget?: string;
|
||||
// t-paliad-348 / yoUPC#178 — surface the engine's two new CalcOptions
|
||||
// axes to the HTTP boundary:
|
||||
//
|
||||
// includeOptional: when true, the engine returns priority='optional'
|
||||
// rules in the timeline. Default false matches the engine default
|
||||
// (mandatory backbone only). The /tools/procedures detailgrad
|
||||
// toggle ("all_options" mode) drives this to true so the dimmed
|
||||
// optional cards can be rendered for the lawyer to opt into.
|
||||
// triggerEventAnchors: per-event-code anchor dates the engine
|
||||
// consults for rules carrying trigger_event_id. Empty/omitted =
|
||||
// no anchors → such rules render as IsConditional (the engine
|
||||
// refuses to fabricate a date off the proceeding's trigger date).
|
||||
includeOptional?: boolean;
|
||||
triggerEventAnchors?: Record<string, string>;
|
||||
}
|
||||
|
||||
const PARTY_CLASS: Record<string, string> = {
|
||||
@@ -1042,7 +1062,15 @@ export function renderColumnsBody(data: DeadlineResponse, opts: ColumnsBodyOpts
|
||||
// timeline-item — dotted border + faded styling.
|
||||
dl.isConditional ? "fr-col-item--conditional" : "",
|
||||
].filter(Boolean).join(" ");
|
||||
return `<div class="${itemClasses}">
|
||||
// data-rule-id on the card root lets the Litigation Builder
|
||||
// overlay per-card state (planned/filed/skipped) + action
|
||||
// affordances onto cards rendered through this shared body
|
||||
// without re-implementing the columns renderer. Empty on
|
||||
// synthetic rows (appeal trigger marker etc.); the Builder
|
||||
// skips state lookup when missing.
|
||||
const ruleIdAttr = dl.ruleId ? ` data-rule-id="${escAttr(dl.ruleId)}"` : "";
|
||||
const submissionCodeAttr = dl.code ? ` data-submission-code="${escAttr(dl.code)}"` : "";
|
||||
return `<div class="${itemClasses}"${ruleIdAttr}${submissionCodeAttr}>
|
||||
${deadlineCardHtml(dl, cardOpts)}
|
||||
${mirrorTag}
|
||||
</div>`;
|
||||
@@ -1110,6 +1138,10 @@ export async function calculateDeadlines(params: CalcParams): Promise<DeadlineRe
|
||||
: undefined,
|
||||
includeHidden: params.includeHidden ? true : undefined,
|
||||
appealTarget: params.appealTarget || undefined,
|
||||
includeOptional: params.includeOptional ? true : undefined,
|
||||
triggerEventAnchors: params.triggerEventAnchors && Object.keys(params.triggerEventAnchors).length > 0
|
||||
? params.triggerEventAnchors
|
||||
: undefined,
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
|
||||
@@ -728,6 +728,138 @@ export type I18nKey =
|
||||
| "bottomnav.add.title"
|
||||
| "bottomnav.badge.deadlines"
|
||||
| "bottomnav.menu"
|
||||
| "builder.action.promote"
|
||||
| "builder.action.rename"
|
||||
| "builder.action.rename.prompt"
|
||||
| "builder.action.share"
|
||||
| "builder.akte.banner.prefix"
|
||||
| "builder.akte.none"
|
||||
| "builder.bucket.active"
|
||||
| "builder.bucket.archived"
|
||||
| "builder.bucket.empty"
|
||||
| "builder.bucket.promoted"
|
||||
| "builder.bucket.shared"
|
||||
| "builder.canvas.add_proceeding"
|
||||
| "builder.empty.cta"
|
||||
| "builder.empty.headline"
|
||||
| "builder.empty.hint"
|
||||
| "builder.empty.recent"
|
||||
| "builder.event.action.file"
|
||||
| "builder.event.action.reset"
|
||||
| "builder.event.action.skip"
|
||||
| "builder.event.actual_date.prompt"
|
||||
| "builder.event.horizon.hide"
|
||||
| "builder.event.horizon.label"
|
||||
| "builder.event.skip_reason.prompt"
|
||||
| "builder.event.state.filed"
|
||||
| "builder.event.state.planned"
|
||||
| "builder.event.state.skipped"
|
||||
| "builder.header.akte"
|
||||
| "builder.header.scenario"
|
||||
| "builder.header.search"
|
||||
| "builder.header.stichtag"
|
||||
| "builder.mobile.blocked"
|
||||
| "builder.mode.akte"
|
||||
| "builder.mode.cold"
|
||||
| "builder.mode.event"
|
||||
| "builder.panel.empty"
|
||||
| "builder.panel.new"
|
||||
| "builder.panel.title"
|
||||
| "builder.picker.aria"
|
||||
| "builder.picker.axis.forum"
|
||||
| "builder.picker.axis.proc"
|
||||
| "builder.picker.close"
|
||||
| "builder.picker.empty"
|
||||
| "builder.picker.future_jurisdiction"
|
||||
| "builder.picker.placeholder"
|
||||
| "builder.picker.title"
|
||||
| "builder.promote.back"
|
||||
| "builder.promote.cancel"
|
||||
| "builder.promote.commit"
|
||||
| "builder.promote.error.generic"
|
||||
| "builder.promote.error.title_required"
|
||||
| "builder.promote.meta.case_number"
|
||||
| "builder.promote.meta.client_number"
|
||||
| "builder.promote.meta.our_side"
|
||||
| "builder.promote.meta.our_side.claimant"
|
||||
| "builder.promote.meta.our_side.defendant"
|
||||
| "builder.promote.meta.our_side.none"
|
||||
| "builder.promote.meta.parent"
|
||||
| "builder.promote.meta.parent.none"
|
||||
| "builder.promote.meta.reference"
|
||||
| "builder.promote.meta.team"
|
||||
| "builder.promote.meta.team.hint"
|
||||
| "builder.promote.meta.title"
|
||||
| "builder.promote.meta.title.placeholder"
|
||||
| "builder.promote.next"
|
||||
| "builder.promote.parties.add"
|
||||
| "builder.promote.parties.empty"
|
||||
| "builder.promote.parties.hint"
|
||||
| "builder.promote.parties.name"
|
||||
| "builder.promote.parties.remove"
|
||||
| "builder.promote.parties.representative"
|
||||
| "builder.promote.parties.role"
|
||||
| "builder.promote.step1"
|
||||
| "builder.promote.step2"
|
||||
| "builder.promote.step3"
|
||||
| "builder.promote.success"
|
||||
| "builder.promote.summary.events_filed"
|
||||
| "builder.promote.summary.events_planned"
|
||||
| "builder.promote.summary.flags"
|
||||
| "builder.promote.summary.heading"
|
||||
| "builder.promote.summary.note_extra"
|
||||
| "builder.promote.summary.proceeding"
|
||||
| "builder.promote.title"
|
||||
| "builder.readonly.blocked"
|
||||
| "builder.readonly.watermark"
|
||||
| "builder.save.error"
|
||||
| "builder.save.idle"
|
||||
| "builder.save.saved"
|
||||
| "builder.save.saving"
|
||||
| "builder.search.anchor.divider"
|
||||
| "builder.search.group.events"
|
||||
| "builder.search.group.projects"
|
||||
| "builder.search.group.scenarios"
|
||||
| "builder.search.hint.akte_b4"
|
||||
| "builder.search.hint.empty"
|
||||
| "builder.search.hint.error"
|
||||
| "builder.search.hint.loading"
|
||||
| "builder.search.hint.short"
|
||||
| "builder.search.hint.start"
|
||||
| "builder.search.placeholder"
|
||||
| "builder.search.summary.events.one"
|
||||
| "builder.search.summary.events.other"
|
||||
| "builder.search.summary.projects.one"
|
||||
| "builder.search.summary.projects.other"
|
||||
| "builder.search.summary.scenarios.one"
|
||||
| "builder.search.summary.scenarios.other"
|
||||
| "builder.share.button"
|
||||
| "builder.share.close"
|
||||
| "builder.share.current.empty"
|
||||
| "builder.share.current.title"
|
||||
| "builder.share.error"
|
||||
| "builder.share.no_results"
|
||||
| "builder.share.revoke"
|
||||
| "builder.share.search.placeholder"
|
||||
| "builder.share.subtitle"
|
||||
| "builder.share.title"
|
||||
| "builder.subtitle"
|
||||
| "builder.triplet.collapse"
|
||||
| "builder.triplet.detailgrad.all_options"
|
||||
| "builder.triplet.detailgrad.label"
|
||||
| "builder.triplet.detailgrad.selected"
|
||||
| "builder.triplet.expand"
|
||||
| "builder.triplet.flags.label"
|
||||
| "builder.triplet.loading"
|
||||
| "builder.triplet.no_flags"
|
||||
| "builder.triplet.perspective.claimant"
|
||||
| "builder.triplet.perspective.defendant"
|
||||
| "builder.triplet.perspective.label"
|
||||
| "builder.triplet.perspective.none"
|
||||
| "builder.triplet.remove"
|
||||
| "builder.triplet.side.claimant"
|
||||
| "builder.triplet.side.defendant"
|
||||
| "builder.triplet.unknown_proceeding"
|
||||
| "cal.day.back_to_month"
|
||||
| "cal.day.fri"
|
||||
| "cal.day.mon"
|
||||
@@ -1358,8 +1490,6 @@ export type I18nKey =
|
||||
| "deadlines.filter.status"
|
||||
| "deadlines.filter.thisweek"
|
||||
| "deadlines.filter.today"
|
||||
| "deadlines.flag.amend"
|
||||
| "deadlines.flag.cci"
|
||||
| "deadlines.flag.ccr"
|
||||
| "deadlines.flag.inf_amend"
|
||||
| "deadlines.flag.rev_amend"
|
||||
@@ -1632,6 +1762,23 @@ export type I18nKey =
|
||||
| "einstellungen.export.what"
|
||||
| "einstellungen.heading"
|
||||
| "einstellungen.loading"
|
||||
| "einstellungen.names.error.invalid"
|
||||
| "einstellungen.names.error.load"
|
||||
| "einstellungen.names.firm.clear"
|
||||
| "einstellungen.names.firm.cleared"
|
||||
| "einstellungen.names.firm.heading"
|
||||
| "einstellungen.names.firm.saved"
|
||||
| "einstellungen.names.firm.set"
|
||||
| "einstellungen.names.firm.status_set"
|
||||
| "einstellungen.names.firm.status_unset"
|
||||
| "einstellungen.names.firm_badge"
|
||||
| "einstellungen.names.override_badge"
|
||||
| "einstellungen.names.preview.empty"
|
||||
| "einstellungen.names.preview.sample"
|
||||
| "einstellungen.names.reset"
|
||||
| "einstellungen.names.reset_done"
|
||||
| "einstellungen.names.saved"
|
||||
| "einstellungen.names.subtitle"
|
||||
| "einstellungen.optional"
|
||||
| "einstellungen.prefs.escalation.default_option"
|
||||
| "einstellungen.prefs.escalation.heading"
|
||||
@@ -1674,6 +1821,7 @@ export type I18nKey =
|
||||
| "einstellungen.tab.benachrichtigungen"
|
||||
| "einstellungen.tab.caldav"
|
||||
| "einstellungen.tab.export"
|
||||
| "einstellungen.tab.names"
|
||||
| "einstellungen.tab.profil"
|
||||
| "einstellungen.title"
|
||||
| "event.description.appointment_approval_approved"
|
||||
@@ -2205,50 +2353,19 @@ export type I18nKey =
|
||||
| "partner_unit.members_label"
|
||||
| "partner_unit.none"
|
||||
| "partner_unit.subtitle"
|
||||
| "procedures.appeal_target.label"
|
||||
| "procedures.cold_open.hint"
|
||||
| "procedures.filter.axis.date"
|
||||
| "procedures.filter.axis.forum"
|
||||
| "procedures.filter.axis.kind"
|
||||
| "procedures.filter.axis.party"
|
||||
| "procedures.filter.axis.proc"
|
||||
| "procedures.filter.forum.all"
|
||||
| "procedures.filter.party.all"
|
||||
| "procedures.filter.search.placeholder"
|
||||
| "procedures.find.summary.akte"
|
||||
| "procedures.find.summary.anchor"
|
||||
| "procedures.find.summary.empty"
|
||||
| "procedures.find.summary.many"
|
||||
| "procedures.find.summary.one"
|
||||
| "procedures.heading"
|
||||
| "procedures.node.actual.done"
|
||||
| "procedures.node.actual.open"
|
||||
| "procedures.node.actual.overdue"
|
||||
| "procedures.node.cross"
|
||||
| "procedures.node.cross.short"
|
||||
| "procedures.node.fokus"
|
||||
| "procedures.node.here"
|
||||
| "procedures.node.pin"
|
||||
| "procedures.panel.akte.placeholder"
|
||||
| "procedures.proceeding.detail.all"
|
||||
| "procedures.proceeding.detail.selected"
|
||||
| "procedures.proceeding.detail.title"
|
||||
| "procedures.proceeding.hide"
|
||||
| "procedures.proceeding.show"
|
||||
| "procedures.proceeding.toggle"
|
||||
| "procedures.subtitle"
|
||||
| "procedures.tab.akte"
|
||||
| "procedures.tab.proceeding"
|
||||
| "procedures.tab.search"
|
||||
| "procedures.tab.wizard"
|
||||
| "procedures.timelines.court_set"
|
||||
| "procedures.timelines.empty"
|
||||
| "procedures.timelines.error"
|
||||
| "procedures.timelines.loading"
|
||||
| "procedures.timelines.options"
|
||||
| "procedures.title"
|
||||
| "procedures.zoom.breadcrumb"
|
||||
| "procedures.zoom.hidden"
|
||||
| "project.instance_level.appeal"
|
||||
| "project.instance_level.cassation"
|
||||
| "project.instance_level.first"
|
||||
@@ -2743,6 +2860,9 @@ export type I18nKey =
|
||||
| "submissions.draft.base.hint"
|
||||
| "submissions.draft.base.label"
|
||||
| "submissions.draft.import.button"
|
||||
| "submissions.draft.keyword.hint"
|
||||
| "submissions.draft.keyword.label"
|
||||
| "submissions.draft.keyword.placeholder"
|
||||
| "submissions.draft.language"
|
||||
| "submissions.draft.language.de"
|
||||
| "submissions.draft.language.en"
|
||||
@@ -2842,6 +2962,18 @@ export type I18nKey =
|
||||
| "team.selection.toggle_card"
|
||||
| "team.subtitle"
|
||||
| "team.title"
|
||||
| "templates.authoring.heading"
|
||||
| "templates.authoring.intro"
|
||||
| "templates.authoring.list.title"
|
||||
| "templates.authoring.slots.title"
|
||||
| "templates.authoring.title"
|
||||
| "templates.authoring.upload.file"
|
||||
| "templates.authoring.upload.firm"
|
||||
| "templates.authoring.upload.name_de"
|
||||
| "templates.authoring.upload.name_en"
|
||||
| "templates.authoring.upload.submit"
|
||||
| "templates.authoring.upload.title"
|
||||
| "templates.authoring.workspace.hint"
|
||||
| "theme.toggle.auto"
|
||||
| "theme.toggle.cycle.auto"
|
||||
| "theme.toggle.cycle.dark"
|
||||
|
||||
43
frontend/src/lib/docforge-editor/catalogue.ts
Normal file
43
frontend/src/lib/docforge-editor/catalogue.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
// docforge-editor — the variable catalogue client.
|
||||
//
|
||||
// The catalogue (key + bilingual label + namespace group) is served by the
|
||||
// Go backend at GET /api/docforge/variables, built from the resolvers'
|
||||
// Keys() as the single source of truth. A consumer fetches it once and uses
|
||||
// labelMap() to label its sidebar form + authoring palette, instead of
|
||||
// hard-coding a parallel label table that can drift from the resolvers.
|
||||
|
||||
export interface VariableEntry {
|
||||
key: string;
|
||||
label_de: string;
|
||||
label_en: string;
|
||||
group: string;
|
||||
}
|
||||
|
||||
interface VariablesResponse {
|
||||
variables: VariableEntry[];
|
||||
}
|
||||
|
||||
// fetchVariableCatalogue loads the catalogue from the backend. Throws on a
|
||||
// non-2xx response so the caller can decide how to degrade.
|
||||
export async function fetchVariableCatalogue(): Promise<VariableEntry[]> {
|
||||
const res = await fetch("/api/docforge/variables", {
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`docforge variables: HTTP ${res.status}`);
|
||||
}
|
||||
const body = (await res.json()) as VariablesResponse;
|
||||
return body.variables ?? [];
|
||||
}
|
||||
|
||||
// labelMap turns a catalogue into a key → {de, en} lookup for a label
|
||||
// function. Keys absent from the map fall back to the raw key at the call
|
||||
// site, so a failed fetch degrades to dotted-key labels rather than a
|
||||
// broken form.
|
||||
export function labelMap(catalogue: VariableEntry[]): Record<string, { de: string; en: string }> {
|
||||
const out: Record<string, { de: string; en: string }> = {};
|
||||
for (const e of catalogue) {
|
||||
out[e.key] = { de: e.label_de, en: e.label_en };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
26
frontend/src/lib/docforge-editor/dom.test.ts
Normal file
26
frontend/src/lib/docforge-editor/dom.test.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { test, expect } from "bun:test";
|
||||
import { escapeHtml, cssEscape } from "./dom";
|
||||
|
||||
test("escapeHtml escapes the five HTML-significant characters", () => {
|
||||
expect(escapeHtml(`<a href="x" title='y'>& z</a>`)).toBe(
|
||||
"<a href="x" title='y'>& z</a>",
|
||||
);
|
||||
});
|
||||
|
||||
test("escapeHtml is a no-op on plain text", () => {
|
||||
expect(escapeHtml("Aktenzeichen 4c O 12/23")).toBe("Aktenzeichen 4c O 12/23");
|
||||
});
|
||||
|
||||
test("escapeHtml escapes & first to avoid double-encoding", () => {
|
||||
expect(escapeHtml("<")).toBe("&lt;");
|
||||
});
|
||||
|
||||
test("cssEscape backslash-escapes the dots in a placeholder key", () => {
|
||||
// Both CSS.escape and the regex fallback escape '.' the same way, so the
|
||||
// result is stable across environments (bun has no CSS global → fallback).
|
||||
expect(cssEscape("project.case_number")).toBe("project\\.case_number");
|
||||
});
|
||||
|
||||
test("cssEscape leaves identifier-safe characters untouched", () => {
|
||||
expect(cssEscape("today")).toBe("today");
|
||||
});
|
||||
32
frontend/src/lib/docforge-editor/dom.ts
Normal file
32
frontend/src/lib/docforge-editor/dom.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
// docforge-editor — shared, framework-agnostic editor utilities.
|
||||
//
|
||||
// Slice 5 of the docforge train (t-paliad-349 / m/paliad#157) begins
|
||||
// extracting the generic editor plumbing out of the submission-specific
|
||||
// client bundle so a second consumer (and the slice-6 authoring page) can
|
||||
// reuse it. This module holds the pure DOM-string helpers — no DOM
|
||||
// mutation, no editor state — so they unit-test cleanly under bun.
|
||||
|
||||
// escapeHtml escapes the five HTML-significant characters for safe
|
||||
// insertion into element text or an attribute value. Matches the
|
||||
// server-side emitTextWithDraftVars/htmlEscape contract so preview markup
|
||||
// round-trips identically.
|
||||
export function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
// cssEscape escapes a string for use inside a CSS attribute selector
|
||||
// (e.g. `[data-var="${cssEscape(key)}"]`). Prefers the native CSS.escape
|
||||
// and falls back to escaping CSS-special characters for older runtimes.
|
||||
// Placeholder keys ([A-Za-z][A-Za-z0-9_.]*) never carry whitespace or
|
||||
// quotes, so the fallback is straightforward.
|
||||
export function cssEscape(s: string): string {
|
||||
if (typeof CSS !== "undefined" && typeof CSS.escape === "function") {
|
||||
return CSS.escape(s);
|
||||
}
|
||||
return s.replace(/([!"#$%&'()*+,./:;<=>?@[\\\]^`{|}~])/g, "\\$1");
|
||||
}
|
||||
@@ -5,31 +5,18 @@ import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
// /tools/procedures — workflow-tracker shell (m/paliad#152 T1,
|
||||
// docs/design-procedures-workflow-tracker-2026-05-27.md §9 T1).
|
||||
// /tools/procedures — Litigation Builder (m/paliad#153 PRD §3).
|
||||
//
|
||||
// Single canonical shape:
|
||||
// 1. Sticky find header — search input + forum / Verfahren / Partei
|
||||
// pill rows + global Stichtag. The header narrows the timeline
|
||||
// set rendered below; it is not itself a tab strip.
|
||||
// 2. Timeline body — one card per matched proceeding, rendered as a
|
||||
// chained tree by parent_id with priority-styled bullets. Cold
|
||||
// open renders the 6 curated default proceedings from
|
||||
// design §8 / §11.Q4.
|
||||
// Replaces cronus's 4-tab catalog (U0-U4) with a persistence-backed
|
||||
// builder shell. Server-rendered chrome is minimal — the page-header
|
||||
// scenario picker, side panel, and canvas are all hydrated by
|
||||
// `builder.ts` at boot. The builder loads scenarios from
|
||||
// /api/builder/scenarios (B0 surface, t-paliad-340) and renders the
|
||||
// per-proceeding triplets with the existing verfahrensablauf-core calc.
|
||||
//
|
||||
// Each later slice layers on top of this shell:
|
||||
// T2 — anchor pin + zoom + multi-proceeding scope (§6.5).
|
||||
// T3 — Akte landing + actuals overlay.
|
||||
// T4 — appeal-target chip group + court-set choices + per-proceeding
|
||||
// "Alle Optionen" toggle.
|
||||
// T5 — dead-code removal (the old per-tab fristenrechner-mode-*,
|
||||
// fristenrechner-wizard, fristenrechner-result, verfahrensablauf
|
||||
// modules + their CSS once nothing imports them).
|
||||
//
|
||||
// No DB dependency — the page itself is static HTML; data flows over
|
||||
// the existing /api/tools/fristenrechner endpoints. The 4 entry-mode
|
||||
// tabs the catalog (U0-U4) shipped earlier today are deleted in this
|
||||
// PR per m's Q7 divergent pick (direct replace, no flag).
|
||||
// B1 — Builder shell + cold-open mode + single triplet end-to-end.
|
||||
// B2 — Multi-triplet stack + spawn nesting + per-event state machine.
|
||||
// B3+ — event-triggered + Akte modes, sharing, promotion (head-gated).
|
||||
|
||||
export function renderProcedures(): string {
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
@@ -45,82 +32,153 @@ export function renderProcedures(): string {
|
||||
<title data-i18n="procedures.title">Verfahren & Fristen — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar page-procedures">
|
||||
<body className="has-sidebar page-procedures page-builder">
|
||||
<Sidebar currentPath="/tools/procedures" />
|
||||
<BottomNav currentPath="/tools/procedures" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
<section className="tool-page builder-page">
|
||||
<div className="container">
|
||||
<div className="tool-header">
|
||||
<h1 data-i18n="procedures.heading">Verfahren & Fristen</h1>
|
||||
<p className="tool-subtitle" data-i18n="procedures.subtitle">
|
||||
Verfahrensabläufe als Zeitstrahl — suchen, filtern, Verzweigungen wählen.
|
||||
<p className="tool-subtitle" data-i18n="builder.subtitle">
|
||||
Litigation Builder — Szenarien bauen, Verfahren stapeln, Fristen behalten.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Find affordance (design §2). Sticky header — search,
|
||||
forum + Verfahren + Partei pills, and the global
|
||||
Stichtag (date input). Pills hydrate from
|
||||
procedures-tracker on boot; markup carries the host
|
||||
rows only. */}
|
||||
<section className="tracker-find" aria-label="Filter" id="tracker-find">
|
||||
<div className="tracker-find-search">
|
||||
<svg className="tracker-find-search-icon" width="18" height="18" viewBox="0 0 24 24"
|
||||
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
||||
stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="11" cy="11" r="7"></circle>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||
</svg>
|
||||
<input
|
||||
type="search"
|
||||
id="tracker-search-input"
|
||||
className="tracker-find-search-input"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
data-i18n-placeholder="procedures.filter.search.placeholder"
|
||||
placeholder="Klageerhebung, Hinweisbeschluss, oral hearing…"
|
||||
/>
|
||||
{/* Page header (PRD §3.1): scenario picker · save state · name · share · promote
|
||||
· Akte picker · Stichtag input. B1 wires the scenario picker
|
||||
+ name action + Stichtag + save indicator. Akte / share /
|
||||
promote land at B4 / B5; the affordances render disabled in
|
||||
B1 so the layout is stable across slices. */}
|
||||
<section className="builder-pageheader" aria-label="Builder-Steuerung">
|
||||
<div className="builder-pageheader-row">
|
||||
<label className="builder-pageheader-field">
|
||||
<span className="builder-pageheader-label" data-i18n="builder.header.scenario">Szenario:</span>
|
||||
<select id="builder-scenario-picker" className="builder-scenario-picker" aria-label="Szenario wählen"></select>
|
||||
</label>
|
||||
<span id="builder-save-status" className="builder-save-status" aria-live="polite" data-state="idle">
|
||||
<span data-i18n="builder.save.idle"> </span>
|
||||
</span>
|
||||
<span className="builder-pageheader-spacer"></span>
|
||||
<button type="button" id="builder-rename-btn"
|
||||
className="builder-action-btn builder-action-btn--secondary"
|
||||
disabled
|
||||
data-i18n="builder.action.rename">Benennen</button>
|
||||
<button type="button" id="builder-share-btn"
|
||||
className="builder-action-btn builder-action-btn--secondary"
|
||||
disabled
|
||||
data-i18n="builder.action.share">Teilen</button>
|
||||
<button type="button" id="builder-promote-btn"
|
||||
className="builder-action-btn builder-action-btn--primary"
|
||||
disabled
|
||||
data-i18n="builder.action.promote">Als Projekt anlegen</button>
|
||||
</div>
|
||||
|
||||
<div className="tracker-find-row" data-axis="forum">
|
||||
<span className="tracker-find-axis-label" data-i18n="procedures.filter.axis.forum">Forum:</span>
|
||||
<div className="tracker-find-pills" id="tracker-pills-forum" role="group"></div>
|
||||
</div>
|
||||
|
||||
<div className="tracker-find-row" data-axis="proc">
|
||||
<span className="tracker-find-axis-label" data-i18n="procedures.filter.axis.proc">Verfahren:</span>
|
||||
<div className="tracker-find-pills" id="tracker-pills-proc" role="group"></div>
|
||||
</div>
|
||||
|
||||
<div className="tracker-find-row" data-axis="party">
|
||||
<span className="tracker-find-axis-label" data-i18n="procedures.filter.axis.party">Partei:</span>
|
||||
<div className="tracker-find-pills" id="tracker-pills-party" role="group"></div>
|
||||
</div>
|
||||
|
||||
<div className="tracker-find-row" data-axis="date">
|
||||
<label htmlFor="tracker-trigger-date" className="tracker-find-axis-label"
|
||||
data-i18n="procedures.filter.axis.date">Stichtag:</label>
|
||||
<input
|
||||
type="date"
|
||||
id="tracker-trigger-date"
|
||||
className="tracker-find-date-input"
|
||||
value={today}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="tracker-find-summary" id="tracker-find-summary" aria-live="polite"></div>
|
||||
</section>
|
||||
|
||||
{/* Timeline body — one card per matched proceeding. Cards
|
||||
are appended by procedures-tracker.ts on boot and
|
||||
re-rendered when the find header changes. */}
|
||||
<section className="tracker-timelines" id="tracker-timelines" aria-label="Verfahren">
|
||||
<div className="tracker-timelines-placeholder" id="tracker-timelines-placeholder"
|
||||
data-i18n="procedures.timelines.loading">
|
||||
Verfahren werden geladen…
|
||||
<div className="builder-pageheader-row">
|
||||
<label className="builder-pageheader-field">
|
||||
<span className="builder-pageheader-label" data-i18n="builder.header.akte">Akte:</span>
|
||||
<select id="builder-akte-picker" className="builder-akte-picker" disabled aria-label="Akte wählen">
|
||||
<option value="" data-i18n="builder.akte.none">— ohne —</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="builder-pageheader-field">
|
||||
<span className="builder-pageheader-label" data-i18n="builder.header.stichtag">Stichtag:</span>
|
||||
<input type="date" id="builder-stichtag-input" className="builder-stichtag-input"
|
||||
defaultValue={today} aria-label="Stichtag" />
|
||||
</label>
|
||||
<label className="builder-pageheader-field builder-pageheader-field--grow">
|
||||
<span className="builder-pageheader-label" data-i18n="builder.header.search">Suche:</span>
|
||||
<input type="search" id="builder-search-input" className="builder-search-input"
|
||||
data-i18n-placeholder="builder.search.placeholder"
|
||||
placeholder="Ereignis, Szenario, Akte …"
|
||||
autocomplete="off" spellcheck="false" />
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Entry-mode radio (PRD §0.2, §2). B1 ships cold-open active;
|
||||
event-triggered + akte ship at B3 / B4 and are disabled
|
||||
here so the layout stays stable across slices. */}
|
||||
<nav className="builder-modebar" role="tablist" aria-label="Einstieg">
|
||||
<button type="button"
|
||||
className="builder-mode is-active"
|
||||
role="tab"
|
||||
aria-selected="true"
|
||||
data-mode="cold"
|
||||
id="builder-mode-cold">
|
||||
<span className="builder-mode-label" data-i18n="builder.mode.cold">Übersicht</span>
|
||||
</button>
|
||||
<button type="button"
|
||||
className="builder-mode"
|
||||
role="tab"
|
||||
aria-selected="false"
|
||||
data-mode="event"
|
||||
id="builder-mode-event">
|
||||
<span className="builder-mode-label" data-i18n="builder.mode.event">Ereignis</span>
|
||||
</button>
|
||||
<button type="button"
|
||||
className="builder-mode"
|
||||
role="tab"
|
||||
aria-selected="false"
|
||||
data-mode="akte"
|
||||
id="builder-mode-akte">
|
||||
<span className="builder-mode-label" data-i18n="builder.mode.akte">Aus Akte</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
{/* Two-column body: side panel (left, scenarios list) + canvas (right). */}
|
||||
<div className="builder-body">
|
||||
<aside className="builder-sidepanel" aria-label="Meine Szenarien">
|
||||
<header className="builder-sidepanel-header">
|
||||
<h2 className="builder-sidepanel-title" data-i18n="builder.panel.title">Meine Szenarien</h2>
|
||||
<button type="button" id="builder-new-scenario-btn"
|
||||
className="builder-sidepanel-newbtn"
|
||||
data-i18n="builder.panel.new">+ Neues Szenario</button>
|
||||
</header>
|
||||
<div className="builder-sidepanel-bucket" data-bucket="active">
|
||||
<h3 className="builder-bucket-label" data-i18n="builder.bucket.active">Aktiv</h3>
|
||||
<ul className="builder-scenario-list" id="builder-scenario-list-active" aria-label="Aktive Szenarien"></ul>
|
||||
</div>
|
||||
{/* B5 — Geteilt mit mir / Als Projekt angelegt / Archiviert.
|
||||
Each bucket hides itself when empty (builder.ts toggles
|
||||
the hidden attribute). */}
|
||||
<div className="builder-sidepanel-bucket" data-bucket="shared" id="builder-bucket-shared" hidden>
|
||||
<h3 className="builder-bucket-label" data-i18n="builder.bucket.shared">Geteilt mit mir</h3>
|
||||
<ul className="builder-scenario-list" id="builder-scenario-list-shared" aria-label="Mit mir geteilte Szenarien"></ul>
|
||||
</div>
|
||||
<div className="builder-sidepanel-bucket" data-bucket="promoted" id="builder-bucket-promoted" hidden>
|
||||
<h3 className="builder-bucket-label" data-i18n="builder.bucket.promoted">Als Projekt angelegt</h3>
|
||||
<ul className="builder-scenario-list" id="builder-scenario-list-promoted" aria-label="Promotete Szenarien"></ul>
|
||||
</div>
|
||||
<div className="builder-sidepanel-bucket" data-bucket="archived" id="builder-bucket-archived" hidden>
|
||||
<h3 className="builder-bucket-label" data-i18n="builder.bucket.archived">Archiviert</h3>
|
||||
<ul className="builder-scenario-list" id="builder-scenario-list-archived" aria-label="Archivierte Szenarien"></ul>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section className="builder-canvas-wrap" aria-label="Builder-Canvas">
|
||||
{/* B5 — read-only watermark for shared / promoted scenarios.
|
||||
builder.ts fills + unhides it when the active scenario
|
||||
is not editable by the current user. */}
|
||||
<div id="builder-readonly-watermark" className="builder-readonly-watermark" hidden></div>
|
||||
<div id="builder-canvas" className="builder-canvas">
|
||||
{/* Cold-open placeholder — replaced by triplet stack once a
|
||||
scenario is loaded. */}
|
||||
<div className="builder-empty" id="builder-empty">
|
||||
<p className="builder-empty-headline" data-i18n="builder.empty.headline">
|
||||
Noch kein Szenario geöffnet.
|
||||
</p>
|
||||
<p className="builder-empty-hint" data-i18n="builder.empty.hint">
|
||||
Starte ein neues Szenario, wähle aus deiner Liste oder übernimm eine Akte (B4).
|
||||
</p>
|
||||
<button type="button" id="builder-cta-new" className="builder-cta-new"
|
||||
data-i18n="builder.empty.cta">
|
||||
Neues Szenario starten
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -40,6 +40,7 @@ export function renderSettings(): string {
|
||||
<a className="entity-tab" data-tab="profil" href="?tab=profil" data-i18n="einstellungen.tab.profil">Profil</a>
|
||||
<a className="entity-tab" data-tab="benachrichtigungen" href="?tab=benachrichtigungen" data-i18n="einstellungen.tab.benachrichtigungen">Benachrichtigungen</a>
|
||||
<a className="entity-tab" data-tab="caldav" href="?tab=caldav" data-i18n="einstellungen.tab.caldav">CalDAV</a>
|
||||
<a className="entity-tab" data-tab="names" href="?tab=names" data-i18n="einstellungen.tab.names">Namensschemata</a>
|
||||
<a className="entity-tab" data-tab="export" href="?tab=export" data-i18n="einstellungen.tab.export">Datenexport</a>
|
||||
</nav>
|
||||
|
||||
@@ -362,6 +363,23 @@ export function renderSettings(): string {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* --- Namensschemata tab (t-paliad-356 Slice 4) -------- */}
|
||||
<section className="entity-tab-panel" id="tab-names" style="display:none">
|
||||
<p className="tool-subtitle" data-i18n="einstellungen.names.subtitle">
|
||||
Legen Sie fest, wie Paliad Entwurfstitel und Dateinamen aus Projektdaten zusammensetzt.
|
||||
Klicken Sie auf einen Platzhalter, um ihn einzufügen; die Vorschau zeigt das Ergebnis sofort.
|
||||
</p>
|
||||
|
||||
<div id="names-loading" className="entity-loading">
|
||||
<p data-i18n="einstellungen.loading">Lädt…</p>
|
||||
</div>
|
||||
|
||||
{/* Per-artifact cards are built client-side from
|
||||
/api/me/name-compositions so the wired-artifact list stays
|
||||
server-driven (no duplicated catalog in the frontend). */}
|
||||
<div id="names-list" className="names-list" style="display:none" />
|
||||
</section>
|
||||
|
||||
{/* --- Datenexport tab (t-paliad-214 Slice 1) ----------- */}
|
||||
<section className="entity-tab-panel" id="tab-export" style="display:none">
|
||||
<p className="tool-subtitle" data-i18n="einstellungen.export.subtitle">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -109,6 +109,35 @@ export function renderSubmissionDraft(): string {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* t-paliad-354 — keyword that leads the exported
|
||||
document name "<date> <keyword> (<case>)". Empty
|
||||
falls back to the auto-derived rule name; the
|
||||
placeholder shows that default. Persisted to
|
||||
composer_meta.filename_keyword via the draft-save
|
||||
path on change. Grouped with the draft-name row
|
||||
(naming controls) ahead of the template controls
|
||||
(base + language) per t-paliad-359. */}
|
||||
<div className="submission-draft-keyword-row">
|
||||
<label
|
||||
htmlFor="submission-draft-keyword"
|
||||
data-i18n="submissions.draft.keyword.label">
|
||||
Stichwort (Dateiname)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="submission-draft-keyword"
|
||||
className="entity-form-input"
|
||||
data-i18n-placeholder="submissions.draft.keyword.placeholder"
|
||||
placeholder="Automatisch aus dem Schriftsatztyp"
|
||||
/>
|
||||
<p
|
||||
className="submission-draft-keyword-hint"
|
||||
id="submission-draft-keyword-hint"
|
||||
data-i18n="submissions.draft.keyword.hint">
|
||||
Führt den Dateinamen an: <Datum> <Stichwort> (<Aktenzeichen>).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* t-paliad-313 (m/paliad#141) Composer Slice A —
|
||||
base picker. Hydrated by client/submission-draft.ts
|
||||
once /api/submission-bases returns. Disabled
|
||||
|
||||
112
frontend/src/templates-authoring.tsx
Normal file
112
frontend/src/templates-authoring.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
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-349 docforge slice 6 — template authoring page at
|
||||
// /admin/templates.
|
||||
//
|
||||
// Admin uploads a base .docx, sees it rendered as run-addressable text,
|
||||
// selects a span + a variable from the palette to drop a {{slot}}, and the
|
||||
// result saves as a reusable docforge template. Pure shell:
|
||||
// client/templates-authoring.ts hydrates the list, upload form, preview,
|
||||
// palette, and slot list after load. The palette labels come from the Go
|
||||
// variable catalogue (GET /api/docforge/variables, the SSOT from slice 5).
|
||||
//
|
||||
// Design ref: docs/plans/prd-docforge-2026-05-29.md §2.1.
|
||||
|
||||
export function renderTemplatesAuthoring(): 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" />
|
||||
<PWAHead />
|
||||
<title data-i18n="templates.authoring.title">Vorlagen — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar page-templates-authoring">
|
||||
<Sidebar currentPath="/admin" />
|
||||
<BottomNav currentPath="/admin" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page docforge-templates-page">
|
||||
<div className="container">
|
||||
<header className="docforge-templates-header">
|
||||
<h1 data-i18n="templates.authoring.heading">Vorlagen</h1>
|
||||
<p
|
||||
className="docforge-templates-intro"
|
||||
data-i18n="templates.authoring.intro">
|
||||
Lade eine Word-Vorlage hoch, markiere Stellen und setze Variablen ein.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* Upload a new base .docx */}
|
||||
<section className="docforge-upload" id="docforge-upload">
|
||||
<h2 data-i18n="templates.authoring.upload.title">Neue Vorlage hochladen</h2>
|
||||
<form id="docforge-upload-form" className="entity-form">
|
||||
<label className="entity-form-row">
|
||||
<span data-i18n="templates.authoring.upload.file">Word-Datei (.docx)</span>
|
||||
<input type="file" name="file" accept=".docx,.dotx,.docm,.dotm" required />
|
||||
</label>
|
||||
<label className="entity-form-row">
|
||||
<span data-i18n="templates.authoring.upload.name_de">Name (DE)</span>
|
||||
<input type="text" name="name_de" className="entity-form-input" required />
|
||||
</label>
|
||||
<label className="entity-form-row">
|
||||
<span data-i18n="templates.authoring.upload.name_en">Name (EN)</span>
|
||||
<input type="text" name="name_en" className="entity-form-input" required />
|
||||
</label>
|
||||
<label className="entity-form-row">
|
||||
<span data-i18n="templates.authoring.upload.firm">Kanzlei (optional)</span>
|
||||
<input type="text" name="firm" className="entity-form-input" />
|
||||
</label>
|
||||
<button type="submit" className="btn-primary" data-i18n="templates.authoring.upload.submit">
|
||||
Hochladen
|
||||
</button>
|
||||
<span className="docforge-upload-status" id="docforge-upload-status" />
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{/* Existing templates */}
|
||||
<section className="docforge-template-list-wrap">
|
||||
<h2 data-i18n="templates.authoring.list.title">Vorhandene Vorlagen</h2>
|
||||
<ul className="entity-table docforge-template-list" id="docforge-template-list" />
|
||||
</section>
|
||||
|
||||
{/* Authoring workspace — hidden until a template is opened. */}
|
||||
<section className="docforge-workspace" id="docforge-workspace" hidden>
|
||||
<header className="docforge-workspace-header">
|
||||
<h2 id="docforge-workspace-title" />
|
||||
<span className="docforge-workspace-hint" data-i18n="templates.authoring.workspace.hint">
|
||||
Text markieren, dann eine Variable wählen, um einen Platzhalter zu setzen.
|
||||
</span>
|
||||
<span className="docforge-workspace-status" id="docforge-workspace-status" />
|
||||
</header>
|
||||
<div className="docforge-workspace-grid">
|
||||
{/* Variable palette (left) — populated from the catalogue. */}
|
||||
<aside className="docforge-palette" id="docforge-palette" />
|
||||
{/* Run-addressable preview (center) — selection target. */}
|
||||
<div className="docforge-preview" id="docforge-preview" />
|
||||
{/* Placed slots (right). */}
|
||||
<aside className="docforge-slots">
|
||||
<h3 data-i18n="templates.authoring.slots.title">Platzhalter</h3>
|
||||
<ul className="docforge-slot-list" id="docforge-slot-list" />
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
|
||||
<script src="/assets/templates-authoring.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
-- 157_scenario_builder_foundation — down
|
||||
--
|
||||
-- Rolls back mig 157 in reverse order. Down files are reference material
|
||||
-- (not auto-applied); operator recovery path is:
|
||||
--
|
||||
-- psql ... < 157_scenario_builder_foundation.down.sql
|
||||
-- DELETE FROM paliad.applied_migrations WHERE version = 157;
|
||||
--
|
||||
-- This restores the legacy paliad.scenarios shape from mig 145 — the
|
||||
-- builder columns and the three sibling tables are dropped wholesale.
|
||||
-- Any builder data in the dropped tables is lost (the tables CASCADE to
|
||||
-- their children, and DROP TABLE doesn't keep a backup).
|
||||
|
||||
BEGIN;
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 157 rollback: tear down Scenario builder foundation (t-paliad-340)',
|
||||
true
|
||||
);
|
||||
|
||||
-- 8. updated_at triggers
|
||||
DROP TRIGGER IF EXISTS scenario_events_touch_updated_at_trg ON paliad.scenario_events;
|
||||
DROP TRIGGER IF EXISTS scenario_proceedings_touch_updated_at_trg ON paliad.scenario_proceedings;
|
||||
|
||||
-- 7. RLS — drop new policies + restore legacy four
|
||||
DROP POLICY IF EXISTS scenario_shares_mutate ON paliad.scenario_shares;
|
||||
DROP POLICY IF EXISTS scenario_shares_select ON paliad.scenario_shares;
|
||||
DROP POLICY IF EXISTS scenario_events_mutate ON paliad.scenario_events;
|
||||
DROP POLICY IF EXISTS scenario_events_select ON paliad.scenario_events;
|
||||
DROP POLICY IF EXISTS scenario_proceedings_mutate ON paliad.scenario_proceedings;
|
||||
DROP POLICY IF EXISTS scenario_proceedings_select ON paliad.scenario_proceedings;
|
||||
DROP POLICY IF EXISTS scenarios_owner_mutate ON paliad.scenarios;
|
||||
DROP POLICY IF EXISTS scenarios_select ON paliad.scenarios;
|
||||
|
||||
-- Restore the four mig-145 policies verbatim.
|
||||
CREATE POLICY scenarios_project_select ON paliad.scenarios
|
||||
FOR SELECT
|
||||
USING (project_id IS NOT NULL AND paliad.can_see_project(project_id));
|
||||
|
||||
CREATE POLICY scenarios_project_mutate ON paliad.scenarios
|
||||
FOR ALL
|
||||
USING (project_id IS NOT NULL AND paliad.can_see_project(project_id))
|
||||
WITH CHECK (project_id IS NOT NULL AND paliad.can_see_project(project_id));
|
||||
|
||||
CREATE POLICY scenarios_abstract_select ON paliad.scenarios
|
||||
FOR SELECT
|
||||
USING (project_id IS NULL AND created_by = auth.uid());
|
||||
|
||||
CREATE POLICY scenarios_abstract_mutate ON paliad.scenarios
|
||||
FOR ALL
|
||||
USING (project_id IS NULL AND created_by = auth.uid())
|
||||
WITH CHECK (project_id IS NULL AND created_by = auth.uid());
|
||||
|
||||
-- 6. helper function
|
||||
DROP FUNCTION IF EXISTS paliad.can_see_scenario(uuid);
|
||||
|
||||
-- 5. paliad.projects.origin_scenario_id
|
||||
DROP INDEX IF EXISTS paliad.projects_origin_scenario_idx;
|
||||
ALTER TABLE paliad.projects DROP COLUMN IF EXISTS origin_scenario_id;
|
||||
|
||||
-- 4. paliad.scenario_shares
|
||||
DROP TABLE IF EXISTS paliad.scenario_shares;
|
||||
|
||||
-- 3. paliad.scenario_events
|
||||
DROP TABLE IF EXISTS paliad.scenario_events;
|
||||
|
||||
-- 2. paliad.scenario_proceedings
|
||||
DROP TABLE IF EXISTS paliad.scenario_proceedings;
|
||||
|
||||
-- 1. paliad.scenarios — restore mig-145 shape
|
||||
DROP INDEX IF EXISTS paliad.scenarios_updated_idx;
|
||||
DROP INDEX IF EXISTS paliad.scenarios_owner_status_idx;
|
||||
|
||||
-- Restore the unique constraint mig 145 had.
|
||||
ALTER TABLE paliad.scenarios
|
||||
ADD CONSTRAINT scenarios_unique_per_scope
|
||||
UNIQUE NULLS NOT DISTINCT (project_id, created_by, name);
|
||||
|
||||
-- spec was NOT NULL in mig 145. Restore that — but only after backfilling
|
||||
-- any NULL specs the builder might have created (none in legacy paths;
|
||||
-- only builder rows have NULL spec, and those are dropped together with
|
||||
-- the builder schema if a real rollback is needed).
|
||||
UPDATE paliad.scenarios SET spec = '{}'::jsonb WHERE spec IS NULL;
|
||||
ALTER TABLE paliad.scenarios ALTER COLUMN spec SET NOT NULL;
|
||||
|
||||
ALTER TABLE paliad.scenarios DROP COLUMN IF EXISTS notes;
|
||||
ALTER TABLE paliad.scenarios DROP COLUMN IF EXISTS stichtag;
|
||||
ALTER TABLE paliad.scenarios DROP COLUMN IF EXISTS promoted_project_id;
|
||||
ALTER TABLE paliad.scenarios DROP COLUMN IF EXISTS origin_project_id;
|
||||
ALTER TABLE paliad.scenarios DROP COLUMN IF EXISTS status;
|
||||
ALTER TABLE paliad.scenarios DROP COLUMN IF EXISTS owner_id;
|
||||
|
||||
COMMIT;
|
||||
500
internal/db/migrations/157_scenario_builder_foundation.up.sql
Normal file
500
internal/db/migrations/157_scenario_builder_foundation.up.sql
Normal file
@@ -0,0 +1,500 @@
|
||||
-- 157_scenario_builder_foundation — t-paliad-340 / m/paliad#153 B0
|
||||
--
|
||||
-- Schema foundation for the Litigation Builder (PRD
|
||||
-- docs/plans/prd-procedures-litigation-planner-2026-05-27.md §5.1 + §5.2).
|
||||
-- Phase B0 of the 7-slice train described in PRD §7.1. DB-only — no UI
|
||||
-- depends on these tables yet; B1 wires the builder shell on top.
|
||||
--
|
||||
-- What this migration adds:
|
||||
--
|
||||
-- 1. Six new columns on paliad.scenarios for the builder shape:
|
||||
-- owner_id, status, origin_project_id, promoted_project_id,
|
||||
-- stichtag, notes.
|
||||
-- Two relaxations on existing columns:
|
||||
-- - spec NOT NULL → NULL (the builder normalises spec contents
|
||||
-- into scenario_proceedings / scenario_events; new rows skip
|
||||
-- spec entirely. Legacy callers from mig 145 still provide it
|
||||
-- explicitly, so they keep inserting valid rows.)
|
||||
-- - DROP CONSTRAINT scenarios_unique_per_scope (the builder
|
||||
-- allows multiple "Unbenanntes Szenario" + multiple scratch
|
||||
-- scenarios per user — uniqueness on (project_id, created_by,
|
||||
-- name) blocks that. The legacy service treated the constraint
|
||||
-- as UX collision avoidance, not correctness.)
|
||||
--
|
||||
-- 2. Three new tables for the normalised builder shape:
|
||||
-- - paliad.scenario_proceedings (one row per proceeding in a
|
||||
-- scenario; multi-proceeding constellations + spawned children)
|
||||
-- - paliad.scenario_events (one row per event card on the
|
||||
-- canvas; planned / filed / skipped state + actual_date + notes
|
||||
-- + per-card optional horizon)
|
||||
-- - paliad.scenario_shares (read-only team shares; owner is
|
||||
-- the sole editor)
|
||||
--
|
||||
-- 3. One new column on paliad.projects:
|
||||
-- - origin_scenario_id — audit trail for promote-to-project
|
||||
-- (B5; the column lands now so the FK is in place when the
|
||||
-- wizard arrives).
|
||||
--
|
||||
-- 4. New helper function paliad.can_see_scenario(_scenario_id) that
|
||||
-- mirrors paliad.can_see_project's STABLE SECURITY DEFINER shape.
|
||||
-- Visibility logic:
|
||||
-- - global_admin sees everything,
|
||||
-- - owner_id = auth.uid() (builder-owned scenarios),
|
||||
-- - scenario_shares.shared_with_user_id = auth.uid()
|
||||
-- (read-only shared scenarios),
|
||||
-- - legacy project-scoped scenarios (owner_id IS NULL AND
|
||||
-- project_id IS NOT NULL) follow can_see_project(project_id),
|
||||
-- - legacy abstract scenarios (owner_id IS NULL AND project_id
|
||||
-- IS NULL) follow created_by = auth.uid().
|
||||
--
|
||||
-- 5. Replacement RLS policies on paliad.scenarios that fold builder
|
||||
-- visibility together with the legacy shape. The legacy
|
||||
-- project_* / abstract_* policies are dropped (they covered only
|
||||
-- legacy paths) and rewritten as a single pair of policies that
|
||||
-- treats owner_id, scenario_shares, and the legacy paths uniformly.
|
||||
--
|
||||
-- Builder-only RLS for the three new tables: read = scenario
|
||||
-- visibility; write = scenario owner (or legacy editor) only.
|
||||
--
|
||||
-- PRD §5.1 deviations called out for the reader:
|
||||
--
|
||||
-- - PRD specs `proceeding_type_id uuid REFERENCES paliad.proceeding_types(id)`.
|
||||
-- The live column is `integer` (see paliad.proceeding_types.id);
|
||||
-- scenario_proceedings.proceeding_type_id is integer here to match
|
||||
-- the real FK target. PRD authors did not check the column type;
|
||||
-- this migration uses the truth on disk.
|
||||
--
|
||||
-- - PRD references `auth.users(id)` for owner_id and share columns;
|
||||
-- the established paliad convention (see paliad.projects.created_by,
|
||||
-- paliad.scenarios.created_by) uses `paliad.users(id)`. Same UUIDs
|
||||
-- either way (paliad.users.id == auth.users.id), but the FK targets
|
||||
-- paliad.users to stay consistent with project tables.
|
||||
--
|
||||
-- Audit-first: all DDL ran clean against a BEGIN/ROLLBACK probe on the
|
||||
-- live DB before this file was committed. paliad.scenarios has 0 rows
|
||||
-- (verified pre-mig), so the column additions and constraint relaxations
|
||||
-- have no data impact.
|
||||
|
||||
BEGIN;
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 157: Scenario builder foundation (t-paliad-340 / m/paliad#153 B0)',
|
||||
true
|
||||
);
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 1. paliad.scenarios — additive columns + constraint relaxations
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
ALTER TABLE paliad.scenarios
|
||||
ADD COLUMN owner_id uuid NULL REFERENCES paliad.users(id) ON DELETE CASCADE,
|
||||
ADD COLUMN status text NOT NULL DEFAULT 'active'
|
||||
CHECK (status IN ('active','archived','promoted')),
|
||||
ADD COLUMN origin_project_id uuid NULL REFERENCES paliad.projects(id) ON DELETE SET NULL,
|
||||
ADD COLUMN promoted_project_id uuid NULL REFERENCES paliad.projects(id) ON DELETE SET NULL,
|
||||
ADD COLUMN stichtag date NULL,
|
||||
ADD COLUMN notes text NULL;
|
||||
|
||||
ALTER TABLE paliad.scenarios ALTER COLUMN spec DROP NOT NULL;
|
||||
ALTER TABLE paliad.scenarios DROP CONSTRAINT IF EXISTS scenarios_unique_per_scope;
|
||||
|
||||
CREATE INDEX scenarios_owner_status_idx
|
||||
ON paliad.scenarios(owner_id, status)
|
||||
WHERE owner_id IS NOT NULL;
|
||||
|
||||
CREATE INDEX scenarios_updated_idx
|
||||
ON paliad.scenarios(owner_id, updated_at DESC)
|
||||
WHERE owner_id IS NOT NULL;
|
||||
|
||||
COMMENT ON COLUMN paliad.scenarios.owner_id IS
|
||||
'Litigation Builder owner (PRD §5.1). NULL = legacy composition-spec '
|
||||
'scenario from m/paliad#124 Slice D (mig 145). Builder rows MUST have '
|
||||
'owner_id set; the application enforces it via ScenarioBuilderService.';
|
||||
|
||||
COMMENT ON COLUMN paliad.scenarios.status IS
|
||||
'Lifecycle: active (default; user-editable) / archived (soft-deleted, '
|
||||
'still visible in side panel) / promoted (converted to project via '
|
||||
'B5 wizard; read-only). Legacy mig-145 rows default to active.';
|
||||
|
||||
COMMENT ON COLUMN paliad.scenarios.origin_project_id IS
|
||||
'Set when the scenario was exported from an existing project '
|
||||
'("Im Builder öffnen" — Akte mode, PRD §2.3).';
|
||||
|
||||
COMMENT ON COLUMN paliad.scenarios.promoted_project_id IS
|
||||
'Set after the scenario was promoted to a real project via the 3-step '
|
||||
'wizard (PRD §5.4). Together with paliad.projects.origin_scenario_id, '
|
||||
'forms the bidirectional audit link.';
|
||||
|
||||
COMMENT ON COLUMN paliad.scenarios.stichtag IS
|
||||
'Scenario-level default Stichtag; per-proceeding overrides in '
|
||||
'paliad.scenario_proceedings.stichtag take precedence.';
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 2. paliad.scenario_proceedings — one proceeding per scenario row
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
CREATE TABLE paliad.scenario_proceedings (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
scenario_id uuid NOT NULL
|
||||
REFERENCES paliad.scenarios(id) ON DELETE CASCADE,
|
||||
proceeding_type_id integer NOT NULL
|
||||
REFERENCES paliad.proceeding_types(id),
|
||||
primary_party text NULL
|
||||
CHECK (primary_party IN ('claimant','defendant')),
|
||||
scenario_flags jsonb NOT NULL DEFAULT '{}'::jsonb
|
||||
CHECK (jsonb_typeof(scenario_flags) = 'object'),
|
||||
parent_scenario_proceeding_id uuid NULL
|
||||
REFERENCES paliad.scenario_proceedings(id) ON DELETE CASCADE,
|
||||
spawn_anchor_event_id uuid NULL
|
||||
REFERENCES paliad.sequencing_rules(id),
|
||||
ordinal int NOT NULL DEFAULT 0,
|
||||
stichtag date NULL,
|
||||
detailgrad text NOT NULL DEFAULT 'selected'
|
||||
CHECK (detailgrad IN ('selected','all_options')),
|
||||
appeal_target text NULL,
|
||||
collapsed boolean NOT NULL DEFAULT false,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX scenario_proceedings_scenario_idx
|
||||
ON paliad.scenario_proceedings(scenario_id, ordinal);
|
||||
|
||||
CREATE INDEX scenario_proceedings_parent_idx
|
||||
ON paliad.scenario_proceedings(parent_scenario_proceeding_id)
|
||||
WHERE parent_scenario_proceeding_id IS NOT NULL;
|
||||
|
||||
COMMENT ON TABLE paliad.scenario_proceedings IS
|
||||
'One proceeding inside a Litigation Builder scenario. Multiple rows '
|
||||
'per scenario for multi-proceeding constellations. '
|
||||
'parent_scenario_proceeding_id self-refs for spawned children '
|
||||
'(e.g. upc.ccr.cfi spawned by with_ccr on upc.inf.cfi). '
|
||||
'PRD §5.1, m/paliad#153 B0.';
|
||||
|
||||
COMMENT ON COLUMN paliad.scenario_proceedings.primary_party IS
|
||||
'Per-proceeding perspective ("our side"). NULL = no perspective '
|
||||
'picked yet (both party columns render with natural labels). '
|
||||
'Per-proceeding so multi-jurisdiction constellations can flip side '
|
||||
'independently (PRD §3.3).';
|
||||
|
||||
COMMENT ON COLUMN paliad.scenario_proceedings.scenario_flags IS
|
||||
'Per-proceeding flags (e.g. {"with_ccr": true, "with_amend": false}). '
|
||||
'Mirrors paliad.projects.scenario_flags shape but lives per-proceeding-'
|
||||
'per-scenario. Validated by the application against '
|
||||
'paliad.scenario_flag_catalog at write time.';
|
||||
|
||||
COMMENT ON COLUMN paliad.scenario_proceedings.spawn_anchor_event_id IS
|
||||
'Which sequencing_rule of the parent proceeding caused this spawn. '
|
||||
'NULL for root proceedings. Used by the UI to place the spawned child '
|
||||
'triplet directly below the parent at the spawn node (PRD §3.6).';
|
||||
|
||||
COMMENT ON COLUMN paliad.scenario_proceedings.ordinal IS
|
||||
'Stack order on canvas (top to bottom). Siblings under the same '
|
||||
'parent (or top-level) are ordered by ordinal asc, then created_at.';
|
||||
|
||||
COMMENT ON COLUMN paliad.scenario_proceedings.detailgrad IS
|
||||
'Per-proceeding optional-detail toggle: selected (only explicitly '
|
||||
'chosen optionals + mandatories) or all_options (every optional '
|
||||
'sequencing_rule surfaces). Matches today''s Verfahrensablauf pattern.';
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 3. paliad.scenario_events — one event card on the canvas
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
CREATE TABLE paliad.scenario_events (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
scenario_proceeding_id uuid NOT NULL
|
||||
REFERENCES paliad.scenario_proceedings(id) ON DELETE CASCADE,
|
||||
sequencing_rule_id uuid NULL
|
||||
REFERENCES paliad.sequencing_rules(id),
|
||||
procedural_event_id uuid NULL
|
||||
REFERENCES paliad.procedural_events(id),
|
||||
custom_label text NULL,
|
||||
state text NOT NULL DEFAULT 'planned'
|
||||
CHECK (state IN ('planned','filed','skipped')),
|
||||
actual_date date NULL,
|
||||
skip_reason text NULL,
|
||||
notes text NULL,
|
||||
horizon_optional int NOT NULL DEFAULT 0
|
||||
CHECK (horizon_optional >= 0),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT scenario_events_one_anchor CHECK (
|
||||
(sequencing_rule_id IS NOT NULL)::int +
|
||||
(procedural_event_id IS NOT NULL)::int +
|
||||
(custom_label IS NOT NULL)::int >= 1
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX scenario_events_proceeding_idx
|
||||
ON paliad.scenario_events(scenario_proceeding_id);
|
||||
|
||||
-- A single proceeding can't carry two cards for the same sequencing rule
|
||||
-- (each rule maps to one card). Free-form / procedural_event-only cards
|
||||
-- skip this uniqueness — multiple custom cards per proceeding are OK.
|
||||
CREATE UNIQUE INDEX scenario_events_rule_uniq_idx
|
||||
ON paliad.scenario_events(scenario_proceeding_id, sequencing_rule_id)
|
||||
WHERE sequencing_rule_id IS NOT NULL;
|
||||
|
||||
COMMENT ON TABLE paliad.scenario_events IS
|
||||
'One event card on the Litigation Builder canvas. Captures state '
|
||||
'(planned/filed/skipped), actual_date, notes, skip_reason, and the '
|
||||
'per-card optional-horizon setting. At least one of '
|
||||
'(sequencing_rule_id, procedural_event_id, custom_label) must be '
|
||||
'set — sequencing-rule-backed cards are the common case; free-form '
|
||||
'cards exist for events the catalog doesn''t cover yet. '
|
||||
'PRD §3.4 / §5.1.';
|
||||
|
||||
COMMENT ON COLUMN paliad.scenario_events.state IS
|
||||
'3-state machine: planned (default, future event with computed date) '
|
||||
'/ filed (past event, actual_date set) / skipped (user chose not to '
|
||||
'file; optional skip_reason). No "overdue" enum — that''s derived '
|
||||
'(date < today AND state=planned), not stored. PRD Q10 / §3.4.';
|
||||
|
||||
COMMENT ON COLUMN paliad.scenario_events.actual_date IS
|
||||
'Set when state=filed (real-world filing date) OR when state=planned '
|
||||
'and the user overrode the computed date (court-set events, manual '
|
||||
'tweaks). NULL when the computed date is canonical.';
|
||||
|
||||
COMMENT ON COLUMN paliad.scenario_events.horizon_optional IS
|
||||
'Per-card "show N more optional follow-ups" affordance. Default 0 '
|
||||
'(hidden). PRD Q4 / §3.4.';
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 4. paliad.scenario_shares — read-only team shares
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
CREATE TABLE paliad.scenario_shares (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
scenario_id uuid NOT NULL
|
||||
REFERENCES paliad.scenarios(id) ON DELETE CASCADE,
|
||||
shared_with_user_id uuid NOT NULL
|
||||
REFERENCES paliad.users(id) ON DELETE CASCADE,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
created_by uuid NOT NULL REFERENCES paliad.users(id),
|
||||
UNIQUE (scenario_id, shared_with_user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX scenario_shares_user_idx
|
||||
ON paliad.scenario_shares(shared_with_user_id);
|
||||
|
||||
COMMENT ON TABLE paliad.scenario_shares IS
|
||||
'Read-only team shares for Litigation Builder scenarios. Owner '
|
||||
'(paliad.scenarios.owner_id) is the sole editor; rows here grant '
|
||||
'view-only access to other paliad users. PRD Q12 / §5.1.';
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 5. paliad.projects.origin_scenario_id — promote-to-project trail
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
ALTER TABLE paliad.projects
|
||||
ADD COLUMN origin_scenario_id uuid NULL
|
||||
REFERENCES paliad.scenarios(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX projects_origin_scenario_idx
|
||||
ON paliad.projects(origin_scenario_id)
|
||||
WHERE origin_scenario_id IS NOT NULL;
|
||||
|
||||
COMMENT ON COLUMN paliad.projects.origin_scenario_id IS
|
||||
'FK to the scenario this project was promoted from (B5 wizard). '
|
||||
'NULL = project was created directly, not via Builder. Together with '
|
||||
'paliad.scenarios.promoted_project_id, forms the bidirectional audit '
|
||||
'link. PRD §5.2.';
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 6. paliad.can_see_scenario — visibility helper
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
CREATE OR REPLACE FUNCTION paliad.can_see_scenario(_scenario_id uuid)
|
||||
RETURNS boolean
|
||||
LANGUAGE sql STABLE SECURITY DEFINER
|
||||
SET search_path TO 'paliad', 'public'
|
||||
AS $func$
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM paliad.users u
|
||||
WHERE u.id = auth.uid() AND u.global_role = 'global_admin'
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM paliad.scenarios s
|
||||
WHERE s.id = _scenario_id AND s.owner_id = auth.uid()
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM paliad.scenario_shares sh
|
||||
WHERE sh.scenario_id = _scenario_id
|
||||
AND sh.shared_with_user_id = auth.uid()
|
||||
)
|
||||
-- Legacy project-scoped scenarios (mig 145) — visible via project
|
||||
-- team membership.
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM paliad.scenarios s
|
||||
WHERE s.id = _scenario_id
|
||||
AND s.owner_id IS NULL
|
||||
AND s.project_id IS NOT NULL
|
||||
AND paliad.can_see_project(s.project_id)
|
||||
)
|
||||
-- Legacy abstract scenarios (mig 145) — owner-only via created_by.
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM paliad.scenarios s
|
||||
WHERE s.id = _scenario_id
|
||||
AND s.owner_id IS NULL
|
||||
AND s.project_id IS NULL
|
||||
AND s.created_by = auth.uid()
|
||||
);
|
||||
$func$;
|
||||
|
||||
COMMENT ON FUNCTION paliad.can_see_scenario(uuid) IS
|
||||
'Returns true if the caller (auth.uid()) can see the given scenario. '
|
||||
'Mirrors paliad.can_see_project. Covers builder-owned scenarios '
|
||||
'(owner_id), read-only shares (scenario_shares), and the two legacy '
|
||||
'paths from mig 145 (project-scoped via can_see_project, abstract '
|
||||
'via created_by). Used by RLS on all four scenario_* tables.';
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 7. RLS — replace legacy scenarios policies + new tables
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
-- Replace mig-145's four policies with a single pair that handles
|
||||
-- builder + legacy shapes together.
|
||||
DROP POLICY IF EXISTS scenarios_project_select ON paliad.scenarios;
|
||||
DROP POLICY IF EXISTS scenarios_project_mutate ON paliad.scenarios;
|
||||
DROP POLICY IF EXISTS scenarios_abstract_select ON paliad.scenarios;
|
||||
DROP POLICY IF EXISTS scenarios_abstract_mutate ON paliad.scenarios;
|
||||
|
||||
CREATE POLICY scenarios_select ON paliad.scenarios
|
||||
FOR SELECT USING (paliad.can_see_scenario(id));
|
||||
|
||||
-- Write rule: builder owner, legacy project team member (if no owner),
|
||||
-- or legacy abstract creator (if no owner + no project). Shares are
|
||||
-- read-only — they don't grant mutate.
|
||||
CREATE POLICY scenarios_owner_mutate ON paliad.scenarios
|
||||
FOR ALL
|
||||
USING (
|
||||
owner_id = auth.uid()
|
||||
OR (owner_id IS NULL AND project_id IS NOT NULL AND paliad.can_see_project(project_id))
|
||||
OR (owner_id IS NULL AND project_id IS NULL AND created_by = auth.uid())
|
||||
)
|
||||
WITH CHECK (
|
||||
owner_id = auth.uid()
|
||||
OR (owner_id IS NULL AND project_id IS NOT NULL AND paliad.can_see_project(project_id))
|
||||
OR (owner_id IS NULL AND project_id IS NULL AND created_by = auth.uid())
|
||||
);
|
||||
|
||||
-- scenario_proceedings — visibility piggybacks on the parent scenario.
|
||||
ALTER TABLE paliad.scenario_proceedings ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY scenario_proceedings_select ON paliad.scenario_proceedings
|
||||
FOR SELECT USING (paliad.can_see_scenario(scenario_id));
|
||||
|
||||
CREATE POLICY scenario_proceedings_mutate ON paliad.scenario_proceedings
|
||||
FOR ALL
|
||||
USING (EXISTS (
|
||||
SELECT 1 FROM paliad.scenarios s
|
||||
WHERE s.id = scenario_id
|
||||
AND (s.owner_id = auth.uid()
|
||||
OR (s.owner_id IS NULL AND s.project_id IS NOT NULL AND paliad.can_see_project(s.project_id))
|
||||
OR (s.owner_id IS NULL AND s.project_id IS NULL AND s.created_by = auth.uid()))
|
||||
))
|
||||
WITH CHECK (EXISTS (
|
||||
SELECT 1 FROM paliad.scenarios s
|
||||
WHERE s.id = scenario_id
|
||||
AND (s.owner_id = auth.uid()
|
||||
OR (s.owner_id IS NULL AND s.project_id IS NOT NULL AND paliad.can_see_project(s.project_id))
|
||||
OR (s.owner_id IS NULL AND s.project_id IS NULL AND s.created_by = auth.uid()))
|
||||
));
|
||||
|
||||
-- scenario_events — visibility piggybacks on the parent scenario via
|
||||
-- the proceeding row.
|
||||
ALTER TABLE paliad.scenario_events ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY scenario_events_select ON paliad.scenario_events
|
||||
FOR SELECT
|
||||
USING (EXISTS (
|
||||
SELECT 1 FROM paliad.scenario_proceedings sp
|
||||
WHERE sp.id = scenario_proceeding_id
|
||||
AND paliad.can_see_scenario(sp.scenario_id)
|
||||
));
|
||||
|
||||
CREATE POLICY scenario_events_mutate ON paliad.scenario_events
|
||||
FOR ALL
|
||||
USING (EXISTS (
|
||||
SELECT 1 FROM paliad.scenario_proceedings sp
|
||||
JOIN paliad.scenarios s ON s.id = sp.scenario_id
|
||||
WHERE sp.id = scenario_proceeding_id
|
||||
AND (s.owner_id = auth.uid()
|
||||
OR (s.owner_id IS NULL AND s.project_id IS NOT NULL AND paliad.can_see_project(s.project_id))
|
||||
OR (s.owner_id IS NULL AND s.project_id IS NULL AND s.created_by = auth.uid()))
|
||||
))
|
||||
WITH CHECK (EXISTS (
|
||||
SELECT 1 FROM paliad.scenario_proceedings sp
|
||||
JOIN paliad.scenarios s ON s.id = sp.scenario_id
|
||||
WHERE sp.id = scenario_proceeding_id
|
||||
AND (s.owner_id = auth.uid()
|
||||
OR (s.owner_id IS NULL AND s.project_id IS NOT NULL AND paliad.can_see_project(s.project_id))
|
||||
OR (s.owner_id IS NULL AND s.project_id IS NULL AND s.created_by = auth.uid()))
|
||||
));
|
||||
|
||||
-- scenario_shares — recipient can see their share rows; the scenario
|
||||
-- owner (or legacy editor) can manage them.
|
||||
ALTER TABLE paliad.scenario_shares ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY scenario_shares_select ON paliad.scenario_shares
|
||||
FOR SELECT
|
||||
USING (
|
||||
shared_with_user_id = auth.uid()
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM paliad.scenarios s
|
||||
WHERE s.id = scenario_id
|
||||
AND (s.owner_id = auth.uid()
|
||||
OR (s.owner_id IS NULL AND s.project_id IS NOT NULL AND paliad.can_see_project(s.project_id))
|
||||
OR (s.owner_id IS NULL AND s.project_id IS NULL AND s.created_by = auth.uid()))
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY scenario_shares_mutate ON paliad.scenario_shares
|
||||
FOR ALL
|
||||
USING (EXISTS (
|
||||
SELECT 1 FROM paliad.scenarios s
|
||||
WHERE s.id = scenario_id
|
||||
AND (s.owner_id = auth.uid()
|
||||
OR (s.owner_id IS NULL AND s.project_id IS NOT NULL AND paliad.can_see_project(s.project_id))
|
||||
OR (s.owner_id IS NULL AND s.project_id IS NULL AND s.created_by = auth.uid()))
|
||||
))
|
||||
WITH CHECK (EXISTS (
|
||||
SELECT 1 FROM paliad.scenarios s
|
||||
WHERE s.id = scenario_id
|
||||
AND (s.owner_id = auth.uid()
|
||||
OR (s.owner_id IS NULL AND s.project_id IS NOT NULL AND paliad.can_see_project(s.project_id))
|
||||
OR (s.owner_id IS NULL AND s.project_id IS NULL AND s.created_by = auth.uid()))
|
||||
));
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 8. updated_at triggers on the new tables (reuse the function mig 145
|
||||
-- already created for paliad.scenarios).
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
CREATE TRIGGER scenario_proceedings_touch_updated_at_trg
|
||||
BEFORE UPDATE ON paliad.scenario_proceedings
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION paliad.scenarios_touch_updated_at();
|
||||
|
||||
CREATE TRIGGER scenario_events_touch_updated_at_trg
|
||||
BEFORE UPDATE ON paliad.scenario_events
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION paliad.scenarios_touch_updated_at();
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 9. Informational NOTICE.
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
RAISE NOTICE '[mig 157] paliad.scenarios extended with builder columns (0 legacy rows affected)';
|
||||
RAISE NOTICE '[mig 157] paliad.scenario_proceedings created';
|
||||
RAISE NOTICE '[mig 157] paliad.scenario_events created';
|
||||
RAISE NOTICE '[mig 157] paliad.scenario_shares created';
|
||||
RAISE NOTICE '[mig 157] paliad.projects.origin_scenario_id added';
|
||||
RAISE NOTICE '[mig 157] paliad.can_see_scenario(uuid) created';
|
||||
END $$;
|
||||
|
||||
COMMIT;
|
||||
12
internal/db/migrations/158_docforge_templates.down.sql
Normal file
12
internal/db/migrations/158_docforge_templates.down.sql
Normal file
@@ -0,0 +1,12 @@
|
||||
-- t-paliad-349: revert docforge template authoring tables.
|
||||
--
|
||||
-- Drop the FK first so the templates ↔ template_versions cycle unwinds,
|
||||
-- then the tables (template_slots + template_versions cascade from their
|
||||
-- parents, but drop explicitly for clarity and order-independence).
|
||||
|
||||
ALTER TABLE IF EXISTS paliad.templates
|
||||
DROP CONSTRAINT IF EXISTS templates_current_version_fk;
|
||||
|
||||
DROP TABLE IF EXISTS paliad.template_slots;
|
||||
DROP TABLE IF EXISTS paliad.template_versions;
|
||||
DROP TABLE IF EXISTS paliad.templates;
|
||||
127
internal/db/migrations/158_docforge_templates.up.sql
Normal file
127
internal/db/migrations/158_docforge_templates.up.sql
Normal file
@@ -0,0 +1,127 @@
|
||||
-- t-paliad-349 (m/paliad#157): docforge slice 4 — template authoring tables.
|
||||
--
|
||||
-- These three tables are the persistence home for the docforge authoring
|
||||
-- flow (upload a base .docx → place variable slots → save as a reusable
|
||||
-- template) and the generation flow (pick a template → bind data →
|
||||
-- export). They are paliad's implementation of the docforge.TemplateStore
|
||||
-- contract; docforge itself owns no tables (the litigationplanner pattern).
|
||||
--
|
||||
-- Generic on purpose (NOT submission_*-named): authoring is a
|
||||
-- domain-neutral capability, so the eventual second docforge consumer can
|
||||
-- reuse the same shape. submission_bases (Gitea-backed, section_spec) stays
|
||||
-- for the legacy base catalog during the transition; convergence is a
|
||||
-- later, separate task.
|
||||
--
|
||||
-- paliad.templates — one row per template (the catalog entry).
|
||||
-- paliad.template_versions — immutable snapshots; editing a template
|
||||
-- inserts a new version. The carrier .docx
|
||||
-- bytes live here (bytea) — the TemplateStore
|
||||
-- bytea backend. A draft pins a version
|
||||
-- (snapshot-at-create, PRD §4 A3) so later
|
||||
-- edits don't shift an in-flight draft.
|
||||
-- paliad.template_slots — the variable slots placed in a version's
|
||||
-- carrier. anchor is the sentinel token the
|
||||
-- authoring surface injects into the carrier
|
||||
-- OOXML to locate the slot (PRD §5 lean);
|
||||
-- slot_key is the variable bound there.
|
||||
--
|
||||
-- Visibility: the template catalog is shared firm-wide (every
|
||||
-- authenticated user generates from it), so SELECT is open to
|
||||
-- authenticated, mirroring submission_bases. Mutations (upload, edit) are
|
||||
-- admin-only and gated in Go at the handler layer — no INSERT/UPDATE/DELETE
|
||||
-- RLS path means RLS denies them by default.
|
||||
--
|
||||
-- Slice 4 ships the schema + the TemplateStore only; no rows are seeded and
|
||||
-- no UI writes here yet (authoring is slice 6, generation-on-templates is
|
||||
-- slice 7).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS paliad.templates (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
slug text UNIQUE,
|
||||
name_de text NOT NULL,
|
||||
name_en text NOT NULL,
|
||||
kind text NOT NULL DEFAULT 'submission',
|
||||
source_format text NOT NULL DEFAULT 'docx',
|
||||
firm text,
|
||||
is_active bool NOT NULL DEFAULT true,
|
||||
current_version_id uuid,
|
||||
created_by uuid NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
CONSTRAINT templates_source_format_check CHECK (source_format IN ('docx'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS paliad.template_versions (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
template_id uuid NOT NULL REFERENCES paliad.templates(id) ON DELETE CASCADE,
|
||||
version int NOT NULL,
|
||||
carrier_blob bytea NOT NULL,
|
||||
stylemap jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_by uuid NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
CONSTRAINT template_versions_unique_per_template UNIQUE (template_id, version)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS paliad.template_slots (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
template_version_id uuid NOT NULL REFERENCES paliad.template_versions(id) ON DELETE CASCADE,
|
||||
slot_key text NOT NULL,
|
||||
anchor text NOT NULL,
|
||||
label text,
|
||||
order_index int NOT NULL DEFAULT 0,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
CONSTRAINT template_slots_unique_anchor UNIQUE (template_version_id, anchor)
|
||||
);
|
||||
|
||||
-- current_version_id FK is added after template_versions exists to avoid a
|
||||
-- circular CREATE-TABLE dependency. ON DELETE SET NULL: dropping the
|
||||
-- pinned version detaches it rather than cascading the template away.
|
||||
ALTER TABLE paliad.templates
|
||||
DROP CONSTRAINT IF EXISTS templates_current_version_fk;
|
||||
ALTER TABLE paliad.templates
|
||||
ADD CONSTRAINT templates_current_version_fk
|
||||
FOREIGN KEY (current_version_id)
|
||||
REFERENCES paliad.template_versions(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS templates_firm_kind_idx
|
||||
ON paliad.templates (firm, kind) WHERE is_active;
|
||||
CREATE INDEX IF NOT EXISTS template_versions_template_idx
|
||||
ON paliad.template_versions (template_id, version);
|
||||
CREATE INDEX IF NOT EXISTS template_slots_version_idx
|
||||
ON paliad.template_slots (template_version_id, order_index);
|
||||
|
||||
ALTER TABLE paliad.templates ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE paliad.template_versions ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE paliad.template_slots ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Firm-shared catalog: any authenticated user reads. Mutations are
|
||||
-- admin-only, gated in Go (no mutation RLS policy = RLS denies by default).
|
||||
DROP POLICY IF EXISTS templates_select ON paliad.templates;
|
||||
CREATE POLICY templates_select
|
||||
ON paliad.templates FOR SELECT TO authenticated
|
||||
USING (true);
|
||||
|
||||
DROP POLICY IF EXISTS template_versions_select ON paliad.template_versions;
|
||||
CREATE POLICY template_versions_select
|
||||
ON paliad.template_versions FOR SELECT TO authenticated
|
||||
USING (true);
|
||||
|
||||
DROP POLICY IF EXISTS template_slots_select ON paliad.template_slots;
|
||||
CREATE POLICY template_slots_select
|
||||
ON paliad.template_slots FOR SELECT TO authenticated
|
||||
USING (true);
|
||||
|
||||
DROP TRIGGER IF EXISTS templates_set_updated_at ON paliad.templates;
|
||||
CREATE TRIGGER templates_set_updated_at
|
||||
BEFORE UPDATE ON paliad.templates
|
||||
FOR EACH ROW EXECUTE FUNCTION paliad.tg_set_updated_at();
|
||||
|
||||
COMMENT ON TABLE paliad.templates IS
|
||||
't-paliad-349: docforge template catalog. One row per uploaded template; current_version_id pins the live version.';
|
||||
COMMENT ON TABLE paliad.template_versions IS
|
||||
't-paliad-349: immutable docforge template snapshots. carrier_blob holds the base .docx bytes (TemplateStore bytea backend).';
|
||||
COMMENT ON TABLE paliad.template_slots IS
|
||||
't-paliad-349: variable slots placed in a template version. anchor = sentinel token locating the slot in the carrier OOXML; slot_key = the bound variable.';
|
||||
@@ -0,0 +1,6 @@
|
||||
-- t-paliad-349: revert the template-version pin on submission drafts.
|
||||
|
||||
DROP INDEX IF EXISTS paliad.submission_drafts_template_version_idx;
|
||||
|
||||
ALTER TABLE paliad.submission_drafts
|
||||
DROP COLUMN IF EXISTS template_version_id;
|
||||
@@ -0,0 +1,28 @@
|
||||
-- t-paliad-349 (m/paliad#157): docforge slice 7 — pin an uploaded template
|
||||
-- version onto a submission draft (generation-on-uploaded-templates).
|
||||
--
|
||||
-- A draft can now source its document from a docforge uploaded template
|
||||
-- (paliad.template_versions) instead of a legacy Gitea base. template_version_id
|
||||
-- is the snapshot pin (PRD §4 A3): the draft renders the exact carrier of the
|
||||
-- version it was bound to, so a later template edit (which creates a new
|
||||
-- version) doesn't shift an in-flight draft.
|
||||
--
|
||||
-- Nullable + additive: existing drafts keep template_version_id NULL and
|
||||
-- render via their existing path (Composer base_id, or the v1 fallback).
|
||||
-- The three sources are mutually exclusive in practice; the export path
|
||||
-- checks template_version_id first, then base_id, then v1.
|
||||
--
|
||||
-- ON DELETE SET NULL: if the pinned version is removed, the draft detaches
|
||||
-- and falls back rather than failing — same posture as base_id's
|
||||
-- ON DELETE SET NULL.
|
||||
|
||||
ALTER TABLE paliad.submission_drafts
|
||||
ADD COLUMN IF NOT EXISTS template_version_id uuid
|
||||
REFERENCES paliad.template_versions(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS submission_drafts_template_version_idx
|
||||
ON paliad.submission_drafts (template_version_id)
|
||||
WHERE template_version_id IS NOT NULL;
|
||||
|
||||
COMMENT ON COLUMN paliad.submission_drafts.template_version_id IS
|
||||
't-paliad-349: pinned docforge template version (snapshot-at-create). NULL = render via base_id Composer path or v1 fallback.';
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE paliad.users
|
||||
DROP COLUMN IF EXISTS name_compositions;
|
||||
12
internal/db/migrations/160_user_name_compositions.up.sql
Normal file
12
internal/db/migrations/160_user_name_compositions.up.sql
Normal file
@@ -0,0 +1,12 @@
|
||||
-- Per-user name-composition overrides (t-paliad-356 Slice 3, PRD §3.2).
|
||||
--
|
||||
-- A free-form JSONB map of { artifact_id: Composition } overriding the
|
||||
-- code-resident system-default name composition for that artifact (the two
|
||||
-- seed schemes: submission_draft_title, submission_docx_filename). An empty
|
||||
-- object means "no overrides — use the system defaults"; unknown artifact
|
||||
-- ids and segments referencing unknown variables are dropped on read
|
||||
-- (NameCompositionSpec.SanitizeForRead) and rejected on write
|
||||
-- (NameCompositionSpec.Validate), mirroring the user_dashboard_layouts
|
||||
-- pattern.
|
||||
ALTER TABLE paliad.users
|
||||
ADD COLUMN IF NOT EXISTS name_compositions jsonb NOT NULL DEFAULT '{}'::jsonb;
|
||||
@@ -0,0 +1,55 @@
|
||||
-- Revert t-paliad-358 A-S2: restore each base's original (pre-parametric)
|
||||
-- caption seed_md from migrations 146 / 150, verbatim. One UPDATE per slug
|
||||
-- because the originals differed per base.
|
||||
|
||||
-- hlc-letterhead (mig 146): heading + parties with "vertreten durch" + court.
|
||||
UPDATE paliad.submission_bases AS b
|
||||
SET section_spec = jsonb_set(b.section_spec, '{defaults}', (
|
||||
SELECT jsonb_agg(
|
||||
CASE WHEN elem->>'section_key' = 'caption'
|
||||
THEN elem || jsonb_build_object(
|
||||
'seed_md_de', E'In der Sache\n\n**{{parties.claimant.0.name}}**\nvertreten durch {{parties.claimant.0.representative}}\n\n— Klägerin —\n\ngegen\n\n**{{parties.defendant.0.name}}**\nvertreten durch {{parties.defendant.0.representative}}\n\n— Beklagte —\n\nAktenzeichen: {{project.case_number}}\n{{project.court}}',
|
||||
'seed_md_en', E'In the matter\n\n**{{parties.claimant.0.name}}**\nrepresented by {{parties.claimant.0.representative}}\n\n— Claimant —\n\nv.\n\n**{{parties.defendant.0.name}}**\nrepresented by {{parties.defendant.0.representative}}\n\n— Defendant —\n\nCase number: {{project.case_number}}\n{{project.court}}')
|
||||
ELSE elem END
|
||||
ORDER BY ord)
|
||||
FROM jsonb_array_elements(b.section_spec->'defaults') WITH ORDINALITY AS d(elem, ord)))
|
||||
WHERE b.slug = 'hlc-letterhead' AND b.section_spec ? 'defaults';
|
||||
|
||||
-- neutral (mig 146): heading + parties (no representative) + Aktenzeichen, no court.
|
||||
UPDATE paliad.submission_bases AS b
|
||||
SET section_spec = jsonb_set(b.section_spec, '{defaults}', (
|
||||
SELECT jsonb_agg(
|
||||
CASE WHEN elem->>'section_key' = 'caption'
|
||||
THEN elem || jsonb_build_object(
|
||||
'seed_md_de', E'In der Sache\n\n**{{parties.claimant.0.name}}**\n— Klägerin —\n\ngegen\n\n**{{parties.defendant.0.name}}**\n— Beklagte —\n\nAktenzeichen: {{project.case_number}}',
|
||||
'seed_md_en', E'In the matter\n\n**{{parties.claimant.0.name}}**\n— Claimant —\n\nv.\n\n**{{parties.defendant.0.name}}**\n— Defendant —\n\nCase number: {{project.case_number}}')
|
||||
ELSE elem END
|
||||
ORDER BY ord)
|
||||
FROM jsonb_array_elements(b.section_spec->'defaults') WITH ORDINALITY AS d(elem, ord)))
|
||||
WHERE b.slug = 'neutral' AND b.section_spec ? 'defaults';
|
||||
|
||||
-- lg-duesseldorf (mig 150): heading + parties (no representative) + court.
|
||||
UPDATE paliad.submission_bases AS b
|
||||
SET section_spec = jsonb_set(b.section_spec, '{defaults}', (
|
||||
SELECT jsonb_agg(
|
||||
CASE WHEN elem->>'section_key' = 'caption'
|
||||
THEN elem || jsonb_build_object(
|
||||
'seed_md_de', E'In der Sache\n\n**{{parties.claimant.0.name}}**\n— Klägerin —\n\ngegen\n\n**{{parties.defendant.0.name}}**\n— Beklagte —\n\nAktenzeichen: {{project.case_number}}\n{{project.court}}',
|
||||
'seed_md_en', E'In the matter\n\n**{{parties.claimant.0.name}}**\n— Claimant —\n\nv.\n\n**{{parties.defendant.0.name}}**\n— Defendant —\n\nCase number: {{project.case_number}}\n{{project.court}}')
|
||||
ELSE elem END
|
||||
ORDER BY ord)
|
||||
FROM jsonb_array_elements(b.section_spec->'defaults') WITH ORDINALITY AS d(elem, ord)))
|
||||
WHERE b.slug = 'lg-duesseldorf' AND b.section_spec ? 'defaults';
|
||||
|
||||
-- upc-formal (mig 150): UPC heading + parties with "represented by" + UPC case number + patent.
|
||||
UPDATE paliad.submission_bases AS b
|
||||
SET section_spec = jsonb_set(b.section_spec, '{defaults}', (
|
||||
SELECT jsonb_agg(
|
||||
CASE WHEN elem->>'section_key' = 'caption'
|
||||
THEN elem || jsonb_build_object(
|
||||
'seed_md_de', E'# In the matter\n\n**{{parties.claimant.0.name}}**\nrepresented by {{parties.claimant.0.representative}}\n— Claimant —\n\nv.\n\n**{{parties.defendant.0.name}}**\nrepresented by {{parties.defendant.0.representative}}\n— Defendant —\n\nUPC-Aktenzeichen: {{project.case_number}}\nStreitpatent: {{project.patent_number_upc}}',
|
||||
'seed_md_en', E'# In the matter\n\n**{{parties.claimant.0.name}}**\nrepresented by {{parties.claimant.0.representative}}\n— Claimant —\n\nv.\n\n**{{parties.defendant.0.name}}**\nrepresented by {{parties.defendant.0.representative}}\n— Defendant —\n\nUPC case number: {{project.case_number}}\nPatent in suit: {{project.patent_number_upc}}')
|
||||
ELSE elem END
|
||||
ORDER BY ord)
|
||||
FROM jsonb_array_elements(b.section_spec->'defaults') WITH ORDINALITY AS d(elem, ord)))
|
||||
WHERE b.slug = 'upc-formal' AND b.section_spec ? 'defaults';
|
||||
@@ -0,0 +1,43 @@
|
||||
-- t-paliad-358 A-S2 — unify the Composer caption (Rubrum) seed across every
|
||||
-- base onto the shared parametric caption.* resolver keys.
|
||||
--
|
||||
-- Before: each base seeded a hand-written caption with hard-coded designations
|
||||
-- ("— Klägerin —" / "— Claimant —") and heading ("In der Sache" / "In the
|
||||
-- matter"). That wording diverged from the per-code .docx templates and the
|
||||
-- merge-fallback skeleton, and could not reflect the forum (UPC vs DE-LG vs
|
||||
-- nullity vs appeal).
|
||||
--
|
||||
-- After: every base's caption section references the {{caption.*}} keys
|
||||
-- (addCaptionVars, submission_vars.go), so the heading, party designations,
|
||||
-- versus connector and "wegen" subject are resolved per forum from
|
||||
-- project.proceeding (jurisdiction + code + role-label overrides) +
|
||||
-- project.instance_level — the SAME wording the templates and the fallback
|
||||
-- skeleton now use. One parametric caption, shared keys.
|
||||
--
|
||||
-- Forward-only effect: section seeds are applied when a NEW draft is created
|
||||
-- from a base; existing drafts keep their already-seeded (possibly user-edited)
|
||||
-- caption text untouched.
|
||||
--
|
||||
-- Position-independent: rewrites only the element whose section_key='caption'
|
||||
-- inside section_spec->'defaults', preserving order (WITH ORDINALITY) and every
|
||||
-- other field on the element (elem || patch).
|
||||
|
||||
UPDATE paliad.submission_bases AS b
|
||||
SET section_spec = jsonb_set(
|
||||
b.section_spec,
|
||||
'{defaults}',
|
||||
(
|
||||
SELECT jsonb_agg(
|
||||
CASE WHEN elem->>'section_key' = 'caption'
|
||||
THEN elem || jsonb_build_object(
|
||||
'seed_md_de', E'{{caption.heading_de}}\n\n**{{parties.claimant.0.name}}**\nvertreten durch {{parties.claimant.0.representative}}\n\n— {{caption.claimant_designation_de}} —\n\n{{caption.versus_de}}\n\n**{{parties.defendant.0.name}}**\nvertreten durch {{parties.defendant.0.representative}}\n\n— {{caption.defendant_designation_de}} —\n\nwegen {{caption.subject_de}}\n\nAktenzeichen: {{project.case_number}}\n{{project.court}}',
|
||||
'seed_md_en', E'{{caption.heading_en}}\n\n**{{parties.claimant.0.name}}**\nrepresented by {{parties.claimant.0.representative}}\n\n— {{caption.claimant_designation_en}} —\n\n{{caption.versus_en}}\n\n**{{parties.defendant.0.name}}**\nrepresented by {{parties.defendant.0.representative}}\n\n— {{caption.defendant_designation_en}} —\n\nre {{caption.subject_en}}\n\nCase number: {{project.case_number}}\n{{project.court}}')
|
||||
ELSE elem
|
||||
END
|
||||
ORDER BY ord
|
||||
)
|
||||
FROM jsonb_array_elements(b.section_spec->'defaults') WITH ORDINALITY AS d(elem, ord)
|
||||
)
|
||||
)
|
||||
WHERE b.slug IN ('hlc-letterhead', 'neutral', 'lg-duesseldorf', 'upc-formal')
|
||||
AND b.section_spec ? 'defaults';
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS paliad.firm_name_compositions;
|
||||
31
internal/db/migrations/162_firm_name_compositions.up.sql
Normal file
31
internal/db/migrations/162_firm_name_compositions.up.sql
Normal file
@@ -0,0 +1,31 @@
|
||||
-- Firm-wide default name compositions (t-paliad-356 Slice 5, PRD §3.2 / §8).
|
||||
--
|
||||
-- The firm tier of the name-composition precedence chain
|
||||
-- (per-document → user → FIRM → system). A single optional row holds the
|
||||
-- firm's house naming convention as a JSONB { artifact_id: Composition } map,
|
||||
-- validated by NameCompositionSpec exactly like the per-user
|
||||
-- users.name_compositions column (mig 160). Cleared → resolution falls through
|
||||
-- to the always-present code-resident system default.
|
||||
--
|
||||
-- Mirrors paliad.firm_dashboard_default (mig 117) exactly: single-row design
|
||||
-- via CHECK (id = 1), all authenticated users may SELECT (the render path
|
||||
-- reads it for every draft-name / filename), writes happen only under the
|
||||
-- service-role connection behind the admin HTTP gate.
|
||||
|
||||
CREATE TABLE paliad.firm_name_compositions (
|
||||
id smallint PRIMARY KEY DEFAULT 1 CHECK (id = 1),
|
||||
compositions_json jsonb NOT NULL,
|
||||
updated_by uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
ALTER TABLE paliad.firm_name_compositions ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- All authenticated users can SELECT — the name-render path needs to read the
|
||||
-- firm default when composing any draft title / export filename. The HTTP
|
||||
-- handler enforces admin-only on the PUT/DELETE paths; the service runs under
|
||||
-- service-role so writes bypass RLS anyway. No INSERT/UPDATE policy means no
|
||||
-- Supabase-JWT-authenticated client can write, which is the desired posture.
|
||||
CREATE POLICY firm_name_compositions_read
|
||||
ON paliad.firm_name_compositions FOR SELECT
|
||||
USING (true);
|
||||
40
internal/db/migrations/163_caption_wording_followup.down.sql
Normal file
40
internal/db/migrations/163_caption_wording_followup.down.sql
Normal file
@@ -0,0 +1,40 @@
|
||||
-- Revert 163_caption_wording_followup (t-paliad-361). Restores the A-S2
|
||||
-- (post-mig-161 / mig-137) state for all three changes.
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- Change 1 down — UPC appeal EN responding party back to 'Appellee'.
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
UPDATE paliad.proceeding_types
|
||||
SET role_reactive_label_en = 'Appellee'
|
||||
WHERE code = 'upc.apl.unified'
|
||||
AND role_reactive_label_en = 'Respondent';
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- Change 2 down — drop the Streitpatent line from the upc-formal caption seed,
|
||||
-- restoring the verbatim post-mig-161 parametric seed.
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
UPDATE paliad.submission_bases AS b
|
||||
SET section_spec = jsonb_set(b.section_spec, '{defaults}', (
|
||||
SELECT jsonb_agg(
|
||||
CASE WHEN elem->>'section_key' = 'caption'
|
||||
THEN elem || jsonb_build_object(
|
||||
'seed_md_de', E'{{caption.heading_de}}\n\n**{{parties.claimant.0.name}}**\nvertreten durch {{parties.claimant.0.representative}}\n\n— {{caption.claimant_designation_de}} —\n\n{{caption.versus_de}}\n\n**{{parties.defendant.0.name}}**\nvertreten durch {{parties.defendant.0.representative}}\n\n— {{caption.defendant_designation_de}} —\n\nwegen {{caption.subject_de}}\n\nAktenzeichen: {{project.case_number}}\n{{project.court}}',
|
||||
'seed_md_en', E'{{caption.heading_en}}\n\n**{{parties.claimant.0.name}}**\nrepresented by {{parties.claimant.0.representative}}\n\n— {{caption.claimant_designation_en}} —\n\n{{caption.versus_en}}\n\n**{{parties.defendant.0.name}}**\nrepresented by {{parties.defendant.0.representative}}\n\n— {{caption.defendant_designation_en}} —\n\nre {{caption.subject_en}}\n\nCase number: {{project.case_number}}\n{{project.court}}')
|
||||
ELSE elem END
|
||||
ORDER BY ord)
|
||||
FROM jsonb_array_elements(b.section_spec->'defaults') WITH ORDINALITY AS d(elem, ord)))
|
||||
WHERE b.slug = 'upc-formal' AND b.section_spec ? 'defaults';
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- Change 3 down — clear the backfilled role labels (back to NULL, the
|
||||
-- pre-163 state for these four proceedings).
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
UPDATE paliad.proceeding_types
|
||||
SET role_proactive_label_de = NULL,
|
||||
role_reactive_label_de = NULL,
|
||||
role_proactive_label_en = NULL,
|
||||
role_reactive_label_en = NULL
|
||||
WHERE code IN ('de.inf.olg', 'de.inf.bgh', 'de.null.bpatg', 'de.null.bgh');
|
||||
108
internal/db/migrations/163_caption_wording_followup.up.sql
Normal file
108
internal/db/migrations/163_caption_wording_followup.up.sql
Normal file
@@ -0,0 +1,108 @@
|
||||
-- 163_caption_wording_followup — t-paliad-361, follow-up to t-paliad-358 A-S2.
|
||||
--
|
||||
-- m ruled on the 7 lexy-wording flags from A-S2 via AskUserQuestion
|
||||
-- (2026-06-01 14:30). Most flags CONFIRMED the live wording; three changes
|
||||
-- land here. All three are caption (Rubrum) wording and share this one
|
||||
-- reversible migration.
|
||||
--
|
||||
-- Change 1 — UPC appeal responding party (EN): 'Appellee' → 'Respondent'.
|
||||
-- m chose Respondent over Appellee. The only place 'Appellee' is stored is
|
||||
-- the mig-137 role-label override on upc.apl.unified (id=160, retired by
|
||||
-- mig 155 but kept as the canonical UPC-appeal role-label row). The caption
|
||||
-- resolver's instance-derived EN fallback already says 'Respondent'
|
||||
-- (submission_vars.go), so this fixes the wording at the data source rather
|
||||
-- than downstream. DE side (Berufungsbeklagter) is left untouched per m.
|
||||
--
|
||||
-- Change 2 — restore the standalone 'Streitpatent' / 'Patent in suit' line in
|
||||
-- the upc-formal Composer caption seed. A-S2 (mig 161) dropped it when it
|
||||
-- unified the caption onto the {{caption.*}} keys. m wants the patent-in-suit
|
||||
-- line back, but KEEPS the parametric 'In der Sache' heading (he did not
|
||||
-- revert that). Only the upc-formal base's caption seed is touched.
|
||||
--
|
||||
-- Change 3 — backfill role-label overrides for the four DE appeal/nullity
|
||||
-- proceedings that carry none (de.inf.olg, de.inf.bgh, de.null.bpatg,
|
||||
-- de.null.bgh). Without an override these fall to the instance-derived path,
|
||||
-- which is only correct when project.instance_level is set. The backfill
|
||||
-- makes the designations right regardless of instance_level. Wording is
|
||||
-- lexy-confirmed (statute-grounded: §§ 511, 542, 544 ZPO; §§ 81, 110 PatG),
|
||||
-- bracketed-inclusive gender style to match the A-S2-confirmed convention.
|
||||
--
|
||||
-- ADDITIVE / data-only. No schema changes. Reversible (see .down.sql).
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- Change 1 — UPC appeal EN responding party: 'Appellee' → 'Respondent'.
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
UPDATE paliad.proceeding_types
|
||||
SET role_reactive_label_en = 'Respondent'
|
||||
WHERE code = 'upc.apl.unified'
|
||||
AND role_reactive_label_en = 'Appellee';
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- Change 2 — restore the Streitpatent line in the upc-formal caption seed.
|
||||
-- Position-independent: rewrites only the section_key='caption' element of
|
||||
-- section_spec->'defaults', preserving order (WITH ORDINALITY) and every
|
||||
-- other field on the element (elem || patch). Keeps the parametric heading;
|
||||
-- re-adds 'Streitpatent: {{project.patent_number_upc}}' (DE) /
|
||||
-- 'Patent in suit: {{...}}' (EN) grouped with the case number, ahead of the
|
||||
-- {{project.court}} line.
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
UPDATE paliad.submission_bases AS b
|
||||
SET section_spec = jsonb_set(
|
||||
b.section_spec,
|
||||
'{defaults}',
|
||||
(
|
||||
SELECT jsonb_agg(
|
||||
CASE WHEN elem->>'section_key' = 'caption'
|
||||
THEN elem || jsonb_build_object(
|
||||
'seed_md_de', E'{{caption.heading_de}}\n\n**{{parties.claimant.0.name}}**\nvertreten durch {{parties.claimant.0.representative}}\n\n— {{caption.claimant_designation_de}} —\n\n{{caption.versus_de}}\n\n**{{parties.defendant.0.name}}**\nvertreten durch {{parties.defendant.0.representative}}\n\n— {{caption.defendant_designation_de}} —\n\nwegen {{caption.subject_de}}\n\nAktenzeichen: {{project.case_number}}\nStreitpatent: {{project.patent_number_upc}}\n{{project.court}}',
|
||||
'seed_md_en', E'{{caption.heading_en}}\n\n**{{parties.claimant.0.name}}**\nrepresented by {{parties.claimant.0.representative}}\n\n— {{caption.claimant_designation_en}} —\n\n{{caption.versus_en}}\n\n**{{parties.defendant.0.name}}**\nrepresented by {{parties.defendant.0.representative}}\n\n— {{caption.defendant_designation_en}} —\n\nre {{caption.subject_en}}\n\nCase number: {{project.case_number}}\nPatent in suit: {{project.patent_number_upc}}\n{{project.court}}')
|
||||
ELSE elem
|
||||
END
|
||||
ORDER BY ord
|
||||
)
|
||||
FROM jsonb_array_elements(b.section_spec->'defaults') WITH ORDINALITY AS d(elem, ord)
|
||||
)
|
||||
)
|
||||
WHERE b.slug = 'upc-formal'
|
||||
AND b.section_spec ? 'defaults';
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- Change 3 — backfill lexy-confirmed role labels for the four DE
|
||||
-- appeal/nullity proceedings (mig-137 mechanism). Bracketed-inclusive
|
||||
-- gender style; EN equivalents.
|
||||
--
|
||||
-- de.inf.olg Berufungskläger(in) / Berufungsbeklagte(r) // Appellant / Respondent (§ 511 ZPO Berufung)
|
||||
-- de.inf.bgh Revisionskläger(in) / Revisionsbeklagte(r) // Appellant / Respondent (§§ 542/544 ZPO; Revision as default over NZB)
|
||||
-- de.null.bpatg Nichtigkeitskläger(in) / Beklagte(r) (Patentinhaber(in)) // Nullity claimant / Defendant (patent proprietor) (§ 81 PatG)
|
||||
-- de.null.bgh Berufungskläger(in) / Berufungsbeklagte(r) // Appellant / Respondent (§ 110 PatG, post-2009 Berufung)
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
UPDATE paliad.proceeding_types
|
||||
SET role_proactive_label_de = 'Berufungskläger(in)',
|
||||
role_reactive_label_de = 'Berufungsbeklagte(r)',
|
||||
role_proactive_label_en = 'Appellant',
|
||||
role_reactive_label_en = 'Respondent'
|
||||
WHERE code = 'de.inf.olg';
|
||||
|
||||
UPDATE paliad.proceeding_types
|
||||
SET role_proactive_label_de = 'Revisionskläger(in)',
|
||||
role_reactive_label_de = 'Revisionsbeklagte(r)',
|
||||
role_proactive_label_en = 'Appellant',
|
||||
role_reactive_label_en = 'Respondent'
|
||||
WHERE code = 'de.inf.bgh';
|
||||
|
||||
UPDATE paliad.proceeding_types
|
||||
SET role_proactive_label_de = 'Nichtigkeitskläger(in)',
|
||||
role_reactive_label_de = 'Beklagte(r) (Patentinhaber(in))',
|
||||
role_proactive_label_en = 'Nullity claimant',
|
||||
role_reactive_label_en = 'Defendant (patent proprietor)'
|
||||
WHERE code = 'de.null.bpatg';
|
||||
|
||||
UPDATE paliad.proceeding_types
|
||||
SET role_proactive_label_de = 'Berufungskläger(in)',
|
||||
role_reactive_label_de = 'Berufungsbeklagte(r)',
|
||||
role_proactive_label_en = 'Appellant',
|
||||
role_reactive_label_en = 'Respondent'
|
||||
WHERE code = 'de.null.bgh';
|
||||
199
internal/handlers/builder_search.go
Normal file
199
internal/handlers/builder_search.go
Normal file
@@ -0,0 +1,199 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
)
|
||||
|
||||
// t-paliad-346 / m/paliad#153 B3 — universal search for the Litigation
|
||||
// Builder. Returns events + scenarios + projects (Akten) keyed by type
|
||||
// so the search dropdown can render typed result groups.
|
||||
//
|
||||
// GET /api/builder/search?q=<term>&limit=<n>
|
||||
//
|
||||
// Response shape:
|
||||
//
|
||||
// {
|
||||
// "query": "<echoed q>",
|
||||
// "events": [ EventSearchHit, ... ], // anchor_rule_id + proceeding_type embedded
|
||||
// "scenarios": [ { id, name, status, updated_at }, ... ],
|
||||
// "projects": [ { id, title, type, reference, case_number, matter_number, client_number }, ... ],
|
||||
// "counts": { "events": N, "scenarios": M, "projects": K }
|
||||
// }
|
||||
//
|
||||
// Each group is independently capped (default 8 events / 5 scenarios /
|
||||
// 5 projects, max 30 per group). Missing services degrade gracefully —
|
||||
// an unavailable group is returned as an empty array, not an error,
|
||||
// so a knowledge-only deploy (DATABASE_URL unset) can still serve a
|
||||
// best-effort empty response shape rather than a 503 wall.
|
||||
|
||||
type builderSearchScenarioHit struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
type builderSearchProjectHit struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Reference *string `json:"reference,omitempty"`
|
||||
CaseNumber *string `json:"case_number,omitempty"`
|
||||
MatterNumber *string `json:"matter_number,omitempty"`
|
||||
ClientNumber *string `json:"client_number,omitempty"`
|
||||
}
|
||||
|
||||
type builderSearchResponse struct {
|
||||
Query string `json:"query"`
|
||||
Events []services.EventSearchHit `json:"events"`
|
||||
Scenarios []builderSearchScenarioHit `json:"scenarios"`
|
||||
Projects []builderSearchProjectHit `json:"projects"`
|
||||
Counts builderSearchCounts `json:"counts"`
|
||||
}
|
||||
|
||||
type builderSearchCounts struct {
|
||||
Events int `json:"events"`
|
||||
Scenarios int `json:"scenarios"`
|
||||
Projects int `json:"projects"`
|
||||
}
|
||||
|
||||
// handleBuilderSearch — GET /api/builder/search?q=<term>&limit=<n>
|
||||
//
|
||||
// Auth required. Returns 200 with empty groups when q is empty (matches
|
||||
// the fristenrechner search ergonomic — frontend can boot without a
|
||||
// pre-flight round trip).
|
||||
func handleBuilderSearch(w http.ResponseWriter, r *http.Request) {
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||
perGroupLimit := parseBuilderSearchLimit(r.URL.Query().Get("limit"))
|
||||
|
||||
resp := builderSearchResponse{
|
||||
Query: q,
|
||||
Events: []services.EventSearchHit{},
|
||||
Scenarios: []builderSearchScenarioHit{},
|
||||
Projects: []builderSearchProjectHit{},
|
||||
}
|
||||
|
||||
if q == "" {
|
||||
// Match fristenrechner search: empty query → empty groups, not 400.
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
|
||||
// Events: reuse the SearchEvents shape so anchor_rule_id +
|
||||
// proceeding_type travel with each hit. UPC v1 (PRD §0.4) — the
|
||||
// jurisdiction filter pins the corpus the builder serves today.
|
||||
if dbSvc != nil && dbSvc.deadlineSearch != nil {
|
||||
eventsResp, err := dbSvc.deadlineSearch.SearchEvents(ctx, q, services.EventSearchOptions{
|
||||
Jurisdiction: "UPC",
|
||||
Limit: perGroupLimit.events,
|
||||
})
|
||||
if err == nil && eventsResp != nil {
|
||||
resp.Events = eventsResp.Events
|
||||
}
|
||||
}
|
||||
|
||||
// Scenarios: caller's own scenarios filtered by ILIKE on name.
|
||||
// Borrows ListMyScenarios + filters in-memory; the list endpoint
|
||||
// already caps at the small per-user fan-out and there's no index
|
||||
// on (owner_id, name) yet — in-memory filter is cheap at 10s-of-
|
||||
// rows scale.
|
||||
if dbSvc != nil && dbSvc.scenarioBuilder != nil {
|
||||
scenarios, err := dbSvc.scenarioBuilder.ListMyScenarios(ctx, uid, "active")
|
||||
if err == nil {
|
||||
needle := strings.ToLower(q)
|
||||
hits := []builderSearchScenarioHit{}
|
||||
for _, sc := range scenarios {
|
||||
if !strings.Contains(strings.ToLower(sc.Name), needle) {
|
||||
continue
|
||||
}
|
||||
hits = append(hits, builderSearchScenarioHit{
|
||||
ID: sc.ID,
|
||||
Name: sc.Name,
|
||||
Status: sc.Status,
|
||||
UpdatedAt: sc.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||
})
|
||||
if len(hits) >= perGroupLimit.scenarios {
|
||||
break
|
||||
}
|
||||
}
|
||||
resp.Scenarios = hits
|
||||
}
|
||||
}
|
||||
|
||||
// Projects (Akten): visible projects filtered by trigram/ILIKE on
|
||||
// title, reference, client_number, matter_number. ProjectService.List
|
||||
// already applies team-based RLS via visibilityPredicate.
|
||||
if dbSvc != nil && dbSvc.projects != nil {
|
||||
projects, err := dbSvc.projects.List(ctx, uid, services.ProjectFilter{
|
||||
Search: q,
|
||||
})
|
||||
if err == nil {
|
||||
hits := make([]builderSearchProjectHit, 0, len(projects))
|
||||
for _, p := range projects {
|
||||
hits = append(hits, builderSearchProjectHit{
|
||||
ID: p.ID,
|
||||
Type: p.Type,
|
||||
Title: p.Title,
|
||||
Reference: p.Reference,
|
||||
CaseNumber: p.CaseNumber,
|
||||
MatterNumber: p.MatterNumber,
|
||||
ClientNumber: p.ClientNumber,
|
||||
})
|
||||
if len(hits) >= perGroupLimit.projects {
|
||||
break
|
||||
}
|
||||
}
|
||||
resp.Projects = hits
|
||||
}
|
||||
}
|
||||
|
||||
resp.Counts = builderSearchCounts{
|
||||
Events: len(resp.Events),
|
||||
Scenarios: len(resp.Scenarios),
|
||||
Projects: len(resp.Projects),
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
type builderSearchPerGroup struct {
|
||||
events int
|
||||
scenarios int
|
||||
projects int
|
||||
}
|
||||
|
||||
// parseBuilderSearchLimit reads ?limit=<n> as a hint for the events
|
||||
// group (largest expected hit count). Scenarios + projects use smaller
|
||||
// caps because their drop-down rows are visually heavier. The shared
|
||||
// caller-supplied bound is interpreted as the events cap; scenarios
|
||||
// and projects are derived from it.
|
||||
func parseBuilderSearchLimit(raw string) builderSearchPerGroup {
|
||||
def := builderSearchPerGroup{events: 8, scenarios: 5, projects: 5}
|
||||
if raw == "" {
|
||||
return def
|
||||
}
|
||||
n, err := strconv.Atoi(raw)
|
||||
if err != nil || n <= 0 {
|
||||
return def
|
||||
}
|
||||
if n > 30 {
|
||||
n = 30
|
||||
}
|
||||
return builderSearchPerGroup{
|
||||
events: n,
|
||||
scenarios: max(1, n/2),
|
||||
projects: max(1, n/2),
|
||||
}
|
||||
}
|
||||
48
internal/handlers/docforge_variables.go
Normal file
48
internal/handlers/docforge_variables.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package handlers
|
||||
|
||||
// docforge variable catalogue handler (t-paliad-349 slice 5).
|
||||
//
|
||||
// Endpoint: GET /api/docforge/variables → the full variable catalogue
|
||||
// (key + bilingual label + namespace group) the sidebar form and the
|
||||
// authoring palette render. The catalogue is the Go-side single source of
|
||||
// truth, built from the submission resolvers' Keys(); it replaces the
|
||||
// duplicated TS VARIABLE_LABELS table so labels can't drift between the
|
||||
// resolver that produces a value and the form that labels it.
|
||||
//
|
||||
// Static — no DB call, no per-user state. Auth-gated only (anonymous 401);
|
||||
// the catalogue is the same for every authenticated user.
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
)
|
||||
|
||||
type docforgeVariablesResponse struct {
|
||||
Variables []variableEntry `json:"variables"`
|
||||
}
|
||||
|
||||
type variableEntry struct {
|
||||
Key string `json:"key"`
|
||||
LabelDE string `json:"label_de"`
|
||||
LabelEN string `json:"label_en"`
|
||||
Group string `json:"group"`
|
||||
}
|
||||
|
||||
// handleDocforgeVariables backs GET /api/docforge/variables.
|
||||
func handleDocforgeVariables(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := requireUser(w, r); !ok {
|
||||
return
|
||||
}
|
||||
cat := services.SubmissionVariableCatalogue()
|
||||
out := make([]variableEntry, 0, len(cat))
|
||||
for _, e := range cat {
|
||||
out = append(out, variableEntry{
|
||||
Key: e.Key,
|
||||
LabelDE: e.LabelDE,
|
||||
LabelEN: e.LabelEN,
|
||||
Group: e.Group,
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, docforgeVariablesResponse{Variables: out})
|
||||
}
|
||||
@@ -91,6 +91,19 @@ func handleFristenrechnerAPI(w http.ResponseWriter, r *http.Request) {
|
||||
// slugs are silently dropped (no filter) so a stale frontend
|
||||
// chip doesn't 400 the request.
|
||||
AppealTarget string `json:"appealTarget,omitempty"`
|
||||
// t-paliad-348 / yoUPC#178 — surface the engine's two new
|
||||
// CalcOptions axes to the HTTP boundary:
|
||||
//
|
||||
// IncludeOptional: when true, priority='optional' rules
|
||||
// surface on the timeline. Default false matches the
|
||||
// engine's default (mandatory backbone only).
|
||||
// TriggerEventAnchors: per-event-code anchor dates the
|
||||
// engine consults for rules carrying trigger_event_id.
|
||||
// When a rule's anchor is absent the engine renders the
|
||||
// rule as IsConditional rather than fabricating a date
|
||||
// off the proceeding's trigger date.
|
||||
IncludeOptional bool `json:"includeOptional,omitempty"`
|
||||
TriggerEventAnchors map[string]string `json:"triggerEventAnchors,omitempty"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Ungültige Anfrage"})
|
||||
@@ -130,15 +143,17 @@ func handleFristenrechnerAPI(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
resp, err := dbSvc.fristenrechner.Calculate(r.Context(), req.ProceedingType, req.TriggerDate, services.CalcOptions{
|
||||
PriorityDateStr: req.PriorityDate,
|
||||
Flags: req.Flags,
|
||||
AnchorOverrides: req.AnchorOverrides,
|
||||
CourtID: req.CourtID,
|
||||
PerCardAppellant: addendum.PerCardAppellant,
|
||||
SkipRules: addendum.SkipRules,
|
||||
IncludeCCRFor: addendum.IncludeCCRFor,
|
||||
IncludeHidden: req.IncludeHidden,
|
||||
AppealTarget: req.AppealTarget,
|
||||
PriorityDateStr: req.PriorityDate,
|
||||
Flags: req.Flags,
|
||||
AnchorOverrides: req.AnchorOverrides,
|
||||
CourtID: req.CourtID,
|
||||
PerCardAppellant: addendum.PerCardAppellant,
|
||||
SkipRules: addendum.SkipRules,
|
||||
IncludeCCRFor: addendum.IncludeCCRFor,
|
||||
IncludeHidden: req.IncludeHidden,
|
||||
AppealTarget: req.AppealTarget,
|
||||
IncludeOptional: req.IncludeOptional,
|
||||
TriggerEventAnchors: req.TriggerEventAnchors,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, services.ErrUnknownProceedingType) {
|
||||
|
||||
@@ -105,6 +105,11 @@ type Services struct {
|
||||
// DashboardLayoutService.defaultLayout(). Nil-safe — falls back to
|
||||
// the code-resident FactoryDefaultLayout.
|
||||
FirmDashboardDefault *services.FirmDashboardDefaultService
|
||||
// FirmNameComposition is the firm-wide default name-composition map
|
||||
// (Slice 5). Admin-only writes; the render path reads it as the firm
|
||||
// tier below a per-user override. Nil-safe — falls back to the
|
||||
// code-resident system default.
|
||||
FirmNameComposition *services.FirmNameCompositionService
|
||||
Projection *services.ProjectionService
|
||||
Export *services.ExportService
|
||||
|
||||
@@ -128,6 +133,10 @@ type Services struct {
|
||||
// editor. Per Q2: paste sources only, no lineage on sections.
|
||||
SubmissionBuildingBlock *services.BuildingBlockService
|
||||
|
||||
// t-paliad-349 docforge slice 4/6 — uploaded-template store backing
|
||||
// the authoring surface.
|
||||
TemplateStore *services.PgTemplateStore
|
||||
|
||||
// t-paliad-265 / m/paliad#96 — per-event-card optional choices on
|
||||
// the Verfahrensablauf timeline.
|
||||
EventChoice *services.EventChoiceService
|
||||
@@ -142,6 +151,12 @@ type Services struct {
|
||||
// and per-rule selection state (`rule:<uuid>` keys).
|
||||
ScenarioFlags *services.ScenarioFlagsService
|
||||
|
||||
// t-paliad-340 / m/paliad#153 B0 — Litigation Builder. CRUD over the
|
||||
// new normalised scenario shape (paliad.scenarios with owner_id +
|
||||
// scenario_proceedings + scenario_events + scenario_shares, mig 157).
|
||||
// Nil when DATABASE_URL is unset — /api/builder/scenarios* routes 503.
|
||||
ScenarioBuilder *services.ScenarioBuilderService
|
||||
|
||||
// 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
|
||||
@@ -201,6 +216,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
cardLayout: svc.CardLayout,
|
||||
dashboardLayout: svc.DashboardLayout,
|
||||
firmDashboardDefault: svc.FirmDashboardDefault,
|
||||
firmNameComposition: svc.FirmNameComposition,
|
||||
projection: svc.Projection,
|
||||
export: svc.Export,
|
||||
backup: svc.Backup,
|
||||
@@ -209,9 +225,11 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
submissionSection: svc.SubmissionSection,
|
||||
submissionComposer: svc.SubmissionComposer,
|
||||
submissionBuildingBlock: svc.SubmissionBuildingBlock,
|
||||
templateStore: svc.TemplateStore,
|
||||
eventChoice: svc.EventChoice,
|
||||
scenario: svc.Scenario,
|
||||
scenarioFlags: svc.ScenarioFlags,
|
||||
scenarioBuilder: svc.ScenarioBuilder,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -448,6 +466,12 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
// the sidebar picker. Wide-open SELECT (any authenticated user);
|
||||
// admin mutations are not exposed yet (Slice C).
|
||||
protected.HandleFunc("GET /api/submission-bases", handleListSubmissionBases)
|
||||
// t-paliad-349 (m/paliad#157) docforge slice 5 — the variable
|
||||
// catalogue (Go-side SSOT) the sidebar form + authoring palette read.
|
||||
protected.HandleFunc("GET /api/docforge/variables", handleDocforgeVariables)
|
||||
// t-paliad-349 slice 7 — firm-shared template picker list for
|
||||
// generation (any authenticated lawyer; admin authoring stays gated).
|
||||
protected.HandleFunc("GET /api/templates", handlePickerTemplates)
|
||||
// t-paliad-313 (m/paliad#141) Composer Slice B — per-section PATCH
|
||||
// for inline editor autosave. URL keyed on draft_id + section_id;
|
||||
// owner-scoped via SubmissionDraftService.Get.
|
||||
@@ -484,6 +508,15 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
protected.HandleFunc("PUT /api/me/dashboard-layout", handlePutDashboardLayout)
|
||||
protected.HandleFunc("POST /api/me/dashboard-layout/reset", handleResetDashboardLayout)
|
||||
protected.HandleFunc("GET /api/dashboard-widget-catalog", handleGetWidgetCatalog)
|
||||
|
||||
// t-paliad-356 Slice 4 — per-user name-composition overrides (settings UX).
|
||||
// Token-template shorthand per wired artifact; parse/validate/preview run
|
||||
// server-side so the nomen engine stays the single source of truth.
|
||||
protected.HandleFunc("GET /api/me/name-compositions", handleGetNameCompositions)
|
||||
protected.HandleFunc("POST /api/me/name-compositions/preview", handlePreviewNameComposition)
|
||||
protected.HandleFunc("PUT /api/me/name-compositions/{artifact_id}", handlePutNameComposition)
|
||||
protected.HandleFunc("DELETE /api/me/name-compositions/{artifact_id}", handleDeleteNameComposition)
|
||||
|
||||
protected.HandleFunc("GET /api/projects/{id}/ancestors", handleListProjectAncestors)
|
||||
protected.HandleFunc("GET /api/projects/{id}/parties", handleListParties)
|
||||
protected.HandleFunc("POST /api/projects/{id}/parties", handleCreateParty)
|
||||
@@ -514,6 +547,39 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
protected.HandleFunc("DELETE /api/scenarios/{id}", handleScenarioDelete)
|
||||
protected.HandleFunc("PUT /api/projects/{id}/active-scenario", handleSetActiveScenario)
|
||||
|
||||
// t-paliad-340 / m/paliad#153 B0 — Litigation Builder API over the
|
||||
// new normalised scenario shape (mig 157). Coexists with the legacy
|
||||
// /api/scenarios surface during the B0→B6 migration; B6 cleanup
|
||||
// retires the legacy routes.
|
||||
protected.HandleFunc("GET /api/builder/scenarios", handleBuilderScenariosList)
|
||||
protected.HandleFunc("POST /api/builder/scenarios", handleBuilderScenarioCreate)
|
||||
// m/paliad#153 B4 — Akte mode entry point. Creates a project-backed
|
||||
// scenario from a paliad.projects row; subsequent edits dual-write
|
||||
// through to paliad.deadlines + paliad.projects.scenario_flags.
|
||||
protected.HandleFunc("POST /api/builder/scenarios/from-project", handleBuilderScenarioFromProject)
|
||||
// m/paliad#153 B5 — "Geteilt mit mir" bucket. Literal segment wins
|
||||
// over {id} in Go 1.22+ ServeMux precedence, so this never shadows GET .../{id}.
|
||||
protected.HandleFunc("GET /api/builder/scenarios/shared", handleBuilderScenariosShared)
|
||||
protected.HandleFunc("GET /api/builder/scenarios/{id}", handleBuilderScenarioGet)
|
||||
protected.HandleFunc("PATCH /api/builder/scenarios/{id}", handleBuilderScenarioPatch)
|
||||
protected.HandleFunc("POST /api/builder/scenarios/{id}/proceedings", handleBuilderProceedingCreate)
|
||||
protected.HandleFunc("PATCH /api/builder/scenarios/{id}/proceedings/{pid}", handleBuilderProceedingPatch)
|
||||
protected.HandleFunc("DELETE /api/builder/scenarios/{id}/proceedings/{pid}", handleBuilderProceedingDelete)
|
||||
protected.HandleFunc("POST /api/builder/scenarios/{id}/proceedings/{pid}/events", handleBuilderEventCreate)
|
||||
protected.HandleFunc("PATCH /api/builder/scenarios/{id}/events/{eid}", handleBuilderEventPatch)
|
||||
protected.HandleFunc("DELETE /api/builder/scenarios/{id}/events/{eid}", handleBuilderEventDelete)
|
||||
protected.HandleFunc("POST /api/builder/scenarios/{id}/shares", handleBuilderShareCreate)
|
||||
protected.HandleFunc("DELETE /api/builder/scenarios/{id}/shares/{sid}", handleBuilderShareDelete)
|
||||
// m/paliad#153 B5 — transactional promote-to-project wizard commit.
|
||||
protected.HandleFunc("POST /api/builder/scenarios/{id}/promote", handleBuilderScenarioPromote)
|
||||
// m/paliad#153 B2 — read-only passthrough so the builder can render
|
||||
// per-triplet flag toggles without a per-project round-trip.
|
||||
protected.HandleFunc("GET /api/builder/scenario-flag-catalog", handleBuilderFlagCatalog)
|
||||
// m/paliad#153 B3 — universal search (events + scenarios + projects).
|
||||
protected.HandleFunc("GET /api/builder/search", handleBuilderSearch)
|
||||
// Dev-only test route — gated to PaliadinOwnerEmail (m).
|
||||
protected.HandleFunc("GET /dev/scenario-builder", handleBuilderDevTestPage)
|
||||
|
||||
// Partner units (structural partner-led units; legacy "Dezernate").
|
||||
protected.HandleFunc("GET /api/partner-units", handleListPartnerUnits)
|
||||
protected.HandleFunc("POST /api/partner-units", handleCreatePartnerUnit)
|
||||
@@ -712,6 +778,15 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
protected.HandleFunc("GET /api/admin/backups/{id}", adminGate(users, handleAdminGetBackup))
|
||||
protected.HandleFunc("GET /api/admin/backups/{id}/file", adminGate(users, handleAdminDownloadBackup))
|
||||
|
||||
// t-paliad-349 docforge slice 6 — template authoring surface
|
||||
// (upload base .docx → place variable slots → save). Admin-only,
|
||||
// firm-shared catalog like submission_bases.
|
||||
protected.HandleFunc("GET /admin/templates", adminGate(users, gateOnboarded(handleTemplatesAuthoringPage)))
|
||||
protected.HandleFunc("GET /api/admin/templates", adminGate(users, handleListTemplates))
|
||||
protected.HandleFunc("POST /api/admin/templates", adminGate(users, handleUploadTemplate))
|
||||
protected.HandleFunc("GET /api/admin/templates/{id}", adminGate(users, handleGetTemplateAuthoring))
|
||||
protected.HandleFunc("POST /api/admin/templates/{id}/slots", adminGate(users, handlePlaceTemplateSlot))
|
||||
|
||||
protected.HandleFunc("GET /api/admin/users", adminGate(users, handleAdminListUsers))
|
||||
protected.HandleFunc("POST /api/admin/users", adminGate(users, handleAdminCreateUser))
|
||||
protected.HandleFunc("POST /api/admin/users/full", adminGate(users, handleAdminCreateFullUser))
|
||||
@@ -723,6 +798,13 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
protected.HandleFunc("GET /api/admin/firm-dashboard-default", adminGate(users, handleGetFirmDashboardDefault))
|
||||
protected.HandleFunc("PUT /api/admin/firm-dashboard-default", adminGate(users, handlePutFirmDashboardDefault))
|
||||
protected.HandleFunc("DELETE /api/admin/firm-dashboard-default", adminGate(users, handleDeleteFirmDashboardDefault))
|
||||
|
||||
// t-paliad-356 Slice 5 — firm-wide default name compositions. Admin
|
||||
// sets the house naming convention (the firm tier below per-user
|
||||
// overrides). Mirrors the firm-dashboard-default admin endpoints.
|
||||
protected.HandleFunc("GET /api/admin/name-compositions", adminGate(users, handleGetFirmNameCompositions))
|
||||
protected.HandleFunc("PUT /api/admin/name-compositions/{artifact_id}", adminGate(users, handlePutFirmNameComposition))
|
||||
protected.HandleFunc("DELETE /api/admin/name-compositions/{artifact_id}", adminGate(users, handleDeleteFirmNameComposition))
|
||||
protected.HandleFunc("POST /api/me/dashboard-layout/promote", adminGate(users, handlePromoteDashboardLayoutToFirmDefault))
|
||||
|
||||
// t-paliad-315 (m/paliad#141) Composer Slice C — admin building blocks editor.
|
||||
|
||||
325
internal/handlers/name_compositions.go
Normal file
325
internal/handlers/name_compositions.go
Normal file
@@ -0,0 +1,325 @@
|
||||
package handlers
|
||||
|
||||
// HTTP handlers for per-user name-composition overrides (t-paliad-356 Slice 4,
|
||||
// PRD §7). The /settings "Namensschemata" tab reads and writes a token-template
|
||||
// shorthand per wired artifact; these endpoints parse + validate + render
|
||||
// through the nomen engine (services), so the frontend never parses templates
|
||||
// itself.
|
||||
//
|
||||
// GET /api/me/name-compositions → all artifact cards
|
||||
// POST /api/me/name-compositions/preview → live preview + validation
|
||||
// PUT /api/me/name-compositions/{artifact_id} → store an override
|
||||
// DELETE /api/me/name-compositions/{artifact_id} → reset to system default
|
||||
//
|
||||
// Storage reuses the Slice-3 service surface
|
||||
// (SubmissionDraftService.UserNameCompositions / SetUserNameCompositions): the
|
||||
// PUT/DELETE handlers read the full spec, mutate one artifact key, and write it
|
||||
// back. No new column, no migration.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
)
|
||||
|
||||
// nameCompositionsService returns the wired SubmissionDraftService (the owner
|
||||
// of the name_compositions read/write path) or writes a 503 and returns nil.
|
||||
func nameCompositionsService(w http.ResponseWriter) *services.SubmissionDraftService {
|
||||
if dbSvc.submissionDraft == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "name-composition service not configured"})
|
||||
return nil
|
||||
}
|
||||
return dbSvc.submissionDraft
|
||||
}
|
||||
|
||||
// firmNameCompositions loads the firm-wide default spec (empty when unset or
|
||||
// the firm service is unwired). Read on every card render so the effective
|
||||
// template reflects the firm tier.
|
||||
func firmNameCompositions(r *http.Request) services.NameCompositionSpec {
|
||||
if dbSvc.firmNameComposition == nil {
|
||||
return services.NameCompositionSpec{}
|
||||
}
|
||||
spec, _, err := dbSvc.firmNameComposition.Get(r.Context())
|
||||
if err != nil {
|
||||
return services.NameCompositionSpec{}
|
||||
}
|
||||
return spec
|
||||
}
|
||||
|
||||
// GET /api/me/name-compositions — the caller's artifact cards with the
|
||||
// effective template (user override → firm default → system) per artifact,
|
||||
// palette, and live previews. is_admin tells the client whether to reveal the
|
||||
// firm-default admin controls.
|
||||
func handleGetNameCompositions(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
svc := nameCompositionsService(w)
|
||||
if svc == nil {
|
||||
return
|
||||
}
|
||||
overrides, err := svc.UserNameCompositions(r.Context(), uid)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
firm := firmNameCompositions(r)
|
||||
isAdmin := false
|
||||
if dbSvc.users != nil {
|
||||
isAdmin, _ = dbSvc.users.IsAdmin(r.Context(), uid)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"artifacts": services.SettingsNameArtifacts(overrides, firm),
|
||||
"is_admin": isAdmin,
|
||||
})
|
||||
}
|
||||
|
||||
// POST /api/me/name-compositions/preview — render a candidate template against
|
||||
// the fixed sample without persisting it. Returns {ok:false, error} on a parse
|
||||
// or validation failure so the UI can show the error inline and disable Save.
|
||||
func handlePreviewNameComposition(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := requireUser(w, r); !ok {
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
ArtifactID string `json:"artifact_id"`
|
||||
Template string `json:"template"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON body"})
|
||||
return
|
||||
}
|
||||
full, empty, err := services.PreviewNameComposition(in.ArtifactID, in.Template)
|
||||
if err != nil {
|
||||
// A bad template is expected user input, not a server error — return
|
||||
// 200 with ok:false so the live-preview fetch path stays simple.
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"ok": true,
|
||||
"preview_full": full,
|
||||
"preview_empty": empty,
|
||||
})
|
||||
}
|
||||
|
||||
// PUT /api/me/name-compositions/{artifact_id} — validate the body template and
|
||||
// store it as the caller's override for that artifact. Returns the refreshed
|
||||
// card.
|
||||
func handlePutNameComposition(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
svc := nameCompositionsService(w)
|
||||
if svc == nil {
|
||||
return
|
||||
}
|
||||
artifactID := r.PathValue("artifact_id")
|
||||
var in struct {
|
||||
Template string `json:"template"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON body"})
|
||||
return
|
||||
}
|
||||
comp, err := services.ParseNameTemplate(artifactID, in.Template)
|
||||
if err != nil {
|
||||
if errors.Is(err, services.ErrInvalidInput) {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
spec, err := svc.UserNameCompositions(r.Context(), uid)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
if spec == nil {
|
||||
spec = services.NameCompositionSpec{}
|
||||
}
|
||||
spec[artifactID] = comp
|
||||
if err := svc.SetUserNameCompositions(r.Context(), uid, spec); err != nil {
|
||||
if errors.Is(err, services.ErrInvalidInput) {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
view, _ := services.SettingsNameArtifact(artifactID, spec, firmNameCompositions(r))
|
||||
writeJSON(w, http.StatusOK, view)
|
||||
}
|
||||
|
||||
// DELETE /api/me/name-compositions/{artifact_id} — drop the caller's override
|
||||
// for that artifact; the artifact reverts to the system default. Returns the
|
||||
// refreshed card. Deleting an absent override is a no-op (still 200).
|
||||
func handleDeleteNameComposition(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
svc := nameCompositionsService(w)
|
||||
if svc == nil {
|
||||
return
|
||||
}
|
||||
artifactID := r.PathValue("artifact_id")
|
||||
if _, ok := services.NameArtifact(artifactID); !ok {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "unknown name artifact"})
|
||||
return
|
||||
}
|
||||
spec, err := svc.UserNameCompositions(r.Context(), uid)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
if _, present := spec[artifactID]; present {
|
||||
delete(spec, artifactID)
|
||||
if err := svc.SetUserNameCompositions(r.Context(), uid, spec); err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
view, _ := services.SettingsNameArtifact(artifactID, spec, firmNameCompositions(r))
|
||||
writeJSON(w, http.StatusOK, view)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Firm-wide default (admin) — t-paliad-356 Slice 5.
|
||||
//
|
||||
// Mirrors the firm_dashboard_default admin endpoints. All three sit behind the
|
||||
// adminGate in handlers.go. The firm default is the tier below a per-user
|
||||
// override and above the system default; setting/clearing it changes the
|
||||
// effective name for every user who has no personal override.
|
||||
//
|
||||
// GET /api/admin/name-compositions → firm-tier cards
|
||||
// PUT /api/admin/name-compositions/{artifact_id} → set firm default
|
||||
// DELETE /api/admin/name-compositions/{artifact_id} → clear firm default
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// firmAdminService returns the wired FirmNameCompositionService or writes 503.
|
||||
func firmAdminService(w http.ResponseWriter) *services.FirmNameCompositionService {
|
||||
if dbSvc.firmNameComposition == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "firm-name-composition service not configured"})
|
||||
return nil
|
||||
}
|
||||
return dbSvc.firmNameComposition
|
||||
}
|
||||
|
||||
// GET /api/admin/name-compositions — the firm-tier cards. Each card's
|
||||
// firm_is_set/firm_template reflects the firm default; the effective template
|
||||
// is computed with no user override (the admin views the firm tier, not their
|
||||
// personal one).
|
||||
func handleGetFirmNameCompositions(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
if firmAdminService(w) == nil {
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"artifacts": services.SettingsNameArtifacts(nil, firmNameCompositions(r)),
|
||||
})
|
||||
}
|
||||
|
||||
// PUT /api/admin/name-compositions/{artifact_id} — set the firm default for an
|
||||
// artifact from the body template. Returns the refreshed firm-tier card.
|
||||
func handlePutFirmNameComposition(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
svc := firmAdminService(w)
|
||||
if svc == nil {
|
||||
return
|
||||
}
|
||||
artifactID := r.PathValue("artifact_id")
|
||||
var in struct {
|
||||
Template string `json:"template"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON body"})
|
||||
return
|
||||
}
|
||||
comp, err := services.ParseNameTemplate(artifactID, in.Template)
|
||||
if err != nil {
|
||||
if errors.Is(err, services.ErrInvalidInput) {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
spec, _, err := svc.Get(r.Context())
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
if spec == nil {
|
||||
spec = services.NameCompositionSpec{}
|
||||
}
|
||||
spec[artifactID] = comp
|
||||
if _, err := svc.Set(r.Context(), spec, uid); err != nil {
|
||||
if errors.Is(err, services.ErrInvalidInput) {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
view, _ := services.SettingsNameArtifact(artifactID, nil, spec)
|
||||
writeJSON(w, http.StatusOK, view)
|
||||
}
|
||||
|
||||
// DELETE /api/admin/name-compositions/{artifact_id} — drop the firm default
|
||||
// for an artifact; it reverts to the system default for everyone without a
|
||||
// personal override. Returns the refreshed firm-tier card. No-op when absent.
|
||||
func handleDeleteFirmNameComposition(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
svc := firmAdminService(w)
|
||||
if svc == nil {
|
||||
return
|
||||
}
|
||||
artifactID := r.PathValue("artifact_id")
|
||||
if _, ok := services.NameArtifact(artifactID); !ok {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "unknown name artifact"})
|
||||
return
|
||||
}
|
||||
spec, _, err := svc.Get(r.Context())
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
if _, present := spec[artifactID]; present {
|
||||
delete(spec, artifactID)
|
||||
if _, err := svc.Set(r.Context(), spec, uid); err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
view, _ := services.SettingsNameArtifact(artifactID, nil, spec)
|
||||
writeJSON(w, http.StatusOK, view)
|
||||
}
|
||||
@@ -18,49 +18,53 @@ import (
|
||||
// dbServices bundles the Phase B services so handlers can stay thin.
|
||||
// Nil if DATABASE_URL was unset at startup.
|
||||
type dbServices struct {
|
||||
projects *services.ProjectService
|
||||
team *services.TeamService
|
||||
partnerUnit *services.PartnerUnitService
|
||||
parties *services.PartyService
|
||||
deadline *services.DeadlineService
|
||||
appointment *services.AppointmentService
|
||||
caldav *services.CalDAVService
|
||||
caldavBindings *services.CalendarBindingService
|
||||
rules *services.DeadlineRuleService
|
||||
calc *services.DeadlineCalculator
|
||||
users *services.UserService
|
||||
fristenrechner *services.FristenrechnerService
|
||||
eventDeadline *services.EventDeadlineService
|
||||
eventTrigger *services.EventTriggerService
|
||||
ruleEditor *services.RuleEditorService
|
||||
deadlineSearch *services.DeadlineSearchService
|
||||
eventCategory *services.EventCategoryService
|
||||
eventType *services.EventTypeService
|
||||
dashboard *services.DashboardService
|
||||
note *services.NoteService
|
||||
checklistInst *services.ChecklistInstanceService
|
||||
checklistCatalog *services.ChecklistCatalogService
|
||||
checklistTemplate *services.ChecklistTemplateService
|
||||
checklistShare *services.ChecklistShareService
|
||||
checklistPromotion *services.ChecklistPromotionService
|
||||
mail *services.MailService
|
||||
invite *services.InviteService
|
||||
agenda *services.AgendaService
|
||||
audit *services.AuditService
|
||||
emailTemplate *services.EmailTemplateService
|
||||
link *services.LinkService
|
||||
event *services.EventService
|
||||
courts *services.CourtService
|
||||
approval *services.ApprovalService
|
||||
derivation *services.DerivationService
|
||||
userView *services.UserViewService
|
||||
broadcast *services.BroadcastService
|
||||
pin *services.PinService
|
||||
cardLayout *services.CardLayoutService
|
||||
dashboardLayout *services.DashboardLayoutService
|
||||
projects *services.ProjectService
|
||||
team *services.TeamService
|
||||
partnerUnit *services.PartnerUnitService
|
||||
parties *services.PartyService
|
||||
deadline *services.DeadlineService
|
||||
appointment *services.AppointmentService
|
||||
caldav *services.CalDAVService
|
||||
caldavBindings *services.CalendarBindingService
|
||||
rules *services.DeadlineRuleService
|
||||
calc *services.DeadlineCalculator
|
||||
users *services.UserService
|
||||
fristenrechner *services.FristenrechnerService
|
||||
eventDeadline *services.EventDeadlineService
|
||||
eventTrigger *services.EventTriggerService
|
||||
ruleEditor *services.RuleEditorService
|
||||
deadlineSearch *services.DeadlineSearchService
|
||||
eventCategory *services.EventCategoryService
|
||||
eventType *services.EventTypeService
|
||||
dashboard *services.DashboardService
|
||||
note *services.NoteService
|
||||
checklistInst *services.ChecklistInstanceService
|
||||
checklistCatalog *services.ChecklistCatalogService
|
||||
checklistTemplate *services.ChecklistTemplateService
|
||||
checklistShare *services.ChecklistShareService
|
||||
checklistPromotion *services.ChecklistPromotionService
|
||||
mail *services.MailService
|
||||
invite *services.InviteService
|
||||
agenda *services.AgendaService
|
||||
audit *services.AuditService
|
||||
emailTemplate *services.EmailTemplateService
|
||||
link *services.LinkService
|
||||
event *services.EventService
|
||||
courts *services.CourtService
|
||||
approval *services.ApprovalService
|
||||
derivation *services.DerivationService
|
||||
userView *services.UserViewService
|
||||
broadcast *services.BroadcastService
|
||||
pin *services.PinService
|
||||
cardLayout *services.CardLayoutService
|
||||
dashboardLayout *services.DashboardLayoutService
|
||||
firmDashboardDefault *services.FirmDashboardDefaultService
|
||||
projection *services.ProjectionService
|
||||
export *services.ExportService
|
||||
// t-paliad-356 Slice 5 — firm-wide default name compositions (the firm
|
||||
// tier of the name-composition precedence chain). Nil-safe: the render
|
||||
// path falls through to user override / system default.
|
||||
firmNameComposition *services.FirmNameCompositionService
|
||||
projection *services.ProjectionService
|
||||
export *services.ExportService
|
||||
|
||||
// t-paliad-246 — Backup Mode orchestrator. Nil when DATABASE_URL or
|
||||
// PALIAD_EXPORT_DIR is unset (the /admin/backups routes return 503).
|
||||
@@ -77,6 +81,9 @@ type dbServices struct {
|
||||
submissionComposer *services.SubmissionComposer
|
||||
submissionBuildingBlock *services.BuildingBlockService
|
||||
|
||||
// t-paliad-349 docforge slice 4/6 — uploaded-template store.
|
||||
templateStore *services.PgTemplateStore
|
||||
|
||||
// t-paliad-265 — per-event-card optional choices.
|
||||
eventChoice *services.EventChoiceService
|
||||
|
||||
@@ -85,6 +92,11 @@ type dbServices struct {
|
||||
|
||||
// m/paliad#149 Phase 2 P0 — per-project scenario_flags SSoT (mig 154).
|
||||
scenarioFlags *services.ScenarioFlagsService
|
||||
|
||||
// t-paliad-340 / m/paliad#153 B0 — Litigation Builder over the new
|
||||
// normalised scenario shape (paliad.scenarios with owner_id +
|
||||
// scenario_proceedings + scenario_events + scenario_shares, mig 157).
|
||||
scenarioBuilder *services.ScenarioBuilderService
|
||||
}
|
||||
|
||||
var dbSvc *dbServices
|
||||
@@ -398,12 +410,13 @@ func handleListProjectChildren(w http.ResponseWriter, r *http.Request) {
|
||||
// render the full hierarchy in one round-trip. Visibility-scoped.
|
||||
//
|
||||
// Query parameters (all optional, additive):
|
||||
// ?scope=all|mine|pinned — chip-driven scope (default "all")
|
||||
// ?status=active,archived,closed — status whitelist (CSV; default = no narrowing)
|
||||
// ?type=client,litigation,patent,case,project,other — type whitelist
|
||||
// ?has_open_deadlines=true|false — narrow by deadline activity
|
||||
// ?q=<term> — search title / reference / clientmatter
|
||||
// ?subtree_counts=true|false — populate *_subtree fields (default true)
|
||||
//
|
||||
// ?scope=all|mine|pinned — chip-driven scope (default "all")
|
||||
// ?status=active,archived,closed — status whitelist (CSV; default = no narrowing)
|
||||
// ?type=client,litigation,patent,case,project,other — type whitelist
|
||||
// ?has_open_deadlines=true|false — narrow by deadline activity
|
||||
// ?q=<term> — search title / reference / clientmatter
|
||||
// ?subtree_counts=true|false — populate *_subtree fields (default true)
|
||||
//
|
||||
// Zero query string preserves the legacy behaviour for back-compat (existing
|
||||
// callers that just want every visible project).
|
||||
|
||||
728
internal/handlers/scenario_builder.go
Normal file
728
internal/handlers/scenario_builder.go
Normal file
@@ -0,0 +1,728 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
)
|
||||
|
||||
// t-paliad-340 / m/paliad#153 B0 — REST endpoints over the new normalised
|
||||
// scenario builder shape (paliad.scenarios with owner_id, +
|
||||
// paliad.scenario_proceedings / scenario_events / scenario_shares).
|
||||
//
|
||||
// Endpoints live under /api/builder/scenarios/* to avoid clashing with
|
||||
// the legacy /api/scenarios/* endpoints from m/paliad#124 Slice D. The
|
||||
// B6 cleanup slice retires the legacy surface; until then both shapes
|
||||
// coexist on the same paliad.scenarios table (the legacy paths require
|
||||
// project_id IS NOT NULL OR an abstract created_by = caller; the builder
|
||||
// paths require owner_id = caller).
|
||||
//
|
||||
// All handlers gate by requireScenarioBuilderService — 503 when the
|
||||
// service is nil (DATABASE_URL unset). Auth is checked via requireUser;
|
||||
// per-row visibility is enforced inside the service.
|
||||
|
||||
func requireScenarioBuilderService(w http.ResponseWriter) bool {
|
||||
if dbSvc == nil || dbSvc.scenarioBuilder == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
|
||||
"error": "Litigation-Builder ist vorübergehend nicht verfügbar (keine Datenbank).",
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// scenarioBuilderErrorToStatus maps service errors to HTTP statuses.
|
||||
func scenarioBuilderErrorToStatus(err error) (int, string) {
|
||||
switch {
|
||||
case errors.Is(err, services.ErrScenarioBuilderNotVisible),
|
||||
errors.Is(err, services.ErrNotVisible):
|
||||
return http.StatusNotFound, "Szenario nicht gefunden"
|
||||
case errors.Is(err, services.ErrInvalidInput):
|
||||
return http.StatusBadRequest, err.Error()
|
||||
}
|
||||
return http.StatusInternalServerError, err.Error()
|
||||
}
|
||||
|
||||
func writeBuilderError(w http.ResponseWriter, err error) {
|
||||
status, msg := scenarioBuilderErrorToStatus(err)
|
||||
writeJSON(w, status, map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Akte mode (B4, t-paliad-347)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// handleBuilderScenarioFromProject — POST /api/builder/scenarios/from-project
|
||||
//
|
||||
// Body: {"project_id": "<uuid>"}
|
||||
//
|
||||
// Creates a fresh project-backed scenario by snapshotting the project's
|
||||
// proceeding_type_id + our_side + scenario_flags into one top-level
|
||||
// triplet, and seeds scenario_events from every existing
|
||||
// paliad.deadlines row tied to a sequencing_rule. The new scenario's
|
||||
// origin_project_id pins the Akte link so subsequent edits dual-write
|
||||
// through to paliad.deadlines + paliad.projects.scenario_flags (PRD §2.3).
|
||||
//
|
||||
// Visibility: caller must be able to see the project. Bad input
|
||||
// (missing proceeding_type_id, invisible project) returns 400 / 404
|
||||
// via the standard service-error mapping.
|
||||
func handleBuilderScenarioFromProject(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireScenarioBuilderService(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Anfrage"})
|
||||
return
|
||||
}
|
||||
if body.ProjectID == uuid.Nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "project_id ist erforderlich"})
|
||||
return
|
||||
}
|
||||
out, err := dbSvc.scenarioBuilder.CreateScenarioFromProject(r.Context(), uid, body.ProjectID)
|
||||
if err != nil {
|
||||
writeBuilderError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, out)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scenario CRUD
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// handleBuilderScenariosList — GET /api/builder/scenarios?status=<active|archived|promoted|all>
|
||||
func handleBuilderScenariosList(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireScenarioBuilderService(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
status := r.URL.Query().Get("status")
|
||||
out, err := dbSvc.scenarioBuilder.ListMyScenarios(r.Context(), uid, status)
|
||||
if err != nil {
|
||||
writeBuilderError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// handleBuilderScenarioCreate — POST /api/builder/scenarios
|
||||
func handleBuilderScenarioCreate(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireScenarioBuilderService(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input services.CreateBuilderScenarioInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Anfrage"})
|
||||
return
|
||||
}
|
||||
out, err := dbSvc.scenarioBuilder.CreateScenario(r.Context(), uid, input)
|
||||
if err != nil {
|
||||
writeBuilderError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, out)
|
||||
}
|
||||
|
||||
// handleBuilderScenarioGet — GET /api/builder/scenarios/{id}
|
||||
func handleBuilderScenarioGet(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireScenarioBuilderService(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige ID"})
|
||||
return
|
||||
}
|
||||
out, err := dbSvc.scenarioBuilder.GetScenarioDeep(r.Context(), uid, id)
|
||||
if err != nil {
|
||||
writeBuilderError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// handleBuilderScenarioPatch — PATCH /api/builder/scenarios/{id}
|
||||
func handleBuilderScenarioPatch(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireScenarioBuilderService(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige ID"})
|
||||
return
|
||||
}
|
||||
var input services.PatchBuilderScenarioInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Anfrage"})
|
||||
return
|
||||
}
|
||||
out, err := dbSvc.scenarioBuilder.PatchScenario(r.Context(), uid, id, input)
|
||||
if err != nil {
|
||||
writeBuilderError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Proceedings
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// handleBuilderProceedingCreate — POST /api/builder/scenarios/{id}/proceedings
|
||||
func handleBuilderProceedingCreate(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireScenarioBuilderService(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
sid, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Szenario-ID"})
|
||||
return
|
||||
}
|
||||
var input services.AddProceedingInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Anfrage"})
|
||||
return
|
||||
}
|
||||
out, err := dbSvc.scenarioBuilder.AddProceeding(r.Context(), uid, sid, input)
|
||||
if err != nil {
|
||||
writeBuilderError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, out)
|
||||
}
|
||||
|
||||
// handleBuilderProceedingPatch — PATCH /api/builder/scenarios/{id}/proceedings/{pid}
|
||||
func handleBuilderProceedingPatch(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireScenarioBuilderService(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
sid, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Szenario-ID"})
|
||||
return
|
||||
}
|
||||
pid, err := uuid.Parse(r.PathValue("pid"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Proceeding-ID"})
|
||||
return
|
||||
}
|
||||
var input services.PatchProceedingInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Anfrage"})
|
||||
return
|
||||
}
|
||||
out, err := dbSvc.scenarioBuilder.PatchProceeding(r.Context(), uid, sid, pid, input)
|
||||
if err != nil {
|
||||
writeBuilderError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// handleBuilderProceedingDelete — DELETE /api/builder/scenarios/{id}/proceedings/{pid}
|
||||
func handleBuilderProceedingDelete(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireScenarioBuilderService(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
sid, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Szenario-ID"})
|
||||
return
|
||||
}
|
||||
pid, err := uuid.Parse(r.PathValue("pid"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Proceeding-ID"})
|
||||
return
|
||||
}
|
||||
if err := dbSvc.scenarioBuilder.DeleteProceeding(r.Context(), uid, sid, pid); err != nil {
|
||||
writeBuilderError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Events
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// handleBuilderEventCreate — POST /api/builder/scenarios/{id}/proceedings/{pid}/events
|
||||
func handleBuilderEventCreate(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireScenarioBuilderService(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
sid, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Szenario-ID"})
|
||||
return
|
||||
}
|
||||
pid, err := uuid.Parse(r.PathValue("pid"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Proceeding-ID"})
|
||||
return
|
||||
}
|
||||
var input services.AddEventInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Anfrage"})
|
||||
return
|
||||
}
|
||||
out, err := dbSvc.scenarioBuilder.AddEvent(r.Context(), uid, sid, pid, input)
|
||||
if err != nil {
|
||||
writeBuilderError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, out)
|
||||
}
|
||||
|
||||
// handleBuilderEventPatch — PATCH /api/builder/scenarios/{id}/events/{eid}
|
||||
func handleBuilderEventPatch(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireScenarioBuilderService(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
sid, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Szenario-ID"})
|
||||
return
|
||||
}
|
||||
eid, err := uuid.Parse(r.PathValue("eid"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Event-ID"})
|
||||
return
|
||||
}
|
||||
var input services.PatchEventInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Anfrage"})
|
||||
return
|
||||
}
|
||||
out, err := dbSvc.scenarioBuilder.PatchEvent(r.Context(), uid, sid, eid, input)
|
||||
if err != nil {
|
||||
writeBuilderError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// handleBuilderEventDelete — DELETE /api/builder/scenarios/{id}/events/{eid}
|
||||
func handleBuilderEventDelete(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireScenarioBuilderService(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
sid, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Szenario-ID"})
|
||||
return
|
||||
}
|
||||
eid, err := uuid.Parse(r.PathValue("eid"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Event-ID"})
|
||||
return
|
||||
}
|
||||
if err := dbSvc.scenarioBuilder.DeleteEvent(r.Context(), uid, sid, eid); err != nil {
|
||||
writeBuilderError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shares
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// handleBuilderShareCreate — POST /api/builder/scenarios/{id}/shares
|
||||
// Body: {"shared_with_user_id": "<uuid>"}
|
||||
func handleBuilderShareCreate(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireScenarioBuilderService(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
sid, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Szenario-ID"})
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
SharedWithUserID uuid.UUID `json:"shared_with_user_id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Anfrage"})
|
||||
return
|
||||
}
|
||||
out, err := dbSvc.scenarioBuilder.AddShare(r.Context(), uid, sid, body.SharedWithUserID)
|
||||
if err != nil {
|
||||
writeBuilderError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, out)
|
||||
}
|
||||
|
||||
// handleBuilderShareDelete — DELETE /api/builder/scenarios/{id}/shares/{sid}
|
||||
func handleBuilderShareDelete(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireScenarioBuilderService(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
scid, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Szenario-ID"})
|
||||
return
|
||||
}
|
||||
shid, err := uuid.Parse(r.PathValue("sid"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Share-ID"})
|
||||
return
|
||||
}
|
||||
if err := dbSvc.scenarioBuilder.DeleteShare(r.Context(), uid, scid, shid); err != nil {
|
||||
writeBuilderError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared-with-me + Promote (B5, m/paliad#153)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// handleBuilderScenariosShared — GET /api/builder/scenarios/shared
|
||||
//
|
||||
// Lists scenarios shared read-only with the caller (the "Geteilt mit mir"
|
||||
// side-panel bucket, PRD §2.5). The caller's own scenarios are excluded.
|
||||
func handleBuilderScenariosShared(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireScenarioBuilderService(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
out, err := dbSvc.scenarioBuilder.ListSharedWithMe(r.Context(), uid)
|
||||
if err != nil {
|
||||
writeBuilderError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// handleBuilderScenarioPromote — POST /api/builder/scenarios/{id}/promote
|
||||
//
|
||||
// Body: PromoteScenarioInput (wizard steps 2 + 3). Promotes the scenario
|
||||
// into a real paliad.projects 'case' row transactionally (PRD §10 — no
|
||||
// partial promotions) and returns PromoteResult with the new project id
|
||||
// the wizard navigates to (/projects/{project_id}).
|
||||
func handleBuilderScenarioPromote(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireScenarioBuilderService(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
sid, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Szenario-ID"})
|
||||
return
|
||||
}
|
||||
var input services.PromoteScenarioInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Anfrage"})
|
||||
return
|
||||
}
|
||||
out, err := dbSvc.scenarioBuilder.PromoteScenario(r.Context(), uid, sid, input)
|
||||
if err != nil {
|
||||
writeBuilderError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, out)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scenario flag catalog passthrough (m/paliad#153 B2)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// handleBuilderFlagCatalog — GET /api/builder/scenario-flag-catalog
|
||||
//
|
||||
// Returns every row of paliad.scenario_flag_catalog so the Litigation
|
||||
// Builder can render per-triplet flag toggles without a per-project
|
||||
// round-trip. The catalog itself is global (no jurisdiction or
|
||||
// proceeding scope baked into the table); which flags actually apply
|
||||
// to a given proceeding type is decided by the calc engine via
|
||||
// condition_expr at calculation time. The client renders every catalog
|
||||
// flag and lets the user toggle them — flags with no effect on the
|
||||
// active proceeding's rules simply have no condition_expr referencing
|
||||
// them, so toggling is a no-op.
|
||||
//
|
||||
// 503 when ScenarioFlagsService is nil (DATABASE_URL unset); per-row
|
||||
// visibility checks aren't needed because the catalog is global.
|
||||
func handleBuilderFlagCatalog(w http.ResponseWriter, r *http.Request) {
|
||||
if dbSvc == nil || dbSvc.scenarioFlags == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
|
||||
"error": "Flag-Katalog vorübergehend nicht verfügbar (keine Datenbank).",
|
||||
})
|
||||
return
|
||||
}
|
||||
if _, ok := requireUser(w, r); !ok {
|
||||
return
|
||||
}
|
||||
out, err := dbSvc.scenarioFlags.ListCatalog(r.Context())
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{
|
||||
"error": "Flag-Katalog konnte nicht geladen werden",
|
||||
})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dev-only test route
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// handleBuilderDevTestPage — GET /dev/scenario-builder
|
||||
//
|
||||
// Gated to services.PaliadinOwnerEmail (the same single-owner gate the
|
||||
// /paliadin route uses). Every other authenticated user gets 404. Pure
|
||||
// HTML — no JS bundle — so the page works even before B1 wires the real
|
||||
// builder shell. Renders curl-equivalent forms for the B0 surface so the
|
||||
// schema can be exercised end-to-end without Postman / shell scripts.
|
||||
//
|
||||
// This is the "dev-only test route" the head's task spec asked for. It
|
||||
// disappears in B6 cleanup once the production builder UI ships at
|
||||
// /tools/procedures.
|
||||
func handleBuilderDevTestPage(w http.ResponseWriter, r *http.Request) {
|
||||
if !requirePaliadinOwner(w, r) {
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_, _ = w.Write([]byte(builderDevTestHTML))
|
||||
}
|
||||
|
||||
const builderDevTestHTML = `<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Scenario Builder — Dev Test (B0)</title>
|
||||
<style>
|
||||
body { font-family: ui-monospace, Menlo, monospace; max-width: 880px; margin: 2em auto;
|
||||
padding: 0 1em; color: #222; background: #fafaf7; }
|
||||
h1, h2 { font-family: ui-sans-serif, system-ui, sans-serif; }
|
||||
h1 { border-bottom: 4px solid #c6f41c; padding-bottom: .2em; }
|
||||
section { background: #fff; border: 1px solid #ddd; border-radius: 4px;
|
||||
padding: 1em 1.2em; margin: 1em 0; }
|
||||
label { display: block; margin: .4em 0 .15em; font-size: .85em; color: #555; }
|
||||
input, textarea, select, button { font: inherit; padding: .35em .5em; box-sizing: border-box; }
|
||||
input[type="text"], input[type="number"], textarea { width: 100%; }
|
||||
button { background: #c6f41c; border: 1px solid #9ec61f; cursor: pointer;
|
||||
padding: .4em 1em; border-radius: 3px; margin: .2em 0; }
|
||||
button.secondary { background: #eee; border-color: #ccc; }
|
||||
pre.out { background: #1e1e1e; color: #e6e6e6; padding: .8em 1em; border-radius: 4px;
|
||||
overflow: auto; max-height: 30em; font-size: .85em; }
|
||||
.note { color: #777; font-size: .9em; }
|
||||
.row { display: flex; gap: .5em; }
|
||||
.row > * { flex: 1; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Scenario Builder — Dev Test (B0)</h1>
|
||||
<p class="note">t-paliad-340 / m/paliad#153 — DB-only slice. Exercises
|
||||
paliad.scenarios (builder rows), scenario_proceedings, scenario_events,
|
||||
scenario_shares via /api/builder/scenarios/*. Gated to PaliadinOwnerEmail.</p>
|
||||
|
||||
<section>
|
||||
<h2>1. Liste meine Szenarien</h2>
|
||||
<label>Status filter</label>
|
||||
<select id="list-status">
|
||||
<option value="">(default: alle)</option>
|
||||
<option value="active">active</option>
|
||||
<option value="archived">archived</option>
|
||||
<option value="promoted">promoted</option>
|
||||
<option value="all">all (explicit)</option>
|
||||
</select>
|
||||
<button onclick="listScenarios()">GET /api/builder/scenarios</button>
|
||||
<pre class="out" id="list-out"></pre>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>2. Szenario anlegen</h2>
|
||||
<label>Name</label>
|
||||
<input type="text" id="create-name" placeholder="(leer = Unbenanntes Szenario)">
|
||||
<label>Notes (optional)</label>
|
||||
<textarea id="create-notes" rows="2"></textarea>
|
||||
<button onclick="createScenario()">POST /api/builder/scenarios</button>
|
||||
<pre class="out" id="create-out"></pre>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>3. Szenario abrufen (deep)</h2>
|
||||
<label>Scenario ID</label>
|
||||
<input type="text" id="get-id">
|
||||
<button onclick="getScenario()">GET /api/builder/scenarios/{id}</button>
|
||||
<pre class="out" id="get-out"></pre>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>4. Verfahren hinzufügen</h2>
|
||||
<label>Scenario ID</label>
|
||||
<input type="text" id="proc-sid">
|
||||
<label>proceeding_type_id (integer)</label>
|
||||
<input type="number" id="proc-pt-id" placeholder="z.B. 7 für upc.inf.cfi">
|
||||
<label>primary_party</label>
|
||||
<select id="proc-party">
|
||||
<option value="">(none)</option>
|
||||
<option value="claimant">claimant</option>
|
||||
<option value="defendant">defendant</option>
|
||||
</select>
|
||||
<button onclick="addProceeding()">POST .../proceedings</button>
|
||||
<pre class="out" id="proc-out"></pre>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>5. Event-Karte hinzufügen</h2>
|
||||
<label>Scenario ID</label>
|
||||
<input type="text" id="ev-sid">
|
||||
<label>Proceeding ID</label>
|
||||
<input type="text" id="ev-pid">
|
||||
<label>custom_label (oder sequencing_rule_id / procedural_event_id)</label>
|
||||
<input type="text" id="ev-label" placeholder="freitext-Karte">
|
||||
<label>state</label>
|
||||
<select id="ev-state">
|
||||
<option value="planned">planned</option>
|
||||
<option value="filed">filed</option>
|
||||
<option value="skipped">skipped</option>
|
||||
</select>
|
||||
<button onclick="addEvent()">POST .../proceedings/{pid}/events</button>
|
||||
<pre class="out" id="ev-out"></pre>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>6. Status patchen (archive / restore)</h2>
|
||||
<label>Scenario ID</label>
|
||||
<input type="text" id="patch-sid">
|
||||
<label>new status</label>
|
||||
<select id="patch-status">
|
||||
<option value="active">active</option>
|
||||
<option value="archived">archived</option>
|
||||
</select>
|
||||
<button onclick="patchStatus()">PATCH /api/builder/scenarios/{id}</button>
|
||||
<pre class="out" id="patch-out"></pre>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
const j = (id, payload) =>
|
||||
document.getElementById(id).textContent = JSON.stringify(payload, null, 2);
|
||||
|
||||
async function call(method, url, body) {
|
||||
const opts = { method, headers: { 'Content-Type': 'application/json' } };
|
||||
if (body !== undefined) opts.body = JSON.stringify(body);
|
||||
const r = await fetch(url, opts);
|
||||
const text = await r.text();
|
||||
let parsed = text;
|
||||
try { parsed = JSON.parse(text); } catch (_) {}
|
||||
return { status: r.status, body: parsed };
|
||||
}
|
||||
|
||||
async function listScenarios() {
|
||||
const status = document.getElementById('list-status').value;
|
||||
const q = status ? '?status=' + encodeURIComponent(status) : '';
|
||||
j('list-out', await call('GET', '/api/builder/scenarios' + q));
|
||||
}
|
||||
|
||||
async function createScenario() {
|
||||
const name = document.getElementById('create-name').value;
|
||||
const notes = document.getElementById('create-notes').value;
|
||||
const body = {};
|
||||
if (name) body.name = name;
|
||||
if (notes) body.notes = notes;
|
||||
j('create-out', await call('POST', '/api/builder/scenarios', body));
|
||||
}
|
||||
|
||||
async function getScenario() {
|
||||
const id = document.getElementById('get-id').value.trim();
|
||||
if (!id) return j('get-out', { error: 'ID erforderlich' });
|
||||
j('get-out', await call('GET', '/api/builder/scenarios/' + id));
|
||||
}
|
||||
|
||||
async function addProceeding() {
|
||||
const sid = document.getElementById('proc-sid').value.trim();
|
||||
const ptID = parseInt(document.getElementById('proc-pt-id').value, 10);
|
||||
const party = document.getElementById('proc-party').value;
|
||||
if (!sid || !ptID) return j('proc-out', { error: 'sid + proceeding_type_id erforderlich' });
|
||||
const body = { proceeding_type_id: ptID };
|
||||
if (party) body.primary_party = party;
|
||||
j('proc-out', await call('POST', '/api/builder/scenarios/' + sid + '/proceedings', body));
|
||||
}
|
||||
|
||||
async function addEvent() {
|
||||
const sid = document.getElementById('ev-sid').value.trim();
|
||||
const pid = document.getElementById('ev-pid').value.trim();
|
||||
const label = document.getElementById('ev-label').value.trim();
|
||||
const state = document.getElementById('ev-state').value;
|
||||
if (!sid || !pid || !label) return j('ev-out', { error: 'sid + pid + custom_label erforderlich' });
|
||||
j('ev-out', await call('POST',
|
||||
'/api/builder/scenarios/' + sid + '/proceedings/' + pid + '/events',
|
||||
{ custom_label: label, state }));
|
||||
}
|
||||
|
||||
async function patchStatus() {
|
||||
const sid = document.getElementById('patch-sid').value.trim();
|
||||
const status = document.getElementById('patch-status').value;
|
||||
if (!sid) return j('patch-out', { error: 'sid erforderlich' });
|
||||
j('patch-out', await call('PATCH', '/api/builder/scenarios/' + sid, { status }));
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
@@ -30,6 +30,8 @@ package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -44,6 +46,8 @@ import (
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/models"
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
"mgit.msbls.de/m/paliad/pkg/docforge"
|
||||
"mgit.msbls.de/m/paliad/pkg/docforge/docx"
|
||||
)
|
||||
|
||||
// submissionDraftPreviewTimeout caps a single preview round-trip.
|
||||
@@ -115,10 +119,14 @@ type submissionDraftJSON struct {
|
||||
// pre-Composer drafts; the editor sidebar surfaces this in the
|
||||
// base picker. PATCH accepts {"base_id": "<uuid>"} or
|
||||
// {"base_id": null} to set or clear.
|
||||
BaseID *uuid.UUID `json:"base_id"`
|
||||
ComposerMeta map[string]any `json:"composer_meta"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
BaseID *uuid.UUID `json:"base_id"`
|
||||
// TemplateVersionID — pinned uploaded docforge template version
|
||||
// (t-paliad-349 slice 7). NULL = base_id/v1 path. The editor's picker
|
||||
// surfaces this; PATCH accepts {"template_version_id": "<uuid>"} | null.
|
||||
TemplateVersionID *uuid.UUID `json:"template_version_id"`
|
||||
ComposerMeta map[string]any `json:"composer_meta"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// submissionSectionJSON is the on-the-wire row for each per-draft
|
||||
@@ -126,15 +134,15 @@ type submissionDraftJSON struct {
|
||||
// section stack but doesn't yet edit prose. Slice B makes content_md_*
|
||||
// editable + adds the PATCH endpoint.
|
||||
type submissionSectionJSON struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
SectionKey string `json:"section_key"`
|
||||
OrderIndex int `json:"order_index"`
|
||||
Kind string `json:"kind"`
|
||||
LabelDE string `json:"label_de"`
|
||||
LabelEN string `json:"label_en"`
|
||||
Included bool `json:"included"`
|
||||
ContentMDDE string `json:"content_md_de"`
|
||||
ContentMDEN string `json:"content_md_en"`
|
||||
ID uuid.UUID `json:"id"`
|
||||
SectionKey string `json:"section_key"`
|
||||
OrderIndex int `json:"order_index"`
|
||||
Kind string `json:"kind"`
|
||||
LabelDE string `json:"label_de"`
|
||||
LabelEN string `json:"label_en"`
|
||||
Included bool `json:"included"`
|
||||
ContentMDDE string `json:"content_md_de"`
|
||||
ContentMDEN string `json:"content_md_en"`
|
||||
}
|
||||
|
||||
type submissionRuleSummary struct {
|
||||
@@ -170,6 +178,16 @@ type submissionDraftPatchInput struct {
|
||||
// admin-recovery flows).
|
||||
BaseID *uuid.UUID `json:"base_id,omitempty"`
|
||||
BaseIDSet bool `json:"-"`
|
||||
// TemplateVersionID pins an uploaded docforge template version
|
||||
// (t-paliad-349 slice 7). Same three-state presence contract as
|
||||
// base_id: absent = no change, uuid = pin, null = clear.
|
||||
TemplateVersionID *uuid.UUID `json:"template_version_id,omitempty"`
|
||||
TemplateVersionIDSet bool `json:"-"`
|
||||
// FilenameKeyword overrides the leading keyword of the exported
|
||||
// document name (t-paliad-354). Absent = no change; "" = clear back
|
||||
// to the auto-derived rule name; "x" = set. Persisted in
|
||||
// composer_meta.filename_keyword.
|
||||
FilenameKeyword *string `json:"filename_keyword,omitempty"`
|
||||
}
|
||||
|
||||
// UnmarshalJSON on submissionDraftPatchInput sets BaseIDSet=true if
|
||||
@@ -193,6 +211,9 @@ func (p *submissionDraftPatchInput) UnmarshalJSON(data []byte) error {
|
||||
if _, ok := raw["base_id"]; ok {
|
||||
p.BaseIDSet = true
|
||||
}
|
||||
if _, ok := raw["template_version_id"]; ok {
|
||||
p.TemplateVersionIDSet = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -433,10 +454,17 @@ func handlePatchSubmissionDraft(w http.ResponseWriter, r *http.Request) {
|
||||
Variables: input.Variables,
|
||||
SelectedParties: input.SelectedParties,
|
||||
Language: input.Language,
|
||||
FilenameKeyword: input.FilenameKeyword,
|
||||
}
|
||||
if input.BaseIDSet {
|
||||
patch.BaseID = &input.BaseID
|
||||
}
|
||||
if input.TemplateVersionIDSet {
|
||||
if !validateTemplateVersionPin(w, r.Context(), input.TemplateVersionID) {
|
||||
return
|
||||
}
|
||||
patch.TemplateVersionID = &input.TemplateVersionID
|
||||
}
|
||||
d, err := dbSvc.submissionDraft.Update(r.Context(), uid, draftID, patch)
|
||||
if err != nil {
|
||||
writeSubmissionDraftServiceError(w, err)
|
||||
@@ -517,7 +545,7 @@ func handlePreviewSubmissionDraft(w http.ResponseWriter, r *http.Request) {
|
||||
writeSubmissionDraftServiceError(w, err)
|
||||
return
|
||||
}
|
||||
tplBytes, _, _, err := resolveSubmissionTemplate(ctx, d.SubmissionCode, d.Language)
|
||||
tplBytes, err := previewTemplateBytes(ctx, d)
|
||||
if err != nil {
|
||||
log.Printf("submission_drafts: template fetch (draft=%s): %v", draftID, err)
|
||||
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "template upstream unreachable"})
|
||||
@@ -573,7 +601,7 @@ func handleExportSubmissionDraft(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
filename := submissionFileName(resolved.Rule, resolved.Project, resolved.Lang)
|
||||
filename := submissionDownloadFilename(ctx, uid, resolved.Rule, resolved.Project, resolved.Lang, submissionFilenameKeyword(d))
|
||||
|
||||
// Audit + provenance updates are best-effort on a background
|
||||
// context so the download still succeeds if the DB races.
|
||||
@@ -597,6 +625,48 @@ func handleExportSubmissionDraft(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// validateTemplateVersionPin checks that a non-nil template-version pin
|
||||
// refers to an existing version (404 otherwise), so a PATCH can't bind a
|
||||
// draft to a vanished template. A nil pin (clear) is always valid. Returns
|
||||
// true when the patch may proceed; writes the error response otherwise.
|
||||
func validateTemplateVersionPin(w http.ResponseWriter, ctx context.Context, pin *uuid.UUID) bool {
|
||||
if pin == nil {
|
||||
return true
|
||||
}
|
||||
if dbSvc.templateStore == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "template store not configured"})
|
||||
return false
|
||||
}
|
||||
if _, err := dbSvc.templateStore.GetVersion(ctx, pin.String()); err != nil {
|
||||
if errors.Is(err, docforge.ErrTemplateNotFound) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "template version not found"})
|
||||
} else {
|
||||
writeServiceError(w, err)
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// previewTemplateBytes returns the carrier bytes to render a draft's
|
||||
// preview: the pinned uploaded-template version's carrier when set
|
||||
// (t-paliad-349 slice 7), otherwise the resolved upstream submission
|
||||
// template (v1/legacy path). A missing pinned version falls through to the
|
||||
// upstream resolution rather than failing.
|
||||
func previewTemplateBytes(ctx context.Context, d *services.SubmissionDraft) ([]byte, error) {
|
||||
if d.TemplateVersionID != nil && dbSvc.templateStore != nil {
|
||||
tmpl, err := dbSvc.templateStore.GetVersion(ctx, d.TemplateVersionID.String())
|
||||
if err == nil {
|
||||
return tmpl.CarrierBytes, nil
|
||||
}
|
||||
if !errors.Is(err, docforge.ErrTemplateNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
b, _, _, err := resolveSubmissionTemplate(ctx, d.SubmissionCode, d.Language)
|
||||
return b, err
|
||||
}
|
||||
|
||||
// exportSubmissionDraft is the shared render entry point used by both
|
||||
// the project-scoped and global export handlers (t-paliad-313 Slice B).
|
||||
// Branches on draft.BaseID: if set AND the base + bytes resolve, the
|
||||
@@ -607,6 +677,27 @@ func handleExportSubmissionDraft(w http.ResponseWriter, r *http.Request) {
|
||||
//
|
||||
// Returns (bytes, resolved-bag, templateSHA, composerUsed, err).
|
||||
func exportSubmissionDraft(ctx context.Context, d *services.SubmissionDraft) ([]byte, *services.SubmissionVarsResult, string, bool, error) {
|
||||
// t-paliad-349 slice 7 — uploaded-template path, checked first. The
|
||||
// pinned version's carrier already carries {{slots}}; Export resolves
|
||||
// the bag + substitutes them via the same renderer the v1 path uses
|
||||
// (no Composer/sections — the uploaded doc IS the document). A missing
|
||||
// pinned version falls through to the base_id / v1 paths.
|
||||
if d.TemplateVersionID != nil && dbSvc.templateStore != nil {
|
||||
tmpl, err := dbSvc.templateStore.GetVersion(ctx, d.TemplateVersionID.String())
|
||||
switch {
|
||||
case err == nil:
|
||||
docx, resolved, rerr := dbSvc.submissionDraft.Export(ctx, d, tmpl.CarrierBytes)
|
||||
if rerr != nil {
|
||||
return nil, nil, "", false, fmt.Errorf("render: %w", rerr)
|
||||
}
|
||||
return docx, resolved, "", false, nil
|
||||
case errors.Is(err, docforge.ErrTemplateNotFound):
|
||||
log.Printf("submission_drafts: pinned template version missing (draft=%s version=%s) — falling back", d.ID, *d.TemplateVersionID)
|
||||
default:
|
||||
return nil, nil, "", false, fmt.Errorf("template version lookup: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if d.BaseID != nil && dbSvc.submissionBase != nil && dbSvc.submissionSection != nil && dbSvc.submissionComposer != nil {
|
||||
base, err := dbSvc.submissionBase.GetByID(ctx, *d.BaseID)
|
||||
switch {
|
||||
@@ -853,16 +944,26 @@ type globalDraftPatchInput struct {
|
||||
// by UnmarshalJSON. t-paliad-313 Composer Slice A.
|
||||
BaseID *uuid.UUID `json:"base_id,omitempty"`
|
||||
baseIDProvided bool
|
||||
// TemplateVersionID + provided flag — uploaded-template pin
|
||||
// (t-paliad-349 slice 7), same present/absent contract as base_id.
|
||||
TemplateVersionID *uuid.UUID `json:"template_version_id,omitempty"`
|
||||
templateVersionIDProvided bool
|
||||
// FilenameKeyword overrides the leading keyword of the exported
|
||||
// document name (t-paliad-354). Absent = no change; "" = clear; "x" =
|
||||
// set. Persisted in composer_meta.filename_keyword.
|
||||
FilenameKeyword *string `json:"filename_keyword,omitempty"`
|
||||
}
|
||||
|
||||
func (g *globalDraftPatchInput) UnmarshalJSON(data []byte) error {
|
||||
type alias struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Variables *services.PlaceholderMap `json:"variables,omitempty"`
|
||||
Language *string `json:"language,omitempty"`
|
||||
ProjectID *uuid.UUID `json:"project_id,omitempty"`
|
||||
SelectedParties *[]uuid.UUID `json:"selected_parties,omitempty"`
|
||||
BaseID *uuid.UUID `json:"base_id,omitempty"`
|
||||
Name *string `json:"name,omitempty"`
|
||||
Variables *services.PlaceholderMap `json:"variables,omitempty"`
|
||||
Language *string `json:"language,omitempty"`
|
||||
ProjectID *uuid.UUID `json:"project_id,omitempty"`
|
||||
SelectedParties *[]uuid.UUID `json:"selected_parties,omitempty"`
|
||||
BaseID *uuid.UUID `json:"base_id,omitempty"`
|
||||
TemplateVersionID *uuid.UUID `json:"template_version_id,omitempty"`
|
||||
FilenameKeyword *string `json:"filename_keyword,omitempty"`
|
||||
}
|
||||
var a alias
|
||||
if err := json.Unmarshal(data, &a); err != nil {
|
||||
@@ -874,14 +975,17 @@ func (g *globalDraftPatchInput) UnmarshalJSON(data []byte) error {
|
||||
g.ProjectID = a.ProjectID
|
||||
g.SelectedParties = a.SelectedParties
|
||||
g.BaseID = a.BaseID
|
||||
// Detect whether "project_id" / "base_id" were present in the JSON
|
||||
// object.
|
||||
g.TemplateVersionID = a.TemplateVersionID
|
||||
g.FilenameKeyword = a.FilenameKeyword
|
||||
// Detect whether "project_id" / "base_id" / "template_version_id" were
|
||||
// present in the JSON object.
|
||||
var raw map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
_, g.projectIDProvided = raw["project_id"]
|
||||
_, g.baseIDProvided = raw["base_id"]
|
||||
_, g.templateVersionIDProvided = raw["template_version_id"]
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -917,6 +1021,7 @@ func handleGlobalPatchSubmissionDraft(w http.ResponseWriter, r *http.Request) {
|
||||
Variables: in.Variables,
|
||||
SelectedParties: in.SelectedParties,
|
||||
Language: in.Language,
|
||||
FilenameKeyword: in.FilenameKeyword,
|
||||
}
|
||||
if in.projectIDProvided {
|
||||
pid := in.ProjectID // may be nil → detach
|
||||
@@ -926,6 +1031,13 @@ func handleGlobalPatchSubmissionDraft(w http.ResponseWriter, r *http.Request) {
|
||||
bid := in.BaseID // may be nil → clear
|
||||
patch.BaseID = &bid
|
||||
}
|
||||
if in.templateVersionIDProvided {
|
||||
if !validateTemplateVersionPin(w, r.Context(), in.TemplateVersionID) {
|
||||
return
|
||||
}
|
||||
tv := in.TemplateVersionID // may be nil → clear
|
||||
patch.TemplateVersionID = &tv
|
||||
}
|
||||
|
||||
d, err := dbSvc.submissionDraft.Update(r.Context(), uid, draftID, patch)
|
||||
if err != nil {
|
||||
@@ -1045,7 +1157,7 @@ func handleGlobalExportSubmissionDraft(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
filename := submissionFileName(resolved.Rule, resolved.Project, resolved.Lang)
|
||||
filename := submissionDownloadFilename(ctx, uid, resolved.Rule, resolved.Project, resolved.Lang, submissionFilenameKeyword(d))
|
||||
|
||||
bgCtx, cancelBG := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancelBG()
|
||||
@@ -1155,6 +1267,23 @@ func buildSubmissionDraftView(ctx context.Context, d *services.SubmissionDraft,
|
||||
view.Rule.LegalSourcePretty = merged["rule.legal_source_pretty"]
|
||||
}
|
||||
|
||||
// t-paliad-349 slice 7 — uploaded-template draft: render the pinned
|
||||
// carrier. The Gitea tier / language-fallback notions don't apply (they
|
||||
// describe the upstream fallback chain), so they stay at their zero
|
||||
// values. A missing pinned version falls through to upstream resolution.
|
||||
if d.TemplateVersionID != nil && dbSvc.templateStore != nil {
|
||||
if tmpl, terr := dbSvc.templateStore.GetVersion(ctx, d.TemplateVersionID.String()); terr == nil {
|
||||
html, rerr := dbSvc.submissionDraft.RenderPreview(ctx, d, tmpl.CarrierBytes)
|
||||
if rerr != nil {
|
||||
return nil, rerr
|
||||
}
|
||||
view.PreviewHTML = html
|
||||
return view, nil
|
||||
} else if !errors.Is(terr, docforge.ErrTemplateNotFound) {
|
||||
return nil, terr
|
||||
}
|
||||
}
|
||||
|
||||
tplBytes, _, tier, err := resolveSubmissionTemplate(ctx, d.SubmissionCode, d.Language)
|
||||
if err != nil {
|
||||
log.Printf("submission_drafts: template fetch for view (draft=%s): %v", d.ID, err)
|
||||
@@ -1184,16 +1313,21 @@ func buildSubmissionDraftView(ctx context.Context, d *services.SubmissionDraft,
|
||||
type submissionTemplateTier string
|
||||
|
||||
const (
|
||||
tplTierPerCodeLang submissionTemplateTier = "per_code_lang" // {firm}/{code}.{lang}.docx
|
||||
tplTierPerCode submissionTemplateTier = "per_code" // {firm}/{code}.docx (unsuffixed)
|
||||
tplTierSkeletonLang submissionTemplateTier = "skeleton_lang" // _skeleton.{lang}.docx
|
||||
tplTierSkeleton submissionTemplateTier = "skeleton" // _skeleton.docx
|
||||
tplTierLetterhead submissionTemplateTier = "letterhead" // HL Patents Style .dotm
|
||||
tplTierPerCodeLang submissionTemplateTier = "per_code_lang" // {firm}/{code}.{lang}.docx
|
||||
tplTierPerCode submissionTemplateTier = "per_code" // {firm}/{code}.docx (unsuffixed)
|
||||
tplTierSkeletonLang submissionTemplateTier = "skeleton_lang" // _skeleton.{lang}.docx
|
||||
tplTierSkeleton submissionTemplateTier = "skeleton" // _skeleton.docx
|
||||
tplTierFallback submissionTemplateTier = "fallback" // embedded merge-safe basic-Rubrum skeleton
|
||||
tplTierLetterhead submissionTemplateTier = "letterhead" // HL Patents Style .dotm
|
||||
)
|
||||
|
||||
// resolveSubmissionTemplate returns the .docx bytes for the given
|
||||
// (submission_code, language). Merges t-paliad-275 (firm-skeleton tier)
|
||||
// and t-paliad-276 (language-selector + EN skeleton tier). Lookup order:
|
||||
// (submission_code, language). This is the *merge-path* resolver: every
|
||||
// caller feeds the result into SubmissionRenderer (merge.go), which fills
|
||||
// {{key}} tokens. The result must therefore be merge-safe — it must carry
|
||||
// real {{key}} placeholders. Merges t-paliad-275 (firm-skeleton tier),
|
||||
// t-paliad-276 (language-selector + EN skeleton tier), t-paliad-358 A-S1
|
||||
// (merge-safe guard + embedded fallback). Lookup order:
|
||||
//
|
||||
// 1. per-firm per-(code, lang) template — most specific. e.g.
|
||||
// `de.inf.lg.erwidg.en.docx` for EN drafts. t-paliad-276.
|
||||
@@ -1202,12 +1336,22 @@ const (
|
||||
// 3. universal language-matched skeleton — `_skeleton.en.docx` for EN
|
||||
// drafts. Skipped for DE drafts (steps 4+5 already cover DE).
|
||||
// 4. firm-formatted skeleton — `_firm-skeleton.docx` (t-paliad-275).
|
||||
// HL paragraph + character styles + letterhead, full placeholder
|
||||
// bag. DE-flavored: counts as language_fallback=true for EN drafts.
|
||||
// 5. universal _skeleton.docx — plain DE skeleton, no firm styles.
|
||||
// Backstop when the firm skeleton is unreachable.
|
||||
// 6. universal HL Patents Style .dotm — macro-only letterhead, no
|
||||
// placeholders. Last-ditch when every skeleton tier is unreachable.
|
||||
// 5. universal _skeleton.docx.
|
||||
// 6. embedded merge-safe fallback — a lang-aware basic-Rubrum skeleton
|
||||
// built in-process (docx.BuildFallbackSkeleton). Always available, no
|
||||
// Gitea round-trip. This is what makes one-click /generate produce a
|
||||
// real merged document for ANY submission_code.
|
||||
// 7. HL Patents Style .dotm — placeholder-free letterhead, the pre-358
|
||||
// last-ditch. Reached only if the in-process build (6) fails.
|
||||
//
|
||||
// Tiers 3/4/5 are GUARDED by docx.HasMergePlaceholders: the firm and
|
||||
// universal skeletons were repurposed into anchors-only Composer bases
|
||||
// (t-paliad-313 Slice B) — their bodies hold only {{#section:KEY}} markers
|
||||
// the merge engine can't fill, so feeding them to merge.go produced literal
|
||||
// "{{#section:…}}" junk (kepler audit §1 Path 3 / §2). The guard skips any
|
||||
// fetched skeleton that lacks real placeholders, so today they fall through
|
||||
// to the embedded fallback (6); should a merge-safe firm-skeleton (with
|
||||
// letterhead) be restored later it is preferred again automatically.
|
||||
//
|
||||
// The returned SHA pins the audit row's template provenance. The tier
|
||||
// tells the editor whether the result language-matches the request so
|
||||
@@ -1231,25 +1375,30 @@ func resolveSubmissionTemplate(ctx context.Context, submissionCode, lang string)
|
||||
// 3. language-matched skeleton — only meaningful for EN drafts; DE
|
||||
// drafts fall through to the firm/universal DE skeletons below.
|
||||
if lang == "en" {
|
||||
if data, sha, langMatched, err := fetchSubmissionSkeletonBytesForLang(ctx, lang); err == nil && langMatched {
|
||||
if data, sha, langMatched, err := fetchSubmissionSkeletonBytesForLang(ctx, lang); err == nil && langMatched && docx.HasMergePlaceholders(data) {
|
||||
return data, sha, tplTierSkeletonLang, nil
|
||||
}
|
||||
}
|
||||
// 4. firm-formatted skeleton (HL styles, DE prose). For DE drafts
|
||||
// this is a first-class match; for EN drafts it counts as a
|
||||
// language fallback (handled by languageFallback()).
|
||||
if data, sha, err := fetchFirmSkeletonBytes(ctx); err == nil {
|
||||
// 4. firm-formatted skeleton — used only if it is merge-safe (carries
|
||||
// real {{key}} placeholders, not anchors-only Composer markers).
|
||||
if data, sha, err := fetchFirmSkeletonBytes(ctx); err == nil && docx.HasMergePlaceholders(data) {
|
||||
return data, sha, tplTierSkeleton, nil
|
||||
} else {
|
||||
log.Printf("submission_drafts: firm-skeleton fetch failed for code=%s lang=%s, falling back to universal skeleton: %v", submissionCode, lang, err)
|
||||
}
|
||||
// 5. universal plain DE skeleton.
|
||||
if data, sha, err := fetchSubmissionSkeletonBytes(ctx); err == nil {
|
||||
// 5. universal plain DE skeleton — same merge-safe guard.
|
||||
if data, sha, err := fetchSubmissionSkeletonBytes(ctx); err == nil && docx.HasMergePlaceholders(data) {
|
||||
return data, sha, tplTierSkeleton, nil
|
||||
} else {
|
||||
log.Printf("submission_drafts: skeleton fetch failed for code=%s lang=%s, falling back to HL Patents Style: %v", submissionCode, lang, err)
|
||||
}
|
||||
// 6. HL Patents Style letterhead (no placeholders, last-ditch).
|
||||
// 6. embedded merge-safe fallback — lang-aware basic Rubrum, always
|
||||
// available. Supersedes the placeholder-free .dotm so /generate on
|
||||
// any code yields a real merged document (basic Rubrum), never the
|
||||
// {{#section:…}} junk an anchors-only base produced (t-paliad-358 A-S1).
|
||||
if data, err := docx.BuildFallbackSkeleton(lang); err == nil {
|
||||
sum := sha256.Sum256(data)
|
||||
return data, hex.EncodeToString(sum[:]), tplTierFallback, nil
|
||||
} else {
|
||||
log.Printf("submission_drafts: embedded fallback skeleton build failed for code=%s lang=%s, falling back to HL Patents Style: %v", submissionCode, lang, err)
|
||||
}
|
||||
// 7. HL Patents Style letterhead (no placeholders, last-ditch).
|
||||
bytes, err := fetchHLPatentsStyleBytes(ctx)
|
||||
if err != nil {
|
||||
return nil, "", "", err
|
||||
@@ -1260,16 +1409,19 @@ func resolveSubmissionTemplate(ctx context.Context, submissionCode, lang string)
|
||||
|
||||
// languageFallback reports whether the resolved template tier failed
|
||||
// to match the requested draft language. For an EN draft, anything
|
||||
// other than per_code_lang or skeleton_lang is a fallback (per_code is
|
||||
// the legacy DE-baked template, skeleton is the DE skeleton). For a DE
|
||||
// draft, only `letterhead` counts as a fallback — the DE skeleton and
|
||||
// per-code template are both first-class DE outputs. t-paliad-276.
|
||||
// other than per_code_lang, skeleton_lang or the lang-aware embedded
|
||||
// fallback is a fallback (per_code is the legacy DE-baked template,
|
||||
// skeleton is the DE skeleton). For a DE draft, only `letterhead` counts
|
||||
// as a fallback — the DE skeleton, per-code template, and the embedded
|
||||
// fallback are all first-class DE outputs. t-paliad-276 / t-paliad-358 A-S1.
|
||||
func languageFallback(lang string, tier submissionTemplateTier) bool {
|
||||
if tier == tplTierLetterhead {
|
||||
return true
|
||||
}
|
||||
if strings.EqualFold(lang, "en") {
|
||||
return tier != tplTierPerCodeLang && tier != tplTierSkeletonLang
|
||||
// tplTierFallback is built per-language (English labels for EN), so
|
||||
// it is NOT a language fallback.
|
||||
return tier != tplTierPerCodeLang && tier != tplTierSkeletonLang && tier != tplTierFallback
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1306,21 +1458,22 @@ func draftToJSON(d *services.SubmissionDraft) submissionDraftJSON {
|
||||
meta = map[string]any{}
|
||||
}
|
||||
return submissionDraftJSON{
|
||||
ID: d.ID,
|
||||
ProjectID: d.ProjectID,
|
||||
SubmissionCode: d.SubmissionCode,
|
||||
UserID: d.UserID,
|
||||
Name: d.Name,
|
||||
Language: lang,
|
||||
Variables: vars,
|
||||
SelectedParties: selected,
|
||||
LastExportedAt: d.LastExportedAt,
|
||||
LastExportedSHA: d.LastExportedSHA,
|
||||
LastImportedAt: d.LastImportedAt,
|
||||
BaseID: d.BaseID,
|
||||
ComposerMeta: meta,
|
||||
CreatedAt: d.CreatedAt,
|
||||
UpdatedAt: d.UpdatedAt,
|
||||
ID: d.ID,
|
||||
ProjectID: d.ProjectID,
|
||||
SubmissionCode: d.SubmissionCode,
|
||||
UserID: d.UserID,
|
||||
Name: d.Name,
|
||||
Language: lang,
|
||||
Variables: vars,
|
||||
SelectedParties: selected,
|
||||
LastExportedAt: d.LastExportedAt,
|
||||
LastExportedSHA: d.LastExportedSHA,
|
||||
LastImportedAt: d.LastImportedAt,
|
||||
BaseID: d.BaseID,
|
||||
TemplateVersionID: d.TemplateVersionID,
|
||||
ComposerMeta: meta,
|
||||
CreatedAt: d.CreatedAt,
|
||||
UpdatedAt: d.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
157
internal/handlers/submission_filename_test.go
Normal file
157
internal/handlers/submission_filename_test.go
Normal file
@@ -0,0 +1,157 @@
|
||||
package handlers
|
||||
|
||||
// Regression tests for the generated-document download name
|
||||
// (t-paliad-354): "<YYYY-MM-DD> <keyword> (<case number>).docx".
|
||||
// The date segment is environment-dependent (Europe/Berlin "today"),
|
||||
// so the assertions pin the keyword + bracketed case-number frame and
|
||||
// the .docx suffix rather than the literal date.
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/models"
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
)
|
||||
|
||||
func strptr(s string) *string { return &s }
|
||||
|
||||
func todayBerlin() string {
|
||||
day := time.Now()
|
||||
if loc, err := time.LoadLocation("Europe/Berlin"); err == nil {
|
||||
day = day.In(loc)
|
||||
}
|
||||
return day.Format("2006-01-02")
|
||||
}
|
||||
|
||||
func TestSubmissionFileName(t *testing.T) {
|
||||
t.Parallel()
|
||||
rule := &models.DeadlineRule{Name: "Klageerwiderung", NameEN: "Statement of defence"}
|
||||
date := todayBerlin()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
rule *models.DeadlineRule
|
||||
project *models.Project
|
||||
lang string
|
||||
keyword string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "full data — rule name + case number",
|
||||
rule: rule,
|
||||
project: &models.Project{CaseNumber: strptr("UPC_CFI_123_2026")},
|
||||
lang: "de",
|
||||
want: date + " Klageerwiderung (UPC_CFI_123_2026).docx",
|
||||
},
|
||||
{
|
||||
name: "missing case number falls back to placeholder",
|
||||
rule: rule,
|
||||
project: &models.Project{CaseNumber: nil},
|
||||
lang: "de",
|
||||
want: date + " Klageerwiderung (Az. folgt).docx",
|
||||
},
|
||||
{
|
||||
name: "user override keyword wins over rule name",
|
||||
rule: rule,
|
||||
project: &models.Project{CaseNumber: strptr("UPC_CFI_123_2026")},
|
||||
lang: "de",
|
||||
keyword: "Replik Hauptantrag",
|
||||
want: date + " Replik Hauptantrag (UPC_CFI_123_2026).docx",
|
||||
},
|
||||
{
|
||||
name: "EN lang uses NameEN when no override",
|
||||
rule: rule,
|
||||
project: &models.Project{CaseNumber: strptr("UPC_CFI_123_2026")},
|
||||
lang: "en",
|
||||
want: date + " Statement of defence (UPC_CFI_123_2026).docx",
|
||||
},
|
||||
{
|
||||
name: "case number containing slash is sanitised inside brackets",
|
||||
rule: rule,
|
||||
project: &models.Project{CaseNumber: strptr("UPC_CFI_123/2026")},
|
||||
lang: "de",
|
||||
want: date + " Klageerwiderung (UPC_CFI_123_2026).docx",
|
||||
},
|
||||
{
|
||||
name: "blank override falls back to rule name",
|
||||
rule: rule,
|
||||
project: &models.Project{CaseNumber: strptr("UPC_CFI_123_2026")},
|
||||
lang: "de",
|
||||
keyword: " ",
|
||||
want: date + " Klageerwiderung (UPC_CFI_123_2026).docx",
|
||||
},
|
||||
{
|
||||
name: "empty rule name + no override falls back to submission",
|
||||
rule: &models.DeadlineRule{Name: "", NameEN: ""},
|
||||
project: &models.Project{CaseNumber: nil},
|
||||
lang: "de",
|
||||
want: date + " submission (Az. folgt).docx",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := services.RenderSubmissionFilename(tc.rule, tc.project, tc.lang, tc.keyword)
|
||||
if got != tc.want {
|
||||
t.Errorf("RenderSubmissionFilename() = %q, want %q", got, tc.want)
|
||||
}
|
||||
if !strings.HasSuffix(got, ".docx") {
|
||||
t.Errorf("filename %q missing .docx suffix", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmissionFilenameKeyword(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
draft *services.SubmissionDraft
|
||||
want string
|
||||
}{
|
||||
{"nil draft", nil, ""},
|
||||
{"nil meta", &services.SubmissionDraft{}, ""},
|
||||
{
|
||||
"key absent",
|
||||
&services.SubmissionDraft{ComposerMeta: map[string]any{"other": "x"}},
|
||||
"",
|
||||
},
|
||||
{
|
||||
"legacy filename_keyword reads back-compat",
|
||||
&services.SubmissionDraft{ComposerMeta: map[string]any{"filename_keyword": "Replik"}},
|
||||
"Replik",
|
||||
},
|
||||
{
|
||||
"new name_overrides.keyword shape",
|
||||
&services.SubmissionDraft{ComposerMeta: map[string]any{"name_overrides": map[string]any{"keyword": "Duplik"}}},
|
||||
"Duplik",
|
||||
},
|
||||
{
|
||||
"name_overrides.keyword wins over legacy filename_keyword",
|
||||
&services.SubmissionDraft{ComposerMeta: map[string]any{
|
||||
"name_overrides": map[string]any{"keyword": "Duplik"},
|
||||
"filename_keyword": "Replik",
|
||||
}},
|
||||
"Duplik",
|
||||
},
|
||||
{
|
||||
"key set with surrounding whitespace is trimmed",
|
||||
&services.SubmissionDraft{ComposerMeta: map[string]any{"filename_keyword": " Replik "}},
|
||||
"Replik",
|
||||
},
|
||||
{
|
||||
"non-string value ignored",
|
||||
&services.SubmissionDraft{ComposerMeta: map[string]any{"filename_keyword": 42}},
|
||||
"",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := submissionFilenameKeyword(tc.draft); got != tc.want {
|
||||
t.Errorf("submissionFilenameKeyword() = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ func TestLanguageFallback(t *testing.T) {
|
||||
{"de_per_code", "de", tplTierPerCode, false},
|
||||
{"de_skeleton_lang", "de", tplTierSkeletonLang, false},
|
||||
{"de_skeleton", "de", tplTierSkeleton, false},
|
||||
{"de_fallback", "de", tplTierFallback, false},
|
||||
{"de_letterhead", "de", tplTierLetterhead, true},
|
||||
|
||||
// EN drafts: per_code (DE-baked) and skeleton (DE-baked) both
|
||||
@@ -30,6 +31,9 @@ func TestLanguageFallback(t *testing.T) {
|
||||
{"en_per_code", "en", tplTierPerCode, true},
|
||||
{"en_skeleton_lang", "en", tplTierSkeletonLang, false},
|
||||
{"en_skeleton", "en", tplTierSkeleton, true},
|
||||
// The embedded fallback is built per-language (EN labels for EN),
|
||||
// so it is NOT a language fallback (t-paliad-358 A-S1).
|
||||
{"en_fallback", "en", tplTierFallback, false},
|
||||
{"en_letterhead", "en", tplTierLetterhead, true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
|
||||
@@ -336,7 +336,9 @@ func handleGenerateProjectSubmission(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
filename := submissionFileName(resolved.Rule, resolved.Project, resolved.Lang)
|
||||
// One-click /generate has no saved draft row → no per-document keyword
|
||||
// override, but the user's composition override still applies.
|
||||
filename := submissionDownloadFilename(ctx, uid, 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
|
||||
@@ -355,34 +357,29 @@ func handleGenerateProjectSubmission(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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 —
|
||||
// the lawyer can rename if the project lacks an Aktenzeichen).
|
||||
// Umlauts in the rule name are ASCII-folded by SanitiseSubmissionFileName
|
||||
// so the file lands cleanly on legacy SMB shares.
|
||||
func submissionFileName(rule *models.DeadlineRule, project *models.Project, lang string) string {
|
||||
day := time.Now()
|
||||
if loc, err := time.LoadLocation("Europe/Berlin"); err == nil {
|
||||
day = day.In(loc)
|
||||
// submissionDownloadFilename produces the user-facing download name
|
||||
// (t-paliad-354): "<YYYY-MM-DD> <keyword> (<case number>).docx", rendered
|
||||
// through the submission_docx_filename artifact and honouring the user's
|
||||
// per-user composition override (Slice 3). A failed override load is
|
||||
// non-fatal — it falls back to the system default. keyword is the
|
||||
// per-document value override (name_overrides.keyword).
|
||||
func submissionDownloadFilename(ctx context.Context, uid uuid.UUID, rule *models.DeadlineRule, project *models.Project, lang, keyword string) string {
|
||||
var overrides, firm services.NameCompositionSpec
|
||||
if dbSvc.submissionDraft != nil {
|
||||
overrides, _ = dbSvc.submissionDraft.UserNameCompositions(ctx, uid)
|
||||
}
|
||||
ruleName := strings.TrimSpace(rule.Name)
|
||||
if strings.EqualFold(lang, "en") && strings.TrimSpace(rule.NameEN) != "" {
|
||||
ruleName = strings.TrimSpace(rule.NameEN)
|
||||
if dbSvc.firmNameComposition != nil {
|
||||
firm, _, _ = dbSvc.firmNameComposition.Get(ctx)
|
||||
}
|
||||
if ruleName == "" {
|
||||
ruleName = "submission"
|
||||
}
|
||||
parts := []string{services.SanitiseSubmissionFileName(ruleName)}
|
||||
caseNo := ""
|
||||
if project != nil && project.CaseNumber != nil {
|
||||
caseNo = strings.TrimSpace(*project.CaseNumber)
|
||||
}
|
||||
if caseNo != "" {
|
||||
parts = append(parts, services.SanitiseSubmissionFileName(caseNo))
|
||||
}
|
||||
parts = append(parts, day.Format("2006-01-02"))
|
||||
return strings.Join(parts, "-") + ".docx"
|
||||
return services.RenderSubmissionFilenameFor(overrides, firm, rule, project, lang, keyword)
|
||||
}
|
||||
|
||||
// submissionFilenameKeyword delegates to services.SubmissionFilenameKeyword
|
||||
// (the back-compat read of the per-document keyword override). Kept as a
|
||||
// package-local alias so the existing call-sites and unit test read
|
||||
// unchanged.
|
||||
func submissionFilenameKeyword(d *services.SubmissionDraft) string {
|
||||
return services.SubmissionFilenameKeyword(d)
|
||||
}
|
||||
|
||||
// writeSubmissionAuditRow files one row in paliad.system_audit_log per
|
||||
|
||||
306
internal/handlers/templates.go
Normal file
306
internal/handlers/templates.go
Normal file
@@ -0,0 +1,306 @@
|
||||
package handlers
|
||||
|
||||
// docforge template authoring handlers (t-paliad-349 slice 6).
|
||||
//
|
||||
// The admin-only authoring surface: upload a base .docx, see it rendered as
|
||||
// run-addressable text, place {{variable}} slots into it, and save the
|
||||
// result as a reusable template. Backed by docforge.TemplateStore
|
||||
// (Postgres bytea carrier) + the docx authoring engine
|
||||
// (ImportForAuthoring / InjectSlot).
|
||||
//
|
||||
// Endpoints (all under adminGate — templates are firm-shared, admin-
|
||||
// authored, like submission_bases):
|
||||
// GET /api/admin/templates — catalog list
|
||||
// POST /api/admin/templates — multipart upload → create v1
|
||||
// GET /api/admin/templates/{id} — authoring view (preview+slots)
|
||||
// POST /api/admin/templates/{id}/slots — place a slot → new version
|
||||
//
|
||||
// Slot placement creates a new template version (immutable snapshot) per
|
||||
// placement. That keeps the snapshot guarantee simple; batching a whole
|
||||
// authoring session into one version on an explicit "save" is a documented
|
||||
// future refinement (it trades the version-per-slot churn for a client- or
|
||||
// session-held draft carrier).
|
||||
//
|
||||
// VERIFICATION CEILING: the live upload→render→select→inject→save flow
|
||||
// needs the app running with DATABASE_URL + Supabase auth + Playwright; it
|
||||
// is verified post-merge. The docx surgery (ImportForAuthoring/InjectSlot)
|
||||
// and the store are unit/live-tested independently.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/branding"
|
||||
"mgit.msbls.de/m/paliad/pkg/docforge"
|
||||
"mgit.msbls.de/m/paliad/pkg/docforge/docx"
|
||||
)
|
||||
|
||||
// maxTemplateUpload bounds an uploaded .docx. Templates are firm letterhead
|
||||
// + chrome — tens of KB in practice; 10 MB is a generous ceiling.
|
||||
const maxTemplateUpload = 10 << 20
|
||||
|
||||
type templateMetaJSON struct {
|
||||
ID string `json:"id"`
|
||||
Slug string `json:"slug,omitempty"`
|
||||
NameDE string `json:"name_de"`
|
||||
NameEN string `json:"name_en"`
|
||||
Kind string `json:"kind"`
|
||||
SourceFormat string `json:"source_format"`
|
||||
Firm string `json:"firm,omitempty"`
|
||||
IsActive bool `json:"is_active"`
|
||||
Version int `json:"version"`
|
||||
VersionID string `json:"version_id,omitempty"`
|
||||
}
|
||||
|
||||
type templateSlotJSON struct {
|
||||
Key string `json:"key"`
|
||||
Anchor string `json:"anchor"`
|
||||
Label string `json:"label,omitempty"`
|
||||
OrderIndex int `json:"order_index"`
|
||||
}
|
||||
|
||||
type authoringViewJSON struct {
|
||||
Template templateMetaJSON `json:"template"`
|
||||
PreviewHTML string `json:"preview_html"`
|
||||
Slots []templateSlotJSON `json:"slots"`
|
||||
}
|
||||
|
||||
func metaJSON(m docforge.TemplateMeta) templateMetaJSON {
|
||||
return templateMetaJSON{
|
||||
ID: m.ID, Slug: m.Slug, NameDE: m.NameDE, NameEN: m.NameEN,
|
||||
Kind: m.Kind, SourceFormat: m.SourceFormat, Firm: m.Firm,
|
||||
IsActive: m.IsActive, Version: m.Version, VersionID: m.VersionID,
|
||||
}
|
||||
}
|
||||
|
||||
func slotsJSON(slots []docforge.TemplateSlot) []templateSlotJSON {
|
||||
out := make([]templateSlotJSON, 0, len(slots))
|
||||
for _, s := range slots {
|
||||
out = append(out, templateSlotJSON{Key: s.Key, Anchor: s.Anchor, Label: s.Label, OrderIndex: s.OrderIndex})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// writeTemplateError maps docforge's not-found sentinel to 404 and falls
|
||||
// back to the shared service-error mapper.
|
||||
func writeTemplateError(w http.ResponseWriter, err error) {
|
||||
if errors.Is(err, docforge.ErrTemplateNotFound) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "template not found"})
|
||||
return
|
||||
}
|
||||
writeServiceError(w, err)
|
||||
}
|
||||
|
||||
func requireTemplateStore(w http.ResponseWriter) bool {
|
||||
if !requireDB(w) {
|
||||
return false
|
||||
}
|
||||
if dbSvc.templateStore == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "template store not configured"})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// handleTemplatesAuthoringPage serves the authoring page shell. The client
|
||||
// bundle hydrates the list, upload, preview, palette, and slots.
|
||||
func handleTemplatesAuthoringPage(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFile(w, r, "dist/templates-authoring.html")
|
||||
}
|
||||
|
||||
// handleListTemplates backs GET /api/admin/templates.
|
||||
func handleListTemplates(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireTemplateStore(w) {
|
||||
return
|
||||
}
|
||||
if _, ok := requireUser(w, r); !ok {
|
||||
return
|
||||
}
|
||||
metas, err := dbSvc.templateStore.List(r.Context(), docforge.TemplateFilter{ActiveOnly: true})
|
||||
if err != nil {
|
||||
writeTemplateError(w, err)
|
||||
return
|
||||
}
|
||||
out := make([]templateMetaJSON, 0, len(metas))
|
||||
for _, m := range metas {
|
||||
out = append(out, metaJSON(m))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"templates": out})
|
||||
}
|
||||
|
||||
// handlePickerTemplates backs GET /api/templates — the firm-shared catalog
|
||||
// any authenticated lawyer reads to pick an uploaded template for
|
||||
// generation (t-paliad-349 slice 7). Unlike the admin list it filters by
|
||||
// firm (the deployment's branding firm + firm-agnostic templates), matching
|
||||
// the submission_bases picker contract. Metadata only — no carrier bytes.
|
||||
func handlePickerTemplates(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireTemplateStore(w) {
|
||||
return
|
||||
}
|
||||
if _, ok := requireUser(w, r); !ok {
|
||||
return
|
||||
}
|
||||
metas, err := dbSvc.templateStore.List(r.Context(),
|
||||
docforge.TemplateFilter{Firm: branding.Name, ActiveOnly: true})
|
||||
if err != nil {
|
||||
writeTemplateError(w, err)
|
||||
return
|
||||
}
|
||||
out := make([]templateMetaJSON, 0, len(metas))
|
||||
for _, m := range metas {
|
||||
out = append(out, metaJSON(m))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"templates": out})
|
||||
}
|
||||
|
||||
// handleUploadTemplate backs POST /api/admin/templates (multipart). Reads
|
||||
// the uploaded .docx, validates it parses, detects any slots already in it,
|
||||
// and creates the template at version 1.
|
||||
func handleUploadTemplate(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireTemplateStore(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := r.ParseMultipartForm(maxTemplateUpload); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid multipart form"})
|
||||
return
|
||||
}
|
||||
file, _, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "file field required"})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
carrier, err := io.ReadAll(io.LimitReader(file, maxTemplateUpload))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "could not read uploaded file"})
|
||||
return
|
||||
}
|
||||
nameDE := r.FormValue("name_de")
|
||||
nameEN := r.FormValue("name_en")
|
||||
if nameDE == "" || nameEN == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name_de and name_en required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate + detect existing slots before persisting.
|
||||
view, err := docx.ImportForAuthoring(carrier)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "not a parseable .docx: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
tmpl, err := dbSvc.templateStore.Create(r.Context(),
|
||||
docforge.TemplateMetaInput{
|
||||
Slug: r.FormValue("slug"),
|
||||
NameDE: nameDE,
|
||||
NameEN: nameEN,
|
||||
Firm: r.FormValue("firm"),
|
||||
CreatedBy: uid.String(),
|
||||
},
|
||||
docforge.TemplateVersionInput{
|
||||
CarrierBytes: carrier,
|
||||
Slots: view.Slots,
|
||||
CreatedBy: uid.String(),
|
||||
})
|
||||
if err != nil {
|
||||
writeTemplateError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, authoringViewJSON{
|
||||
Template: metaJSON(tmpl.TemplateMeta),
|
||||
PreviewHTML: view.PreviewHTML,
|
||||
Slots: slotsJSON(tmpl.Slots),
|
||||
})
|
||||
}
|
||||
|
||||
// handleGetTemplateAuthoring backs GET /api/admin/templates/{id} — the
|
||||
// authoring view: current carrier rendered run-addressable + its slots.
|
||||
func handleGetTemplateAuthoring(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireTemplateStore(w) {
|
||||
return
|
||||
}
|
||||
if _, ok := requireUser(w, r); !ok {
|
||||
return
|
||||
}
|
||||
tmpl, err := dbSvc.templateStore.Get(r.Context(), r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeTemplateError(w, err)
|
||||
return
|
||||
}
|
||||
view, err := docx.ImportForAuthoring(tmpl.CarrierBytes)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "stored carrier failed to parse: " + err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, authoringViewJSON{
|
||||
Template: metaJSON(tmpl.TemplateMeta),
|
||||
PreviewHTML: view.PreviewHTML,
|
||||
Slots: slotsJSON(view.Slots),
|
||||
})
|
||||
}
|
||||
|
||||
type placeSlotInput struct {
|
||||
RunIndex int `json:"run_index"`
|
||||
SelectedText string `json:"selected_text"`
|
||||
SlotKey string `json:"slot_key"`
|
||||
}
|
||||
|
||||
// handlePlaceTemplateSlot backs POST /api/admin/templates/{id}/slots —
|
||||
// inject a slot at the selection and persist as a new version.
|
||||
func handlePlaceTemplateSlot(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireTemplateStore(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var in placeSlotInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON body"})
|
||||
return
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
tmpl, err := dbSvc.templateStore.Get(r.Context(), id)
|
||||
if err != nil {
|
||||
writeTemplateError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
newCarrier, err := docx.InjectSlot(tmpl.CarrierBytes, in.RunIndex, in.SelectedText, in.SlotKey)
|
||||
if err != nil {
|
||||
// Injection failures are client-fixable (bad selection / key).
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// Re-detect slots from the new carrier so template_slots mirrors the
|
||||
// carrier's actual {{tokens}} (single source of truth).
|
||||
newView, err := docx.ImportForAuthoring(newCarrier)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": fmt.Sprintf("post-inject parse: %v", err)})
|
||||
return
|
||||
}
|
||||
updated, err := dbSvc.templateStore.AddVersion(r.Context(), id,
|
||||
docforge.TemplateVersionInput{
|
||||
CarrierBytes: newCarrier,
|
||||
Stylemap: tmpl.Stylemap,
|
||||
Slots: newView.Slots,
|
||||
CreatedBy: uid.String(),
|
||||
})
|
||||
if err != nil {
|
||||
writeTemplateError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, authoringViewJSON{
|
||||
Template: metaJSON(updated.TemplateMeta),
|
||||
PreviewHTML: newView.PreviewHTML,
|
||||
Slots: slotsJSON(updated.Slots),
|
||||
})
|
||||
}
|
||||
@@ -265,7 +265,7 @@ func (s *DeadlineService) ListVisibleForUser(ctx context.Context, userID uuid.UU
|
||||
|
||||
query := `
|
||||
SELECT f.id, f.project_id, f.title, f.description, f.due_date, f.original_due_date,
|
||||
f.warning_date, f.source, f.rule_id, f.rule_code, f.custom_rule_text, f.status, f.completed_at,
|
||||
f.warning_date, f.source, f.sequencing_rule_id, f.rule_code, f.custom_rule_text, f.status, f.completed_at,
|
||||
f.caldav_uid, f.caldav_etag, f.notes, f.created_by,
|
||||
f.created_at, f.updated_at,
|
||||
f.approval_status, f.pending_request_id, f.approved_by, f.approved_at,
|
||||
|
||||
66
internal/services/docforge_shims.go
Normal file
66
internal/services/docforge_shims.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package services
|
||||
|
||||
// Shims bridging the submission generator to the extracted docforge .docx
|
||||
// adapter (pkg/docforge/docx). Slice 1 of the docforge train
|
||||
// (t-paliad-349 / m/paliad#157) relocated the Markdown→OOXML walker, the
|
||||
// placeholder substitution engine, and the .dotm→.docx converter into
|
||||
// pkg/docforge/docx with no behaviour change. These type aliases and
|
||||
// forwarders keep every existing caller in internal/services and
|
||||
// internal/handlers compiling and behaving identically — the names,
|
||||
// signatures, and semantics are unchanged; only the implementation moved.
|
||||
//
|
||||
// Later slices retire these shims as the submission services are
|
||||
// refactored to call docforge directly through the neutral model and the
|
||||
// VariableResolver interface.
|
||||
|
||||
import (
|
||||
"mgit.msbls.de/m/paliad/pkg/docforge"
|
||||
"mgit.msbls.de/m/paliad/pkg/docforge/docx"
|
||||
)
|
||||
|
||||
// PlaceholderMap is the variable bag (dotted-key → substituted value),
|
||||
// built by SubmissionVarsService and consumed by the renderer. The
|
||||
// canonical type lives in the docforge root (the format-neutral
|
||||
// variable-bag contract).
|
||||
type PlaceholderMap = docforge.PlaceholderMap
|
||||
|
||||
// MissingPlaceholderFn translates an unbound placeholder key into the
|
||||
// in-document marker token.
|
||||
type MissingPlaceholderFn = docforge.MissingPlaceholderFn
|
||||
|
||||
// SubmissionRenderer renders a .docx template by substituting
|
||||
// {{placeholder}} tokens. Stateless; safe for concurrent use.
|
||||
type SubmissionRenderer = docx.SubmissionRenderer
|
||||
|
||||
// HyperlinkAllocator hands the Markdown walker a rId for each external
|
||||
// URL it encounters in [label](url) inline links.
|
||||
type HyperlinkAllocator = docx.HyperlinkAllocator
|
||||
|
||||
// NewSubmissionRenderer constructs the renderer.
|
||||
func NewSubmissionRenderer() *SubmissionRenderer { return docx.NewSubmissionRenderer() }
|
||||
|
||||
// DefaultMissingMarker returns the standard missing-value marker for the
|
||||
// given UI language ("[KEIN WERT: <key>]" / "[NO VALUE: <key>]").
|
||||
func DefaultMissingMarker(lang string) MissingPlaceholderFn {
|
||||
return docforge.DefaultMissingMarker(lang)
|
||||
}
|
||||
|
||||
// RenderMarkdownToOOXML renders Markdown source into OOXML paragraph
|
||||
// elements using a single paragraph style.
|
||||
func RenderMarkdownToOOXML(md, paragraphStyle string) string {
|
||||
return docx.RenderMarkdownToOOXML(md, paragraphStyle)
|
||||
}
|
||||
|
||||
// RenderMarkdownToOOXMLWithStyles is the full rich-prose entry point
|
||||
// (headings, lists, blockquote, inline hyperlinks via the allocator).
|
||||
func RenderMarkdownToOOXMLWithStyles(md string, stylemap map[string]string, links HyperlinkAllocator) string {
|
||||
return docx.RenderMarkdownToOOXMLWithStyles(md, stylemap, links)
|
||||
}
|
||||
|
||||
// ConvertDotmToDocx rewrites a .dotm/.docm/.dotx zip into a clean .docx
|
||||
// zip. Idempotent on a zip that is already a plain .docx.
|
||||
func ConvertDotmToDocx(dotmBytes []byte) ([]byte, error) { return docx.ConvertDotmToDocx(dotmBytes) }
|
||||
|
||||
// SanitiseSubmissionFileName cleans a string for use inside a download
|
||||
// filename (strips path separators / quotes, ASCII-folds DE umlauts).
|
||||
func SanitiseSubmissionFileName(s string) string { return docx.SanitiseSubmissionFileName(s) }
|
||||
122
internal/services/firm_name_composition_live_test.go
Normal file
122
internal/services/firm_name_composition_live_test.go
Normal file
@@ -0,0 +1,122 @@
|
||||
package services
|
||||
|
||||
// Live-DB tests for FirmNameCompositionService (t-paliad-356 Slice 5) — gated
|
||||
// on TEST_DATABASE_URL like the rest of the integration suite. Covers the
|
||||
// round-trip (Set → Get → Clear → Get), the Validate rejection on write, and
|
||||
// that a stored firm default flows through the render path below a per-user
|
||||
// override and above the system default. Pure-function precedence is pinned in
|
||||
// name_template_test.go (TestResolveComposition_Precedence).
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/lib/pq"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/db"
|
||||
"mgit.msbls.de/m/paliad/internal/models"
|
||||
"mgit.msbls.de/m/paliad/pkg/nomen"
|
||||
)
|
||||
|
||||
func openTestDBForFirmNameComp(t *testing.T) *sqlx.DB {
|
||||
t.Helper()
|
||||
url := os.Getenv("TEST_DATABASE_URL")
|
||||
if url == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set — skipping firm-name-composition live test")
|
||||
}
|
||||
// Apply embedded migrations (incl. 162 which creates the table) so the
|
||||
// test is self-sufficient regardless of run order — mirrors the Slice-3
|
||||
// live test (TestNameCompositions_Precedence_Live).
|
||||
if err := db.ApplyMigrations(url); err != nil {
|
||||
t.Fatalf("apply migrations: %v", err)
|
||||
}
|
||||
conn, err := sqlx.Connect("postgres", url)
|
||||
if err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
return conn
|
||||
}
|
||||
|
||||
func TestFirmNameComposition_RoundTripAndRender(t *testing.T) {
|
||||
db := openTestDBForFirmNameComp(t)
|
||||
defer db.Close()
|
||||
svc := NewFirmNameCompositionService(db)
|
||||
ctx := context.Background()
|
||||
|
||||
// Start clean — a prior test may have left a row.
|
||||
if err := svc.Clear(ctx); err != nil {
|
||||
t.Fatalf("pre-clear: %v", err)
|
||||
}
|
||||
if _, ok, err := svc.Get(ctx); err != nil || ok {
|
||||
t.Fatalf("Get after Clear: ok=%v err=%v; want false/nil", ok, err)
|
||||
}
|
||||
|
||||
// A firm default that drops the case-number segment from the filename:
|
||||
// "<date> <keyword>".
|
||||
firmComp := nomen.Composition{Version: nomen.Version, Segments: []nomen.Segment{
|
||||
{Var: "date", Sep: " ", Missing: nomen.Omit()},
|
||||
{Var: "keyword", Missing: nomen.Literal(submissionKeywordFallback)},
|
||||
}}
|
||||
spec := NameCompositionSpec{ArtifactSubmissionDocxFilename: firmComp}
|
||||
if _, err := svc.Set(ctx, spec, uuid.Nil); err != nil {
|
||||
t.Fatalf("Set: %v", err)
|
||||
}
|
||||
|
||||
got, ok, err := svc.Get(ctx)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("Get after Set: ok=%v err=%v; want true/nil", ok, err)
|
||||
}
|
||||
if c := got[ArtifactSubmissionDocxFilename]; c.Template() != "{date} {keyword}" {
|
||||
t.Errorf("stored firm composition = %q, want '{date} {keyword}'", c.Template())
|
||||
}
|
||||
|
||||
// Render path: with the firm default and no user override, the filename
|
||||
// loses the "(Az. folgt)" case segment.
|
||||
rule := &models.DeadlineRule{Name: "Klageerwiderung"}
|
||||
proj := &models.Project{}
|
||||
firm, _, _ := svc.Get(ctx)
|
||||
if name := RenderSubmissionFilenameFor(nil, firm, rule, proj, "de", ""); name != nomenDateBerlin(time.Now())+" Klageerwiderung.docx" {
|
||||
t.Errorf("firm-tier filename = %q, want '<date> Klageerwiderung.docx'", name)
|
||||
}
|
||||
|
||||
// A per-user override still wins over the firm default.
|
||||
userComp := nomen.Composition{Version: nomen.Version, Segments: []nomen.Segment{
|
||||
{Var: "keyword", Missing: nomen.Literal(submissionKeywordFallback)},
|
||||
}}
|
||||
user := NameCompositionSpec{ArtifactSubmissionDocxFilename: userComp}
|
||||
if name := RenderSubmissionFilenameFor(user, firm, rule, proj, "de", ""); name != "Klageerwiderung.docx" {
|
||||
t.Errorf("user override should beat firm: got %q, want 'Klageerwiderung.docx'", name)
|
||||
}
|
||||
|
||||
// Clear is idempotent and reverts the render to the system default.
|
||||
if err := svc.Clear(ctx); err != nil {
|
||||
t.Fatalf("clear: %v", err)
|
||||
}
|
||||
if err := svc.Clear(ctx); err != nil {
|
||||
t.Fatalf("second clear: %v", err)
|
||||
}
|
||||
if _, ok, err := svc.Get(ctx); err != nil || ok {
|
||||
t.Fatalf("Get after Clear: ok=%v err=%v; want false/nil", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirmNameComposition_RejectsInvalid(t *testing.T) {
|
||||
db := openTestDBForFirmNameComp(t)
|
||||
defer db.Close()
|
||||
svc := NewFirmNameCompositionService(db)
|
||||
ctx := context.Background()
|
||||
|
||||
// A composition referencing a variable the artifact catalog does not know
|
||||
// must be rejected on write (Validate), never persisted.
|
||||
bad := NameCompositionSpec{ArtifactSubmissionDocxFilename: nomen.Composition{
|
||||
Version: nomen.Version,
|
||||
Segments: []nomen.Segment{{Var: "client", Missing: nomen.Omit()}}, // client not in filename catalog
|
||||
}}
|
||||
if _, err := svc.Set(ctx, bad, uuid.Nil); err == nil {
|
||||
t.Fatal("Set with unknown variable: err=nil; want ErrInvalidInput")
|
||||
}
|
||||
}
|
||||
61
internal/services/firm_name_composition_service.go
Normal file
61
internal/services/firm_name_composition_service.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package services
|
||||
|
||||
// FirmNameCompositionService manages paliad.firm_name_compositions — the
|
||||
// optional firm-wide default name-composition map that the render path prefers
|
||||
// over the code-resident system default (but below a per-user override) when
|
||||
// composing draft titles and export filenames.
|
||||
//
|
||||
// PRD §3.1/§3.2 of docs/plans/prd-filename-generator-2026-06-01.md (Slice 5).
|
||||
// Mirrors FirmDashboardDefaultService exactly: a single optional row (id=1).
|
||||
// Get returns (spec, true, nil) when set, (empty, false, nil) when never set.
|
||||
// Set validates + upserts; Clear deletes (so resolution reverts to system).
|
||||
//
|
||||
// The HTTP layer (handlers/name_compositions.go admin endpoints) enforces
|
||||
// admin-only via auth.RequireAdmin. The service takes no admin parameter — the
|
||||
// only writer is the admin handler; the read path is used by the render path
|
||||
// on every name composition.
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
// FirmNameCompositionService manages paliad.firm_name_compositions.
|
||||
type FirmNameCompositionService struct {
|
||||
db *sqlx.DB
|
||||
}
|
||||
|
||||
// NewFirmNameCompositionService wires the service.
|
||||
func NewFirmNameCompositionService(db *sqlx.DB) *FirmNameCompositionService {
|
||||
return &FirmNameCompositionService{db: db}
|
||||
}
|
||||
|
||||
// Get returns (spec, true, nil) when a firm default is set, (empty, false,
|
||||
// nil) otherwise. The spec is SanitizeForRead'd so callers always get a
|
||||
// version-coherent map. "Set" means the singleton row exists AND carries at
|
||||
// least one artifact override — an empty stored map reads as "not set" so the
|
||||
// admin UI and the render fall-through treat it the same as absent.
|
||||
func (s *FirmNameCompositionService) Get(ctx context.Context) (NameCompositionSpec, bool, error) {
|
||||
spec, err := getFirmNameCompositions(ctx, s.db)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return spec, len(spec) > 0, nil
|
||||
}
|
||||
|
||||
// Set validates and persists the firm-wide default. updatedBy is recorded for
|
||||
// audit; uuid.Nil clears the column.
|
||||
func (s *FirmNameCompositionService) Set(ctx context.Context, spec NameCompositionSpec, updatedBy uuid.UUID) (NameCompositionSpec, error) {
|
||||
if err := setFirmNameCompositions(ctx, s.db, spec, updatedBy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
// Clear deletes the firm default so resolution reverts to the system default.
|
||||
// Idempotent.
|
||||
func (s *FirmNameCompositionService) Clear(ctx context.Context) error {
|
||||
return clearFirmNameCompositions(ctx, s.db)
|
||||
}
|
||||
@@ -145,7 +145,7 @@ func (s *FristenrechnerService) ListProceedings(ctx context.Context, opts Procee
|
||||
AND pe.event_kind = $%d
|
||||
)`, opts.EventKind)
|
||||
}
|
||||
query := `SELECT code, name, name_en, jurisdiction
|
||||
query := `SELECT id, code, name, name_en, jurisdiction
|
||||
FROM paliad.proceeding_types
|
||||
WHERE ` + strings.Join(where, " AND ") + `
|
||||
ORDER BY sort_order`
|
||||
@@ -160,7 +160,7 @@ func (s *FristenrechnerService) ListProceedings(ctx context.Context, opts Procee
|
||||
for rows.Next() {
|
||||
var t lp.FristenrechnerType
|
||||
var juris sql.NullString
|
||||
if err := rows.Scan(&t.Code, &t.Name, &t.NameEN, &juris); err != nil {
|
||||
if err := rows.Scan(&t.ID, &t.Code, &t.Name, &t.NameEN, &juris); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if juris.Valid {
|
||||
|
||||
167
internal/services/name_composition_live_test.go
Normal file
167
internal/services/name_composition_live_test.go
Normal file
@@ -0,0 +1,167 @@
|
||||
package services
|
||||
|
||||
// Live-DB gate for the system→user name-composition precedence
|
||||
// (t-paliad-356 Slice 3, PRD §3). Skipped without TEST_DATABASE_URL.
|
||||
//
|
||||
// Covers: (a) users.name_compositions round-trip via Set/Get + write-time
|
||||
// Validate rejection; (b) a user override beating the system default for both
|
||||
// the draft-title artifact (through Create) and the .docx-filename artifact
|
||||
// (through RenderSubmissionFilenameFor); (c) the legacy
|
||||
// composer_meta.filename_keyword reading cleanly as name_overrides.keyword.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/lib/pq"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/db"
|
||||
"mgit.msbls.de/m/paliad/internal/models"
|
||||
"mgit.msbls.de/m/paliad/pkg/nomen"
|
||||
)
|
||||
|
||||
func TestNameCompositions_Precedence_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()
|
||||
email := "nc-" + userID.String()[:8] + "@hlc.com"
|
||||
cleanup := func() {
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.submission_drafts WHERE user_id = $1`, userID)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.users WHERE id = $1`, userID)
|
||||
pool.ExecContext(ctx, `DELETE FROM auth.users WHERE id = $1`, userID)
|
||||
}
|
||||
defer cleanup()
|
||||
if _, err := pool.ExecContext(ctx, `INSERT INTO auth.users (id, email) VALUES ($1, $2)`, userID, email); 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, $2, 'NameComp Tester', 'munich', 'standard', 'de')`, userID, email); err != nil {
|
||||
t.Fatalf("seed paliad.users: %v", err)
|
||||
}
|
||||
|
||||
users := NewUserService(pool)
|
||||
projects := NewProjectService(pool, users)
|
||||
parties := NewPartyService(pool, projects)
|
||||
vars := NewSubmissionVarsService(pool, projects, parties, users)
|
||||
renderer := NewSubmissionRenderer()
|
||||
drafts := NewSubmissionDraftService(pool, projects, vars, renderer)
|
||||
|
||||
date := todayBerlinDate()
|
||||
|
||||
// (a) Round-trip + Validate ------------------------------------------
|
||||
validSpec := NameCompositionSpec{
|
||||
ArtifactSubmissionDocxFilename: {
|
||||
Version: nomen.Version,
|
||||
Segments: []nomen.Segment{
|
||||
{Var: "keyword", Sep: "", Missing: nomen.Literal("submission")},
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := drafts.SetUserNameCompositions(ctx, userID, validSpec); err != nil {
|
||||
t.Fatalf("set valid spec: %v", err)
|
||||
}
|
||||
got, err := drafts.UserNameCompositions(ctx, userID)
|
||||
if err != nil {
|
||||
t.Fatalf("get spec: %v", err)
|
||||
}
|
||||
if comp, ok := got[ArtifactSubmissionDocxFilename]; !ok || len(comp.Segments) != 1 || comp.Segments[0].Var != "keyword" {
|
||||
t.Fatalf("round-trip mismatch: %+v", got)
|
||||
}
|
||||
|
||||
// An override referencing a variable outside the artifact catalog is
|
||||
// rejected on write.
|
||||
badSpec := NameCompositionSpec{
|
||||
ArtifactSubmissionDocxFilename: {
|
||||
Version: nomen.Version,
|
||||
Segments: []nomen.Segment{{Var: "opponent"}}, // not a filename variable
|
||||
},
|
||||
}
|
||||
if err := drafts.SetUserNameCompositions(ctx, userID, badSpec); err == nil {
|
||||
t.Fatalf("invalid spec was accepted on write")
|
||||
}
|
||||
|
||||
// (b1) Title override beats system default (through Create) ----------
|
||||
titleOverride := NameCompositionSpec{
|
||||
ArtifactSubmissionDraftTitle: {
|
||||
Version: nomen.Version,
|
||||
Segments: []nomen.Segment{
|
||||
{Var: "keyword", Sep: " ", Missing: nomen.Omit()},
|
||||
{Var: "date", Sep: "", Missing: nomen.Omit()},
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := drafts.SetUserNameCompositions(ctx, userID, titleOverride); err != nil {
|
||||
t.Fatalf("set title override: %v", err)
|
||||
}
|
||||
d, err := drafts.Create(ctx, userID, nil, "de.inf.lg.erwidg", "de")
|
||||
if err != nil {
|
||||
t.Fatalf("create with title override: %v", err)
|
||||
}
|
||||
// System default would be "<date> Klageerwiderung"; the override flips
|
||||
// the order to "<keyword> <date>".
|
||||
if want := "Klageerwiderung " + date; d.Name != want {
|
||||
t.Errorf("title override not applied: name = %q, want %q", d.Name, want)
|
||||
}
|
||||
|
||||
// (b2) Filename override beats system default ------------------------
|
||||
fnOverride := NameCompositionSpec{
|
||||
ArtifactSubmissionDocxFilename: {
|
||||
Version: nomen.Version,
|
||||
Segments: []nomen.Segment{
|
||||
{Var: "keyword", Sep: "", Missing: nomen.Literal("submission")},
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := drafts.SetUserNameCompositions(ctx, userID, fnOverride); err != nil {
|
||||
t.Fatalf("set filename override: %v", err)
|
||||
}
|
||||
overrides, err := drafts.UserNameCompositions(ctx, userID)
|
||||
if err != nil {
|
||||
t.Fatalf("load overrides: %v", err)
|
||||
}
|
||||
rule := &models.DeadlineRule{Name: "Klageerwiderung"}
|
||||
proj := &models.Project{CaseNumber: strPtr("UPC_CFI_1_2026")}
|
||||
// System default would be "<date> Klageerwiderung (UPC_CFI_1_2026).docx";
|
||||
// the override reduces it to just the keyword.
|
||||
if got := RenderSubmissionFilenameFor(overrides, nil, rule, proj, "de", ""); got != "Klageerwiderung.docx" {
|
||||
t.Errorf("filename override not applied: %q, want %q", got, "Klageerwiderung.docx")
|
||||
}
|
||||
// And the system default (nil overrides) is unchanged.
|
||||
if got := RenderSubmissionFilename(rule, proj, "de", ""); got != date+" Klageerwiderung (UPC_CFI_1_2026).docx" {
|
||||
t.Errorf("system default filename drifted: %q", got)
|
||||
}
|
||||
|
||||
// (c) Legacy filename_keyword reads back-compat ----------------------
|
||||
dLegacy, err := drafts.Create(ctx, userID, nil, "de.inf.lg.duplik", "de")
|
||||
if err != nil {
|
||||
t.Fatalf("create legacy draft: %v", err)
|
||||
}
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`UPDATE paliad.submission_drafts SET composer_meta = '{"filename_keyword":"LegacyKW"}'::jsonb WHERE id = $1`,
|
||||
dLegacy.ID); err != nil {
|
||||
t.Fatalf("seed legacy composer_meta: %v", err)
|
||||
}
|
||||
reloaded, err := drafts.Get(ctx, userID, dLegacy.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get legacy draft: %v", err)
|
||||
}
|
||||
if kw := SubmissionFilenameKeyword(reloaded); kw != "LegacyKW" {
|
||||
t.Errorf("legacy filename_keyword back-compat read = %q, want %q", kw, "LegacyKW")
|
||||
}
|
||||
}
|
||||
225
internal/services/name_composition_spec.go
Normal file
225
internal/services/name_composition_spec.go
Normal file
@@ -0,0 +1,225 @@
|
||||
package services
|
||||
|
||||
// Per-user name-composition overrides (t-paliad-356 Slice 3, PRD §3).
|
||||
//
|
||||
// users.name_compositions is a JSONB map { artifact_id: Composition } that
|
||||
// overrides the code-resident system default for an artifact. The validation
|
||||
// surface mirrors DashboardLayoutSpec exactly: Validate on write (known
|
||||
// artifact, segments reference known variables, version + segment cap),
|
||||
// SanitizeForRead on read (drop unknown artifacts and segments referencing
|
||||
// variables the catalog no longer has, clamp version). Resolution prefers a
|
||||
// valid user override over the system default; the firm slot (PRD §3.1) is
|
||||
// reserved for Slice 5 and not wired yet, so the system default is the
|
||||
// fallback directly below the user level in Slice 3.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
|
||||
"mgit.msbls.de/m/paliad/pkg/nomen"
|
||||
)
|
||||
|
||||
// NameCompositionSpec is the parsed users.name_compositions jsonb: a map of
|
||||
// artifact_id -> overriding Composition. It marshals as the bare map.
|
||||
type NameCompositionSpec map[string]nomen.Composition
|
||||
|
||||
// Validate enforces the write-time invariants: every key is a known artifact
|
||||
// and every composition is valid against that artifact's variable catalog.
|
||||
func (s NameCompositionSpec) Validate() error {
|
||||
for id, comp := range s {
|
||||
art, ok := NameArtifact(id)
|
||||
if !ok {
|
||||
return fmt.Errorf("%w: unknown name artifact %q", ErrInvalidInput, id)
|
||||
}
|
||||
if err := comp.Validate(art.Catalog); err != nil {
|
||||
return fmt.Errorf("%w: artifact %q: %v", ErrInvalidInput, id, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SanitizeForRead applies the forgiving read-path rules: drop overrides for
|
||||
// artifacts that no longer exist, and within each surviving override drop
|
||||
// segments referencing unknown variables and clamp the version. Mutates the
|
||||
// receiver; returns true if anything changed so the caller can persist the
|
||||
// cleaned value.
|
||||
func (s NameCompositionSpec) SanitizeForRead() bool {
|
||||
changed := false
|
||||
for id, comp := range s {
|
||||
art, ok := NameArtifact(id)
|
||||
if !ok {
|
||||
delete(s, id)
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
if comp.SanitizeForRead(art.Catalog) {
|
||||
changed = true
|
||||
}
|
||||
s[id] = comp
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
// ParseNameCompositionSpec decodes and validates a name_compositions payload.
|
||||
// Used on writes (API/test). An empty/NULL payload yields an empty spec.
|
||||
func ParseNameCompositionSpec(b []byte) (NameCompositionSpec, error) {
|
||||
spec := NameCompositionSpec{}
|
||||
if len(b) > 0 {
|
||||
if err := json.Unmarshal(b, &spec); err != nil {
|
||||
return nil, fmt.Errorf("%w: name_compositions JSON decode: %v", ErrInvalidInput, err)
|
||||
}
|
||||
}
|
||||
if err := spec.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
// resolveComposition returns the first valid override for an artifact from the
|
||||
// supplied specs (highest precedence first), else the artifact's system
|
||||
// default. The precedence chain is per-document → user → firm → system (PRD
|
||||
// §3.1); the per-document layer is a variable-value override resolved in the
|
||||
// VarResolver, not here, so the specs passed are [user, firm] in that order
|
||||
// (Slice 5). A stored override is sanitised then validated; anything that
|
||||
// fails validation is skipped so a broken stored value can never render — the
|
||||
// next valid tier (or the system default) wins.
|
||||
func resolveComposition(artifactID string, specs ...NameCompositionSpec) nomen.Composition {
|
||||
art := nameArtifacts[artifactID]
|
||||
for _, spec := range specs {
|
||||
if spec == nil {
|
||||
continue
|
||||
}
|
||||
comp, ok := spec[artifactID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
comp.SanitizeForRead(art.Catalog)
|
||||
if len(comp.Segments) > 0 && comp.Validate(art.Catalog) == nil {
|
||||
return comp
|
||||
}
|
||||
}
|
||||
return art.SystemDefault
|
||||
}
|
||||
|
||||
// getUserNameCompositions loads a user's name_compositions, sanitised for
|
||||
// read. A missing user or NULL column yields an empty (nil-safe) spec — the
|
||||
// caller then renders with system defaults. Shared by the title create path
|
||||
// and the filename download path so the SELECT lives in one place.
|
||||
func getUserNameCompositions(ctx context.Context, db *sqlx.DB, userID uuid.UUID) (NameCompositionSpec, error) {
|
||||
var raw []byte
|
||||
err := db.GetContext(ctx, &raw,
|
||||
`SELECT name_compositions FROM paliad.users WHERE id = $1`, userID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return NameCompositionSpec{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load name_compositions: %w", err)
|
||||
}
|
||||
spec := NameCompositionSpec{}
|
||||
if len(raw) > 0 {
|
||||
if err := json.Unmarshal(raw, &spec); err != nil {
|
||||
// A corrupt stored value must not break draft creation — treat
|
||||
// it as "no overrides" and let the next write replace it.
|
||||
return NameCompositionSpec{}, nil
|
||||
}
|
||||
}
|
||||
spec.SanitizeForRead()
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
// getFirmNameCompositions loads the firm-wide default name_compositions
|
||||
// (Slice 5), sanitised for read. A missing singleton row yields an empty
|
||||
// (nil-safe) spec — the caller then renders with the user override or the
|
||||
// system default. Shared by the render path and the admin service so the
|
||||
// SELECT lives in one place; mirrors getUserNameCompositions.
|
||||
func getFirmNameCompositions(ctx context.Context, db *sqlx.DB) (NameCompositionSpec, error) {
|
||||
var raw []byte
|
||||
err := db.GetContext(ctx, &raw,
|
||||
`SELECT compositions_json FROM paliad.firm_name_compositions WHERE id = 1`)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return NameCompositionSpec{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load firm_name_compositions: %w", err)
|
||||
}
|
||||
spec := NameCompositionSpec{}
|
||||
if len(raw) > 0 {
|
||||
if err := json.Unmarshal(raw, &spec); err != nil {
|
||||
// A corrupt stored value must not break name rendering — treat it
|
||||
// as "no firm default" and let the next admin write replace it.
|
||||
return NameCompositionSpec{}, nil
|
||||
}
|
||||
}
|
||||
spec.SanitizeForRead()
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
// setFirmNameCompositions validates and upserts the firm-wide default map into
|
||||
// the id=1 singleton, recording updatedBy (uuid.Nil clears the column). The
|
||||
// admin API is the only writer.
|
||||
func setFirmNameCompositions(ctx context.Context, db *sqlx.DB, spec NameCompositionSpec, updatedBy uuid.UUID) error {
|
||||
if err := spec.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if spec == nil {
|
||||
spec = NameCompositionSpec{}
|
||||
}
|
||||
b, err := json.Marshal(spec)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal firm_name_compositions: %w", err)
|
||||
}
|
||||
var updaterArg any
|
||||
if updatedBy != uuid.Nil {
|
||||
updaterArg = updatedBy
|
||||
}
|
||||
_, err = db.ExecContext(ctx, `
|
||||
INSERT INTO paliad.firm_name_compositions (id, compositions_json, updated_by, updated_at)
|
||||
VALUES (1, $1::jsonb, $2, now())
|
||||
ON CONFLICT (id) DO UPDATE
|
||||
SET compositions_json = EXCLUDED.compositions_json,
|
||||
updated_by = EXCLUDED.updated_by,
|
||||
updated_at = now()
|
||||
`, json.RawMessage(b), updaterArg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("persist firm_name_compositions: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// clearFirmNameCompositions deletes the firm default so resolution falls
|
||||
// through to the system default. Idempotent.
|
||||
func clearFirmNameCompositions(ctx context.Context, db *sqlx.DB) error {
|
||||
if _, err := db.ExecContext(ctx, `DELETE FROM paliad.firm_name_compositions WHERE id = 1`); err != nil {
|
||||
return fmt.Errorf("clear firm_name_compositions: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// setUserNameCompositions validates and persists a user's full
|
||||
// name_compositions map. The S4 settings API and the Slice-3 live tests call
|
||||
// this; it is the single write path.
|
||||
func setUserNameCompositions(ctx context.Context, db *sqlx.DB, userID uuid.UUID, spec NameCompositionSpec) error {
|
||||
if err := spec.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if spec == nil {
|
||||
spec = NameCompositionSpec{}
|
||||
}
|
||||
b, err := json.Marshal(spec)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal name_compositions: %w", err)
|
||||
}
|
||||
_, err = db.ExecContext(ctx,
|
||||
`UPDATE paliad.users SET name_compositions = $1::jsonb WHERE id = $2`,
|
||||
json.RawMessage(b), userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("persist name_compositions: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
81
internal/services/name_composition_spec_test.go
Normal file
81
internal/services/name_composition_spec_test.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"mgit.msbls.de/m/paliad/pkg/nomen"
|
||||
)
|
||||
|
||||
// A minimal valid override for the filename artifact: date + keyword only.
|
||||
func sampleFilenameOverride() nomen.Composition {
|
||||
return nomen.Composition{
|
||||
Version: nomen.Version,
|
||||
Segments: []nomen.Segment{
|
||||
{Var: "date", Sep: " ", Missing: nomen.Omit()},
|
||||
{Var: "keyword", Sep: "", Missing: nomen.Literal("submission")},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestNameCompositionSpec_Validate(t *testing.T) {
|
||||
ok := NameCompositionSpec{ArtifactSubmissionDocxFilename: sampleFilenameOverride()}
|
||||
if err := ok.Validate(); err != nil {
|
||||
t.Fatalf("valid spec rejected: %v", err)
|
||||
}
|
||||
|
||||
unknownArtifact := NameCompositionSpec{"no_such_artifact": sampleFilenameOverride()}
|
||||
if err := unknownArtifact.Validate(); err == nil {
|
||||
t.Errorf("unknown artifact accepted")
|
||||
}
|
||||
|
||||
unknownVar := NameCompositionSpec{ArtifactSubmissionDocxFilename: {
|
||||
Version: nomen.Version,
|
||||
Segments: []nomen.Segment{{Var: "opponent"}}, // not in the filename catalog
|
||||
}}
|
||||
if err := unknownVar.Validate(); err == nil {
|
||||
t.Errorf("override referencing a variable outside the artifact catalog accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNameCompositionSpec_SanitizeForRead(t *testing.T) {
|
||||
spec := NameCompositionSpec{
|
||||
"no_such_artifact": sampleFilenameOverride(),
|
||||
ArtifactSubmissionDocxFilename: {Version: 0, Segments: []nomen.Segment{{Var: "date"}, {Var: "ghost"}}},
|
||||
}
|
||||
changed := spec.SanitizeForRead()
|
||||
if !changed {
|
||||
t.Fatalf("SanitizeForRead reported no change")
|
||||
}
|
||||
if _, ok := spec["no_such_artifact"]; ok {
|
||||
t.Errorf("unknown artifact survived sanitisation")
|
||||
}
|
||||
got := spec[ArtifactSubmissionDocxFilename]
|
||||
if got.Version != nomen.Version {
|
||||
t.Errorf("version not clamped: %d", got.Version)
|
||||
}
|
||||
if len(got.Segments) != 1 || got.Segments[0].Var != "date" {
|
||||
t.Errorf("ghost segment survived: %+v", got.Segments)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveComposition(t *testing.T) {
|
||||
// nil overrides → system default.
|
||||
sys := resolveComposition(ArtifactSubmissionDocxFilename, nil)
|
||||
if len(sys.Segments) != 3 {
|
||||
t.Errorf("system default filename composition = %d segments, want 3", len(sys.Segments))
|
||||
}
|
||||
|
||||
// A valid user override wins.
|
||||
override := sampleFilenameOverride()
|
||||
got := resolveComposition(ArtifactSubmissionDocxFilename, NameCompositionSpec{ArtifactSubmissionDocxFilename: override})
|
||||
if len(got.Segments) != 2 {
|
||||
t.Errorf("override not applied: got %d segments, want 2", len(got.Segments))
|
||||
}
|
||||
|
||||
// An override that sanitises down to zero segments falls back to system.
|
||||
empty := NameCompositionSpec{ArtifactSubmissionDocxFilename: {Version: nomen.Version, Segments: []nomen.Segment{{Var: "ghost"}}}}
|
||||
fb := resolveComposition(ArtifactSubmissionDocxFilename, empty)
|
||||
if len(fb.Segments) != 3 {
|
||||
t.Errorf("invalid override should fall back to system default; got %d segments", len(fb.Segments))
|
||||
}
|
||||
}
|
||||
241
internal/services/name_template.go
Normal file
241
internal/services/name_template.go
Normal file
@@ -0,0 +1,241 @@
|
||||
package services
|
||||
|
||||
// Paliad-side glue for the nomen token-template shorthand (t-paliad-356 Slice 4,
|
||||
// PRD §7). The settings UI edits a single-line "{var}" template per artifact;
|
||||
// this file is the single authority that turns that string into a validated
|
||||
// nomen.Composition and renders the live previews. The frontend never parses
|
||||
// templates itself — it round-trips through these functions so the engine stays
|
||||
// the one source of truth (no duplicated parser to drift out of sync).
|
||||
//
|
||||
// - ParseNameTemplate: shorthand -> Composition. The shorthand carries Var,
|
||||
// separators and paren Wraps (nomen.ParseTemplate); MissingRules are NOT in
|
||||
// the shorthand (PRD §7), so they are overlaid here from the artifact's
|
||||
// system default. The result is validated against the artifact catalog.
|
||||
// - PreviewNameComposition: renders a parsed template against a fixed sample
|
||||
// (all project vars present) and an empties resolver (only the always-on
|
||||
// date), so the user sees both the normal result and the missing-rule
|
||||
// behaviour.
|
||||
// - SettingsNameArtifacts: the ordered, localised view the settings page
|
||||
// reads to build its per-artifact cards.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"mgit.msbls.de/m/paliad/pkg/nomen"
|
||||
)
|
||||
|
||||
// settingsNameArtifactOrder fixes the order the two wired artifacts appear in
|
||||
// the settings UI (title before filename). New wired artifacts append here.
|
||||
var settingsNameArtifactOrder = []string{
|
||||
ArtifactSubmissionDraftTitle,
|
||||
ArtifactSubmissionDocxFilename,
|
||||
}
|
||||
|
||||
// canonicalVarOrder fixes the palette chip order so it is deterministic across
|
||||
// requests (catalogs are maps). Vars absent from this list sort after the known
|
||||
// ones, alphabetically — a safety net for future catalog additions.
|
||||
var canonicalVarOrder = []string{"date", "client", "forum", "opponent", "keyword", "case_number"}
|
||||
|
||||
// ParseNameTemplate compiles a token-template shorthand into a validated
|
||||
// Composition for an artifact. MissingRules come from the artifact's system
|
||||
// default (a var the default does not carry keeps the parser's KindOmit); the
|
||||
// shorthand never sets them (PRD §7). Returns an ErrInvalidInput-wrapped error
|
||||
// for an unknown artifact, a malformed template, or an unknown variable.
|
||||
func ParseNameTemplate(artifactID, template string) (nomen.Composition, error) {
|
||||
art, ok := NameArtifact(artifactID)
|
||||
if !ok {
|
||||
return nomen.Composition{}, fmt.Errorf("%w: unknown name artifact %q", ErrInvalidInput, artifactID)
|
||||
}
|
||||
comp, err := nomen.ParseTemplate(template)
|
||||
if err != nil {
|
||||
return nomen.Composition{}, fmt.Errorf("%w: %v", ErrInvalidInput, err)
|
||||
}
|
||||
missing := make(map[string]nomen.MissingRule, len(art.SystemDefault.Segments))
|
||||
for _, seg := range art.SystemDefault.Segments {
|
||||
missing[seg.Var] = seg.Missing
|
||||
}
|
||||
for i := range comp.Segments {
|
||||
if m, ok := missing[comp.Segments[i].Var]; ok {
|
||||
comp.Segments[i].Missing = m
|
||||
}
|
||||
}
|
||||
if err := comp.Validate(art.Catalog); err != nil {
|
||||
return nomen.Composition{}, fmt.Errorf("%w: %v", ErrInvalidInput, err)
|
||||
}
|
||||
return comp, nil
|
||||
}
|
||||
|
||||
// nameSampleResolver is the fixed preview fixture (PRD §7): client "Bayer AG",
|
||||
// forum "UPC", opponent "Sandoz", case "UPC_CFI_123/2026", render-time today.
|
||||
// keyword is intentionally absent so it exercises its missing rule (the title
|
||||
// omits it — matching a real project draft; the filename falls back to the
|
||||
// "submission" literal). When full is false only the always-on date resolves,
|
||||
// so the preview shows the missing-rule behaviour for every project-derived
|
||||
// variable.
|
||||
func nameSampleResolver(full bool) nomen.VarResolver {
|
||||
return func(key string) (string, bool) {
|
||||
if key == "date" {
|
||||
return nomenDateBerlin(time.Now()), true
|
||||
}
|
||||
if !full {
|
||||
return "", false
|
||||
}
|
||||
switch key {
|
||||
case "client":
|
||||
return "Bayer AG", true
|
||||
case "forum":
|
||||
return "UPC", true
|
||||
case "opponent":
|
||||
return "Sandoz", true
|
||||
case "case_number":
|
||||
return "UPC_CFI_123/2026", true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
// PreviewNameComposition parses a template for an artifact and renders it twice:
|
||||
// full (the fixed sample with all project vars present) and empty (only the
|
||||
// always-on date, so missing rules show). A parse/validation error is returned
|
||||
// instead — the caller surfaces it inline and disables Save.
|
||||
func PreviewNameComposition(artifactID, template string) (full, empty string, err error) {
|
||||
comp, err := ParseNameTemplate(artifactID, template)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
art, _ := NameArtifact(artifactID)
|
||||
full = comp.Render(nameSampleResolver(true), art.Target)
|
||||
empty = comp.Render(nameSampleResolver(false), art.Target)
|
||||
return full, empty, nil
|
||||
}
|
||||
|
||||
// NameVarView is one palette chip: a variable's key plus its localised labels.
|
||||
type NameVarView struct {
|
||||
Var string `json:"var"`
|
||||
Label string `json:"label"`
|
||||
LabelEN string `json:"label_en"`
|
||||
}
|
||||
|
||||
// NameCompositionView is one artifact's settings card. Template is the
|
||||
// effective composition shown to the user (user override → firm default →
|
||||
// system, first present wins); previews render that effective template.
|
||||
// IsOverride flags a per-user override; FirmIsSet/FirmTemplate expose the firm
|
||||
// tier (for the admin firm controls and the "firm default" badge);
|
||||
// SystemTemplate is the code-resident default (the ultimate fallback and the
|
||||
// admin "reset firm to system" reference).
|
||||
type NameCompositionView struct {
|
||||
ArtifactID string `json:"artifact_id"`
|
||||
Label string `json:"label"`
|
||||
LabelEN string `json:"label_en"`
|
||||
Template string `json:"template"`
|
||||
SystemTemplate string `json:"system_template"`
|
||||
IsOverride bool `json:"is_override"`
|
||||
FirmIsSet bool `json:"firm_is_set"`
|
||||
FirmTemplate string `json:"firm_template"`
|
||||
Palette []NameVarView `json:"palette"`
|
||||
PreviewFull string `json:"preview_full"`
|
||||
PreviewEmpty string `json:"preview_empty"`
|
||||
}
|
||||
|
||||
// orderedPalette returns an artifact catalog's variables as palette chips in
|
||||
// canonicalVarOrder (unknown vars alphabetical, last).
|
||||
func orderedPalette(catalog nomen.VarCatalog) []NameVarView {
|
||||
rank := make(map[string]int, len(canonicalVarOrder))
|
||||
for i, v := range canonicalVarOrder {
|
||||
rank[v] = i
|
||||
}
|
||||
out := make([]NameVarView, 0, len(catalog))
|
||||
for key, def := range catalog {
|
||||
out = append(out, NameVarView{Var: key, Label: def.Label, LabelEN: def.LabelEN})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
ri, oki := rank[out[i].Var]
|
||||
rj, okj := rank[out[j].Var]
|
||||
switch {
|
||||
case oki && okj:
|
||||
return ri < rj
|
||||
case oki != okj:
|
||||
return oki // known vars before unknown
|
||||
default:
|
||||
return out[i].Var < out[j].Var
|
||||
}
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// SettingsNameArtifacts builds the per-artifact views for the settings page,
|
||||
// applying the precedence chain user → firm → system per artifact. Both spec
|
||||
// maps are already SanitizeForRead'd by their loaders; either may be nil.
|
||||
// Order is fixed by settingsNameArtifactOrder.
|
||||
func SettingsNameArtifacts(user, firm NameCompositionSpec) []NameCompositionView {
|
||||
views := make([]NameCompositionView, 0, len(settingsNameArtifactOrder))
|
||||
for _, id := range settingsNameArtifactOrder {
|
||||
if v, ok := SettingsNameArtifact(id, user, firm); ok {
|
||||
views = append(views, v)
|
||||
}
|
||||
}
|
||||
return views
|
||||
}
|
||||
|
||||
// SettingsNameArtifact builds one artifact's settings view, resolving the
|
||||
// effective template via user → firm → system. Returns (zero, false) for an
|
||||
// unknown artifact id. Used by the per-artifact PUT/DELETE responses so the
|
||||
// client refreshes only the touched card.
|
||||
func SettingsNameArtifact(id string, user, firm NameCompositionSpec) (NameCompositionView, bool) {
|
||||
art, ok := NameArtifact(id)
|
||||
if !ok {
|
||||
return NameCompositionView{}, false
|
||||
}
|
||||
systemTemplate := art.SystemDefault.Template()
|
||||
|
||||
firmComp, firmIsSet := storedComposition(firm, id)
|
||||
firmTemplate := ""
|
||||
if firmIsSet {
|
||||
firmTemplate = firmComp.Template()
|
||||
}
|
||||
|
||||
// Effective template: user override wins, else the firm default, else
|
||||
// system. IsOverride flags only the per-user tier (the "you customised
|
||||
// this" badge); the firm tier surfaces via FirmIsSet/FirmTemplate.
|
||||
template := systemTemplate
|
||||
isOverride := false
|
||||
if userComp, ok := storedComposition(user, id); ok {
|
||||
template = userComp.Template()
|
||||
isOverride = true
|
||||
} else if firmIsSet {
|
||||
template = firmTemplate
|
||||
}
|
||||
|
||||
// Previews reflect the effective template; a parse error here would mean a
|
||||
// stored composition we already validated is somehow unparseable — fall
|
||||
// back to empty previews rather than failing the page.
|
||||
full, empty, _ := PreviewNameComposition(id, template)
|
||||
return NameCompositionView{
|
||||
ArtifactID: id,
|
||||
Label: art.Label,
|
||||
LabelEN: art.LabelEN,
|
||||
Template: template,
|
||||
SystemTemplate: systemTemplate,
|
||||
IsOverride: isOverride,
|
||||
FirmIsSet: firmIsSet,
|
||||
FirmTemplate: firmTemplate,
|
||||
Palette: orderedPalette(art.Catalog),
|
||||
PreviewFull: full,
|
||||
PreviewEmpty: empty,
|
||||
}, true
|
||||
}
|
||||
|
||||
// storedComposition returns (comp, true) when spec carries a non-empty
|
||||
// composition for the artifact, else (zero, false).
|
||||
func storedComposition(spec NameCompositionSpec, id string) (nomen.Composition, bool) {
|
||||
if spec == nil {
|
||||
return nomen.Composition{}, false
|
||||
}
|
||||
comp, ok := spec[id]
|
||||
if !ok || len(comp.Segments) == 0 {
|
||||
return nomen.Composition{}, false
|
||||
}
|
||||
return comp, true
|
||||
}
|
||||
174
internal/services/name_template_test.go
Normal file
174
internal/services/name_template_test.go
Normal file
@@ -0,0 +1,174 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"mgit.msbls.de/m/paliad/pkg/nomen"
|
||||
)
|
||||
|
||||
var datePrefix = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}`)
|
||||
|
||||
// TestParseNameTemplate_RoundTripsSystemDefaults asserts the system-default
|
||||
// compositions survive Template() -> ParseNameTemplate unchanged in
|
||||
// Var/Sep/Wrap, with MissingRules re-overlaid from the default. This is the
|
||||
// guard that the settings shorthand is a faithful authoring view of the seed.
|
||||
func TestParseNameTemplate_RoundTripsSystemDefaults(t *testing.T) {
|
||||
for _, id := range []string{ArtifactSubmissionDraftTitle, ArtifactSubmissionDocxFilename} {
|
||||
art, _ := NameArtifact(id)
|
||||
tmpl := art.SystemDefault.Template()
|
||||
got, err := ParseNameTemplate(id, tmpl)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: ParseNameTemplate(%q): %v", id, tmpl, err)
|
||||
}
|
||||
want := art.SystemDefault
|
||||
if len(got.Segments) != len(want.Segments) {
|
||||
t.Fatalf("%s: %d segments, want %d", id, len(got.Segments), len(want.Segments))
|
||||
}
|
||||
for i, seg := range got.Segments {
|
||||
w := want.Segments[i]
|
||||
if seg.Var != w.Var || seg.Sep != w.Sep || seg.Wrap != w.Wrap || seg.Missing != w.Missing {
|
||||
t.Errorf("%s seg %d = %+v, want %+v", id, i, seg, w)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNameTemplate_Errors(t *testing.T) {
|
||||
cases := []struct {
|
||||
name, artifact, template string
|
||||
}{
|
||||
{"unknown artifact", "nope", "{date}"},
|
||||
{"unknown variable", ArtifactSubmissionDocxFilename, "{date} {client}"}, // client not in filename catalog
|
||||
{"malformed", ArtifactSubmissionDraftTitle, "{date"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if _, err := ParseNameTemplate(c.artifact, c.template); err == nil {
|
||||
t.Errorf("expected error, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPreviewNameComposition_SystemDefaults asserts the fixed-sample previews
|
||||
// match the two shipped schemes. The date is render-time today, so only its
|
||||
// shape is checked; the rest is byte-exact.
|
||||
func TestPreviewNameComposition_SystemDefaults(t *testing.T) {
|
||||
titleTmpl, _ := NameArtifact(ArtifactSubmissionDraftTitle)
|
||||
full, empty, err := PreviewNameComposition(ArtifactSubmissionDraftTitle, titleTmpl.SystemDefault.Template())
|
||||
if err != nil {
|
||||
t.Fatalf("title preview: %v", err)
|
||||
}
|
||||
if !datePrefix.MatchString(full) {
|
||||
t.Errorf("title full preview %q has no leading date", full)
|
||||
}
|
||||
if !strings.HasSuffix(full, " Bayer AG ./. UPC ./. Sandoz") {
|
||||
t.Errorf("title full preview = %q, want date + ' Bayer AG ./. UPC ./. Sandoz'", full)
|
||||
}
|
||||
if !datePrefix.MatchString(empty) || strings.ContainsAny(empty, " ") {
|
||||
t.Errorf("title empty preview = %q, want bare date (all party segments omitted)", empty)
|
||||
}
|
||||
|
||||
fnTmpl, _ := NameArtifact(ArtifactSubmissionDocxFilename)
|
||||
full, empty, err = PreviewNameComposition(ArtifactSubmissionDocxFilename, fnTmpl.SystemDefault.Template())
|
||||
if err != nil {
|
||||
t.Fatalf("filename preview: %v", err)
|
||||
}
|
||||
if !strings.HasSuffix(full, " submission (UPC_CFI_123_2026).docx") {
|
||||
// '/' in the sample case number is sanitised to '_' by the filename target.
|
||||
t.Errorf("filename full preview = %q, want date + ' submission (UPC_CFI_123_2026).docx'", full)
|
||||
}
|
||||
if !strings.HasSuffix(empty, " submission (Az. folgt).docx") {
|
||||
t.Errorf("filename empty preview = %q, want date + ' submission (Az. folgt).docx'", empty)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSettingsNameArtifacts_OverrideShown asserts a stored override surfaces as
|
||||
// IsOverride with its own template, while the untouched artifact stays system.
|
||||
func TestSettingsNameArtifacts_OverrideShown(t *testing.T) {
|
||||
override := nomen.Composition{Version: nomen.Version, Segments: []nomen.Segment{
|
||||
{Var: "date", Sep: " ", Missing: nomen.Omit()},
|
||||
{Var: "keyword", Missing: nomen.Literal(submissionKeywordFallback)},
|
||||
}}
|
||||
spec := NameCompositionSpec{ArtifactSubmissionDocxFilename: override}
|
||||
|
||||
views := SettingsNameArtifacts(spec, nil)
|
||||
if len(views) != 2 {
|
||||
t.Fatalf("got %d views, want 2", len(views))
|
||||
}
|
||||
byID := map[string]NameCompositionView{}
|
||||
for _, v := range views {
|
||||
byID[v.ArtifactID] = v
|
||||
}
|
||||
if v := byID[ArtifactSubmissionDocxFilename]; !v.IsOverride || v.Template != "{date} {keyword}" {
|
||||
t.Errorf("filename view = %+v, want IsOverride + template '{date} {keyword}'", v)
|
||||
}
|
||||
if v := byID[ArtifactSubmissionDraftTitle]; v.IsOverride {
|
||||
t.Errorf("title view should be system default (no override), got IsOverride")
|
||||
}
|
||||
// Order is fixed: title first, filename second.
|
||||
if views[0].ArtifactID != ArtifactSubmissionDraftTitle || views[1].ArtifactID != ArtifactSubmissionDocxFilename {
|
||||
t.Errorf("artifact order = [%s %s], want [title filename]", views[0].ArtifactID, views[1].ArtifactID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSettingsNameArtifact_FirmTier asserts the firm tier shows through when
|
||||
// the user has no override, and that a user override still wins over the firm
|
||||
// default. Mirrors the precedence user → firm → system.
|
||||
func TestSettingsNameArtifact_FirmTier(t *testing.T) {
|
||||
firmComp := nomen.Composition{Version: nomen.Version, Segments: []nomen.Segment{
|
||||
{Var: "date", Sep: " ", Missing: nomen.Omit()},
|
||||
{Var: "keyword", Missing: nomen.Literal(submissionKeywordFallback)},
|
||||
}}
|
||||
firm := NameCompositionSpec{ArtifactSubmissionDocxFilename: firmComp}
|
||||
|
||||
// No user override → effective template is the firm default; FirmIsSet set.
|
||||
v, ok := SettingsNameArtifact(ArtifactSubmissionDocxFilename, nil, firm)
|
||||
if !ok {
|
||||
t.Fatal("artifact not found")
|
||||
}
|
||||
if v.IsOverride {
|
||||
t.Errorf("IsOverride should be false (no user override), got true")
|
||||
}
|
||||
if !v.FirmIsSet || v.FirmTemplate != "{date} {keyword}" {
|
||||
t.Errorf("firm tier = (set=%v, tmpl=%q), want (true, '{date} {keyword}')", v.FirmIsSet, v.FirmTemplate)
|
||||
}
|
||||
if v.Template != "{date} {keyword}" {
|
||||
t.Errorf("effective template = %q, want firm default '{date} {keyword}'", v.Template)
|
||||
}
|
||||
|
||||
// A user override beats the firm default in the effective template.
|
||||
userComp := nomen.Composition{Version: nomen.Version, Segments: []nomen.Segment{
|
||||
{Var: "date", Missing: nomen.Omit()},
|
||||
}}
|
||||
user := NameCompositionSpec{ArtifactSubmissionDocxFilename: userComp}
|
||||
v, _ = SettingsNameArtifact(ArtifactSubmissionDocxFilename, user, firm)
|
||||
if !v.IsOverride || v.Template != "{date}" {
|
||||
t.Errorf("user override should win: IsOverride=%v template=%q, want true '{date}'", v.IsOverride, v.Template)
|
||||
}
|
||||
if !v.FirmIsSet {
|
||||
t.Errorf("FirmIsSet should remain true even when user override wins")
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveComposition_Precedence pins the render-path precedence: user beats
|
||||
// firm beats system; nil/empty tiers are skipped.
|
||||
func TestResolveComposition_Precedence(t *testing.T) {
|
||||
sys := nameArtifacts[ArtifactSubmissionDocxFilename].SystemDefault
|
||||
firmComp := nomen.Composition{Version: nomen.Version, Segments: []nomen.Segment{{Var: "date", Missing: nomen.Omit()}}}
|
||||
userComp := nomen.Composition{Version: nomen.Version, Segments: []nomen.Segment{{Var: "keyword", Missing: nomen.Literal("x")}}}
|
||||
firm := NameCompositionSpec{ArtifactSubmissionDocxFilename: firmComp}
|
||||
user := NameCompositionSpec{ArtifactSubmissionDocxFilename: userComp}
|
||||
|
||||
if got := resolveComposition(ArtifactSubmissionDocxFilename, nil, nil); len(got.Segments) != len(sys.Segments) {
|
||||
t.Errorf("no overrides → system default, got %d segments", len(got.Segments))
|
||||
}
|
||||
if got := resolveComposition(ArtifactSubmissionDocxFilename, nil, firm); got.Template() != firmComp.Template() {
|
||||
t.Errorf("firm beats system: got %q", got.Template())
|
||||
}
|
||||
if got := resolveComposition(ArtifactSubmissionDocxFilename, user, firm); got.Template() != userComp.Template() {
|
||||
t.Errorf("user beats firm: got %q", got.Template())
|
||||
}
|
||||
}
|
||||
302
internal/services/namegen.go
Normal file
302
internal/services/namegen.go
Normal file
@@ -0,0 +1,302 @@
|
||||
package services
|
||||
|
||||
// Paliad-side wiring for the pkg/nomen composition engine
|
||||
// (docs/plans/prd-filename-generator-2026-06-01.md, Slice 1).
|
||||
//
|
||||
// pkg/nomen stays pure; this file holds the paliad-specific pieces:
|
||||
// - the variable catalogs (which variables each artifact exposes),
|
||||
// - the seed system-default Compositions that reproduce the two shipped
|
||||
// naming schemes byte-for-byte (#155 draft title, t-paliad-354 .docx
|
||||
// filename),
|
||||
// - the per-render VarResolvers built from the existing submission_autoname
|
||||
// helpers (submissionForumShort / submissionOpponentName / derefString),
|
||||
// - and the artifact registry binding artifact -> catalog -> target ->
|
||||
// default.
|
||||
//
|
||||
// The two public entry points (AutoSubmissionTitle here-adjacent, and
|
||||
// RenderSubmissionFilename) render through the registry so the engine is the
|
||||
// single source of truth. Folding the two schemes in as DATA (compositions)
|
||||
// rather than code is the whole point: future levels (user/firm overrides,
|
||||
// non-project degradation) layer on without re-deriving the assembly logic.
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/models"
|
||||
"mgit.msbls.de/m/paliad/pkg/nomen"
|
||||
)
|
||||
|
||||
// Artifact identifiers. v1 wires the two submission artifacts; further
|
||||
// artifacts (docforge export, data-zip, projection slug — PRD §4) register
|
||||
// alongside their own slice, with their own catalog/resolver, when they opt
|
||||
// in. They are intentionally NOT registered here as placeholders: an
|
||||
// artifact with no resolver and no consumer would be dead code.
|
||||
const (
|
||||
ArtifactSubmissionDraftTitle = "submission_draft_title"
|
||||
ArtifactSubmissionDocxFilename = "submission_docx_filename"
|
||||
)
|
||||
|
||||
// submissionFilenamePlaceholder fills the bracketed case-number slot when the
|
||||
// project has no Aktenzeichen yet (t-paliad-354). Kept as a named const so
|
||||
// the wording stays one-line changeable (m left the exact text open).
|
||||
const submissionFilenamePlaceholder = "Az. folgt"
|
||||
|
||||
// submissionKeywordFallback is the keyword used when neither a user override
|
||||
// nor a rule name resolves (t-paliad-354).
|
||||
const submissionKeywordFallback = "submission"
|
||||
|
||||
// Artifact binds a named output to its variable catalog, render target, and
|
||||
// system-default composition. The catalog drives validation + the settings
|
||||
// palette; the default is the seed used when no override exists.
|
||||
type Artifact struct {
|
||||
ID string
|
||||
Label string
|
||||
LabelEN string
|
||||
Catalog nomen.VarCatalog
|
||||
Target nomen.RenderTarget
|
||||
SystemDefault nomen.Composition
|
||||
}
|
||||
|
||||
// nameArtifacts is the v1 registry. Lookup via NameArtifact.
|
||||
var nameArtifacts = map[string]Artifact{
|
||||
ArtifactSubmissionDraftTitle: {
|
||||
ID: ArtifactSubmissionDraftTitle,
|
||||
Label: "Entwurfstitel",
|
||||
LabelEN: "Draft title",
|
||||
Catalog: submissionTitleCatalog(),
|
||||
Target: nomen.PlainTarget("title"),
|
||||
SystemDefault: submissionDraftTitleComposition(),
|
||||
},
|
||||
ArtifactSubmissionDocxFilename: {
|
||||
ID: ArtifactSubmissionDocxFilename,
|
||||
Label: "Dateiname (.docx)",
|
||||
LabelEN: "File name (.docx)",
|
||||
Catalog: submissionFilenameCatalog(),
|
||||
Target: nomen.FuncTarget{
|
||||
NameVal: "filename",
|
||||
Sanitiser: SanitiseSubmissionFileName,
|
||||
Suffix: ".docx",
|
||||
},
|
||||
SystemDefault: submissionDocxFilenameComposition(),
|
||||
},
|
||||
}
|
||||
|
||||
// NameArtifact returns the registered artifact for id, or (zero, false).
|
||||
func NameArtifact(id string) (Artifact, bool) {
|
||||
a, ok := nameArtifacts[id]
|
||||
return a, ok
|
||||
}
|
||||
|
||||
// SubmissionFilenameKeyword reads the per-document keyword override from a
|
||||
// draft's decoded composer_meta. The canonical shape is
|
||||
// composer_meta.name_overrides.keyword (Slice 3); the legacy
|
||||
// composer_meta.filename_keyword (t-paliad-354) is still honoured as
|
||||
// name_overrides.keyword (back-compat read). Returns "" when absent/blank —
|
||||
// the caller then falls back to the auto-derived rule name.
|
||||
func SubmissionFilenameKeyword(d *SubmissionDraft) string {
|
||||
if d == nil || d.ComposerMeta == nil {
|
||||
return ""
|
||||
}
|
||||
if no, ok := d.ComposerMeta["name_overrides"].(map[string]any); ok {
|
||||
if v, ok := no["keyword"].(string); ok {
|
||||
if t := strings.TrimSpace(v); t != "" {
|
||||
return t
|
||||
}
|
||||
}
|
||||
}
|
||||
if v, ok := d.ComposerMeta["filename_keyword"].(string); ok {
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Seed compositions (the two shipped schemes, as data — PRD §5).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// submissionDraftTitleComposition reproduces AutoSubmissionTitle (#155) and
|
||||
// carries the non-project degradation (Slice 2, PRD §6):
|
||||
//
|
||||
// project draft: <date> <client> ./. <forum> ./. <opponent>
|
||||
// non-project draft: <date> <keyword>
|
||||
//
|
||||
// Trailing separators: the date joins the next segment with a space, the
|
||||
// identity segments join each other with " ./. ". Because separators are
|
||||
// owned by the left segment, dropping any identity segment (or all of them)
|
||||
// still yields the byte-exact original — e.g. client-absent renders
|
||||
// "<date> <forum> ./. <opponent>" with a single space after the date.
|
||||
//
|
||||
// The identity trio and the keyword are mutually exclusive by construction:
|
||||
// project drafts resolve client/forum/opponent and leave keyword empty;
|
||||
// non-project drafts have no project so the trio omits and the keyword
|
||||
// (document type, or an "Entwurf"/"Draft" fallback) carries the name. A
|
||||
// project draft therefore renders identically to #155 (keyword omits), which
|
||||
// is the Slice-2 regression guard. opponent.Sep is unused under this
|
||||
// invariant (it would only fire if both opponent and keyword emitted).
|
||||
func submissionDraftTitleComposition() nomen.Composition {
|
||||
return nomen.Composition{
|
||||
Version: nomen.Version,
|
||||
Segments: []nomen.Segment{
|
||||
{Var: "date", Sep: " ", Missing: nomen.Omit()},
|
||||
{Var: "client", Sep: " ./. ", Missing: nomen.Omit()},
|
||||
{Var: "forum", Sep: " ./. ", Missing: nomen.Omit()},
|
||||
{Var: "opponent", Sep: " ./. ", Missing: nomen.Omit()},
|
||||
{Var: "keyword", Sep: "", Missing: nomen.Omit()},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// submissionDocxFilenameComposition reproduces submissionFileName (354):
|
||||
//
|
||||
// <date> <keyword> (<case number>).docx
|
||||
//
|
||||
// keyword falls back to a fixed "submission" literal; the case number is
|
||||
// always rendered in parentheses, falling back to a placeholder when the
|
||||
// project has no Aktenzeichen. The .docx suffix and per-value sanitisation
|
||||
// come from the artifact's FuncTarget, not the composition.
|
||||
func submissionDocxFilenameComposition() nomen.Composition {
|
||||
return nomen.Composition{
|
||||
Version: nomen.Version,
|
||||
Segments: []nomen.Segment{
|
||||
{Var: "date", Sep: " ", Missing: nomen.Omit()},
|
||||
{Var: "keyword", Sep: " ", Missing: nomen.Literal(submissionKeywordFallback)},
|
||||
{Var: "case_number", Sep: "", Wrap: [2]string{"(", ")"}, Missing: nomen.Placeholder(submissionFilenamePlaceholder)},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Variable catalogs.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func submissionTitleCatalog() nomen.VarCatalog {
|
||||
return nomen.VarCatalog{
|
||||
"date": {Key: "date", Label: "Datum", LabelEN: "Date", Group: "common", Description: "Aktuelles Datum (Europe/Berlin), JJJJ-MM-TT"},
|
||||
"client": {Key: "client", Label: "Mandant", LabelEN: "Client", Group: "parties", Description: "Name des Mandanten (Wurzel der Akte)"},
|
||||
"forum": {Key: "forum", Label: "Forum", LabelEN: "Forum", Group: "proceeding", Description: "Kurzbezeichnung des Forums (UPC, EPA, LG, …)"},
|
||||
"opponent": {Key: "opponent", Label: "Gegner", LabelEN: "Opponent", Group: "parties", Description: "Name der Gegenseite"},
|
||||
"keyword": {Key: "keyword", Label: "Stichwort", LabelEN: "Keyword", Group: "document", Description: "Dokumenttyp — trägt den Namen projektloser Entwürfe"},
|
||||
}
|
||||
}
|
||||
|
||||
func submissionFilenameCatalog() nomen.VarCatalog {
|
||||
return nomen.VarCatalog{
|
||||
"date": {Key: "date", Label: "Datum", LabelEN: "Date", Group: "common", Description: "Aktuelles Datum (Europe/Berlin), JJJJ-MM-TT"},
|
||||
"keyword": {Key: "keyword", Label: "Stichwort", LabelEN: "Keyword", Group: "document", Description: "Dokument-/Schriftsatztyp; überschreibbar"},
|
||||
"case_number": {Key: "case_number", Label: "Aktenzeichen", LabelEN: "Case number", Group: "proceeding", Description: "Aktenzeichen des Verfahrens"},
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Resolvers.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// nomenDateBerlin formats t as the JJJJ-MM-TT date in Europe/Berlin,
|
||||
// matching both shipped schemes. A failed zone load leaves t untouched
|
||||
// (same fallback the original code used).
|
||||
func nomenDateBerlin(t time.Time) string {
|
||||
if loc, err := time.LoadLocation("Europe/Berlin"); err == nil {
|
||||
t = t.In(loc)
|
||||
}
|
||||
return t.Format("2006-01-02")
|
||||
}
|
||||
|
||||
// submissionTitleResolver yields the draft-title variables. now is injected
|
||||
// (tests pin a fixed instant); the three identity segments resolve from the
|
||||
// existing helpers and report absence so the composition's Omit rule drops
|
||||
// them. keyword is empty for project drafts (the trio carries the name) and
|
||||
// holds the document type — or an "Entwurf"/"Draft" fallback — for
|
||||
// project-less drafts (Slice 2); the caller resolves it (it needs a DB hop)
|
||||
// and passes the value in, keeping this resolver pure.
|
||||
func submissionTitleResolver(now time.Time, clientName string, project *models.Project, parties []models.Party, pt *models.ProceedingType, keyword string) nomen.VarResolver {
|
||||
return func(key string) (string, bool) {
|
||||
switch key {
|
||||
case "date":
|
||||
return nomenDateBerlin(now), true
|
||||
case "client":
|
||||
c := strings.TrimSpace(clientName)
|
||||
return c, c != ""
|
||||
case "forum":
|
||||
f := submissionForumShort(pt)
|
||||
return f, f != ""
|
||||
case "opponent":
|
||||
ourSide := ""
|
||||
if project != nil {
|
||||
ourSide = derefString(project.OurSide)
|
||||
}
|
||||
o := submissionOpponentName(parties, ourSide)
|
||||
return o, o != ""
|
||||
case "keyword":
|
||||
k := strings.TrimSpace(keyword)
|
||||
return k, k != ""
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
// renderSubmissionDraftTitle is the single render path for the
|
||||
// submission_draft_title artifact, shared by the project path
|
||||
// (AutoSubmissionTitle, keyword="") and the non-project path
|
||||
// (autoNameForNonProject, trio nil + keyword set). overrides may carry a
|
||||
// per-user composition override (Slice 3); nil renders the system default.
|
||||
func renderSubmissionDraftTitle(user, firm NameCompositionSpec, now time.Time, clientName string, project *models.Project, parties []models.Party, pt *models.ProceedingType, keyword string) string {
|
||||
comp := resolveComposition(ArtifactSubmissionDraftTitle, user, firm)
|
||||
resolve := submissionTitleResolver(now, clientName, project, parties, pt, keyword)
|
||||
return comp.Render(resolve, nameArtifacts[ArtifactSubmissionDraftTitle].Target)
|
||||
}
|
||||
|
||||
// submissionFilenameResolver yields the .docx-filename variables. The date is
|
||||
// render-time "today" (the original used time.Now()); keyword applies the
|
||||
// override -> lang-aware rule name precedence and reports absence so the
|
||||
// composition's "submission" literal kicks in; case_number reports absence so
|
||||
// the "(Az. folgt)" placeholder kicks in.
|
||||
func submissionFilenameResolver(rule *models.DeadlineRule, project *models.Project, lang, keyword string) nomen.VarResolver {
|
||||
return func(key string) (string, bool) {
|
||||
switch key {
|
||||
case "date":
|
||||
return nomenDateBerlin(time.Now()), true
|
||||
case "keyword":
|
||||
kw := strings.TrimSpace(keyword)
|
||||
if kw == "" && rule != nil {
|
||||
kw = strings.TrimSpace(rule.Name)
|
||||
if strings.EqualFold(lang, "en") && strings.TrimSpace(rule.NameEN) != "" {
|
||||
kw = strings.TrimSpace(rule.NameEN)
|
||||
}
|
||||
}
|
||||
return kw, kw != ""
|
||||
case "case_number":
|
||||
if project != nil && project.CaseNumber != nil {
|
||||
c := strings.TrimSpace(*project.CaseNumber)
|
||||
if c != "" {
|
||||
return c, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
// RenderSubmissionFilename produces the user-facing download name for a
|
||||
// generated submission (t-paliad-354), rendered through the nomen engine:
|
||||
// "<JJJJ-MM-TT> <keyword> (<case number>).docx". keyword is the user override
|
||||
// when set, else the lang-aware rule name, else "submission"; the case number
|
||||
// falls back to "(Az. folgt)" when the project has no Aktenzeichen. Each
|
||||
// variable value is sanitised for SMB-safe filenames while the frame (spaces,
|
||||
// parentheses, .docx) is preserved.
|
||||
func RenderSubmissionFilename(rule *models.DeadlineRule, project *models.Project, lang, keyword string) string {
|
||||
return RenderSubmissionFilenameFor(nil, nil, rule, project, lang, keyword)
|
||||
}
|
||||
|
||||
// RenderSubmissionFilenameFor renders the .docx filename honouring the
|
||||
// composition precedence chain user → firm → system (Slice 3 + Slice 5); pass
|
||||
// nil for a tier the caller hasn't loaded. keyword is still the per-document
|
||||
// value override (name_overrides.keyword); the value override and the
|
||||
// composition overrides are independent — one swaps a variable's value, the
|
||||
// other swaps the template.
|
||||
func RenderSubmissionFilenameFor(user, firm NameCompositionSpec, rule *models.DeadlineRule, project *models.Project, lang, keyword string) string {
|
||||
comp := resolveComposition(ArtifactSubmissionDocxFilename, user, firm)
|
||||
resolve := submissionFilenameResolver(rule, project, lang, keyword)
|
||||
return comp.Render(resolve, nameArtifacts[ArtifactSubmissionDocxFilename].Target)
|
||||
}
|
||||
34
internal/services/namegen_test.go
Normal file
34
internal/services/namegen_test.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package services
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestNameArtifactsValidate guards the seed system-default compositions
|
||||
// against their own catalogs — a typo'd variable in a seed composition (a key
|
||||
// the catalog doesn't declare) fails here rather than silently rendering
|
||||
// nothing in production.
|
||||
func TestNameArtifactsValidate(t *testing.T) {
|
||||
for id, art := range nameArtifacts {
|
||||
if art.ID != id {
|
||||
t.Errorf("artifact %q has mismatched ID %q", id, art.ID)
|
||||
}
|
||||
if art.Target == nil {
|
||||
t.Errorf("artifact %q has nil target", id)
|
||||
}
|
||||
if err := art.SystemDefault.Validate(art.Catalog); err != nil {
|
||||
t.Errorf("artifact %q system default invalid: %v", id, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNameArtifactLookup covers the registry accessor.
|
||||
func TestNameArtifactLookup(t *testing.T) {
|
||||
if _, ok := NameArtifact(ArtifactSubmissionDraftTitle); !ok {
|
||||
t.Errorf("draft-title artifact not registered")
|
||||
}
|
||||
if _, ok := NameArtifact(ArtifactSubmissionDocxFilename); !ok {
|
||||
t.Errorf("docx-filename artifact not registered")
|
||||
}
|
||||
if _, ok := NameArtifact("nonexistent"); ok {
|
||||
t.Errorf("lookup of unknown artifact returned ok")
|
||||
}
|
||||
}
|
||||
@@ -1247,7 +1247,7 @@ func (s *ProjectionService) collectActualsForOverrides(
|
||||
}
|
||||
var dRows []drow
|
||||
scopeFilter := scopeProjectIDFilter("d", "project_id", projectID, directOnly)
|
||||
q := `SELECT d.rule_id, d.rule_code, d.due_date, d.completed_at, d.status
|
||||
q := `SELECT d.sequencing_rule_id AS rule_id, d.rule_code, d.due_date, d.completed_at, d.status
|
||||
FROM paliad.deadlines d
|
||||
WHERE ` + scopeFilter
|
||||
if err := s.db.SelectContext(ctx, &dRows, q, projectID); err != nil {
|
||||
|
||||
1744
internal/services/scenario_builder_service.go
Normal file
1744
internal/services/scenario_builder_service.go
Normal file
File diff suppressed because it is too large
Load Diff
658
internal/services/scenario_builder_service_test.go
Normal file
658
internal/services/scenario_builder_service_test.go
Normal file
@@ -0,0 +1,658 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/lib/pq"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/db"
|
||||
)
|
||||
|
||||
// TestScenarioBuilderService exercises the t-paliad-340 / m/paliad#153 B0
|
||||
// surface end-to-end against a live DB: create + list + deep-get + patch
|
||||
// + add-proceeding + add-event + add/delete-share, plus the visibility
|
||||
// negative case (a non-owner can't see the scenario unless shared).
|
||||
//
|
||||
// Skipped without TEST_DATABASE_URL — matches the pattern in
|
||||
// project_service_test.go / event_choice_service_test.go.
|
||||
func TestScenarioBuilderService(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()
|
||||
|
||||
owner := uuid.New()
|
||||
other := uuid.New()
|
||||
cleanup := func() {
|
||||
// Cascade order: delete from scenarios → CASCADE clears
|
||||
// proceedings, events, shares. Then the two users.
|
||||
pool.ExecContext(ctx,
|
||||
`DELETE FROM paliad.scenarios WHERE owner_id IN ($1, $2)`, owner, other)
|
||||
pool.ExecContext(ctx,
|
||||
`DELETE FROM paliad.users WHERE id IN ($1, $2)`, owner, other)
|
||||
pool.ExecContext(ctx,
|
||||
`DELETE FROM auth.users WHERE id IN ($1, $2)`, owner, other)
|
||||
}
|
||||
cleanup()
|
||||
defer cleanup()
|
||||
|
||||
for _, seed := range []struct {
|
||||
id uuid.UUID
|
||||
email string
|
||||
name string
|
||||
}{
|
||||
{owner, "builder-owner-test@hlc.com", "Builder Owner"},
|
||||
{other, "builder-other-test@hlc.com", "Builder Other"},
|
||||
} {
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO auth.users (id, email) VALUES ($1, $2)`,
|
||||
seed.id, seed.email); err != nil {
|
||||
t.Fatalf("seed auth.users %s: %v", seed.email, err)
|
||||
}
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO paliad.users (id, email, display_name, office, lang)
|
||||
VALUES ($1, $2, $3, 'munich', 'de')`,
|
||||
seed.id, seed.email, seed.name); err != nil {
|
||||
t.Fatalf("seed paliad.users %s: %v", seed.email, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Pick a real proceeding_type_id so the FK insert succeeds.
|
||||
var ptID int
|
||||
if err := pool.GetContext(ctx, &ptID,
|
||||
`SELECT id FROM paliad.proceeding_types
|
||||
WHERE code = $1 AND is_active = true
|
||||
LIMIT 1`, CodeUPCInfringement); err != nil {
|
||||
t.Fatalf("look up upc.inf id: %v", err)
|
||||
}
|
||||
|
||||
svc := NewScenarioBuilderService(pool, nil, nil, nil)
|
||||
|
||||
// 1. Create a scenario for the owner. Empty name should default.
|
||||
sc, err := svc.CreateScenario(ctx, owner, CreateBuilderScenarioInput{})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateScenario: %v", err)
|
||||
}
|
||||
if sc.Name != "Unbenanntes Szenario" {
|
||||
t.Errorf("default name = %q, want %q", sc.Name, "Unbenanntes Szenario")
|
||||
}
|
||||
if sc.Status != "active" {
|
||||
t.Errorf("default status = %q, want active", sc.Status)
|
||||
}
|
||||
if sc.OwnerID == nil || *sc.OwnerID != owner {
|
||||
t.Errorf("owner_id = %v, want %v", sc.OwnerID, owner)
|
||||
}
|
||||
|
||||
// 2. List — should return the one row.
|
||||
list, err := svc.ListMyScenarios(ctx, owner, "active")
|
||||
if err != nil {
|
||||
t.Fatalf("ListMyScenarios: %v", err)
|
||||
}
|
||||
if len(list) != 1 || list[0].ID != sc.ID {
|
||||
t.Errorf("ListMyScenarios returned %d rows; want 1 with id %s", len(list), sc.ID)
|
||||
}
|
||||
|
||||
// 3. Other user can NOT see the scenario.
|
||||
if _, err := svc.GetScenarioDeep(ctx, other, sc.ID); !errors.Is(err, ErrScenarioBuilderNotVisible) {
|
||||
t.Errorf("GetScenarioDeep by non-owner = %v, want ErrScenarioBuilderNotVisible", err)
|
||||
}
|
||||
|
||||
// 4. Add a proceeding.
|
||||
pr, err := svc.AddProceeding(ctx, owner, sc.ID, AddProceedingInput{
|
||||
ProceedingTypeID: ptID,
|
||||
PrimaryParty: ptrString("defendant"),
|
||||
ScenarioFlags: json.RawMessage(`{"with_ccr": true}`),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("AddProceeding: %v", err)
|
||||
}
|
||||
if pr.ProceedingTypeID != ptID {
|
||||
t.Errorf("ProceedingTypeID = %d, want %d", pr.ProceedingTypeID, ptID)
|
||||
}
|
||||
if pr.PrimaryParty == nil || *pr.PrimaryParty != "defendant" {
|
||||
t.Errorf("PrimaryParty = %v, want defendant", pr.PrimaryParty)
|
||||
}
|
||||
|
||||
// 5. Add a custom-label event card.
|
||||
ev, err := svc.AddEvent(ctx, owner, sc.ID, pr.ID, AddEventInput{
|
||||
CustomLabel: ptrString("Klageerwiderung"),
|
||||
State: ptrString("planned"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("AddEvent: %v", err)
|
||||
}
|
||||
if ev.State != "planned" {
|
||||
t.Errorf("event state = %q, want planned", ev.State)
|
||||
}
|
||||
|
||||
// 5b. Add-event with NO anchor fields fails.
|
||||
if _, err := svc.AddEvent(ctx, owner, sc.ID, pr.ID, AddEventInput{}); !errors.Is(err, ErrInvalidInput) {
|
||||
t.Errorf("AddEvent without anchor = %v, want ErrInvalidInput", err)
|
||||
}
|
||||
|
||||
// 6. Deep get — should bundle the scenario + 1 proceeding + 1 event + 0 shares.
|
||||
deep, err := svc.GetScenarioDeep(ctx, owner, sc.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetScenarioDeep: %v", err)
|
||||
}
|
||||
if len(deep.Proceedings) != 1 || deep.Proceedings[0].ID != pr.ID {
|
||||
t.Errorf("deep proceedings count=%d want 1; ids: %+v", len(deep.Proceedings), deep.Proceedings)
|
||||
}
|
||||
if len(deep.Events) != 1 || deep.Events[0].ID != ev.ID {
|
||||
t.Errorf("deep events count=%d want 1; ids: %+v", len(deep.Events), deep.Events)
|
||||
}
|
||||
if len(deep.Shares) != 0 {
|
||||
t.Errorf("deep shares count=%d want 0", len(deep.Shares))
|
||||
}
|
||||
|
||||
// 7. Share with `other`. Recipient should now see the scenario.
|
||||
sh, err := svc.AddShare(ctx, owner, sc.ID, other)
|
||||
if err != nil {
|
||||
t.Fatalf("AddShare: %v", err)
|
||||
}
|
||||
if _, err := svc.GetScenarioDeep(ctx, other, sc.ID); err != nil {
|
||||
t.Errorf("GetScenarioDeep by share recipient: %v", err)
|
||||
}
|
||||
// But the recipient can NOT add proceedings.
|
||||
if _, err := svc.AddProceeding(ctx, other, sc.ID, AddProceedingInput{
|
||||
ProceedingTypeID: ptID,
|
||||
}); !errors.Is(err, ErrScenarioBuilderNotVisible) {
|
||||
t.Errorf("AddProceeding by share recipient = %v, want ErrScenarioBuilderNotVisible", err)
|
||||
}
|
||||
|
||||
// 7b. Self-share should be rejected.
|
||||
if _, err := svc.AddShare(ctx, owner, sc.ID, owner); !errors.Is(err, ErrInvalidInput) {
|
||||
t.Errorf("self-share = %v, want ErrInvalidInput", err)
|
||||
}
|
||||
|
||||
// 8. Patch — archive then re-activate.
|
||||
patched, err := svc.PatchScenario(ctx, owner, sc.ID, PatchBuilderScenarioInput{
|
||||
Status: ptrString("archived"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PatchScenario archive: %v", err)
|
||||
}
|
||||
if patched.Status != "archived" {
|
||||
t.Errorf("after archive, status = %q, want archived", patched.Status)
|
||||
}
|
||||
// PATCH to 'promoted' is rejected — that's the wizard's job.
|
||||
if _, err := svc.PatchScenario(ctx, owner, sc.ID, PatchBuilderScenarioInput{
|
||||
Status: ptrString("promoted"),
|
||||
}); !errors.Is(err, ErrInvalidInput) {
|
||||
t.Errorf("PATCH status=promoted = %v, want ErrInvalidInput", err)
|
||||
}
|
||||
patched, err = svc.PatchScenario(ctx, owner, sc.ID, PatchBuilderScenarioInput{
|
||||
Status: ptrString("active"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PatchScenario re-activate: %v", err)
|
||||
}
|
||||
if patched.Status != "active" {
|
||||
t.Errorf("after re-activate, status = %q, want active", patched.Status)
|
||||
}
|
||||
|
||||
// 9. Revoke the share. Recipient loses visibility.
|
||||
if err := svc.DeleteShare(ctx, owner, sc.ID, sh.ID); err != nil {
|
||||
t.Fatalf("DeleteShare: %v", err)
|
||||
}
|
||||
if _, err := svc.GetScenarioDeep(ctx, other, sc.ID); !errors.Is(err, ErrScenarioBuilderNotVisible) {
|
||||
t.Errorf("after revoke, recipient GetScenarioDeep = %v, want ErrScenarioBuilderNotVisible", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestScenarioBuilderAkteDualWrite pins B4's load-bearing contract
|
||||
// (m/paliad#153 / t-paliad-347 / PRD §2.3 + §10):
|
||||
//
|
||||
// - PatchProceeding on a project-backed scenario (origin_project_id
|
||||
// IS NOT NULL) MUST mirror scenario_flags onto
|
||||
// paliad.projects.scenario_flags;
|
||||
// - PatchEvent flipping state→'filed' on a project-backed scenario
|
||||
// MUST upsert a paliad.deadlines row (status='completed',
|
||||
// completed_at=actual_date);
|
||||
// - PatchProceeding/PatchEvent on a non-Akte (kontextfrei) scenario
|
||||
// MUST NOT touch paliad.projects.scenario_flags or
|
||||
// paliad.deadlines.
|
||||
//
|
||||
// Skipped without TEST_DATABASE_URL. Mirrors the live-DB pattern used
|
||||
// by TestScenarioBuilderService above.
|
||||
func TestScenarioBuilderAkteDualWrite(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()
|
||||
|
||||
owner := uuid.New()
|
||||
cleanup := func() {
|
||||
pool.ExecContext(ctx,
|
||||
`DELETE FROM paliad.scenarios WHERE owner_id = $1`, owner)
|
||||
pool.ExecContext(ctx,
|
||||
`DELETE FROM paliad.projects WHERE created_by = $1`, owner)
|
||||
pool.ExecContext(ctx,
|
||||
`DELETE FROM paliad.users WHERE id = $1`, owner)
|
||||
pool.ExecContext(ctx,
|
||||
`DELETE FROM auth.users WHERE id = $1`, owner)
|
||||
}
|
||||
cleanup()
|
||||
defer cleanup()
|
||||
|
||||
// Seed owner.
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO auth.users (id, email) VALUES ($1, $2)`,
|
||||
owner, "builder-akte-test@hlc.com"); err != nil {
|
||||
t.Fatalf("seed auth.users: %v", err)
|
||||
}
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO paliad.users (id, email, display_name, office, lang, global_role)
|
||||
VALUES ($1, $2, $3, 'munich', 'de', 'global_admin')`,
|
||||
owner, "builder-akte-test@hlc.com", "Builder Akte Owner"); err != nil {
|
||||
t.Fatalf("seed paliad.users: %v", err)
|
||||
}
|
||||
|
||||
// Look up a real proceeding_type_id + a sequencing_rule_id on that
|
||||
// proceeding so the deadline upsert has a real rule to point at.
|
||||
var ptID int
|
||||
if err := pool.GetContext(ctx, &ptID,
|
||||
`SELECT id FROM paliad.proceeding_types
|
||||
WHERE code = $1 AND is_active = true LIMIT 1`,
|
||||
CodeUPCInfringement); err != nil {
|
||||
t.Fatalf("look up upc.inf id: %v", err)
|
||||
}
|
||||
var ruleID uuid.UUID
|
||||
if err := pool.GetContext(ctx, &ruleID,
|
||||
`SELECT id FROM paliad.sequencing_rules
|
||||
WHERE proceeding_type_id = $1
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
ORDER BY sequence_order NULLS LAST, id LIMIT 1`, ptID); err != nil {
|
||||
t.Fatalf("look up sequencing_rule: %v", err)
|
||||
}
|
||||
|
||||
// Seed a paliad.projects (type='case') row pinned to that
|
||||
// proceeding_type. our_side='defendant' so the builder triplet's
|
||||
// primary_party derives from it.
|
||||
projectID := uuid.New()
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO paliad.projects
|
||||
(id, type, title, status, proceeding_type_id, our_side, created_by)
|
||||
VALUES ($1, 'case', 'Builder Akte Test Project', 'active', $2, 'defendant', $3)`,
|
||||
projectID, ptID, owner); err != nil {
|
||||
t.Fatalf("seed project: %v", err)
|
||||
}
|
||||
// Place the owner on the project team so visibility checks pass.
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO paliad.project_teams (project_id, user_id, role, responsibility, inherited)
|
||||
VALUES ($1, $2, 'lead', 'lead', false)`, projectID, owner); err != nil {
|
||||
t.Fatalf("seed project_teams: %v", err)
|
||||
}
|
||||
|
||||
// Wire up the service with the real project + flag deps so dual-
|
||||
// write hits live tables. NewProjectService + NewScenarioFlags
|
||||
// match the production wiring in cmd/server/main.go.
|
||||
userSvc := NewUserService(pool)
|
||||
projSvc := NewProjectService(pool, userSvc)
|
||||
flagsSvc := NewScenarioFlagsService(pool, projSvc)
|
||||
svc := NewScenarioBuilderService(pool, projSvc, flagsSvc, nil)
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// Phase A — Akte-backed scenario writes through to project tables.
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
|
||||
akte, err := svc.CreateScenarioFromProject(ctx, owner, projectID)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateScenarioFromProject: %v", err)
|
||||
}
|
||||
if akte.OriginProjectID == nil || *akte.OriginProjectID != projectID {
|
||||
t.Fatalf("origin_project_id = %v, want %v", akte.OriginProjectID, projectID)
|
||||
}
|
||||
if len(akte.Proceedings) != 1 {
|
||||
t.Fatalf("seed proceedings = %d, want 1", len(akte.Proceedings))
|
||||
}
|
||||
procID := akte.Proceedings[0].ID
|
||||
if akte.Proceedings[0].PrimaryParty == nil || *akte.Proceedings[0].PrimaryParty != "defendant" {
|
||||
t.Errorf("primary_party = %v, want defendant", akte.Proceedings[0].PrimaryParty)
|
||||
}
|
||||
|
||||
// Toggle with_ccr=true via PatchProceeding. Dual-write should land
|
||||
// the same key on projects.scenario_flags.
|
||||
if _, err := svc.PatchProceeding(ctx, owner, akte.ID, procID, PatchProceedingInput{
|
||||
ScenarioFlags: json.RawMessage(`{"with_ccr": true}`),
|
||||
}); err != nil {
|
||||
t.Fatalf("PatchProceeding (Akte): %v", err)
|
||||
}
|
||||
var rawProjFlags []byte
|
||||
if err := pool.GetContext(ctx, &rawProjFlags,
|
||||
`SELECT scenario_flags FROM paliad.projects WHERE id = $1`, projectID); err != nil {
|
||||
t.Fatalf("read project scenario_flags: %v", err)
|
||||
}
|
||||
var projFlags map[string]any
|
||||
if err := json.Unmarshal(rawProjFlags, &projFlags); err != nil {
|
||||
t.Fatalf("decode project scenario_flags: %v", err)
|
||||
}
|
||||
if v, ok := projFlags["with_ccr"].(bool); !ok || !v {
|
||||
t.Errorf("after Akte PatchProceeding, projects.scenario_flags.with_ccr = %v, want true", projFlags["with_ccr"])
|
||||
}
|
||||
|
||||
// Add an event card backed by a real sequencing rule, then PATCH
|
||||
// state='filed' with actual_date. Dual-write should insert a
|
||||
// paliad.deadlines row (status='completed', completed_at=actual_date).
|
||||
ev, err := svc.AddEvent(ctx, owner, akte.ID, procID, AddEventInput{
|
||||
SequencingRuleID: &ruleID,
|
||||
State: ptrString("planned"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("AddEvent (Akte): %v", err)
|
||||
}
|
||||
filedDate := mustDate(t, "2026-04-15")
|
||||
if _, err := svc.PatchEvent(ctx, owner, akte.ID, ev.ID, PatchEventInput{
|
||||
State: ptrString("filed"),
|
||||
ActualDate: &filedDate,
|
||||
}); err != nil {
|
||||
t.Fatalf("PatchEvent filed (Akte): %v", err)
|
||||
}
|
||||
var deadlineCount int
|
||||
if err := pool.GetContext(ctx, &deadlineCount,
|
||||
`SELECT COUNT(*) FROM paliad.deadlines
|
||||
WHERE project_id = $1 AND sequencing_rule_id = $2
|
||||
AND status = 'completed'`,
|
||||
projectID, ruleID); err != nil {
|
||||
t.Fatalf("count deadlines: %v", err)
|
||||
}
|
||||
if deadlineCount != 1 {
|
||||
t.Errorf("after Akte PatchEvent filed, deadlines rows = %d, want 1", deadlineCount)
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// Phase B — kontextfrei scenario does NOT touch project surfaces.
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// Capture project scenario_flags + deadline count before the
|
||||
// kontextfrei mutations so we can assert no change.
|
||||
var beforeFlagsRaw []byte
|
||||
_ = pool.GetContext(ctx, &beforeFlagsRaw,
|
||||
`SELECT scenario_flags FROM paliad.projects WHERE id = $1`, projectID)
|
||||
var beforeDeadlines int
|
||||
_ = pool.GetContext(ctx, &beforeDeadlines,
|
||||
`SELECT COUNT(*) FROM paliad.deadlines WHERE project_id = $1`, projectID)
|
||||
|
||||
kf, err := svc.CreateScenario(ctx, owner, CreateBuilderScenarioInput{})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateScenario (kontextfrei): %v", err)
|
||||
}
|
||||
if kf.OriginProjectID != nil {
|
||||
t.Fatalf("kontextfrei origin_project_id = %v, want nil", kf.OriginProjectID)
|
||||
}
|
||||
kfProc, err := svc.AddProceeding(ctx, owner, kf.ID, AddProceedingInput{
|
||||
ProceedingTypeID: ptID,
|
||||
PrimaryParty: ptrString("claimant"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("AddProceeding (kontextfrei): %v", err)
|
||||
}
|
||||
// Flag toggle on a kontextfrei scenario MUST NOT mutate the
|
||||
// project's scenario_flags.
|
||||
if _, err := svc.PatchProceeding(ctx, owner, kf.ID, kfProc.ID, PatchProceedingInput{
|
||||
ScenarioFlags: json.RawMessage(`{"with_amend": true}`),
|
||||
}); err != nil {
|
||||
t.Fatalf("PatchProceeding (kontextfrei): %v", err)
|
||||
}
|
||||
var afterFlagsRaw []byte
|
||||
if err := pool.GetContext(ctx, &afterFlagsRaw,
|
||||
`SELECT scenario_flags FROM paliad.projects WHERE id = $1`, projectID); err != nil {
|
||||
t.Fatalf("re-read project scenario_flags: %v", err)
|
||||
}
|
||||
if string(beforeFlagsRaw) != string(afterFlagsRaw) {
|
||||
t.Errorf("kontextfrei PatchProceeding leaked into projects.scenario_flags: before=%s after=%s",
|
||||
beforeFlagsRaw, afterFlagsRaw)
|
||||
}
|
||||
|
||||
// Filed-state event on a kontextfrei scenario MUST NOT touch
|
||||
// paliad.deadlines.
|
||||
kfEv, err := svc.AddEvent(ctx, owner, kf.ID, kfProc.ID, AddEventInput{
|
||||
SequencingRuleID: &ruleID,
|
||||
State: ptrString("planned"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("AddEvent (kontextfrei): %v", err)
|
||||
}
|
||||
kfDate := mustDate(t, "2026-04-16")
|
||||
if _, err := svc.PatchEvent(ctx, owner, kf.ID, kfEv.ID, PatchEventInput{
|
||||
State: ptrString("filed"),
|
||||
ActualDate: &kfDate,
|
||||
}); err != nil {
|
||||
t.Fatalf("PatchEvent filed (kontextfrei): %v", err)
|
||||
}
|
||||
var afterDeadlines int
|
||||
if err := pool.GetContext(ctx, &afterDeadlines,
|
||||
`SELECT COUNT(*) FROM paliad.deadlines WHERE project_id = $1`, projectID); err != nil {
|
||||
t.Fatalf("re-count deadlines: %v", err)
|
||||
}
|
||||
if afterDeadlines != beforeDeadlines {
|
||||
t.Errorf("kontextfrei PatchEvent filed leaked into deadlines: before=%d after=%d",
|
||||
beforeDeadlines, afterDeadlines)
|
||||
}
|
||||
}
|
||||
|
||||
// TestScenarioBuilderPromote pins B5's load-bearing contract
|
||||
// (m/paliad#153 / t-paliad-350 / PRD §2.4 + §5.4 + §10): PromoteScenario
|
||||
// creates a paliad.projects 'case' row transactionally, cascades parties
|
||||
// + deadlines, flips the scenario to 'promoted' with a back-ref, and
|
||||
// makes the original scenario read-only afterwards.
|
||||
func TestScenarioBuilderPromote(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()
|
||||
|
||||
owner := uuid.New()
|
||||
var createdProjectID uuid.UUID
|
||||
cleanup := func() {
|
||||
if createdProjectID != uuid.Nil {
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE id = $1`, createdProjectID)
|
||||
}
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.scenarios WHERE owner_id = $1`, owner)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.users WHERE id = $1`, owner)
|
||||
pool.ExecContext(ctx, `DELETE FROM auth.users WHERE id = $1`, owner)
|
||||
}
|
||||
cleanup()
|
||||
defer cleanup()
|
||||
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO auth.users (id, email) VALUES ($1, 'promote-owner-test@hlc.com')`, owner); err != nil {
|
||||
t.Fatalf("seed auth.users: %v", err)
|
||||
}
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO paliad.users (id, email, display_name, office, lang)
|
||||
VALUES ($1, 'promote-owner-test@hlc.com', 'Promote Owner', 'munich', 'de')`, owner); err != nil {
|
||||
t.Fatalf("seed paliad.users: %v", err)
|
||||
}
|
||||
|
||||
var ptID int
|
||||
if err := pool.GetContext(ctx, &ptID,
|
||||
`SELECT id FROM paliad.proceeding_types WHERE code = $1 AND is_active = true LIMIT 1`,
|
||||
CodeUPCInfringement); err != nil {
|
||||
t.Fatalf("look up upc.inf id: %v", err)
|
||||
}
|
||||
// Two distinct rule ids: one filed, one planned (with an explicit
|
||||
// actual_date so the planned deadline lands even without a calc engine).
|
||||
var ruleIDs []uuid.UUID
|
||||
if err := pool.SelectContext(ctx, &ruleIDs,
|
||||
`SELECT id FROM paliad.sequencing_rules
|
||||
WHERE proceeding_type_id = $1 AND is_active = true AND lifecycle_state = 'published'
|
||||
ORDER BY sequence_order NULLS LAST, id LIMIT 2`, ptID); err != nil {
|
||||
t.Fatalf("look up sequencing_rules: %v", err)
|
||||
}
|
||||
if len(ruleIDs) < 2 {
|
||||
t.Skipf("need >=2 published rules for upc.inf; got %d", len(ruleIDs))
|
||||
}
|
||||
|
||||
userSvc := NewUserService(pool)
|
||||
projSvc := NewProjectService(pool, userSvc)
|
||||
flagsSvc := NewScenarioFlagsService(pool, projSvc)
|
||||
// fristenrechner nil — planned events carry an explicit actual_date in
|
||||
// this test, so the cascade doesn't need computed dates.
|
||||
svc := NewScenarioBuilderService(pool, projSvc, flagsSvc, nil)
|
||||
|
||||
sc, err := svc.CreateScenario(ctx, owner, CreateBuilderScenarioInput{Name: "Promote-Test"})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateScenario: %v", err)
|
||||
}
|
||||
pr, err := svc.AddProceeding(ctx, owner, sc.ID, AddProceedingInput{
|
||||
ProceedingTypeID: ptID,
|
||||
PrimaryParty: ptrString("defendant"),
|
||||
ScenarioFlags: json.RawMessage(`{"with_ccr": true}`),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("AddProceeding: %v", err)
|
||||
}
|
||||
filedDate := mustDate(t, "2026-01-15")
|
||||
if _, err := svc.AddEvent(ctx, owner, sc.ID, pr.ID, AddEventInput{
|
||||
SequencingRuleID: &ruleIDs[0], State: ptrString("filed"), ActualDate: &filedDate,
|
||||
}); err != nil {
|
||||
t.Fatalf("AddEvent filed: %v", err)
|
||||
}
|
||||
plannedDate := mustDate(t, "2026-04-01")
|
||||
if _, err := svc.AddEvent(ctx, owner, sc.ID, pr.ID, AddEventInput{
|
||||
SequencingRuleID: &ruleIDs[1], State: ptrString("planned"), ActualDate: &plannedDate,
|
||||
}); err != nil {
|
||||
t.Fatalf("AddEvent planned: %v", err)
|
||||
}
|
||||
|
||||
res, err := svc.PromoteScenario(ctx, owner, sc.ID, PromoteScenarioInput{
|
||||
Title: "Becker ./. X — UPC",
|
||||
CaseNumber: ptrString("UPC_CFI_123/2026"),
|
||||
OurSide: ptrString("defendant"),
|
||||
Parties: []PromotePartyInput{
|
||||
{Name: "Becker GmbH", Role: ptrString("defendant")},
|
||||
{Name: "X Corp", Role: ptrString("claimant")},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PromoteScenario: %v", err)
|
||||
}
|
||||
createdProjectID = res.ProjectID
|
||||
if res.ProjectID == uuid.Nil {
|
||||
t.Fatal("PromoteScenario returned nil project id")
|
||||
}
|
||||
if res.PartiesCreated != 2 {
|
||||
t.Errorf("PartiesCreated = %d, want 2", res.PartiesCreated)
|
||||
}
|
||||
if res.DeadlinesCreated != 2 {
|
||||
t.Errorf("DeadlinesCreated = %d, want 2 (1 filed + 1 planned-with-date)", res.DeadlinesCreated)
|
||||
}
|
||||
|
||||
// Project row exists, is a 'case', carries origin_scenario_id + flags.
|
||||
var proj struct {
|
||||
Type string `db:"type"`
|
||||
OriginScenario *uuid.UUID `db:"origin_scenario_id"`
|
||||
ProceedingType *int `db:"proceeding_type_id"`
|
||||
OurSide *string `db:"our_side"`
|
||||
ScenarioFlags json.RawMessage `db:"scenario_flags"`
|
||||
CaseNumber *string `db:"case_number"`
|
||||
}
|
||||
if err := pool.GetContext(ctx, &proj,
|
||||
`SELECT type, origin_scenario_id, proceeding_type_id, our_side, scenario_flags, case_number
|
||||
FROM paliad.projects WHERE id = $1`, res.ProjectID); err != nil {
|
||||
t.Fatalf("load promoted project: %v", err)
|
||||
}
|
||||
if proj.Type != "case" {
|
||||
t.Errorf("project type = %q, want case", proj.Type)
|
||||
}
|
||||
if proj.OriginScenario == nil || *proj.OriginScenario != sc.ID {
|
||||
t.Errorf("origin_scenario_id = %v, want %v", proj.OriginScenario, sc.ID)
|
||||
}
|
||||
if proj.ProceedingType == nil || *proj.ProceedingType != ptID {
|
||||
t.Errorf("proceeding_type_id = %v, want %d", proj.ProceedingType, ptID)
|
||||
}
|
||||
if proj.OurSide == nil || *proj.OurSide != "defendant" {
|
||||
t.Errorf("our_side = %v, want defendant", proj.OurSide)
|
||||
}
|
||||
|
||||
// Scenario flipped to promoted with the back-ref.
|
||||
var after BuilderScenario
|
||||
if err := pool.GetContext(ctx, &after,
|
||||
`SELECT id, owner_id, name, status, origin_project_id, promoted_project_id,
|
||||
stichtag, notes, project_id, description, created_by, created_at, updated_at
|
||||
FROM paliad.scenarios WHERE id = $1`, sc.ID); err != nil {
|
||||
t.Fatalf("reload scenario: %v", err)
|
||||
}
|
||||
if after.Status != "promoted" {
|
||||
t.Errorf("scenario status = %q, want promoted", after.Status)
|
||||
}
|
||||
if after.PromotedProjectID == nil || *after.PromotedProjectID != res.ProjectID {
|
||||
t.Errorf("promoted_project_id = %v, want %v", after.PromotedProjectID, res.ProjectID)
|
||||
}
|
||||
|
||||
// Deadlines + parties physically present.
|
||||
var deadlineCount, partyCount int
|
||||
pool.GetContext(ctx, &deadlineCount,
|
||||
`SELECT COUNT(*) FROM paliad.deadlines WHERE project_id = $1`, res.ProjectID)
|
||||
pool.GetContext(ctx, &partyCount,
|
||||
`SELECT COUNT(*) FROM paliad.parties WHERE project_id = $1`, res.ProjectID)
|
||||
if deadlineCount != 2 {
|
||||
t.Errorf("deadlines in DB = %d, want 2", deadlineCount)
|
||||
}
|
||||
if partyCount != 2 {
|
||||
t.Errorf("parties in DB = %d, want 2", partyCount)
|
||||
}
|
||||
|
||||
// Promoted scenario is now read-only: further PATCH is rejected.
|
||||
if _, err := svc.PatchScenario(ctx, owner, sc.ID, PatchBuilderScenarioInput{
|
||||
Name: ptrString("rename-after-promote"),
|
||||
}); !errors.Is(err, ErrInvalidInput) {
|
||||
t.Errorf("PatchScenario after promote = %v, want ErrInvalidInput", err)
|
||||
}
|
||||
// Re-promoting is rejected.
|
||||
if _, err := svc.PromoteScenario(ctx, owner, sc.ID, PromoteScenarioInput{Title: "again"}); !errors.Is(err, ErrInvalidInput) {
|
||||
t.Errorf("re-promote = %v, want ErrInvalidInput", err)
|
||||
}
|
||||
}
|
||||
|
||||
// mustDate parses an ISO date or fails the test. Helper for the
|
||||
// dual-write test above.
|
||||
func mustDate(t *testing.T, s string) time.Time {
|
||||
t.Helper()
|
||||
d, err := time.Parse("2006-01-02", s)
|
||||
if err != nil {
|
||||
t.Fatalf("parse date %q: %v", s, err)
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// (Note: ptrString lives in rule_editor_service_test.go in this package
|
||||
// and is reused here. No second declaration needed.)
|
||||
157
internal/services/submission_autoname.go
Normal file
157
internal/services/submission_autoname.go
Normal file
@@ -0,0 +1,157 @@
|
||||
package services
|
||||
|
||||
// Auto-naming for freshly-created submission drafts (t-paliad-352 /
|
||||
// m/paliad#155). A new project-bound draft gets a sortable, legal-
|
||||
// convention default title instead of the bare "Entwurf N" counter:
|
||||
//
|
||||
// <YYYY-MM-DD> <ClientName> ./. <ForumShort> ./. <OpponentName>
|
||||
//
|
||||
// The date leads so drafts sort chronologically; " ./. " is the German
|
||||
// legal shorthand for "gegen". The three identity segments are the
|
||||
// client we act for, the forum the proceeding runs in, and the opposing
|
||||
// party — exactly the trio m named ("CLIENTNAME / UPC / OPPONENTNAME").
|
||||
//
|
||||
// Missing-segment rule: any segment that resolves empty is dropped
|
||||
// together with its leading separator, so a project without an opponent
|
||||
// yet renders "2026-05-31 Bayer AG ./. UPC" (no trailing separator) and
|
||||
// a project-less draft never reaches this path at all (it keeps the
|
||||
// "Entwurf N" counter — see SubmissionDraftService.Create).
|
||||
//
|
||||
// v1 promotes this scheme into the pkg/nomen composition engine: the
|
||||
// template lives as the submission_draft_title artifact's system-default
|
||||
// Composition (see namegen.go, PRD §5.1) and the identity resolvers below
|
||||
// stay as the value source. AutoSubmissionTitle is now a thin wrapper that
|
||||
// renders that composition; the assembly logic (separators, missing-segment
|
||||
// rules) is the engine's. Per-user / per-firm overrides (Slices 3–5) layer
|
||||
// onto the artifact without touching this file.
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/models"
|
||||
)
|
||||
|
||||
// AutoSubmissionTitle assembles the auto-generated draft title from the
|
||||
// resolved identity pieces. Pure and table-testable — every DB hop
|
||||
// happens in the caller (SubmissionDraftService.autoNameForProject).
|
||||
//
|
||||
// clientName is passed separately because the client we act for is the
|
||||
// root ancestor of the project tree, not a field on the draft's own
|
||||
// project node; the caller walks the path to resolve it. ourSide and
|
||||
// the proceeding type both come off the draft's project node, the
|
||||
// parties hang directly off it.
|
||||
//
|
||||
// The date is always present (formatted in Europe/Berlin); the three
|
||||
// identity segments are appended only when non-empty. Rendered through the
|
||||
// submission_draft_title artifact (namegen.go).
|
||||
func AutoSubmissionTitle(now time.Time, clientName string, project *models.Project, parties []models.Party, pt *models.ProceedingType) string {
|
||||
// Pure system-default render (nil overrides). The identity trio carries
|
||||
// the name, keyword stays empty (and its segment omits) — so a project
|
||||
// draft renders identically to #155. The create path uses the
|
||||
// overrides-aware autoNameForProject; this stays the system-default
|
||||
// reference that the #155 test matrix pins.
|
||||
return renderSubmissionDraftTitle(nil, nil, now, clientName, project, parties, pt, "")
|
||||
}
|
||||
|
||||
// submissionForumShort maps a proceeding type to the short forum label
|
||||
// used in the auto-name. The jurisdiction is the forum for the
|
||||
// supranational / office tracks (UPC, EPA, DPMA); German court
|
||||
// proceedings disambiguate by the court that hears them (LG / OLG /
|
||||
// BGH / BPatG), which is the tail segment of the proceeding code
|
||||
// (de.inf.lg → LG, de.null.bpatg → BPatG). nil / unknown → "".
|
||||
func submissionForumShort(pt *models.ProceedingType) string {
|
||||
if pt == nil {
|
||||
return ""
|
||||
}
|
||||
switch j := strings.ToUpper(strings.TrimSpace(derefString(pt.Jurisdiction))); j {
|
||||
case "":
|
||||
return ""
|
||||
case "DE":
|
||||
return germanCourtShort(pt.Code)
|
||||
default:
|
||||
// UPC / EPA / DPMA and any future jurisdiction are their own
|
||||
// forum label.
|
||||
return j
|
||||
}
|
||||
}
|
||||
|
||||
// germanCourtShort returns the court abbreviation from the tail segment
|
||||
// of a German proceeding code (the part after the last "."). Known
|
||||
// courts get their canonical casing; anything else falls back to the
|
||||
// uppercased tail so a new German proceeding still yields a label.
|
||||
func germanCourtShort(code string) string {
|
||||
parts := strings.Split(code, ".")
|
||||
tail := strings.ToLower(strings.TrimSpace(parts[len(parts)-1]))
|
||||
switch tail {
|
||||
case "":
|
||||
return ""
|
||||
case "lg":
|
||||
return "LG"
|
||||
case "olg":
|
||||
return "OLG"
|
||||
case "bgh":
|
||||
return "BGH"
|
||||
case "bpatg":
|
||||
return "BPatG"
|
||||
default:
|
||||
return strings.ToUpper(tail)
|
||||
}
|
||||
}
|
||||
|
||||
// submissionOpponentName picks the name of the primary opposing party
|
||||
// given the side we act for. We act actively (claimant / applicant /
|
||||
// appellant) → the opponent is on the defendant bucket; we act
|
||||
// reactively (defendant / respondent) → the opponent is the claimant.
|
||||
// An unknown / unset side (third_party, other, NULL) can't fix a
|
||||
// posture, so no opponent is derived (the segment is omitted). The
|
||||
// first party of the opposing bucket wins — PartyService.ListForProject
|
||||
// orders by name, so the pick is deterministic for a given project.
|
||||
func submissionOpponentName(parties []models.Party, ourSide string) string {
|
||||
var want string
|
||||
switch sidePosture(ourSide) {
|
||||
case "active":
|
||||
want = "defendant"
|
||||
case "reactive":
|
||||
want = "claimant"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
for i := range parties {
|
||||
if partyRoleBucket(parties[i].Role) == want {
|
||||
if n := strings.TrimSpace(parties[i].Name); n != "" {
|
||||
return n
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// sidePosture folds the our_side sub-role vocabulary (t-paliad-222)
|
||||
// down to the active / reactive axis. Returns "" for sides that have no
|
||||
// clear posture (third_party, other) or an unset value.
|
||||
func sidePosture(ourSide string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(ourSide)) {
|
||||
case "claimant", "applicant", "appellant":
|
||||
return "active"
|
||||
case "defendant", "respondent":
|
||||
return "reactive"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// partyRoleBucket folds a party's free-text role into the
|
||||
// claimant / defendant / other buckets. German and English spellings
|
||||
// both fold in; everything else (Streithelfer, Patentinhaberin, …) is
|
||||
// "other". Shared with addPartyVars so the two paths can't drift.
|
||||
func partyRoleBucket(role *string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(derefString(role))) {
|
||||
case "claimant", "kläger", "klaeger", "klägerin", "klaegerin":
|
||||
return "claimant"
|
||||
case "defendant", "beklagter", "beklagte":
|
||||
return "defendant"
|
||||
default:
|
||||
return "other"
|
||||
}
|
||||
}
|
||||
224
internal/services/submission_autoname_test.go
Normal file
224
internal/services/submission_autoname_test.go
Normal file
@@ -0,0 +1,224 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/models"
|
||||
)
|
||||
|
||||
func party(name, role string) models.Party {
|
||||
return models.Party{Name: name, Role: strPtr(role)}
|
||||
}
|
||||
|
||||
func proceeding(jurisdiction, code string) *models.ProceedingType {
|
||||
return &models.ProceedingType{Jurisdiction: strPtr(jurisdiction), Code: code}
|
||||
}
|
||||
|
||||
func projectSide(side string) *models.Project {
|
||||
if side == "" {
|
||||
return &models.Project{}
|
||||
}
|
||||
return &models.Project{OurSide: strPtr(side)}
|
||||
}
|
||||
|
||||
// noon UTC on 2026-05-31 → 14:00 Europe/Berlin (CEST), same calendar day.
|
||||
var fixedNow = time.Date(2026, 5, 31, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
func TestAutoSubmissionTitle(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
clientName string
|
||||
project *models.Project
|
||||
parties []models.Party
|
||||
pt *models.ProceedingType
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "full data — UPC, we are claimant",
|
||||
clientName: "Bayer AG",
|
||||
project: projectSide("claimant"),
|
||||
parties: []models.Party{party("Novartis Pharma", "Beklagte")},
|
||||
pt: proceeding("UPC", "upc.inf.cfi"),
|
||||
want: "2026-05-31 Bayer AG ./. UPC ./. Novartis Pharma",
|
||||
},
|
||||
{
|
||||
name: "full data — German court, we are respondent",
|
||||
clientName: "Bayer AG",
|
||||
project: projectSide("respondent"),
|
||||
parties: []models.Party{party("Acme Generics", "Klägerin")},
|
||||
pt: proceeding("DE", "de.null.bpatg"),
|
||||
want: "2026-05-31 Bayer AG ./. BPatG ./. Acme Generics",
|
||||
},
|
||||
{
|
||||
name: "no opponent — opposing bucket empty",
|
||||
clientName: "Bayer AG",
|
||||
project: projectSide("claimant"),
|
||||
parties: []models.Party{party("Bayer AG", "Klägerin")}, // only our own side
|
||||
pt: proceeding("UPC", "upc.inf.cfi"),
|
||||
want: "2026-05-31 Bayer AG ./. UPC",
|
||||
},
|
||||
{
|
||||
name: "no forum — proceeding type missing",
|
||||
clientName: "Bayer AG",
|
||||
project: projectSide("respondent"),
|
||||
parties: []models.Party{party("Acme Generics", "Klägerin")},
|
||||
pt: nil,
|
||||
want: "2026-05-31 Bayer AG ./. Acme Generics",
|
||||
},
|
||||
{
|
||||
name: "no client — client segment omitted",
|
||||
clientName: "",
|
||||
project: projectSide("claimant"),
|
||||
parties: []models.Party{party("Novartis Pharma", "Beklagte")},
|
||||
pt: proceeding("UPC", "upc.inf.cfi"),
|
||||
want: "2026-05-31 UPC ./. Novartis Pharma",
|
||||
},
|
||||
{
|
||||
name: "all identity segments missing — date only",
|
||||
clientName: "",
|
||||
project: projectSide(""), // no our_side → no opponent posture
|
||||
parties: nil,
|
||||
pt: nil,
|
||||
want: "2026-05-31",
|
||||
},
|
||||
{
|
||||
name: "unknown side — opponent omitted even with parties",
|
||||
clientName: "Bayer AG",
|
||||
project: projectSide("third_party"),
|
||||
parties: []models.Party{party("Acme Generics", "Klägerin")},
|
||||
pt: proceeding("EPA", "epa.opp.opd"),
|
||||
want: "2026-05-31 Bayer AG ./. EPA",
|
||||
},
|
||||
{
|
||||
name: "nil project — opponent omitted, client + forum stand",
|
||||
clientName: "Bayer AG",
|
||||
project: nil,
|
||||
parties: []models.Party{party("Acme Generics", "Klägerin")},
|
||||
pt: proceeding("DPMA", "dpma.opp.dpma"),
|
||||
want: "2026-05-31 Bayer AG ./. DPMA",
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := AutoSubmissionTitle(fixedNow, c.clientName, c.project, c.parties, c.pt)
|
||||
if got != c.want {
|
||||
t.Errorf("AutoSubmissionTitle = %q, want %q", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAutoSubmissionTitleBerlinDate locks the Europe/Berlin localisation:
|
||||
// 22:30 UTC on 2026-05-31 is already 00:30 on 2026-06-01 in CEST, so the
|
||||
// date segment must roll over.
|
||||
func TestAutoSubmissionTitleBerlinDate(t *testing.T) {
|
||||
lateUTC := time.Date(2026, 5, 31, 22, 30, 0, 0, time.UTC)
|
||||
got := AutoSubmissionTitle(lateUTC, "Bayer AG", projectSide("claimant"),
|
||||
[]models.Party{party("Novartis", "Beklagte")}, proceeding("UPC", "upc.inf.cfi"))
|
||||
want := "2026-06-01 Bayer AG ./. UPC ./. Novartis"
|
||||
if got != want {
|
||||
t.Errorf("AutoSubmissionTitle (late UTC) = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmissionForumShort(t *testing.T) {
|
||||
cases := []struct {
|
||||
pt *models.ProceedingType
|
||||
want string
|
||||
}{
|
||||
{nil, ""},
|
||||
{proceeding("UPC", "upc.inf.cfi"), "UPC"},
|
||||
{proceeding("EPA", "epa.opp.opd"), "EPA"},
|
||||
{proceeding("DPMA", "dpma.opp.dpma"), "DPMA"},
|
||||
{proceeding("DE", "de.inf.lg"), "LG"},
|
||||
{proceeding("DE", "de.inf.olg"), "OLG"},
|
||||
{proceeding("DE", "de.inf.bgh"), "BGH"},
|
||||
{proceeding("DE", "de.null.bpatg"), "BPatG"},
|
||||
{proceeding("DE", "de.null.bgh"), "BGH"},
|
||||
{proceeding("DE", "de.foo.amtsgericht"), "AMTSGERICHT"}, // unknown court → uppercased tail
|
||||
{proceeding("de", "de.inf.lg"), "LG"}, // lowercase jurisdiction folds
|
||||
{proceeding("", ""), ""}, // no jurisdiction
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := submissionForumShort(c.pt); got != c.want {
|
||||
t.Errorf("submissionForumShort(%+v) = %q, want %q", c.pt, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmissionOpponentName(t *testing.T) {
|
||||
claimantA := party("Acme", "Klägerin")
|
||||
defendantB := party("Novartis", "Beklagte")
|
||||
other := party("Streithelfer X", "Streithelfer")
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
parties []models.Party
|
||||
ourSide string
|
||||
want string
|
||||
}{
|
||||
{"active → first defendant", []models.Party{claimantA, defendantB}, "claimant", "Novartis"},
|
||||
{"reactive → first claimant", []models.Party{claimantA, defendantB}, "respondent", "Acme"},
|
||||
{"applicant (active) → defendant", []models.Party{defendantB}, "applicant", "Novartis"},
|
||||
{"appellant (active) → defendant", []models.Party{defendantB}, "appellant", "Novartis"},
|
||||
{"defendant (reactive) → claimant", []models.Party{claimantA}, "defendant", "Acme"},
|
||||
{"unknown side → none", []models.Party{claimantA, defendantB}, "third_party", ""},
|
||||
{"empty side → none", []models.Party{claimantA, defendantB}, "", ""},
|
||||
{"no opposing party → none", []models.Party{claimantA, other}, "claimant", ""},
|
||||
{"opposing bucket only 'other' → none", []models.Party{other}, "respondent", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := submissionOpponentName(c.parties, c.ourSide); got != c.want {
|
||||
t.Errorf("submissionOpponentName = %q, want %q", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUniqueDraftName(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
base string
|
||||
existing []string
|
||||
want string
|
||||
}{
|
||||
{"free", "2026-05-31 Bayer AG ./. UPC", nil, "2026-05-31 Bayer AG ./. UPC"},
|
||||
{"first clash → (2)", "2026-05-31 Bayer AG ./. UPC",
|
||||
[]string{"2026-05-31 Bayer AG ./. UPC"}, "2026-05-31 Bayer AG ./. UPC (2)"},
|
||||
{"two clash → (3)", "2026-05-31 Bayer AG ./. UPC",
|
||||
[]string{"2026-05-31 Bayer AG ./. UPC", "2026-05-31 Bayer AG ./. UPC (2)"},
|
||||
"2026-05-31 Bayer AG ./. UPC (3)"},
|
||||
{"gap reused → (2)", "X",
|
||||
[]string{"X", "X (3)"}, "X (2)"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := uniqueDraftName(c.base, c.existing); got != c.want {
|
||||
t.Errorf("uniqueDraftName = %q, want %q", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextDraftName(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
existing []string
|
||||
lang string
|
||||
want string
|
||||
}{
|
||||
{"empty de", nil, "de", "Entwurf 1"},
|
||||
{"empty en", nil, "en", "Draft 1"},
|
||||
{"highest+1", []string{"Entwurf 1", "Entwurf 3"}, "de", "Entwurf 4"},
|
||||
{"ignores foreign names", []string{"2026-05-31 Bayer AG"}, "de", "Entwurf 1"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := nextDraftName(c.existing, c.lang); got != c.want {
|
||||
t.Errorf("nextDraftName = %q, want %q", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,93 +1,73 @@
|
||||
package services
|
||||
|
||||
// Composer render pipeline — t-paliad-313 Slice B (design doc §9.1 +
|
||||
// §9.2). Assembles a base .docx and a draft's section rows into a
|
||||
// merged .docx ready for export.
|
||||
// Composer wrapper — bridges paliad's submission draft model
|
||||
// (SubmissionSection + SubmissionBase) to the format-neutral docforge
|
||||
// .docx composer (pkg/docforge/docx), extracted in slice 2 of the
|
||||
// docforge train (t-paliad-349 / m/paliad#157).
|
||||
//
|
||||
// Pipeline (high-level):
|
||||
// The full splice/assembly pipeline now lives in pkg/docforge/docx
|
||||
// (compose.go): macro pre-pass, anchor-pair splicing, append-before-sectPr,
|
||||
// hyperlink-rels patching, zip repack, and the final placeholder pass. This
|
||||
// wrapper does the one thing the engine must not know about — mapping
|
||||
// paliad's DB row types onto the neutral docx.Section / docx.Carrier
|
||||
// inputs. Behaviour is byte-identical to the pre-extraction composer; the
|
||||
// in-package compose_test still drives this wrapper end-to-end.
|
||||
//
|
||||
// 1. ConvertDotmToDocx pre-pass on the base bytes (idempotent on .docx).
|
||||
// 2. Locate `word/document.xml` inside the zip; pull the body XML.
|
||||
// 3. For each section in the draft (order_index ASC, included=true):
|
||||
// render content_md_<lang> → OOXML via RenderMarkdownToOOXML using
|
||||
// base.section_spec.stylemap.paragraph.
|
||||
// 4. Splice the rendered OOXML into the base body. Two splice modes:
|
||||
// - Anchor mode: when the body carries `{{#section:KEY}}` /
|
||||
// `{{/section:KEY}}` marker pairs, replace the slot's content
|
||||
// (including the anchor paragraphs themselves) with the rendered
|
||||
// section.
|
||||
// - Append mode: when no anchor pair is found for a section, the
|
||||
// rendered OOXML appends at the end of the body, just before any
|
||||
// `<w:sectPr>` element. Sections with `included=false` are
|
||||
// dropped silently.
|
||||
// 5. Strip any leftover unmatched anchor paragraphs.
|
||||
// 6. Re-pack the document.xml into the zip, leaving every other part
|
||||
// untouched.
|
||||
// 7. Run the v1 SubmissionRenderer placeholder pass over the assembly
|
||||
// so `{{path}}` placeholders inside section content (and inside
|
||||
// the base's untouched chrome) get substituted by the merged bag.
|
||||
// Cross-run merge in pass 2 handles autocorrect-fragmented
|
||||
// placeholders the same as v1.
|
||||
//
|
||||
// Result: a fully-merged .docx. No new third-party Go dep — reuses
|
||||
// archive/zip + the existing SubmissionRenderer.
|
||||
// Slice note: the paragraph-level neutral document model (Document / Block
|
||||
// / Slot) the PRD §3.2 sketches lands in slice 6, where the authoring
|
||||
// importer and the format exporters actually consume it. Building it now,
|
||||
// ahead of any consumer, would be speculative and would put the
|
||||
// byte-identical guarantee at risk for no gain (PRD §4 B3 principle:
|
||||
// extractions earn their keep this cycle).
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"mgit.msbls.de/m/paliad/pkg/docforge/docx"
|
||||
)
|
||||
|
||||
// SubmissionComposer assembles base + sections into a final .docx.
|
||||
// Stateless; safe for concurrent use.
|
||||
// SubmissionComposer assembles a base + a draft's sections into a final
|
||||
// .docx. Stateless; safe for concurrent use.
|
||||
type SubmissionComposer struct {
|
||||
renderer *SubmissionRenderer
|
||||
inner *docx.Composer
|
||||
}
|
||||
|
||||
// NewSubmissionComposer wires the composer. The renderer is required —
|
||||
// a nil renderer is a programmer error and the composer panics at
|
||||
// NewSubmissionComposer wires the composer. The renderer is required — a
|
||||
// nil renderer is a programmer error and the composer panics at
|
||||
// construction.
|
||||
func NewSubmissionComposer(renderer *SubmissionRenderer) *SubmissionComposer {
|
||||
if renderer == nil {
|
||||
panic("submission composer: renderer required")
|
||||
}
|
||||
return &SubmissionComposer{renderer: renderer}
|
||||
return &SubmissionComposer{inner: docx.NewComposer(renderer)}
|
||||
}
|
||||
|
||||
// ComposeOptions carries the per-call composition inputs.
|
||||
// ComposeOptions carries the per-call composition inputs in paliad's own
|
||||
// terms (SubmissionSection rows + the SubmissionBase chrome).
|
||||
type ComposeOptions struct {
|
||||
// Sections are the draft's section rows in display order. The
|
||||
// composer renders included sections; excluded rows are dropped.
|
||||
// Caller is responsible for visibility — by the time the composer
|
||||
// runs, the section rows have already been gated through
|
||||
// SubmissionDraftService.Get + can_see_project.
|
||||
// Sections are the draft's section rows in display order. Included
|
||||
// sections render; excluded rows are dropped. The caller is
|
||||
// responsible for visibility — by the time the composer runs the rows
|
||||
// have already been gated through SubmissionDraftService.Get +
|
||||
// can_see_project.
|
||||
Sections []SubmissionSection
|
||||
|
||||
// Base supplies the document chrome (.docx body host) plus the
|
||||
// stylemap for the MD walker. Must not be nil.
|
||||
// Base supplies the document chrome plus the stylemap for the MD
|
||||
// walker. Must not be nil.
|
||||
Base *SubmissionBase
|
||||
|
||||
// BaseBytes is the raw .docx bytes for the base. Typically fetched
|
||||
// BaseBytes is the raw .docx bytes for the base, typically fetched
|
||||
// from Gitea via the existing template cache.
|
||||
BaseBytes []byte
|
||||
|
||||
// Lang ('de' or 'en') selects which content_md_* column the
|
||||
// composer reads per section. Defaults to 'de' if empty.
|
||||
// Lang ('de' or 'en') selects which content_md_* column the composer
|
||||
// reads per section. Defaults to 'de' if empty.
|
||||
Lang string
|
||||
|
||||
// Vars is the merged placeholder bag the v1 renderer pass
|
||||
// substitutes after the composer assembly. Passed straight through
|
||||
// to SubmissionRenderer.Render.
|
||||
// Vars is the merged placeholder bag the renderer pass substitutes
|
||||
// after assembly.
|
||||
Vars PlaceholderMap
|
||||
|
||||
// Missing translates an unbound placeholder key into the marker
|
||||
// the lawyer sees in Word. Passed straight to the renderer.
|
||||
// Missing translates an unbound placeholder key into the marker the
|
||||
// lawyer sees in Word.
|
||||
Missing MissingPlaceholderFn
|
||||
}
|
||||
|
||||
@@ -96,512 +76,24 @@ func (c *SubmissionComposer) Compose(ctx context.Context, opts ComposeOptions) (
|
||||
if opts.Base == nil {
|
||||
return nil, fmt.Errorf("submission compose: base required")
|
||||
}
|
||||
_ = ctx // reserved for cancellation propagation in later slices
|
||||
sections := opts.Sections
|
||||
|
||||
// Pre-pass: strip macros so the base reads as a plain .docx zip.
|
||||
cleanBytes, err := ConvertDotmToDocx(opts.BaseBytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("submission compose: convert base: %w", err)
|
||||
}
|
||||
|
||||
// Locate + extract word/document.xml so we can splice in-place.
|
||||
documentXML, otherParts, err := splitBaseZip(cleanBytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Per-compose hyperlink allocator. Each unique URL gets a fresh
|
||||
// rId outside the base's existing namespace. The post-pass
|
||||
// (patchDocumentXMLRels) writes the matching Relationship rows
|
||||
// before the zip is repacked. Slice D adds inline `[label](url)`
|
||||
// hyperlink support.
|
||||
linkAlloc := newComposerLinkAllocator()
|
||||
|
||||
// Build the rendered-section map: section_key → OOXML span.
|
||||
stylemap := opts.Base.SectionSpec.Stylemap
|
||||
rendered := make(map[string]string, len(sections))
|
||||
keptSections := make([]SubmissionSection, 0, len(sections))
|
||||
for _, sec := range sections {
|
||||
if !sec.Included {
|
||||
continue
|
||||
secs := make([]docx.Section, len(opts.Sections))
|
||||
for i, s := range opts.Sections {
|
||||
secs[i] = docx.Section{
|
||||
Key: s.SectionKey,
|
||||
OrderIndex: s.OrderIndex,
|
||||
Included: s.Included,
|
||||
ContentMDDE: s.ContentMDDE,
|
||||
ContentMDEN: s.ContentMDEN,
|
||||
}
|
||||
md := sec.ContentMDDE
|
||||
if strings.EqualFold(opts.Lang, "en") {
|
||||
md = sec.ContentMDEN
|
||||
}
|
||||
rendered[sec.SectionKey] = RenderMarkdownToOOXMLWithStyles(md, stylemap, linkAlloc.Alloc)
|
||||
keptSections = append(keptSections, sec)
|
||||
}
|
||||
// Stable order — already sorted ascending by ListForDraft, but
|
||||
// belt-and-braces in case the caller swaps the ordering policy
|
||||
// later.
|
||||
sort.SliceStable(keptSections, func(i, j int) bool {
|
||||
return keptSections[i].OrderIndex < keptSections[j].OrderIndex
|
||||
return c.inner.Compose(ctx, docx.ComposeOptions{
|
||||
Sections: secs,
|
||||
Carrier: docx.Carrier{
|
||||
Bytes: opts.BaseBytes,
|
||||
Stylemap: opts.Base.SectionSpec.Stylemap,
|
||||
},
|
||||
Lang: opts.Lang,
|
||||
Vars: opts.Vars,
|
||||
Missing: opts.Missing,
|
||||
})
|
||||
|
||||
assembledBody := spliceSections(documentXML, rendered, keptSections, sections)
|
||||
|
||||
// Slice D hyperlink patch: when the walker emitted hyperlink rIds
|
||||
// for inline `[label](url)` links, the base's
|
||||
// word/_rels/document.xml.rels needs matching <Relationship>
|
||||
// entries so Word can resolve the rIds. Mutates one zip part in
|
||||
// otherParts (or appends if missing).
|
||||
if linkAlloc.HasLinks() {
|
||||
updatedParts, err := patchDocumentXMLRels(otherParts, linkAlloc.Pairs())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
otherParts = updatedParts
|
||||
}
|
||||
|
||||
// Re-pack into a zip with the assembled document.xml. All other
|
||||
// parts (styles, fonts, headers, footers, theme, settings) pass
|
||||
// through bit-for-bit at their original mtime + compression.
|
||||
repacked, err := repackBaseZip(otherParts, assembledBody)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Final pass: substitute placeholders against the merged bag. The
|
||||
// existing renderer handles cross-run fragmentation, the `{{rule.X}}`
|
||||
// alias contract, and the missing-marker emission. Reusing it
|
||||
// guarantees v1's placeholder grammar stays intact inside section
|
||||
// content + base chrome.
|
||||
merged, err := c.renderer.Render(repacked, opts.Vars, opts.Missing)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("submission compose: placeholder pass: %w", err)
|
||||
}
|
||||
return merged, nil
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Section splicing
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Anchor markers as they appear inside a <w:t> text node. We don't
|
||||
// need a full XML parse — finding the marker text inside the body is
|
||||
// sufficient because:
|
||||
// - {{ and }} are never legitimate document content (placeholders
|
||||
// follow the same convention everywhere else in paliad).
|
||||
// - The anchor key grammar [A-Za-z0-9_]+ rules out any HTML/XML
|
||||
// special characters.
|
||||
// - Each anchor lives in exactly one <w:t>...<w:t>, which lives in
|
||||
// exactly one <w:r>...</w:r>, which lives in exactly one
|
||||
// <w:p>...</w:p>. We expand from the marker outward to find the
|
||||
// enclosing <w:p> span and drop the entire paragraph as part of
|
||||
// the splice.
|
||||
//
|
||||
// RE2 has no lookahead, so the "find enclosing <w:p>" logic is
|
||||
// implemented as manual byte-index search around the marker hit
|
||||
// (anchorParagraphSpan below) rather than a single regex pattern.
|
||||
|
||||
const (
|
||||
anchorOpenPrefix = "{{#section:"
|
||||
anchorClosePrefix = "{{/section:"
|
||||
anchorSuffix = "}}"
|
||||
)
|
||||
|
||||
// anchorKeyRegex validates that the captured anchor key is a clean
|
||||
// identifier. Keys that include other characters (which can't actually
|
||||
// appear in our authored .docx) are treated as no match.
|
||||
var anchorKeyRegex = regexp.MustCompile(`^[A-Za-z0-9_]+$`)
|
||||
|
||||
// anchorPair records the byte span of one matched anchor pair inside
|
||||
// the body — from the start of the opening anchor's <w:p> element
|
||||
// through the end of the closing anchor's </w:p>.
|
||||
type anchorPair struct {
|
||||
key string
|
||||
openStart int // start of <w:p> for the opening anchor
|
||||
closeEnd int // index just past </w:p> for the closing anchor
|
||||
}
|
||||
|
||||
// findAllAnchorPairs scans the body for matched open/close anchor
|
||||
// pairs. Unbalanced markers (open without close, or vice versa) are
|
||||
// dropped from the result. Returns pairs in body-order; each pair's
|
||||
// span is non-overlapping.
|
||||
func findAllAnchorPairs(body string) []anchorPair {
|
||||
type marker struct {
|
||||
key string
|
||||
paraStart int
|
||||
paraEnd int
|
||||
isOpen bool
|
||||
}
|
||||
var markers []marker
|
||||
|
||||
collect := func(prefix string, isOpen bool) {
|
||||
offset := 0
|
||||
for {
|
||||
idx := strings.Index(body[offset:], prefix)
|
||||
if idx < 0 {
|
||||
return
|
||||
}
|
||||
start := offset + idx
|
||||
suffixIdx := strings.Index(body[start+len(prefix):], anchorSuffix)
|
||||
if suffixIdx < 0 {
|
||||
return
|
||||
}
|
||||
key := body[start+len(prefix) : start+len(prefix)+suffixIdx]
|
||||
if !anchorKeyRegex.MatchString(key) {
|
||||
offset = start + len(prefix)
|
||||
continue
|
||||
}
|
||||
markerEnd := start + len(prefix) + suffixIdx + len(anchorSuffix)
|
||||
pStart, pEnd, ok := paragraphSpanAround(body, start, markerEnd)
|
||||
if !ok {
|
||||
offset = markerEnd
|
||||
continue
|
||||
}
|
||||
markers = append(markers, marker{key: key, paraStart: pStart, paraEnd: pEnd, isOpen: isOpen})
|
||||
offset = pEnd
|
||||
}
|
||||
}
|
||||
collect(anchorOpenPrefix, true)
|
||||
collect(anchorClosePrefix, false)
|
||||
|
||||
// Walk markers in body-order, matching each open with the next
|
||||
// close that carries the same key.
|
||||
sort.SliceStable(markers, func(i, j int) bool {
|
||||
return markers[i].paraStart < markers[j].paraStart
|
||||
})
|
||||
var pairs []anchorPair
|
||||
openStack := map[string]marker{}
|
||||
for _, m := range markers {
|
||||
if m.isOpen {
|
||||
openStack[m.key] = m
|
||||
continue
|
||||
}
|
||||
o, ok := openStack[m.key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
pairs = append(pairs, anchorPair{
|
||||
key: m.key,
|
||||
openStart: o.paraStart,
|
||||
closeEnd: m.paraEnd,
|
||||
})
|
||||
delete(openStack, m.key)
|
||||
}
|
||||
return pairs
|
||||
}
|
||||
|
||||
// paragraphSpanAround returns the byte span of the smallest `<w:p>...</w:p>`
|
||||
// element that fully contains the byte range [markerStart, markerEnd).
|
||||
// Returns false when the byte range doesn't sit inside a single
|
||||
// paragraph (which would mean the marker survived a cross-paragraph
|
||||
// edit — defensive guard, shouldn't happen in well-formed input).
|
||||
func paragraphSpanAround(body string, markerStart, markerEnd int) (int, int, bool) {
|
||||
// Walk backwards to find the nearest unclosed <w:p ... > opening.
|
||||
// Since <w:p> doesn't nest, the nearest <w:p before markerStart is
|
||||
// the enclosing paragraph's opening tag.
|
||||
pStart := -1
|
||||
cursor := markerStart
|
||||
for cursor > 0 {
|
||||
idx := strings.LastIndex(body[:cursor], "<w:p")
|
||||
if idx < 0 {
|
||||
break
|
||||
}
|
||||
// Confirm this is a paragraph open, not a different
|
||||
// w:p-prefixed tag (e.g. <w:pPr>).
|
||||
if idx+4 <= len(body) {
|
||||
after := body[idx+4]
|
||||
if after == ' ' || after == '>' || after == '/' {
|
||||
// <w:p ...> or <w:p>; not <w:pPr>.
|
||||
close := strings.Index(body[idx:], ">")
|
||||
if close < 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
pStart = idx
|
||||
break
|
||||
}
|
||||
}
|
||||
cursor = idx
|
||||
}
|
||||
if pStart < 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
// Walk forward to find the matching </w:p>. <w:p> doesn't nest so
|
||||
// the next </w:p> after the marker is the close.
|
||||
pEndIdx := strings.Index(body[markerEnd:], "</w:p>")
|
||||
if pEndIdx < 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
pEnd := markerEnd + pEndIdx + len("</w:p>")
|
||||
return pStart, pEnd, true
|
||||
}
|
||||
|
||||
// spliceSections replaces anchor slots with rendered sections and
|
||||
// appends any unanchored sections before sectPr. Returns the assembled
|
||||
// document.xml body.
|
||||
func spliceSections(documentXML []byte, rendered map[string]string, kept []SubmissionSection, all []SubmissionSection) []byte {
|
||||
body := string(documentXML)
|
||||
pairs := findAllAnchorPairs(body)
|
||||
|
||||
// Build a lookup of kept section keys for quick membership tests.
|
||||
keptByKey := map[string]int{}
|
||||
for i, sec := range kept {
|
||||
keptByKey[sec.SectionKey] = i
|
||||
}
|
||||
allByKey := map[string]int{}
|
||||
for i, sec := range all {
|
||||
allByKey[sec.SectionKey] = i
|
||||
}
|
||||
|
||||
matchedKeys := map[string]bool{}
|
||||
|
||||
// Walk pairs in REVERSE body-order so slice mutations don't shift
|
||||
// later offsets.
|
||||
sort.SliceStable(pairs, func(i, j int) bool {
|
||||
return pairs[i].openStart > pairs[j].openStart
|
||||
})
|
||||
for _, p := range pairs {
|
||||
replacement := ""
|
||||
if idx, ok := keptByKey[p.key]; ok {
|
||||
replacement = rendered[p.key]
|
||||
matchedKeys[p.key] = true
|
||||
_ = idx
|
||||
} else if _, isOnDraft := allByKey[p.key]; isOnDraft {
|
||||
// Anchor matches an excluded section on the draft — drop
|
||||
// the entire slot.
|
||||
replacement = ""
|
||||
} else {
|
||||
// Anchor doesn't match any section on this draft — drop
|
||||
// to leave the base's chrome unbroken.
|
||||
replacement = ""
|
||||
}
|
||||
body = body[:p.openStart] + replacement + body[p.closeEnd:]
|
||||
}
|
||||
|
||||
// Append unanchored sections before sectPr in order_index ASC.
|
||||
var unanchored strings.Builder
|
||||
for _, sec := range kept {
|
||||
if matchedKeys[sec.SectionKey] {
|
||||
continue
|
||||
}
|
||||
unanchored.WriteString(rendered[sec.SectionKey])
|
||||
}
|
||||
if unanchored.Len() > 0 {
|
||||
body = appendBeforeSectPr(body, unanchored.String())
|
||||
}
|
||||
|
||||
return []byte(body)
|
||||
}
|
||||
|
||||
// appendBeforeSectPr inserts content immediately before the first
|
||||
// `<w:sectPr` element in the body, or at the end of the body if there
|
||||
// is none. Word documents conventionally close the body with a sectPr
|
||||
// describing page setup; we want to land sections before that element
|
||||
// so they show up on the actual pages.
|
||||
var sectPrRegex = regexp.MustCompile(`<w:sectPr\b`)
|
||||
|
||||
func appendBeforeSectPr(body, content string) string {
|
||||
loc := sectPrRegex.FindStringIndex(body)
|
||||
if loc == nil {
|
||||
// No sectPr → append before `</w:body>` if present, else at
|
||||
// the very end.
|
||||
idx := strings.LastIndex(body, "</w:body>")
|
||||
if idx < 0 {
|
||||
return body + content
|
||||
}
|
||||
return body[:idx] + content + body[idx:]
|
||||
}
|
||||
return body[:loc[0]] + content + body[loc[0]:]
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Zip plumbing
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
// baseZipPart captures one zip entry we kept aside while extracting
|
||||
// document.xml.
|
||||
type baseZipPart struct {
|
||||
name string
|
||||
method uint16
|
||||
modTime int64 // wall seconds; converted back to time.Time on repack
|
||||
body []byte
|
||||
}
|
||||
|
||||
// splitBaseZip extracts document.xml and returns it alongside every
|
||||
// other zip entry, ready for repacking.
|
||||
func splitBaseZip(cleanBytes []byte) ([]byte, []baseZipPart, error) {
|
||||
zr, err := zip.NewReader(bytes.NewReader(cleanBytes), int64(len(cleanBytes)))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("submission compose: open base zip: %w", err)
|
||||
}
|
||||
var documentXML []byte
|
||||
parts := make([]baseZipPart, 0, len(zr.File))
|
||||
for _, f := range zr.File {
|
||||
body, err := readZipEntry(f)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("submission compose: read %s: %w", f.Name, err)
|
||||
}
|
||||
if f.Name == "word/document.xml" {
|
||||
documentXML = body
|
||||
parts = append(parts, baseZipPart{name: f.Name, method: f.Method, modTime: f.Modified.Unix(), body: nil})
|
||||
continue
|
||||
}
|
||||
parts = append(parts, baseZipPart{name: f.Name, method: f.Method, modTime: f.Modified.Unix(), body: body})
|
||||
}
|
||||
if documentXML == nil {
|
||||
return nil, nil, fmt.Errorf("submission compose: base zip missing word/document.xml")
|
||||
}
|
||||
return documentXML, parts, nil
|
||||
}
|
||||
|
||||
// repackBaseZip rebuilds the zip, swapping document.xml for the
|
||||
// assembled body and leaving every other part untouched.
|
||||
func repackBaseZip(parts []baseZipPart, assembledBody []byte) ([]byte, error) {
|
||||
var out bytes.Buffer
|
||||
zw := zip.NewWriter(&out)
|
||||
for _, p := range parts {
|
||||
hdr := &zip.FileHeader{
|
||||
Name: p.name,
|
||||
Method: p.method,
|
||||
}
|
||||
if p.modTime > 0 {
|
||||
hdr.Modified = time.Unix(p.modTime, 0)
|
||||
}
|
||||
w, err := zw.CreateHeader(hdr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("submission compose: write header %s: %w", p.name, err)
|
||||
}
|
||||
body := p.body
|
||||
if p.name == "word/document.xml" {
|
||||
body = assembledBody
|
||||
}
|
||||
if _, err := w.Write(body); err != nil {
|
||||
return nil, fmt.Errorf("submission compose: write body %s: %w", p.name, err)
|
||||
}
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
return nil, fmt.Errorf("submission compose: finalise zip: %w", err)
|
||||
}
|
||||
return out.Bytes(), nil
|
||||
}
|
||||
|
||||
func readZipEntry(f *zip.File) ([]byte, error) {
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rc.Close()
|
||||
return io.ReadAll(rc)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Slice D — hyperlink wiring
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
// composerLinkAllocator hands out fresh rIds for inline hyperlink
|
||||
// targets discovered by the MD walker. Each unique URL gets one rId
|
||||
// (deduped — repeated links to the same URL share one Relationship).
|
||||
// Allocations land outside the base's rId namespace by prefixing with
|
||||
// "rIdComposer" so they can't collide with existing relationships.
|
||||
type composerLinkAllocator struct {
|
||||
next int
|
||||
byURL map[string]string
|
||||
order []string // URLs in allocation order
|
||||
}
|
||||
|
||||
func newComposerLinkAllocator() *composerLinkAllocator {
|
||||
return &composerLinkAllocator{byURL: map[string]string{}}
|
||||
}
|
||||
|
||||
// Alloc returns the rId for url, allocating one on first sight.
|
||||
func (a *composerLinkAllocator) Alloc(url string) string {
|
||||
if rid, ok := a.byURL[url]; ok {
|
||||
return rid
|
||||
}
|
||||
a.next++
|
||||
rid := fmt.Sprintf("rIdComposer%d", a.next)
|
||||
a.byURL[url] = rid
|
||||
a.order = append(a.order, url)
|
||||
return rid
|
||||
}
|
||||
|
||||
// HasLinks reports whether any links were allocated during this compose.
|
||||
func (a *composerLinkAllocator) HasLinks() bool {
|
||||
return len(a.order) > 0
|
||||
}
|
||||
|
||||
// Pairs returns the (rId, URL) pairs in allocation order. The
|
||||
// document.xml.rels patcher consumes this to emit <Relationship>
|
||||
// elements.
|
||||
func (a *composerLinkAllocator) Pairs() [][2]string {
|
||||
pairs := make([][2]string, 0, len(a.order))
|
||||
for _, url := range a.order {
|
||||
pairs = append(pairs, [2]string{a.byURL[url], url})
|
||||
}
|
||||
return pairs
|
||||
}
|
||||
|
||||
// patchDocumentXMLRels mutates the word/_rels/document.xml.rels entry
|
||||
// in `parts` to append the given (rId, URL) pairs as hyperlink
|
||||
// relationships. If the rels part doesn't exist (some bases omit it
|
||||
// when the body has no relationships), this function appends a fresh
|
||||
// part with the minimal Relationships wrapper.
|
||||
//
|
||||
// Idempotent on (rId, URL) pairs already present (e.g. when a base
|
||||
// already references the URL for some other reason).
|
||||
//
|
||||
// Returns the (possibly extended) parts slice — callers must overwrite
|
||||
// their reference because the append in the no-rels-yet case grows the
|
||||
// backing array.
|
||||
func patchDocumentXMLRels(parts []baseZipPart, pairs [][2]string) ([]baseZipPart, error) {
|
||||
const path = "word/_rels/document.xml.rels"
|
||||
const hyperlinkType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink"
|
||||
|
||||
existingIdx := -1
|
||||
for i := range parts {
|
||||
if parts[i].name == path {
|
||||
existingIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
var body string
|
||||
if existingIdx >= 0 {
|
||||
body = string(parts[existingIdx].body)
|
||||
} else {
|
||||
body = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
|
||||
`<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"></Relationships>`
|
||||
}
|
||||
|
||||
var inserts strings.Builder
|
||||
for _, p := range pairs {
|
||||
rid := p[0]
|
||||
url := p[1]
|
||||
if strings.Contains(body, `Id="`+rid+`"`) {
|
||||
continue
|
||||
}
|
||||
inserts.WriteString(`<Relationship Id="`)
|
||||
inserts.WriteString(xmlAttrEscape(rid))
|
||||
inserts.WriteString(`" Type="`)
|
||||
inserts.WriteString(hyperlinkType)
|
||||
inserts.WriteString(`" Target="`)
|
||||
inserts.WriteString(xmlAttrEscape(url))
|
||||
inserts.WriteString(`" TargetMode="External"/>`)
|
||||
}
|
||||
|
||||
if inserts.Len() == 0 {
|
||||
return parts, nil
|
||||
}
|
||||
|
||||
closeIdx := strings.LastIndex(body, "</Relationships>")
|
||||
if closeIdx < 0 {
|
||||
return parts, fmt.Errorf("submission compose: malformed document.xml.rels (no closing tag)")
|
||||
}
|
||||
patched := body[:closeIdx] + inserts.String() + body[closeIdx:]
|
||||
|
||||
if existingIdx >= 0 {
|
||||
parts[existingIdx].body = []byte(patched)
|
||||
return parts, nil
|
||||
}
|
||||
parts = append(parts, baseZipPart{
|
||||
name: path,
|
||||
method: zip.Deflate,
|
||||
modTime: time.Now().Unix(),
|
||||
body: []byte(patched),
|
||||
})
|
||||
return parts, nil
|
||||
}
|
||||
|
||||
129
internal/services/submission_draft_autoname_live_test.go
Normal file
129
internal/services/submission_draft_autoname_live_test.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package services
|
||||
|
||||
// Live-DB test for the submission-draft auto-naming scheme
|
||||
// (t-paliad-352 / m/paliad#155). Skipped without TEST_DATABASE_URL.
|
||||
//
|
||||
// Verifies the shipped Create flow end-to-end against real Postgres:
|
||||
// a project-bound draft is auto-named "<date> <client> ./. <forum> ./.
|
||||
// <opponent>" rather than "Entwurf N", the segments resolve from the
|
||||
// real project tree (client = root ancestor, forum = proceeding-type
|
||||
// jurisdiction, opponent = opposing party by our_side), and a second
|
||||
// draft on the same slot de-duplicates with a " (2)" suffix.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/lib/pq"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/db"
|
||||
)
|
||||
|
||||
func TestSubmissionDraft_AutoName_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()
|
||||
email := "autoname-" + userID.String()[:8] + "@hlc.com"
|
||||
var clientID, caseID uuid.UUID
|
||||
cleanup := func() {
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.submission_drafts WHERE user_id = $1`, userID)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.parties WHERE project_id = $1`, caseID)
|
||||
// Children first (FK), then root.
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.project_teams WHERE user_id = $1`, userID)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE id = $1`, caseID)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE id = $1`, clientID)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.users WHERE id = $1`, userID)
|
||||
pool.ExecContext(ctx, `DELETE FROM auth.users WHERE id = $1`, userID)
|
||||
}
|
||||
defer cleanup()
|
||||
if _, err := pool.ExecContext(ctx, `INSERT INTO auth.users (id, email) VALUES ($1, $2)`, userID, email); 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, $2, 'Auto Name', 'munich', 'standard', 'de')`, userID, email); err != nil {
|
||||
t.Fatalf("seed paliad.users: %v", err)
|
||||
}
|
||||
|
||||
users := NewUserService(pool)
|
||||
projects := NewProjectService(pool, users)
|
||||
parties := NewPartyService(pool, projects)
|
||||
vars := NewSubmissionVarsService(pool, projects, parties, users)
|
||||
renderer := NewSubmissionRenderer()
|
||||
drafts := NewSubmissionDraftService(pool, projects, vars, renderer)
|
||||
|
||||
// Client root → case child. The case carries the proceeding type
|
||||
// (UPC) and our_side (claimant), the party is the opponent.
|
||||
client, err := projects.Create(ctx, userID, CreateProjectInput{
|
||||
Type: "client", Title: "Bayer AG",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create client project: %v", err)
|
||||
}
|
||||
clientID = client.ID
|
||||
|
||||
ptID := 8 // upc.inf.cfi → jurisdiction UPC
|
||||
side := "claimant"
|
||||
caseProj, err := projects.Create(ctx, userID, CreateProjectInput{
|
||||
Type: "case", Title: "Streitsache", ParentID: &client.ID,
|
||||
ProceedingTypeID: &ptID, OurSide: &side,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create case project: %v", err)
|
||||
}
|
||||
caseID = caseProj.ID
|
||||
|
||||
beklagte := "Beklagte"
|
||||
if _, err := parties.Create(ctx, userID, caseProj.ID, CreatePartyInput{
|
||||
Name: "Novartis Pharma", Role: &beklagte,
|
||||
}); err != nil {
|
||||
t.Fatalf("create party: %v", err)
|
||||
}
|
||||
|
||||
loc, _ := time.LoadLocation("Europe/Berlin")
|
||||
today := time.Now().In(loc).Format("2006-01-02")
|
||||
wantBase := today + " Bayer AG ./. UPC ./. Novartis Pharma"
|
||||
|
||||
d1, err := drafts.Create(ctx, userID, &caseProj.ID, "upc.inf.cfi.sod", "de")
|
||||
if err != nil {
|
||||
t.Fatalf("create draft 1: %v", err)
|
||||
}
|
||||
if d1.Name != wantBase {
|
||||
t.Fatalf("draft 1 name = %q, want %q", d1.Name, wantBase)
|
||||
}
|
||||
|
||||
// Second draft on the same (project, code) slot must de-duplicate.
|
||||
d2, err := drafts.Create(ctx, userID, &caseProj.ID, "upc.inf.cfi.sod", "de")
|
||||
if err != nil {
|
||||
t.Fatalf("create draft 2: %v", err)
|
||||
}
|
||||
want2 := wantBase + " (2)"
|
||||
if d2.Name != want2 {
|
||||
t.Fatalf("draft 2 name = %q, want %q", d2.Name, want2)
|
||||
}
|
||||
|
||||
// A project-less draft keeps the legacy Entwurf-N counter.
|
||||
dless, err := drafts.Create(ctx, userID, nil, "upc.inf.cfi.sod", "de")
|
||||
if err != nil {
|
||||
t.Fatalf("create project-less draft: %v", err)
|
||||
}
|
||||
if dless.Name != "Entwurf 1" {
|
||||
t.Fatalf("project-less draft name = %q, want %q", dless.Name, "Entwurf 1")
|
||||
}
|
||||
}
|
||||
123
internal/services/submission_draft_keyword_live_test.go
Normal file
123
internal/services/submission_draft_keyword_live_test.go
Normal file
@@ -0,0 +1,123 @@
|
||||
package services
|
||||
|
||||
// Live-DB test for the user-replaceable filename keyword
|
||||
// (t-paliad-354). Skipped without TEST_DATABASE_URL.
|
||||
//
|
||||
// Exercises the real Update → Get code path against Postgres: setting the
|
||||
// override merges into composer_meta.filename_keyword without clobbering
|
||||
// other composer keys, clearing it removes only that key, and the value
|
||||
// reads back through the same jsonb decode the export handler relies on.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/lib/pq"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/db"
|
||||
)
|
||||
|
||||
func TestSubmissionDraft_FilenameKeyword_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()
|
||||
email := "kw-" + userID.String()[:8] + "@hlc.com"
|
||||
cleanup := func() {
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.submission_drafts WHERE user_id = $1`, userID)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.users WHERE id = $1`, userID)
|
||||
pool.ExecContext(ctx, `DELETE FROM auth.users WHERE id = $1`, userID)
|
||||
}
|
||||
defer cleanup()
|
||||
if _, err := pool.ExecContext(ctx, `INSERT INTO auth.users (id, email) VALUES ($1, $2)`, userID, email); 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, $2, 'Keyword Tester', 'munich', 'standard', 'de')`, userID, email); err != nil {
|
||||
t.Fatalf("seed paliad.users: %v", err)
|
||||
}
|
||||
|
||||
users := NewUserService(pool)
|
||||
projects := NewProjectService(pool, users)
|
||||
parties := NewPartyService(pool, projects)
|
||||
vars := NewSubmissionVarsService(pool, projects, parties, users)
|
||||
renderer := NewSubmissionRenderer()
|
||||
drafts := NewSubmissionDraftService(pool, projects, vars, renderer)
|
||||
|
||||
// A project-less draft is the simplest fixture — no project tree
|
||||
// needed to exercise composer_meta persistence.
|
||||
d, err := drafts.Create(ctx, userID, nil, "upc.inf.cfi.sod", "de")
|
||||
if err != nil {
|
||||
t.Fatalf("create draft: %v", err)
|
||||
}
|
||||
|
||||
// Pre-seed an unrelated composer_meta key to prove the merge/delete
|
||||
// only touches filename_keyword.
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`UPDATE paliad.submission_drafts SET composer_meta = '{"other":"keep-me"}'::jsonb WHERE id = $1`,
|
||||
d.ID); err != nil {
|
||||
t.Fatalf("seed composer_meta: %v", err)
|
||||
}
|
||||
|
||||
// Set the override. The canonical shape is now
|
||||
// composer_meta.name_overrides.keyword (Slice 3).
|
||||
kw := "Replik Hauptantrag"
|
||||
got, err := drafts.Update(ctx, userID, d.ID, DraftPatch{FilenameKeyword: &kw})
|
||||
if err != nil {
|
||||
t.Fatalf("update set keyword: %v", err)
|
||||
}
|
||||
if v := nameOverrideKeyword(got.ComposerMeta); v != kw {
|
||||
t.Fatalf("after set: name_overrides.keyword = %q, want %q", v, kw)
|
||||
}
|
||||
if v, _ := got.ComposerMeta["other"].(string); v != "keep-me" {
|
||||
t.Fatalf("after set: unrelated key 'other' = %q, want %q (merge clobbered it)", v, "keep-me")
|
||||
}
|
||||
|
||||
// Read back through Get (the path the export handler uses).
|
||||
reload, err := drafts.Get(ctx, userID, d.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get after set: %v", err)
|
||||
}
|
||||
if v := nameOverrideKeyword(reload.ComposerMeta); v != kw {
|
||||
t.Fatalf("reload: name_overrides.keyword = %q, want %q", v, kw)
|
||||
}
|
||||
|
||||
// Clear the override (empty string) — only the keyword should go.
|
||||
empty := ""
|
||||
cleared, err := drafts.Update(ctx, userID, d.ID, DraftPatch{FilenameKeyword: &empty})
|
||||
if err != nil {
|
||||
t.Fatalf("update clear keyword: %v", err)
|
||||
}
|
||||
if v := nameOverrideKeyword(cleared.ComposerMeta); v != "" {
|
||||
t.Fatalf("after clear: name_overrides.keyword still present: %v", cleared.ComposerMeta)
|
||||
}
|
||||
if v, _ := cleared.ComposerMeta["other"].(string); v != "keep-me" {
|
||||
t.Fatalf("after clear: unrelated key 'other' = %q, want %q (delete removed too much)", v, "keep-me")
|
||||
}
|
||||
}
|
||||
|
||||
// nameOverrideKeyword reads composer_meta.name_overrides.keyword from a
|
||||
// decoded composer_meta map (the new Slice-3 shape).
|
||||
func nameOverrideKeyword(meta map[string]any) string {
|
||||
no, ok := meta["name_overrides"].(map[string]any)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
v, _ := no["keyword"].(string)
|
||||
return v
|
||||
}
|
||||
121
internal/services/submission_draft_nonproject_name_live_test.go
Normal file
121
internal/services/submission_draft_nonproject_name_live_test.go
Normal file
@@ -0,0 +1,121 @@
|
||||
package services
|
||||
|
||||
// Live-DB gate for the non-project date-first draft name
|
||||
// (t-paliad-356 Slice 2, PRD §6). Skipped without TEST_DATABASE_URL.
|
||||
//
|
||||
// Exercises the real SubmissionDraftService.Create path for a project-less
|
||||
// draft (projectID == nil): the title must lead with today's date and carry
|
||||
// the document type resolved from the submission_code, degrade to an
|
||||
// "Entwurf"/"Draft" fallback when the code has no published filing rule, and
|
||||
// stay unique on collision. Project-draft titles are guarded byte-for-byte by
|
||||
// the pure TestAutoSubmissionTitle matrix and are unchanged by this slice.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/lib/pq"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/db"
|
||||
)
|
||||
|
||||
func todayBerlinDate() string {
|
||||
day := time.Now()
|
||||
if loc, err := time.LoadLocation("Europe/Berlin"); err == nil {
|
||||
day = day.In(loc)
|
||||
}
|
||||
return day.Format("2006-01-02")
|
||||
}
|
||||
|
||||
func TestSubmissionDraft_NonProjectName_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()
|
||||
email := "np-" + userID.String()[:8] + "@hlc.com"
|
||||
cleanup := func() {
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.submission_drafts WHERE user_id = $1`, userID)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.users WHERE id = $1`, userID)
|
||||
pool.ExecContext(ctx, `DELETE FROM auth.users WHERE id = $1`, userID)
|
||||
}
|
||||
defer cleanup()
|
||||
if _, err := pool.ExecContext(ctx, `INSERT INTO auth.users (id, email) VALUES ($1, $2)`, userID, email); 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, $2, 'Non-Project Tester', 'munich', 'standard', 'de')`, userID, email); err != nil {
|
||||
t.Fatalf("seed paliad.users: %v", err)
|
||||
}
|
||||
|
||||
users := NewUserService(pool)
|
||||
projects := NewProjectService(pool, users)
|
||||
parties := NewPartyService(pool, projects)
|
||||
vars := NewSubmissionVarsService(pool, projects, parties, users)
|
||||
renderer := NewSubmissionRenderer()
|
||||
drafts := NewSubmissionDraftService(pool, projects, vars, renderer)
|
||||
|
||||
date := todayBerlinDate()
|
||||
|
||||
// de.inf.lg.erwidg is a published filing rule → "Klageerwiderung" (DE) /
|
||||
// "Statement of Defence" (EN). A project-less draft must lead with the
|
||||
// date and carry that keyword.
|
||||
d1, err := drafts.Create(ctx, userID, nil, "de.inf.lg.erwidg", "de")
|
||||
if err != nil {
|
||||
t.Fatalf("create draft 1: %v", err)
|
||||
}
|
||||
if want := date + " Klageerwiderung"; d1.Name != want {
|
||||
t.Errorf("draft 1 name = %q, want %q", d1.Name, want)
|
||||
}
|
||||
|
||||
// Same code again → collision → " (2)" via uniqueDraftName.
|
||||
d2, err := drafts.Create(ctx, userID, nil, "de.inf.lg.erwidg", "de")
|
||||
if err != nil {
|
||||
t.Fatalf("create draft 2: %v", err)
|
||||
}
|
||||
if want := date + " Klageerwiderung (2)"; d2.Name != want {
|
||||
t.Errorf("draft 2 name = %q, want %q", d2.Name, want)
|
||||
}
|
||||
|
||||
// EN locale resolves the English document type.
|
||||
dEN, err := drafts.Create(ctx, userID, nil, "de.inf.lg.erwidg", "en")
|
||||
if err != nil {
|
||||
t.Fatalf("create draft EN: %v", err)
|
||||
}
|
||||
if want := date + " Statement of Defence"; dEN.Name != want {
|
||||
t.Errorf("draft EN name = %q, want %q", dEN.Name, want)
|
||||
}
|
||||
|
||||
// A code with no published filing rule falls back to "<date> Entwurf".
|
||||
dFallback, err := drafts.Create(ctx, userID, nil, "zzz.bogus.nope", "de")
|
||||
if err != nil {
|
||||
t.Fatalf("create fallback draft: %v", err)
|
||||
}
|
||||
if want := date + " Entwurf"; dFallback.Name != want {
|
||||
t.Errorf("fallback draft name = %q, want %q", dFallback.Name, want)
|
||||
}
|
||||
|
||||
// EN fallback word.
|
||||
dFallbackEN, err := drafts.Create(ctx, userID, nil, "zzz.bogus.nope", "en")
|
||||
if err != nil {
|
||||
t.Fatalf("create EN fallback draft: %v", err)
|
||||
}
|
||||
if want := date + " Draft"; dFallbackEN.Name != want {
|
||||
t.Errorf("EN fallback draft name = %q, want %q", dFallbackEN.Name, want)
|
||||
}
|
||||
}
|
||||
@@ -63,12 +63,17 @@ type SubmissionDraft struct {
|
||||
// ON DELETE SET NULL keeps a draft renderable if its base is
|
||||
// removed; the lawyer picks a new one via the sidebar.
|
||||
BaseID *uuid.UUID `db:"base_id" json:"base_id,omitempty"`
|
||||
// TemplateVersionID pins an uploaded docforge template version
|
||||
// (t-paliad-349 slice 7). NULL = render via base_id Composer path or
|
||||
// the v1 fallback; non-NULL = render the pinned version's carrier.
|
||||
// The export/preview path checks this first. ON DELETE SET NULL.
|
||||
TemplateVersionID *uuid.UUID `db:"template_version_id" json:"template_version_id,omitempty"`
|
||||
// ComposerMetaRaw / ComposerMeta — Composer-side metadata jsonb.
|
||||
// Slice A: empty default. Future slices populate section_order,
|
||||
// hidden_sections, etc.
|
||||
ComposerMetaRaw []byte `db:"composer_meta" json:"-"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
ComposerMetaRaw []byte `db:"composer_meta" json:"-"`
|
||||
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.
|
||||
@@ -170,6 +175,22 @@ type DraftPatch struct {
|
||||
// content is unaffected — the base swap is render-side only.
|
||||
// t-paliad-313.
|
||||
BaseID **uuid.UUID
|
||||
|
||||
// TemplateVersionID pins (or clears) an uploaded docforge template
|
||||
// version. Same three-state two-level pointer as BaseID:
|
||||
// nil → no change
|
||||
// *p == nil → clear (back to base_id / v1)
|
||||
// **p → pin the version (validated via TemplateStore.GetVersion)
|
||||
// t-paliad-349 slice 7.
|
||||
TemplateVersionID **uuid.UUID
|
||||
|
||||
// FilenameKeyword sets (or clears) the user override that leads the
|
||||
// exported document name "<date> <keyword> (<case>)" (t-paliad-354).
|
||||
// Stored under composer_meta.filename_keyword — no dedicated column:
|
||||
// nil → no change
|
||||
// *p == "" → clear the key (back to the auto-derived rule name)
|
||||
// *p == "x" → set the override
|
||||
FilenameKeyword *string
|
||||
}
|
||||
|
||||
// ErrSubmissionDraftNotFound is the sentinel for "no draft with that id
|
||||
@@ -186,7 +207,7 @@ const draftColumns = `id, project_id, submission_code, user_id, name, language,
|
||||
variables, selected_parties,
|
||||
last_exported_at, last_exported_sha,
|
||||
last_imported_at,
|
||||
base_id, composer_meta,
|
||||
base_id, template_version_id, composer_meta,
|
||||
created_at, updated_at`
|
||||
|
||||
// List returns every draft for (project, submission_code, user)
|
||||
@@ -239,7 +260,7 @@ func (s *SubmissionDraftService) ListAllForUser(ctx context.Context, userID uuid
|
||||
`SELECT d.id, d.project_id, d.submission_code, d.user_id, d.name, d.language,
|
||||
d.variables, d.selected_parties,
|
||||
d.last_exported_at, d.last_exported_sha, d.last_imported_at,
|
||||
d.base_id, d.composer_meta,
|
||||
d.base_id, d.template_version_id, d.composer_meta,
|
||||
d.created_at, d.updated_at,
|
||||
p.title AS project_title,
|
||||
p.reference AS project_reference
|
||||
@@ -343,12 +364,15 @@ func (s *SubmissionDraftService) EnsureLatest(ctx context.Context, userID, proje
|
||||
// creates with base_id=NULL — Composer is additive, the v1 fallback
|
||||
// path remains valid.
|
||||
func (s *SubmissionDraftService) Create(ctx context.Context, userID uuid.UUID, projectID *uuid.UUID, submissionCode, lang string) (*SubmissionDraft, error) {
|
||||
var project *models.Project
|
||||
if projectID != nil {
|
||||
if _, err := s.projects.GetByID(ctx, userID, *projectID); err != nil {
|
||||
p, err := s.projects.GetByID(ctx, userID, *projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
project = p
|
||||
}
|
||||
name, err := s.nextDraftName(ctx, projectID, submissionCode, userID, lang)
|
||||
name, err := s.newDraftName(ctx, userID, project, projectID, submissionCode, lang)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -418,20 +442,186 @@ func (s *SubmissionDraftService) Create(ctx context.Context, userID uuid.UUID, p
|
||||
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.
|
||||
// newDraftName picks the title for a freshly-created draft. Project-
|
||||
// bound drafts get the auto-name scheme (t-paliad-352 / m/paliad#155) —
|
||||
// "<date> <client> ./. <forum> ./. <opponent>", de-duplicated against
|
||||
// the user's existing drafts for the same (project, submission_code).
|
||||
// Project-less drafts (and any project-bound draft whose auto-name
|
||||
// resolves to nothing) fall back to the "Entwurf N" / "Draft N"
|
||||
// counter.
|
||||
//
|
||||
// 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"
|
||||
// Only Create calls this — existing drafts are never renamed (the
|
||||
// scheme is create-time only, per #155). A lawyer's later manual rename
|
||||
// flows through Update and is left untouched.
|
||||
func (s *SubmissionDraftService) newDraftName(ctx context.Context, userID uuid.UUID, project *models.Project, projectID *uuid.UUID, submissionCode, lang string) (string, error) {
|
||||
existing, err := s.existingDraftNames(ctx, projectID, submissionCode, userID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// Composition precedence (Slice 3 + Slice 5): per-user override, then the
|
||||
// firm-wide default, then the system default. Both loads are empty-safe.
|
||||
overrides, err := getUserNameCompositions(ctx, s.db, userID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
firm, err := getFirmNameCompositions(ctx, s.db)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if project != nil {
|
||||
auto, err := s.autoNameForProject(ctx, time.Now(), project, overrides, firm)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if strings.TrimSpace(auto) != "" {
|
||||
return uniqueDraftName(auto, existing), nil
|
||||
}
|
||||
// A project draft whose auto-name resolved to nothing (date always
|
||||
// renders, so this is unreachable in practice) keeps the legacy
|
||||
// counter as a defensive fallback.
|
||||
return nextDraftName(existing, lang), nil
|
||||
}
|
||||
// Project-less draft (t-paliad-243): date-first name as well
|
||||
// (t-paliad-356 Slice 2, PRD §6) — "<date> <keyword>", keyword being the
|
||||
// document type resolved from submission_code, or an "Entwurf"/"Draft"
|
||||
// fallback when the code has no published filing rule.
|
||||
auto, err := s.autoNameForNonProject(ctx, time.Now(), submissionCode, lang, overrides, firm)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return uniqueDraftName(auto, existing), nil
|
||||
}
|
||||
|
||||
// autoNameForNonProject builds the date-first title for a project-less draft.
|
||||
// It resolves the keyword (document type) from the submission_code via the
|
||||
// catalog — which is project-independent because submission_code → name is a
|
||||
// function across the published filing rules — and falls back to the
|
||||
// localized "Entwurf"/"Draft" word when the code has no matching rule. The
|
||||
// identity trio is absent (no project), so the title degrades to
|
||||
// "<date> <keyword>".
|
||||
func (s *SubmissionDraftService) autoNameForNonProject(ctx context.Context, now time.Time, submissionCode, lang string, overrides, firm NameCompositionSpec) (string, error) {
|
||||
keyword, err := s.keywordForSubmissionCode(ctx, submissionCode, lang)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if strings.TrimSpace(keyword) == "" {
|
||||
keyword = draftWord(lang)
|
||||
}
|
||||
return renderSubmissionDraftTitle(overrides, firm, now, "", nil, nil, nil, keyword), nil
|
||||
}
|
||||
|
||||
// keywordForSubmissionCode resolves the document-type label for a
|
||||
// submission_code, lang-aware, without needing a project. submission_code is
|
||||
// a globally-unique key for a published filing rule (the code encodes the
|
||||
// proceeding, e.g. de.inf.lg.erwidg → "Klageerwiderung"), so a project-free
|
||||
// LIMIT 1 lookup is deterministic. Returns "" (no error) when the code has no
|
||||
// active published filing rule — the caller then uses the "Entwurf"/"Draft"
|
||||
// fallback.
|
||||
func (s *SubmissionDraftService) keywordForSubmissionCode(ctx context.Context, submissionCode, lang string) (string, error) {
|
||||
code := strings.TrimSpace(submissionCode)
|
||||
if code == "" {
|
||||
return "", nil
|
||||
}
|
||||
var row struct {
|
||||
Name string `db:"name"`
|
||||
NameEN string `db:"name_en"`
|
||||
}
|
||||
err := s.db.GetContext(ctx, &row,
|
||||
`SELECT dr.name AS name, COALESCE(dr.name_en, '') AS name_en
|
||||
FROM paliad.deadline_rules_unified dr
|
||||
WHERE dr.submission_code = $1
|
||||
AND dr.is_active = true
|
||||
AND dr.lifecycle_state = 'published'
|
||||
AND dr.event_type = 'filing'
|
||||
ORDER BY dr.sequence_order ASC
|
||||
LIMIT 1`, code)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("auto-name: resolve keyword for %q: %w", code, err)
|
||||
}
|
||||
if strings.EqualFold(lang, "en") && strings.TrimSpace(row.NameEN) != "" {
|
||||
return strings.TrimSpace(row.NameEN), nil
|
||||
}
|
||||
return strings.TrimSpace(row.Name), nil
|
||||
}
|
||||
|
||||
// UserNameCompositions loads a user's per-user name-composition overrides
|
||||
// (Slice 3), sanitised for read. Empty when the user has none. Exposed so the
|
||||
// download handlers can apply the filename override and the settings API
|
||||
// (Slice 4) can read the current value.
|
||||
func (s *SubmissionDraftService) UserNameCompositions(ctx context.Context, userID uuid.UUID) (NameCompositionSpec, error) {
|
||||
return getUserNameCompositions(ctx, s.db, userID)
|
||||
}
|
||||
|
||||
// SetUserNameCompositions validates and persists a user's full
|
||||
// name-composition override map. The single write path — used by the
|
||||
// settings API (Slice 4) and the Slice-3 live tests.
|
||||
func (s *SubmissionDraftService) SetUserNameCompositions(ctx context.Context, userID uuid.UUID, spec NameCompositionSpec) error {
|
||||
return setUserNameCompositions(ctx, s.db, userID, spec)
|
||||
}
|
||||
|
||||
// autoNameForProject resolves the three identity segments for a
|
||||
// project-bound draft and hands them to the pure AutoSubmissionTitle
|
||||
// assembler. The client is the root ancestor of the project tree (the
|
||||
// 'client' node), the proceeding type and our_side come off the draft's
|
||||
// own project node, and the parties hang directly off it.
|
||||
//
|
||||
// A failure to resolve the client / proceeding type is not fatal —
|
||||
// AutoSubmissionTitle just omits the empty segment — so the only errors
|
||||
// returned here are genuine DB faults.
|
||||
func (s *SubmissionDraftService) autoNameForProject(ctx context.Context, now time.Time, project *models.Project, overrides, firm NameCompositionSpec) (string, error) {
|
||||
clientName, err := s.clientNameForProject(ctx, project.ID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
pt, err := s.vars.loadProceedingType(ctx, project.ProceedingTypeID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var parties []models.Party
|
||||
if err := s.db.SelectContext(ctx, &parties,
|
||||
`SELECT id, project_id, name, role, representative, contact_info,
|
||||
created_at, updated_at
|
||||
FROM paliad.parties
|
||||
WHERE project_id = $1
|
||||
ORDER BY name`, project.ID); err != nil {
|
||||
return "", fmt.Errorf("auto-name: load parties: %w", err)
|
||||
}
|
||||
|
||||
return renderSubmissionDraftTitle(overrides, firm, now, clientName, project, parties, pt, ""), nil
|
||||
}
|
||||
|
||||
// clientNameForProject returns the title of the 'client' ancestor in
|
||||
// the project's path (the firm's mandant). Empty string when the tree
|
||||
// has no client node — the auto-name then omits the client segment.
|
||||
func (s *SubmissionDraftService) clientNameForProject(ctx context.Context, projectID uuid.UUID) (string, error) {
|
||||
var title string
|
||||
err := s.db.GetContext(ctx, &title,
|
||||
`SELECT p.title
|
||||
FROM paliad.projects target
|
||||
JOIN paliad.projects p
|
||||
ON p.id = ANY(string_to_array(target.path, '.')::uuid[])
|
||||
WHERE target.id = $1 AND p.type = 'client'
|
||||
LIMIT 1`, projectID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("auto-name: resolve client name: %w", err)
|
||||
}
|
||||
return title, nil
|
||||
}
|
||||
|
||||
// existingDraftNames returns the names already in use for the
|
||||
// (project, submission_code, user) slot. A nil projectID scopes to the
|
||||
// user's project-less drafts for this submission_code — matching the
|
||||
// DB unique contract (project_id, submission_code, user_id, name) where
|
||||
// project_id IS NULL is its own equivalence class.
|
||||
func (s *SubmissionDraftService) existingDraftNames(ctx context.Context, projectID *uuid.UUID, submissionCode string, userID uuid.UUID) ([]string, error) {
|
||||
var names []string
|
||||
var err error
|
||||
if projectID == nil {
|
||||
@@ -446,16 +636,55 @@ func (s *SubmissionDraftService) nextDraftName(ctx context.Context, projectID *u
|
||||
*projectID, submissionCode, userID)
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("scan existing draft names: %w", err)
|
||||
return nil, fmt.Errorf("scan existing draft names: %w", err)
|
||||
}
|
||||
return names, 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. Pure over the supplied name list.
|
||||
func nextDraftName(existing []string, lang string) string {
|
||||
prefix := draftWord(lang)
|
||||
highest := 0
|
||||
for _, n := range names {
|
||||
for _, n := range existing {
|
||||
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
|
||||
return fmt.Sprintf("%s %d", prefix, highest+1)
|
||||
}
|
||||
|
||||
// draftWord is the localized noun for an unnamed draft: "Draft" for English,
|
||||
// "Entwurf" otherwise. Shared by nextDraftName (the legacy counter) and the
|
||||
// non-project date-first fallback (Slice 2).
|
||||
func draftWord(lang string) string {
|
||||
if strings.EqualFold(lang, "en") {
|
||||
return "Draft"
|
||||
}
|
||||
return "Entwurf"
|
||||
}
|
||||
|
||||
// uniqueDraftName returns base unchanged when it's free, otherwise
|
||||
// appends " (N)" with the lowest N≥2 that isn't taken. Mirrors the
|
||||
// "race → unique constraint is the final guard" contract of
|
||||
// nextDraftName; pure over the supplied name list.
|
||||
func uniqueDraftName(base string, existing []string) string {
|
||||
taken := make(map[string]struct{}, len(existing))
|
||||
for _, n := range existing {
|
||||
taken[n] = struct{}{}
|
||||
}
|
||||
if _, clash := taken[base]; !clash {
|
||||
return base
|
||||
}
|
||||
for i := 2; ; i++ {
|
||||
cand := fmt.Sprintf("%s (%d)", base, i)
|
||||
if _, clash := taken[cand]; !clash {
|
||||
return cand
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update patches the draft. Variables is replace-semantics — pass the
|
||||
@@ -567,6 +796,40 @@ func (s *SubmissionDraftService) Update(ctx context.Context, userID, draftID uui
|
||||
idx++
|
||||
}
|
||||
|
||||
if patch.TemplateVersionID != nil {
|
||||
newTV := *patch.TemplateVersionID // *uuid.UUID — nil means clear
|
||||
// Existence is enforced by the FK + validated at the handler via
|
||||
// TemplateStore.GetVersion (clean 404); here we just set it.
|
||||
setParts = append(setParts, fmt.Sprintf("template_version_id = $%d", idx))
|
||||
args = append(args, newTV)
|
||||
idx++
|
||||
}
|
||||
|
||||
if patch.FilenameKeyword != nil {
|
||||
// Per-document value override, now under the general
|
||||
// composer_meta.name_overrides.{var:value} shape (Slice 3, PRD §3.1);
|
||||
// the keyword lives at name_overrides.keyword. Targeted jsonb edits so
|
||||
// other composer_meta keys survive. An empty override removes the key,
|
||||
// restoring the auto-derived rule name. Legacy composer_meta.
|
||||
// filename_keyword rows are still honoured on read (back-compat).
|
||||
kw := strings.TrimSpace(*patch.FilenameKeyword)
|
||||
if kw == "" {
|
||||
// Drop both the new and the legacy key so a clear always clears.
|
||||
setParts = append(setParts,
|
||||
"composer_meta = (coalesce(composer_meta, '{}'::jsonb) #- '{name_overrides,keyword}') - 'filename_keyword'")
|
||||
} else {
|
||||
// jsonb_set won't create a missing parent, so ensure
|
||||
// name_overrides exists (preserving any sibling overrides) before
|
||||
// setting the keyword leaf.
|
||||
setParts = append(setParts,
|
||||
fmt.Sprintf("composer_meta = jsonb_set("+
|
||||
"jsonb_set(coalesce(composer_meta, '{}'::jsonb), '{name_overrides}', coalesce(composer_meta->'name_overrides', '{}'::jsonb), true), "+
|
||||
"'{name_overrides,keyword}', to_jsonb($%d::text), true)", idx))
|
||||
args = append(args, kw)
|
||||
idx++
|
||||
}
|
||||
}
|
||||
|
||||
if len(setParts) == 0 {
|
||||
return existing, nil
|
||||
}
|
||||
@@ -878,7 +1141,6 @@ func normalizeDraftLanguage(lang string) string {
|
||||
return "de"
|
||||
}
|
||||
|
||||
|
||||
// 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 —
|
||||
|
||||
184
internal/services/submission_draft_template_live_test.go
Normal file
184
internal/services/submission_draft_template_live_test.go
Normal file
@@ -0,0 +1,184 @@
|
||||
package services
|
||||
|
||||
// Live-DB test for generation-on-uploaded-templates (t-paliad-349 slice 7).
|
||||
// Skipped without TEST_DATABASE_URL. Verifies the shipped draft-service
|
||||
// change end-to-end against real Postgres:
|
||||
// 1. submission_drafts.template_version_id round-trips through
|
||||
// Update → Get (the column-sync + patch path), and clears to NULL.
|
||||
// 2. An uploaded template's carrier renders via the v1 Export path:
|
||||
// {{firm.name}} in the carrier substitutes to the branding name.
|
||||
//
|
||||
// This is the verification the head greenlit (option C) before the
|
||||
// shipped-code change is committed.
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/lib/pq"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/db"
|
||||
"mgit.msbls.de/m/paliad/pkg/docforge"
|
||||
)
|
||||
|
||||
func TestSubmissionDraft_TemplateVersionPin(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()
|
||||
email := "tplpin-" + userID.String()[:8] + "@hlc.com"
|
||||
cleanup := func() {
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.submission_drafts WHERE user_id = $1`, userID)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.templates WHERE created_by = $1`, userID)
|
||||
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, $2)`, userID, email); 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, $2, 'Tpl Pin', 'munich', 'standard', 'de')`, userID, email); err != nil {
|
||||
t.Fatalf("seed paliad.users: %v", err)
|
||||
}
|
||||
|
||||
users := NewUserService(pool)
|
||||
projects := NewProjectService(pool, users)
|
||||
parties := NewPartyService(pool, projects)
|
||||
vars := NewSubmissionVarsService(pool, projects, parties, users)
|
||||
renderer := NewSubmissionRenderer()
|
||||
drafts := NewSubmissionDraftService(pool, projects, vars, renderer)
|
||||
store := NewPgTemplateStore(pool)
|
||||
|
||||
// Uploaded template whose carrier carries a {{firm.name}} slot.
|
||||
carrier := minimalDocxWithBody(t, `<w:p><w:r><w:t>Von {{firm.name}}</w:t></w:r></w:p>`)
|
||||
tmpl, err := store.Create(ctx,
|
||||
docforge.TemplateMetaInput{NameDE: "Pin-Test", NameEN: "Pin test", CreatedBy: userID.String()},
|
||||
docforge.TemplateVersionInput{CarrierBytes: carrier, CreatedBy: userID.String()})
|
||||
if err != nil {
|
||||
t.Fatalf("store.Create: %v", err)
|
||||
}
|
||||
if tmpl.VersionID == "" {
|
||||
t.Fatalf("template VersionID empty — generation can't pin it")
|
||||
}
|
||||
versionID := uuid.MustParse(tmpl.VersionID)
|
||||
|
||||
// Project-less draft on a code that has a published rule (so Build
|
||||
// resolves). No composer attached → plain draft.
|
||||
d, err := drafts.Create(ctx, userID, nil, "de.inf.lg.erwidg", "de")
|
||||
if err != nil {
|
||||
t.Fatalf("drafts.Create: %v", err)
|
||||
}
|
||||
if d.TemplateVersionID != nil {
|
||||
t.Errorf("fresh draft has a template pin: %v", d.TemplateVersionID)
|
||||
}
|
||||
|
||||
// --- Pin the version via Update, read it back via Get.
|
||||
pin := &versionID
|
||||
if _, err := drafts.Update(ctx, userID, d.ID, DraftPatch{TemplateVersionID: &pin}); err != nil {
|
||||
t.Fatalf("Update(pin): %v", err)
|
||||
}
|
||||
got, err := drafts.Get(ctx, userID, d.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Get after pin: %v", err)
|
||||
}
|
||||
if got.TemplateVersionID == nil || *got.TemplateVersionID != versionID {
|
||||
t.Fatalf("pinned template_version_id = %v; want %s", got.TemplateVersionID, versionID)
|
||||
}
|
||||
|
||||
// --- The uploaded carrier renders via Export: {{firm.name}} → "HLC".
|
||||
out, _, err := drafts.Export(ctx, got, carrier)
|
||||
if err != nil {
|
||||
t.Fatalf("Export: %v", err)
|
||||
}
|
||||
doc := unzipDocumentXML(t, out)
|
||||
if strings.Contains(doc, "{{firm.name}}") {
|
||||
t.Errorf("placeholder not substituted; doc=%s", doc)
|
||||
}
|
||||
if !strings.Contains(doc, "HLC") {
|
||||
t.Errorf("firm.name did not resolve to HLC; doc=%s", doc)
|
||||
}
|
||||
|
||||
// --- Clearing the pin sets it back to NULL.
|
||||
var nilPin *uuid.UUID
|
||||
if _, err := drafts.Update(ctx, userID, d.ID, DraftPatch{TemplateVersionID: &nilPin}); err != nil {
|
||||
t.Fatalf("Update(clear): %v", err)
|
||||
}
|
||||
cleared, err := drafts.Get(ctx, userID, d.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Get after clear: %v", err)
|
||||
}
|
||||
if cleared.TemplateVersionID != nil {
|
||||
t.Errorf("template_version_id = %v after clear; want nil", cleared.TemplateVersionID)
|
||||
}
|
||||
}
|
||||
|
||||
// minimalDocxWithBody builds a tiny valid .docx (zip) whose document.xml
|
||||
// body is the given inner XML.
|
||||
func minimalDocxWithBody(t *testing.T, inner string) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
add := func(name, body string) {
|
||||
w, err := zw.Create(name)
|
||||
if err != nil {
|
||||
t.Fatalf("zip create %s: %v", name, err)
|
||||
}
|
||||
if _, err := io.WriteString(w, body); err != nil {
|
||||
t.Fatalf("zip write %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
add("[Content_Types].xml",
|
||||
`<?xml version="1.0"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">`+
|
||||
`<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>`)
|
||||
add("word/document.xml",
|
||||
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`+
|
||||
`<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">`+
|
||||
`<w:body>`+inner+`</w:body></w:document>`)
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatalf("zip close: %v", err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func unzipDocumentXML(t *testing.T, b []byte) string {
|
||||
t.Helper()
|
||||
zr, err := zip.NewReader(bytes.NewReader(b), int64(len(b)))
|
||||
if err != nil {
|
||||
t.Fatalf("open 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()
|
||||
data, _ := io.ReadAll(rc)
|
||||
return string(data)
|
||||
}
|
||||
t.Fatal("document.xml not found in output")
|
||||
return ""
|
||||
}
|
||||
@@ -1,486 +0,0 @@
|
||||
package services
|
||||
|
||||
// Markdown → OOXML walker for Composer section content (t-paliad-313
|
||||
// Slice B, design doc §9.2).
|
||||
//
|
||||
// Scope per the head's Slice B brief: paragraphs + inline bold/italic
|
||||
// only. Headings, lists, blockquote, links land in Slice D's rich-prose
|
||||
// pass. This walker is intentionally minimal — every Markdown construct
|
||||
// it doesn't recognise is rendered as a plain paragraph so the lawyer's
|
||||
// prose round-trips losslessly even when they hit Markdown the walker
|
||||
// doesn't yet understand.
|
||||
//
|
||||
// The output uses the base's stylemap.paragraph entry for the
|
||||
// <w:pStyle> on each paragraph so the styling matches the base's
|
||||
// typography (HLpat-Body-B0 on the HLC base, Normal on the neutral
|
||||
// base, etc.).
|
||||
//
|
||||
// Placeholders ({{path.dot.notation}}) are preserved verbatim — they
|
||||
// pass through the walker untouched and get substituted by the v1
|
||||
// SubmissionRenderer's placeholder pass after the composer assembly.
|
||||
//
|
||||
// Grammar supported:
|
||||
//
|
||||
// - Blank line → paragraph break
|
||||
// - `**bold**` → <w:r><w:rPr><w:b/></w:rPr><w:t>…</w:t></w:r>
|
||||
// - `*italic*` or `_italic_` → <w:r><w:rPr><w:i/></w:rPr>…</w:r>
|
||||
// - Otherwise → plain text run
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// HyperlinkAllocator hands the walker a `rId` for each external URL
|
||||
// it encounters in `[label](url)` inline links. The composer's
|
||||
// post-pass uses these allocations to mutate
|
||||
// `word/_rels/document.xml.rels` so the emitted `<w:hyperlink
|
||||
// r:id="…">` elements resolve correctly. Pass nil to drop links to
|
||||
// plain text (the label survives, the URL doesn't render).
|
||||
//
|
||||
// t-paliad-316 Slice D.
|
||||
type HyperlinkAllocator func(url string) string
|
||||
|
||||
// RenderMarkdownToOOXML renders the given Markdown source into OOXML
|
||||
// paragraph elements (`<w:p>…</w:p>`), suitable for splicing into a
|
||||
// .docx body. Each paragraph carries `<w:pStyle w:val="<paragraphStyle>"/>`
|
||||
// when paragraphStyle is non-empty.
|
||||
//
|
||||
// Slice B shipped paragraphs + bold/italic. Slice D extends to
|
||||
// headings (h1/h2/h3), bullet/numbered lists, blockquote, and inline
|
||||
// hyperlinks via the optional HyperlinkAllocator.
|
||||
//
|
||||
// stylemap supplies the paragraph-style names for each kind:
|
||||
// stylemap["paragraph"] — default body
|
||||
// stylemap["heading_1/2/3"] — heading levels
|
||||
// stylemap["list_bullet"] — bullet list paragraph style
|
||||
// stylemap["list_numbered"] — numbered list paragraph style
|
||||
// stylemap["blockquote"] — blockquote
|
||||
// Missing entries fall back to the "paragraph" style.
|
||||
//
|
||||
// Empty input renders one empty paragraph so the splice site is
|
||||
// well-formed even when the lawyer hasn't typed anything in this
|
||||
// section.
|
||||
func RenderMarkdownToOOXML(md, paragraphStyle string) string {
|
||||
return RenderMarkdownToOOXMLWithStyles(md, map[string]string{"paragraph": paragraphStyle}, nil)
|
||||
}
|
||||
|
||||
// RenderMarkdownToOOXMLWithStyles is the full Slice-D-aware entry
|
||||
// point. Slice B's RenderMarkdownToOOXML is a wrapper for back-compat.
|
||||
func RenderMarkdownToOOXMLWithStyles(md string, stylemap map[string]string, links HyperlinkAllocator) string {
|
||||
defaultStyle := stylemap["paragraph"]
|
||||
if md == "" {
|
||||
return emptyParagraph(defaultStyle)
|
||||
}
|
||||
blocks := splitMarkdownBlocks(md)
|
||||
if len(blocks) == 0 {
|
||||
return emptyParagraph(defaultStyle)
|
||||
}
|
||||
// Numbered-list counter resets on every non-numbered block so
|
||||
// "1. A\n2. B\n\n1. C" renders as 1./2./1. (the lawyer's input
|
||||
// determined the ordinal, the walker just renders).
|
||||
numberedCounter := 0
|
||||
var b strings.Builder
|
||||
for _, blk := range blocks {
|
||||
style := stylemap[blk.styleKey]
|
||||
if style == "" {
|
||||
style = defaultStyle
|
||||
}
|
||||
if blk.styleKey == "list_numbered" {
|
||||
numberedCounter++
|
||||
} else {
|
||||
numberedCounter = 0
|
||||
}
|
||||
b.WriteString(renderBlockParagraph(blk, style, links, numberedCounter))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// mdBlock is one rendered paragraph: a kind (paragraph / heading_*
|
||||
// / list_bullet / list_numbered / blockquote) and the inline content
|
||||
// text. List markers, heading hashes, blockquote `> ` etc. are
|
||||
// stripped from the text before storage.
|
||||
type mdBlock struct {
|
||||
styleKey string // "paragraph" | "heading_1" | "heading_2" | "heading_3" | "list_bullet" | "list_numbered" | "blockquote"
|
||||
text string
|
||||
}
|
||||
|
||||
// splitMarkdownBlocks parses the source into a sequence of blocks,
|
||||
// detecting heading / list / blockquote prefixes line-by-line. Blank
|
||||
// lines split paragraph runs (same semantics as splitMarkdownParagraphs)
|
||||
// but each line is also tagged with its block kind.
|
||||
//
|
||||
// Lines that look like block markers don't merge with their neighbours
|
||||
// even across blank lines — every list / heading / blockquote line is
|
||||
// its own block in the output. A run of unmarked lines collapses into
|
||||
// one "paragraph" block (so soft line breaks inside a paragraph still
|
||||
// concatenate).
|
||||
//
|
||||
// CRLF normalised to LF before parsing.
|
||||
func splitMarkdownBlocks(md string) []mdBlock {
|
||||
normalised := strings.ReplaceAll(md, "\r\n", "\n")
|
||||
lines := strings.Split(normalised, "\n")
|
||||
var blocks []mdBlock
|
||||
var pendingPara []string
|
||||
blankRun := 0
|
||||
|
||||
flushPara := func() {
|
||||
if len(pendingPara) > 0 {
|
||||
blocks = append(blocks, mdBlock{styleKey: "paragraph", text: strings.Join(pendingPara, "\n")})
|
||||
pendingPara = nil
|
||||
}
|
||||
}
|
||||
|
||||
for _, raw := range lines {
|
||||
line := raw
|
||||
if strings.TrimSpace(line) == "" {
|
||||
if len(pendingPara) > 0 {
|
||||
flushPara()
|
||||
blankRun = 1
|
||||
continue
|
||||
}
|
||||
blankRun++
|
||||
continue
|
||||
}
|
||||
// Detect heading / list / blockquote markers BEFORE we accumulate
|
||||
// into the paragraph buffer.
|
||||
kind, payload, ok := detectBlockMarker(line)
|
||||
if ok {
|
||||
flushPara()
|
||||
// Emit spacing paragraphs equivalent to (blankRun - 1) extra.
|
||||
for i := 1; i < blankRun; i++ {
|
||||
blocks = append(blocks, mdBlock{styleKey: "paragraph", text: ""})
|
||||
}
|
||||
blankRun = 0
|
||||
blocks = append(blocks, mdBlock{styleKey: kind, text: payload})
|
||||
continue
|
||||
}
|
||||
// Plain paragraph line.
|
||||
if len(pendingPara) == 0 {
|
||||
// Starting a new paragraph after a blank run — emit
|
||||
// (blankRun-1) extra empty paragraphs for vertical spacing.
|
||||
for i := 1; i < blankRun; i++ {
|
||||
blocks = append(blocks, mdBlock{styleKey: "paragraph", text: ""})
|
||||
}
|
||||
}
|
||||
blankRun = 0
|
||||
pendingPara = append(pendingPara, line)
|
||||
}
|
||||
flushPara()
|
||||
return blocks
|
||||
}
|
||||
|
||||
// detectBlockMarker classifies a single line. Returns (styleKey,
|
||||
// payload-with-marker-stripped, true) for recognised markers; false
|
||||
// for plain paragraph lines.
|
||||
//
|
||||
// Recognised markers (Slice D):
|
||||
// # Heading → heading_1
|
||||
// ## Heading → heading_2
|
||||
// ### Heading → heading_3
|
||||
// - item / * item → list_bullet
|
||||
// 1. item / 2. item ... → list_numbered (any positive integer)
|
||||
// > quote → blockquote
|
||||
//
|
||||
// Leading whitespace inside the line is tolerated up to 3 spaces (per
|
||||
// CommonMark) so the lawyer's contentEditable indentation doesn't
|
||||
// hide the marker.
|
||||
func detectBlockMarker(line string) (string, string, bool) {
|
||||
trimmed := strings.TrimLeft(line, " ")
|
||||
// Cap to 3 spaces of leading indent — beyond that, treat as a
|
||||
// regular paragraph line (matches CommonMark).
|
||||
if len(line)-len(trimmed) > 3 {
|
||||
return "", "", false
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "### ") {
|
||||
return "heading_3", strings.TrimSpace(trimmed[4:]), true
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "## ") {
|
||||
return "heading_2", strings.TrimSpace(trimmed[3:]), true
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "# ") {
|
||||
return "heading_1", strings.TrimSpace(trimmed[2:]), true
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "> ") {
|
||||
return "blockquote", strings.TrimSpace(trimmed[2:]), true
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "- ") || strings.HasPrefix(trimmed, "* ") {
|
||||
return "list_bullet", strings.TrimSpace(trimmed[2:]), true
|
||||
}
|
||||
// Numbered: "N. " where N is one or more digits.
|
||||
if i := indexOfNumberedMarker(trimmed); i > 0 {
|
||||
return "list_numbered", strings.TrimSpace(trimmed[i:]), true
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
// indexOfNumberedMarker checks for "N. " or "N) " at the start of the
|
||||
// trimmed line; returns the byte index just past the marker, or -1 if
|
||||
// no marker present.
|
||||
func indexOfNumberedMarker(s string) int {
|
||||
i := 0
|
||||
for i < len(s) && s[i] >= '0' && s[i] <= '9' {
|
||||
i++
|
||||
}
|
||||
if i == 0 {
|
||||
return -1
|
||||
}
|
||||
if i >= len(s) {
|
||||
return -1
|
||||
}
|
||||
if s[i] != '.' && s[i] != ')' {
|
||||
return -1
|
||||
}
|
||||
if i+1 >= len(s) || s[i+1] != ' ' {
|
||||
return -1
|
||||
}
|
||||
return i + 2
|
||||
}
|
||||
|
||||
// renderBlockParagraph emits one `<w:p>` for a block. List blocks
|
||||
// keep the same paragraph style as a default paragraph (the Slice D
|
||||
// design's contract — list styles come from the base's stylemap and
|
||||
// Word's numbering.xml is honoured by adding a leading bullet/number
|
||||
// prefix in the rendered text). This keeps the composer free of
|
||||
// numbering.xml mutations.
|
||||
func renderBlockParagraph(blk mdBlock, paragraphStyle string, links HyperlinkAllocator, numberedOrdinal int) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(`<w:p>`)
|
||||
if paragraphStyle != "" {
|
||||
b.WriteString(`<w:pPr><w:pStyle w:val="`)
|
||||
b.WriteString(xmlAttrEscape(paragraphStyle))
|
||||
b.WriteString(`"/></w:pPr>`)
|
||||
}
|
||||
if blk.text == "" {
|
||||
b.WriteString(`<w:r><w:t xml:space="preserve"></w:t></w:r>`)
|
||||
b.WriteString(`</w:p>`)
|
||||
return b.String()
|
||||
}
|
||||
text := blk.text
|
||||
// List blocks emit a visible "• " / "N. " prefix run. The
|
||||
// stylemap entry handles paragraph indentation if the base
|
||||
// defines a list paragraph style; otherwise the prefix at least
|
||||
// surfaces the structure in plain Word. Lawyers who want Word's
|
||||
// auto-numbering reapply a list style post-export.
|
||||
switch blk.styleKey {
|
||||
case "list_bullet":
|
||||
b.WriteString(`<w:r><w:t xml:space="preserve">• </w:t></w:r>`)
|
||||
case "list_numbered":
|
||||
ordinal := numberedOrdinal
|
||||
if ordinal <= 0 {
|
||||
ordinal = 1
|
||||
}
|
||||
b.WriteString(`<w:r><w:t xml:space="preserve">`)
|
||||
b.WriteString(fmt.Sprintf("%d. ", ordinal))
|
||||
b.WriteString(`</w:t></w:r>`)
|
||||
}
|
||||
for _, run := range parseInlineRuns(text, links) {
|
||||
b.WriteString(run)
|
||||
}
|
||||
b.WriteString(`</w:p>`)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// parseInlineRuns extracts inline spans + hyperlink runs and serialises
|
||||
// each to OOXML. Hyperlinks become `<w:hyperlink r:id="RID">…runs…</w:hyperlink>`
|
||||
// where RID comes from the HyperlinkAllocator.
|
||||
func parseInlineRuns(text string, links HyperlinkAllocator) []string {
|
||||
// Phase 1: find all hyperlink spans `[label](url)` and split the
|
||||
// text around them.
|
||||
type segment struct {
|
||||
text string
|
||||
isLink bool
|
||||
url string
|
||||
}
|
||||
var segs []segment
|
||||
rest := text
|
||||
for {
|
||||
idx := strings.Index(rest, "[")
|
||||
if idx < 0 {
|
||||
if rest != "" {
|
||||
segs = append(segs, segment{text: rest})
|
||||
}
|
||||
break
|
||||
}
|
||||
// Find matching closing bracket, then a "(" right after.
|
||||
closeBracket := strings.Index(rest[idx:], "](")
|
||||
if closeBracket < 0 {
|
||||
segs = append(segs, segment{text: rest})
|
||||
break
|
||||
}
|
||||
closeParen := strings.Index(rest[idx+closeBracket:], ")")
|
||||
if closeParen < 0 {
|
||||
segs = append(segs, segment{text: rest})
|
||||
break
|
||||
}
|
||||
// idx = start of "["
|
||||
// idx+closeBracket = position of "]"
|
||||
// idx+closeBracket+1 = position of "("
|
||||
// idx+closeBracket+closeParen = position of ")"
|
||||
label := rest[idx+1 : idx+closeBracket]
|
||||
url := rest[idx+closeBracket+2 : idx+closeBracket+closeParen]
|
||||
if idx > 0 {
|
||||
segs = append(segs, segment{text: rest[:idx]})
|
||||
}
|
||||
segs = append(segs, segment{text: label, isLink: true, url: url})
|
||||
rest = rest[idx+closeBracket+closeParen+1:]
|
||||
}
|
||||
|
||||
var runs []string
|
||||
for _, seg := range segs {
|
||||
if seg.isLink && links != nil {
|
||||
rid := links(seg.url)
|
||||
if rid != "" {
|
||||
var hb strings.Builder
|
||||
hb.WriteString(`<w:hyperlink r:id="`)
|
||||
hb.WriteString(xmlAttrEscape(rid))
|
||||
hb.WriteString(`">`)
|
||||
for _, span := range parseInlineSpans(seg.text) {
|
||||
hb.WriteString(renderRunWithLinkStyle(span))
|
||||
}
|
||||
hb.WriteString(`</w:hyperlink>`)
|
||||
runs = append(runs, hb.String())
|
||||
continue
|
||||
}
|
||||
}
|
||||
for _, span := range parseInlineSpans(seg.text) {
|
||||
runs = append(runs, renderRun(span))
|
||||
}
|
||||
}
|
||||
return runs
|
||||
}
|
||||
|
||||
// renderRunWithLinkStyle emits a hyperlink child run. Same B/I support
|
||||
// as renderRun, but additionally tags the run with the "Hyperlink"
|
||||
// character style (Word's built-in) so the link renders in the
|
||||
// document's hyperlink colour + underline.
|
||||
func renderRunWithLinkStyle(span inlineSpan) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(`<w:r><w:rPr><w:rStyle w:val="Hyperlink"/>`)
|
||||
if span.Bold {
|
||||
b.WriteString(`<w:b/>`)
|
||||
}
|
||||
if span.Italic {
|
||||
b.WriteString(`<w:i/>`)
|
||||
}
|
||||
b.WriteString(`</w:rPr><w:t xml:space="preserve">`)
|
||||
b.WriteString(xmlTextEscape(span.Text))
|
||||
b.WriteString(`</w:t></w:r>`)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// inlineSpan is one piece of inline content: a text payload plus
|
||||
// formatting flags. Bold and italic are independent — `***both***`
|
||||
// produces one span with both flags set.
|
||||
type inlineSpan struct {
|
||||
Text string
|
||||
Bold bool
|
||||
Italic bool
|
||||
}
|
||||
|
||||
// parseInlineSpans tokenises Markdown inline formatting into runs of
|
||||
// (text, bold, italic). The grammar is intentionally narrow:
|
||||
//
|
||||
// - `**…**` → bold
|
||||
// - `__…__` → bold (Markdown alternate)
|
||||
// - `*…*` → italic
|
||||
// - `_…_` → italic (Markdown alternate)
|
||||
// - Anything else flows through as plain text.
|
||||
//
|
||||
// Unbalanced delimiters fall through as literal characters — the
|
||||
// walker never errors on malformed Markdown. Nested formatting (e.g.
|
||||
// `**bold *bold-italic* bold**`) toggles flags as it walks.
|
||||
func parseInlineSpans(text string) []inlineSpan {
|
||||
var out []inlineSpan
|
||||
var cur strings.Builder
|
||||
bold := false
|
||||
italic := false
|
||||
flush := func() {
|
||||
if cur.Len() == 0 {
|
||||
return
|
||||
}
|
||||
out = append(out, inlineSpan{Text: cur.String(), Bold: bold, Italic: italic})
|
||||
cur.Reset()
|
||||
}
|
||||
i := 0
|
||||
n := len(text)
|
||||
for i < n {
|
||||
// Bold delimiters first (longer match wins over italic).
|
||||
if i+1 < n && (text[i:i+2] == "**" || text[i:i+2] == "__") {
|
||||
flush()
|
||||
bold = !bold
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
if text[i] == '*' || text[i] == '_' {
|
||||
flush()
|
||||
italic = !italic
|
||||
i++
|
||||
continue
|
||||
}
|
||||
cur.WriteByte(text[i])
|
||||
i++
|
||||
}
|
||||
flush()
|
||||
if len(out) == 0 {
|
||||
out = append(out, inlineSpan{Text: ""})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// renderRun emits one `<w:r>` element for an inline span. Empty text
|
||||
// spans render as empty runs (Word accepts them; they're harmless).
|
||||
func renderRun(span inlineSpan) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(`<w:r>`)
|
||||
if span.Bold || span.Italic {
|
||||
b.WriteString(`<w:rPr>`)
|
||||
if span.Bold {
|
||||
b.WriteString(`<w:b/>`)
|
||||
}
|
||||
if span.Italic {
|
||||
b.WriteString(`<w:i/>`)
|
||||
}
|
||||
b.WriteString(`</w:rPr>`)
|
||||
}
|
||||
b.WriteString(`<w:t xml:space="preserve">`)
|
||||
b.WriteString(xmlTextEscape(span.Text))
|
||||
b.WriteString(`</w:t></w:r>`)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// emptyParagraph returns one empty `<w:p>` with the given style. Used
|
||||
// when a section's content_md is empty so the splice site stays
|
||||
// well-formed.
|
||||
func emptyParagraph(paragraphStyle string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(`<w:p>`)
|
||||
if paragraphStyle != "" {
|
||||
b.WriteString(`<w:pPr><w:pStyle w:val="`)
|
||||
b.WriteString(xmlAttrEscape(paragraphStyle))
|
||||
b.WriteString(`"/></w:pPr>`)
|
||||
}
|
||||
b.WriteString(`<w:r><w:t xml:space="preserve"></w:t></w:r></w:p>`)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// xmlTextEscape escapes the five XML-significant characters for safe
|
||||
// insertion into <w:t> content. & first to avoid double-encoding.
|
||||
func xmlTextEscape(s string) string {
|
||||
s = strings.ReplaceAll(s, "&", "&")
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
s = strings.ReplaceAll(s, ">", ">")
|
||||
// Quotes and apostrophes are legal inside element text content;
|
||||
// no need to escape them here.
|
||||
return s
|
||||
}
|
||||
|
||||
// xmlAttrEscape escapes for safe insertion into an attribute value
|
||||
// (e.g. `<w:pStyle w:val="…"/>`).
|
||||
func xmlAttrEscape(s string) string {
|
||||
s = strings.ReplaceAll(s, "&", "&")
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
s = strings.ReplaceAll(s, ">", ">")
|
||||
s = strings.ReplaceAll(s, `"`, """)
|
||||
return s
|
||||
}
|
||||
@@ -47,6 +47,7 @@ import (
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/branding"
|
||||
"mgit.msbls.de/m/paliad/internal/models"
|
||||
"mgit.msbls.de/m/paliad/pkg/docforge"
|
||||
)
|
||||
|
||||
// SubmissionVarsService assembles the placeholder map.
|
||||
@@ -151,17 +152,20 @@ func (s *SubmissionVarsService) Build(ctx context.Context, in SubmissionVarsCont
|
||||
if lang == "" {
|
||||
lang = "de"
|
||||
}
|
||||
bag := PlaceholderMap{}
|
||||
addFirmVars(bag)
|
||||
addTodayVars(bag, time.Now())
|
||||
addUserVars(bag, user)
|
||||
addRuleVars(bag, rule, lang)
|
||||
// firm / today / user / procedural_event apply to every render,
|
||||
// project-bound or not. Each resolver wraps the matching addXxxVars
|
||||
// builder (unchanged); ResolverSet.BuildBag runs them into one bag.
|
||||
resolvers := []docforge.VariableResolver{
|
||||
firmResolver{},
|
||||
todayResolver{now: time.Now()},
|
||||
userResolver{user: user},
|
||||
proceduralEventResolver{rule: rule, lang: lang},
|
||||
}
|
||||
|
||||
out := &SubmissionVarsResult{
|
||||
Placeholders: bag,
|
||||
User: user,
|
||||
Rule: rule,
|
||||
Lang: lang,
|
||||
User: user,
|
||||
Rule: rule,
|
||||
Lang: lang,
|
||||
}
|
||||
|
||||
if in.ProjectID == nil {
|
||||
@@ -169,6 +173,7 @@ func (s *SubmissionVarsService) Build(ctx context.Context, in SubmissionVarsCont
|
||||
// deadline state to resolve. The lawyer's overrides will fill
|
||||
// the placeholder map; missing keys render as
|
||||
// [KEIN WERT: …] / [NO VALUE: …] in the preview.
|
||||
out.Placeholders = docforge.NewResolverSet(resolvers...).BuildBag()
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -195,14 +200,18 @@ func (s *SubmissionVarsService) Build(ctx context.Context, in SubmissionVarsCont
|
||||
return nil, err
|
||||
}
|
||||
|
||||
addProjectVars(bag, project, pt, lang)
|
||||
addPartyVars(bag, filterPartiesBySelection(parties, in.SelectedParties))
|
||||
addDeadlineVars(bag, next, project, lang)
|
||||
resolvers = append(resolvers,
|
||||
projectResolver{project: project, pt: pt, lang: lang},
|
||||
captionResolver{project: project, pt: pt, lang: lang},
|
||||
partiesResolver{parties: filterPartiesBySelection(parties, in.SelectedParties)},
|
||||
deadlineResolver{deadline: next, project: project, lang: lang},
|
||||
)
|
||||
|
||||
out.Project = project
|
||||
out.ProceedingType = pt
|
||||
out.Parties = parties
|
||||
out.NextDeadline = next
|
||||
out.Placeholders = docforge.NewResolverSet(resolvers...).BuildBag()
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -310,10 +319,19 @@ func (s *SubmissionVarsService) nextOpenDeadline(ctx context.Context, projectID,
|
||||
// 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"] = ""
|
||||
// firm.signature_block is the firm identity line of a submission's
|
||||
// signature block — the signature section seeds with
|
||||
// {{firm.signature_block}} + {{user.display_name}} (the lawyer's name),
|
||||
// so this carries the firm, not the person. It is firm-agnostic:
|
||||
// derived from branding.Name so a FIRM_NAME redeploy or non-HLC
|
||||
// deployment signs with the right firm (t-paliad-358 A-S1). It used to
|
||||
// emit "" ("reserved for Phase 2"), which left every template that
|
||||
// referenced it blank. A richer block (postal/contact address,
|
||||
// professional designation such as "Rechtsanwälte/Patentanwälte") needs
|
||||
// per-firm config paliad does not capture yet — deferred to the
|
||||
// structured-data work (Option B); we do not guess legally-flavoured
|
||||
// designations here.
|
||||
bag["firm.signature_block"] = branding.Name
|
||||
}
|
||||
|
||||
// addTodayVars populates today.* in both DE and EN long forms. ISO
|
||||
@@ -360,6 +378,7 @@ func addProjectVars(bag PlaceholderMap, p *models.Project, pt *models.Proceeding
|
||||
bag["project.matter_number"] = derefString(p.MatterNumber)
|
||||
if pt != nil {
|
||||
bag["project.proceeding.code"] = pt.Code
|
||||
bag["project.proceeding.jurisdiction"] = derefString(pt.Jurisdiction)
|
||||
if strings.EqualFold(lang, "en") {
|
||||
bag["project.proceeding.name"] = pt.NameEN
|
||||
} else {
|
||||
@@ -370,6 +389,160 @@ func addProjectVars(bag PlaceholderMap, p *models.Project, pt *models.Proceeding
|
||||
}
|
||||
}
|
||||
|
||||
// addCaptionVars populates the caption.* namespace — the parametric pieces of
|
||||
// the case caption (Rubrum) shared by every render path (the merge fallback
|
||||
// skeleton, the per-code .docx templates, and the Composer caption seeds) so
|
||||
// the wording stays unified rather than diverging per path (t-paliad-358 A-S2).
|
||||
//
|
||||
// Each piece is offered in three forms, mirroring the project.proceeding.name
|
||||
// convention: a bare key resolved to the draft language, plus explicit _de /
|
||||
// _en variants (the bilingual .docx/seed surfaces reference the explicit
|
||||
// variant for the language they are written in).
|
||||
//
|
||||
// Parametrisation is driven by data the bag already has — no new schema:
|
||||
// - designations (claimant/defendant) reuse the proceeding-type role-label
|
||||
// overrides (Berufungskläger, Antragsteller (Nichtigkeit), Einsprechende(r),
|
||||
// …; mig 137). Where a proceeding carries no override the caption falls back
|
||||
// to the civil default Klägerin/Beklagte // Claimant/Defendant. This means
|
||||
// DE appeal/nullity/cassation forums that lack role-label data today
|
||||
// (de.inf.olg, de.inf.bgh, de.null.bpatg, de.null.bgh) render the generic
|
||||
// designation — flagged for a lexy review + role-label backfill, NOT
|
||||
// guessed here.
|
||||
// - heading / subject are computed from the proceeding jurisdiction + the
|
||||
// "nature" segment of the dotted code (inf / null / rev / opp / …). These
|
||||
// are practitioner-convention wordings (German caption conventions are not
|
||||
// in the youpc corpus) — flagged for lexy.
|
||||
// - the court line is left as {{project.court}} (free text); forum-specific
|
||||
// framing ("an das Landgericht …, … Kammer/Senat") needs chamber data we
|
||||
// do not capture (Option B).
|
||||
//
|
||||
// our_side is intentionally NOT a driver: the caption designates BOTH parties
|
||||
// by their procedural role regardless of which side we act for; our_side has
|
||||
// its own prose keys (project.our_side_*).
|
||||
func addCaptionVars(bag PlaceholderMap, p *models.Project, pt *models.ProceedingType, lang string) {
|
||||
c := resolveCaption(p, pt)
|
||||
|
||||
set := func(base, de, en string) {
|
||||
bag["caption."+base+"_de"] = de
|
||||
bag["caption."+base+"_en"] = en
|
||||
if strings.EqualFold(lang, "en") {
|
||||
bag["caption."+base] = en
|
||||
} else {
|
||||
bag["caption."+base] = de
|
||||
}
|
||||
}
|
||||
set("heading", c.headingDE, c.headingEN)
|
||||
set("claimant_designation", c.claimantDE, c.claimantEN)
|
||||
set("defendant_designation", c.defendantDE, c.defendantEN)
|
||||
set("versus", c.versusDE, c.versusEN)
|
||||
set("subject", c.subjectDE, c.subjectEN)
|
||||
}
|
||||
|
||||
// captionParts holds the resolved bilingual caption pieces.
|
||||
type captionParts struct {
|
||||
headingDE, headingEN string
|
||||
claimantDE, claimantEN string
|
||||
defendantDE, defendantEN string
|
||||
versusDE, versusEN string
|
||||
subjectDE, subjectEN string
|
||||
}
|
||||
|
||||
// resolveCaption computes the parametric caption pieces from the proceeding
|
||||
// type (jurisdiction + dotted code + role-label overrides). Pure function for
|
||||
// unit testing — no DB, no bag.
|
||||
func resolveCaption(p *models.Project, pt *models.ProceedingType) captionParts {
|
||||
c := captionParts{
|
||||
// Civil defaults — overridden below per forum / role-label data.
|
||||
headingDE: "In der Sache", headingEN: "In the matter",
|
||||
claimantDE: "Klägerin", claimantEN: "Claimant",
|
||||
defendantDE: "Beklagte", defendantEN: "Defendant",
|
||||
versusDE: "gegen", versusEN: "v.",
|
||||
subjectDE: "Patentstreitsache", subjectEN: "patent matter",
|
||||
}
|
||||
|
||||
var jurisdiction, nature string
|
||||
if pt != nil {
|
||||
jurisdiction = strings.ToUpper(derefString(pt.Jurisdiction))
|
||||
nature = captionNature(pt.Code)
|
||||
}
|
||||
|
||||
// Heading + subject by jurisdiction and proceeding nature.
|
||||
switch {
|
||||
case jurisdiction == "UPC":
|
||||
c.headingDE, c.headingEN = "In der Sache", "In the matter"
|
||||
case jurisdiction == "DE" && nature == "null":
|
||||
c.headingDE, c.headingEN = "In der Patentnichtigkeitssache", "In the nullity matter"
|
||||
case jurisdiction == "DE" && nature == "inf":
|
||||
c.headingDE, c.headingEN = "In dem Rechtsstreit", "In the matter"
|
||||
case nature == "opp": // EPA / DPMA opposition
|
||||
c.headingDE, c.headingEN = "Im Einspruchsverfahren", "In the opposition proceedings"
|
||||
}
|
||||
|
||||
switch nature {
|
||||
case "inf":
|
||||
c.subjectDE, c.subjectEN = "Patentverletzung", "patent infringement"
|
||||
case "null", "rev":
|
||||
c.subjectDE, c.subjectEN = "Nichtigkeit des Streitpatents", "revocation of the patent in suit"
|
||||
case "opp":
|
||||
c.subjectDE, c.subjectEN = "Einspruch gegen das Streitpatent", "opposition to the patent in suit"
|
||||
}
|
||||
|
||||
// Designations — precedence: explicit proceeding role-label override >
|
||||
// instance-derived (appeal/cassation) > civil default.
|
||||
//
|
||||
// 1. Role-label overrides (mig 137) capture the proceedings whose naming
|
||||
// diverges in a forum-specific way: upc.apl.unified (Berufungskläger),
|
||||
// upc.rev.cfi (Antragsteller (Nichtigkeit)), epa.opp.* (Einsprechende(r)
|
||||
// / Patentinhaber(in)). These are authoritative — use them verbatim.
|
||||
// 2. Otherwise the procedural instance shifts the civil designation: an
|
||||
// appeal makes the parties Berufungskläger(in)/Berufungsbeklagte(r)
|
||||
// (Appellant/Respondent), a cassation Revisionskläger(in)/
|
||||
// Revisionsbeklagte(r). DE appeal/nullity forums (de.inf.olg,
|
||||
// de.null.bgh, …) carry no role-label override today, so this fills the
|
||||
// gap when project.instance_level is set.
|
||||
// 3. Else the first-instance civil default (Klägerin/Beklagte // Claimant/
|
||||
// Defendant) already in c.
|
||||
instance := ""
|
||||
if p != nil {
|
||||
instance = strings.ToLower(derefString(p.InstanceLevel))
|
||||
}
|
||||
switch instance {
|
||||
case "appeal":
|
||||
c.claimantDE, c.defendantDE = "Berufungskläger(in)", "Berufungsbeklagte(r)"
|
||||
c.claimantEN, c.defendantEN = "Appellant", "Respondent"
|
||||
case "cassation":
|
||||
c.claimantDE, c.defendantDE = "Revisionskläger(in)", "Revisionsbeklagte(r)"
|
||||
c.claimantEN, c.defendantEN = "Appellant", "Respondent"
|
||||
}
|
||||
if pt != nil {
|
||||
if v := derefString(pt.RoleProactiveLabelDE); v != "" {
|
||||
c.claimantDE = v
|
||||
}
|
||||
if v := derefString(pt.RoleProactiveLabelEN); v != "" {
|
||||
c.claimantEN = v
|
||||
}
|
||||
if v := derefString(pt.RoleReactiveLabelDE); v != "" {
|
||||
c.defendantDE = v
|
||||
}
|
||||
if v := derefString(pt.RoleReactiveLabelEN); v != "" {
|
||||
c.defendantEN = v
|
||||
}
|
||||
}
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
// captionNature returns the proceeding "nature" segment of a dotted proceeding
|
||||
// code (e.g. "de.inf.lg" → "inf", "upc.rev.cfi" → "rev", "epa.opp.opd" →
|
||||
// "opp", "de.null.bpatg" → "null"). Empty when the code has no second segment.
|
||||
func captionNature(code string) string {
|
||||
parts := strings.Split(code, ".")
|
||||
if len(parts) >= 2 {
|
||||
return parts[1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// addPartyVars populates the parties.* namespace from the (already
|
||||
// filtered) list of parties.
|
||||
//
|
||||
@@ -404,11 +577,10 @@ func addProjectVars(bag PlaceholderMap, p *models.Project, pt *models.Proceeding
|
||||
func addPartyVars(bag PlaceholderMap, parties []models.Party) {
|
||||
var claimants, defendants, others []models.Party
|
||||
for i := range parties {
|
||||
role := strings.ToLower(strings.TrimSpace(derefString(parties[i].Role)))
|
||||
switch role {
|
||||
case "claimant", "kläger", "klaeger", "klägerin", "klaegerin":
|
||||
switch partyRoleBucket(parties[i].Role) {
|
||||
case "claimant":
|
||||
claimants = append(claimants, parties[i])
|
||||
case "defendant", "beklagter", "beklagte":
|
||||
case "defendant":
|
||||
defendants = append(defendants, parties[i])
|
||||
default:
|
||||
others = append(others, parties[i])
|
||||
|
||||
245
internal/services/submission_vars_caption_test.go
Normal file
245
internal/services/submission_vars_caption_test.go
Normal file
@@ -0,0 +1,245 @@
|
||||
package services
|
||||
|
||||
// Pins the parametric caption resolver (t-paliad-358 A-S2 + t-paliad-361
|
||||
// wording follow-up): heading / subject derive from jurisdiction + the
|
||||
// proceeding code's nature segment; designations reuse the proceeding
|
||||
// role-label overrides, fall back to instance-derived appeal/cassation
|
||||
// wording, then to the civil default.
|
||||
//
|
||||
// t-paliad-361 additions:
|
||||
// - UPC appeal EN responding party is now "Respondent" (not "Appellee").
|
||||
// - The four DE appeal/nullity proceedings (de.inf.olg, de.inf.bgh,
|
||||
// de.null.bpatg, de.null.bgh) carry lexy-confirmed role-label overrides
|
||||
// (mig 163), so their designations are correct even when
|
||||
// project.instance_level is unset — pinned by the cases below that pass an
|
||||
// empty Project but still expect the appeal/nullity wording.
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/models"
|
||||
)
|
||||
|
||||
func sp(s string) *string { return &s }
|
||||
|
||||
func ptType(code, jurisdiction string) *models.ProceedingType {
|
||||
return &models.ProceedingType{Code: code, Jurisdiction: sp(jurisdiction)}
|
||||
}
|
||||
|
||||
// ptRoles builds a proceeding type carrying the mig-137/mig-163 role-label
|
||||
// overrides (the four-column bracketed-inclusive designations).
|
||||
func ptRoles(code, jurisdiction, proDE, reDE, proEN, reEN string) *models.ProceedingType {
|
||||
return &models.ProceedingType{
|
||||
Code: code, Jurisdiction: sp(jurisdiction),
|
||||
RoleProactiveLabelDE: sp(proDE),
|
||||
RoleReactiveLabelDE: sp(reDE),
|
||||
RoleProactiveLabelEN: sp(proEN),
|
||||
RoleReactiveLabelEN: sp(reEN),
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCaption(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
project *models.Project
|
||||
pt *models.ProceedingType
|
||||
wantHeadDE string
|
||||
wantClaimDE string
|
||||
wantDefDE string
|
||||
wantSubjDE string
|
||||
wantClaimEN string
|
||||
wantDefEN string
|
||||
}{
|
||||
{
|
||||
name: "DE LG infringement → Rechtsstreit / Kläger-Beklagte / Patentverletzung",
|
||||
project: &models.Project{},
|
||||
pt: ptType("de.inf.lg", "DE"),
|
||||
wantHeadDE: "In dem Rechtsstreit",
|
||||
wantClaimDE: "Klägerin",
|
||||
wantDefDE: "Beklagte",
|
||||
wantSubjDE: "Patentverletzung",
|
||||
wantClaimEN: "Claimant",
|
||||
wantDefEN: "Defendant",
|
||||
},
|
||||
{
|
||||
name: "DE BPatG nullity (no role-label data) → civil default fallback",
|
||||
project: &models.Project{},
|
||||
pt: ptType("de.null.bpatg", "DE"),
|
||||
wantHeadDE: "In der Patentnichtigkeitssache",
|
||||
wantClaimDE: "Klägerin",
|
||||
wantDefDE: "Beklagte",
|
||||
wantSubjDE: "Nichtigkeit des Streitpatents",
|
||||
wantClaimEN: "Claimant",
|
||||
wantDefEN: "Defendant",
|
||||
},
|
||||
{
|
||||
name: "UPC infringement → In der Sache / civil default",
|
||||
project: &models.Project{},
|
||||
pt: ptType("upc.inf.cfi", "UPC"),
|
||||
wantHeadDE: "In der Sache",
|
||||
wantClaimDE: "Klägerin",
|
||||
wantDefDE: "Beklagte",
|
||||
wantSubjDE: "Patentverletzung",
|
||||
wantClaimEN: "Claimant",
|
||||
wantDefEN: "Defendant",
|
||||
},
|
||||
{
|
||||
name: "UPC revocation → role-label override (Antragsteller Nichtigkeit)",
|
||||
project: &models.Project{},
|
||||
pt: ptRoles("upc.rev.cfi", "UPC",
|
||||
"Antragsteller (Nichtigkeit)", "Antragsgegner (Nichtigkeit)",
|
||||
"Revocation claimant", "Revocation defendant"),
|
||||
wantHeadDE: "In der Sache",
|
||||
wantClaimDE: "Antragsteller (Nichtigkeit)",
|
||||
wantDefDE: "Antragsgegner (Nichtigkeit)",
|
||||
wantSubjDE: "Nichtigkeit des Streitpatents",
|
||||
wantClaimEN: "Revocation claimant",
|
||||
wantDefEN: "Revocation defendant",
|
||||
},
|
||||
{
|
||||
// t-paliad-361 Change 1: the role-label override wins over the
|
||||
// instance-derived path, and its EN reactive label is now
|
||||
// "Respondent" (was "Appellee", mig 163).
|
||||
name: "UPC appeal → role-label override wins, EN reactive is Respondent",
|
||||
project: &models.Project{InstanceLevel: sp("appeal")},
|
||||
pt: ptRoles("upc.apl.unified", "UPC",
|
||||
"Berufungskläger", "Berufungsbeklagter",
|
||||
"Appellant", "Respondent"),
|
||||
wantHeadDE: "In der Sache",
|
||||
wantClaimDE: "Berufungskläger",
|
||||
wantDefDE: "Berufungsbeklagter",
|
||||
wantSubjDE: "Patentstreitsache",
|
||||
wantClaimEN: "Appellant",
|
||||
wantDefEN: "Respondent",
|
||||
},
|
||||
{
|
||||
name: "DE OLG appeal via instance_level (no role-label data) → instance-derived",
|
||||
project: &models.Project{InstanceLevel: sp("appeal")},
|
||||
pt: ptType("de.inf.olg", "DE"),
|
||||
wantHeadDE: "In dem Rechtsstreit",
|
||||
wantClaimDE: "Berufungskläger(in)",
|
||||
wantDefDE: "Berufungsbeklagte(r)",
|
||||
wantSubjDE: "Patentverletzung",
|
||||
wantClaimEN: "Appellant",
|
||||
wantDefEN: "Respondent",
|
||||
},
|
||||
{
|
||||
name: "EPA opposition → Einsprechende(r) / Patentinhaber(in)",
|
||||
project: &models.Project{},
|
||||
pt: ptRoles("epa.opp.opd", "EPA",
|
||||
"Einsprechende(r)", "Patentinhaber(in)",
|
||||
"Opponent", "Patentee"),
|
||||
wantHeadDE: "Im Einspruchsverfahren",
|
||||
wantClaimDE: "Einsprechende(r)",
|
||||
wantDefDE: "Patentinhaber(in)",
|
||||
wantSubjDE: "Einspruch gegen das Streitpatent",
|
||||
wantClaimEN: "Opponent",
|
||||
wantDefEN: "Patentee",
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// t-paliad-361 Change 3: the four DE appeal/nullity proceedings now
|
||||
// carry lexy-confirmed role-label overrides (mig 163). Each case
|
||||
// passes an EMPTY Project (instance_level unset) to prove the override
|
||||
// yields the correct designation without relying on the instance path.
|
||||
// ---------------------------------------------------------------
|
||||
{
|
||||
name: "de.inf.olg backfill → Berufungskläger(in)/Berufungsbeklagte(r), instance unset",
|
||||
project: &models.Project{},
|
||||
pt: ptRoles("de.inf.olg", "DE",
|
||||
"Berufungskläger(in)", "Berufungsbeklagte(r)",
|
||||
"Appellant", "Respondent"),
|
||||
wantHeadDE: "In dem Rechtsstreit",
|
||||
wantClaimDE: "Berufungskläger(in)",
|
||||
wantDefDE: "Berufungsbeklagte(r)",
|
||||
wantSubjDE: "Patentverletzung",
|
||||
wantClaimEN: "Appellant",
|
||||
wantDefEN: "Respondent",
|
||||
},
|
||||
{
|
||||
name: "de.inf.bgh backfill → Revisionskläger(in)/Revisionsbeklagte(r), instance unset",
|
||||
project: &models.Project{},
|
||||
pt: ptRoles("de.inf.bgh", "DE",
|
||||
"Revisionskläger(in)", "Revisionsbeklagte(r)",
|
||||
"Appellant", "Respondent"),
|
||||
wantHeadDE: "In dem Rechtsstreit",
|
||||
wantClaimDE: "Revisionskläger(in)",
|
||||
wantDefDE: "Revisionsbeklagte(r)",
|
||||
wantSubjDE: "Patentverletzung",
|
||||
wantClaimEN: "Appellant",
|
||||
wantDefEN: "Respondent",
|
||||
},
|
||||
{
|
||||
name: "de.null.bpatg backfill → Nichtigkeitskläger(in)/Beklagte(r) (Patentinhaber(in))",
|
||||
project: &models.Project{},
|
||||
pt: ptRoles("de.null.bpatg", "DE",
|
||||
"Nichtigkeitskläger(in)", "Beklagte(r) (Patentinhaber(in))",
|
||||
"Nullity claimant", "Defendant (patent proprietor)"),
|
||||
wantHeadDE: "In der Patentnichtigkeitssache",
|
||||
wantClaimDE: "Nichtigkeitskläger(in)",
|
||||
wantDefDE: "Beklagte(r) (Patentinhaber(in))",
|
||||
wantSubjDE: "Nichtigkeit des Streitpatents",
|
||||
wantClaimEN: "Nullity claimant",
|
||||
wantDefEN: "Defendant (patent proprietor)",
|
||||
},
|
||||
{
|
||||
name: "de.null.bgh backfill → Berufungskläger(in)/Berufungsbeklagte(r) (§110 PatG Berufung)",
|
||||
project: &models.Project{},
|
||||
pt: ptRoles("de.null.bgh", "DE",
|
||||
"Berufungskläger(in)", "Berufungsbeklagte(r)",
|
||||
"Appellant", "Respondent"),
|
||||
wantHeadDE: "In der Patentnichtigkeitssache",
|
||||
wantClaimDE: "Berufungskläger(in)",
|
||||
wantDefDE: "Berufungsbeklagte(r)",
|
||||
wantSubjDE: "Nichtigkeit des Streitpatents",
|
||||
wantClaimEN: "Appellant",
|
||||
wantDefEN: "Respondent",
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := resolveCaption(c.project, c.pt)
|
||||
if got.headingDE != c.wantHeadDE {
|
||||
t.Errorf("headingDE = %q, want %q", got.headingDE, c.wantHeadDE)
|
||||
}
|
||||
if got.claimantDE != c.wantClaimDE {
|
||||
t.Errorf("claimantDE = %q, want %q", got.claimantDE, c.wantClaimDE)
|
||||
}
|
||||
if got.defendantDE != c.wantDefDE {
|
||||
t.Errorf("defendantDE = %q, want %q", got.defendantDE, c.wantDefDE)
|
||||
}
|
||||
if got.subjectDE != c.wantSubjDE {
|
||||
t.Errorf("subjectDE = %q, want %q", got.subjectDE, c.wantSubjDE)
|
||||
}
|
||||
if got.claimantEN != c.wantClaimEN {
|
||||
t.Errorf("claimantEN = %q, want %q", got.claimantEN, c.wantClaimEN)
|
||||
}
|
||||
if got.defendantEN != c.wantDefEN {
|
||||
t.Errorf("defendantEN = %q, want %q", got.defendantEN, c.wantDefEN)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// addCaptionVars must emit bare + _de + _en forms, with the bare form resolved
|
||||
// to the draft language.
|
||||
func TestAddCaptionVars_BareResolvesToLang(t *testing.T) {
|
||||
pt := ptType("de.inf.lg", "DE")
|
||||
proj := &models.Project{}
|
||||
|
||||
bagDE := PlaceholderMap{}
|
||||
addCaptionVars(bagDE, proj, pt, "de")
|
||||
if bagDE["caption.heading"] != "In dem Rechtsstreit" {
|
||||
t.Errorf("DE bare heading = %q", bagDE["caption.heading"])
|
||||
}
|
||||
if bagDE["caption.heading_en"] != "In the matter" {
|
||||
t.Errorf("heading_en = %q", bagDE["caption.heading_en"])
|
||||
}
|
||||
|
||||
bagEN := PlaceholderMap{}
|
||||
addCaptionVars(bagEN, proj, pt, "en")
|
||||
if bagEN["caption.heading"] != "In the matter" {
|
||||
t.Errorf("EN bare heading = %q", bagEN["caption.heading"])
|
||||
}
|
||||
}
|
||||
55
internal/services/submission_vars_catalogue_test.go
Normal file
55
internal/services/submission_vars_catalogue_test.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package services
|
||||
|
||||
import "testing"
|
||||
|
||||
// The variable catalogue is the single source of truth for the sidebar
|
||||
// form + authoring palette labels (t-paliad-349 slice 5). These checks
|
||||
// pin its integrity so a resolver Keys() edit can't silently ship a
|
||||
// malformed entry or a duplicate key.
|
||||
func TestSubmissionVariableCatalogue(t *testing.T) {
|
||||
cat := SubmissionVariableCatalogue()
|
||||
if len(cat) == 0 {
|
||||
t.Fatal("catalogue is empty")
|
||||
}
|
||||
|
||||
seen := map[string]bool{}
|
||||
for _, e := range cat {
|
||||
if e.Key == "" || e.LabelDE == "" || e.LabelEN == "" || e.Group == "" {
|
||||
t.Errorf("incomplete catalogue entry: %+v", e)
|
||||
}
|
||||
if seen[e.Key] {
|
||||
t.Errorf("duplicate catalogue key: %q", e.Key)
|
||||
}
|
||||
seen[e.Key] = true
|
||||
}
|
||||
|
||||
// Spot-check one key per namespace resolves with the expected label.
|
||||
want := map[string]struct{ group, de string }{
|
||||
"firm.name": {"firm", "Kanzlei"},
|
||||
"today.long_de": {"today", "Heute (DE lang)"},
|
||||
"user.display_name": {"user", "Bearbeiter"},
|
||||
"project.case_number": {"project", "Aktenzeichen (Gericht)"},
|
||||
"parties.claimant.name": {"parties", "Klägerin"},
|
||||
"procedural_event.legal_source_pretty": {"procedural_event", "Rechtsgrundlage"},
|
||||
"deadline.due_date": {"deadline", "Frist (ISO)"},
|
||||
}
|
||||
byKey := map[string]struct{ group, de string }{}
|
||||
for _, e := range cat {
|
||||
byKey[e.Key] = struct{ group, de string }{e.Group, e.LabelDE}
|
||||
}
|
||||
for k, exp := range want {
|
||||
got, ok := byKey[k]
|
||||
if !ok {
|
||||
t.Errorf("catalogue missing expected key %q", k)
|
||||
continue
|
||||
}
|
||||
if got.group != exp.group || got.de != exp.de {
|
||||
t.Errorf("catalogue[%q] = {%q, %q}; want {%q, %q}", k, got.group, got.de, exp.group, exp.de)
|
||||
}
|
||||
}
|
||||
|
||||
// The legacy rule.* aliases must be present for labelFor coverage.
|
||||
if !seen["rule.name"] || !seen["rule.legal_source_pretty"] {
|
||||
t.Error("legacy rule.* aliases missing from catalogue")
|
||||
}
|
||||
}
|
||||
27
internal/services/submission_vars_firm_test.go
Normal file
27
internal/services/submission_vars_firm_test.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package services
|
||||
|
||||
// Pins the firm.* namespace (t-paliad-358 A-S1): firm.signature_block must
|
||||
// be filled from branding.Name, not left empty. Before A-S1 it emitted ""
|
||||
// ("reserved for Phase 2"), which made every template that referenced
|
||||
// {{firm.signature_block}} render blank.
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/branding"
|
||||
)
|
||||
|
||||
func TestAddFirmVars_SignatureBlockFilledFromBranding(t *testing.T) {
|
||||
bag := PlaceholderMap{}
|
||||
addFirmVars(bag)
|
||||
|
||||
if got := bag["firm.name"]; got != branding.Name {
|
||||
t.Errorf("firm.name = %q, want %q", got, branding.Name)
|
||||
}
|
||||
if got := bag["firm.signature_block"]; got == "" {
|
||||
t.Fatal("firm.signature_block is empty — the A-S1 fix should fill it from branding")
|
||||
}
|
||||
if got := bag["firm.signature_block"]; got != branding.Name {
|
||||
t.Errorf("firm.signature_block = %q, want %q (firm identity line, firm-agnostic)", got, branding.Name)
|
||||
}
|
||||
}
|
||||
81
internal/services/submission_vars_pretty_test.go
Normal file
81
internal/services/submission_vars_pretty_test.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package services
|
||||
|
||||
// Pretty-printer tests for the variable-resolution layer (legalSourcePretty,
|
||||
// ourSideDE/EN, patentNumberUPC). These live with submission_vars.go;
|
||||
// they were relocated out of the docx engine test suite when the
|
||||
// .docx renderer moved to pkg/docforge/docx (t-paliad-349 slice 1).
|
||||
|
||||
import "testing"
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
228
internal/services/submission_vars_resolvers.go
Normal file
228
internal/services/submission_vars_resolvers.go
Normal file
@@ -0,0 +1,228 @@
|
||||
package services
|
||||
|
||||
// Variable resolvers — the paliad-side implementations of
|
||||
// docforge.VariableResolver (t-paliad-349 slice 3). Each wraps one of the
|
||||
// addXxxVars push-builders, capturing the entity it needs, so the proven
|
||||
// builder bodies stay byte-for-byte unchanged while the composition moves
|
||||
// behind the docforge.ResolverSet seam. SubmissionVarsService.Build wires
|
||||
// the applicable resolvers and calls ResolverSet.BuildBag().
|
||||
//
|
||||
// These live in paliad (not docforge) because they read paliad's domain
|
||||
// model — branding, user, project, parties, deadline_rules, deadlines. A
|
||||
// second docforge consumer implements its own resolvers against its own
|
||||
// data and plugs them into a ResolverSet the same way.
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/models"
|
||||
"mgit.msbls.de/m/paliad/pkg/docforge"
|
||||
)
|
||||
|
||||
// Compile-time conformance: each resolver satisfies docforge.VariableResolver.
|
||||
var (
|
||||
_ docforge.VariableResolver = firmResolver{}
|
||||
_ docforge.VariableResolver = todayResolver{}
|
||||
_ docforge.VariableResolver = userResolver{}
|
||||
_ docforge.VariableResolver = proceduralEventResolver{}
|
||||
_ docforge.VariableResolver = projectResolver{}
|
||||
_ docforge.VariableResolver = captionResolver{}
|
||||
_ docforge.VariableResolver = partiesResolver{}
|
||||
_ docforge.VariableResolver = deadlineResolver{}
|
||||
)
|
||||
|
||||
// vk is a terse constructor for a catalogue entry in the given group.
|
||||
func vk(group, key, de, en string) docforge.VariableKey {
|
||||
return docforge.VariableKey{Key: key, LabelDE: de, LabelEN: en, Group: group}
|
||||
}
|
||||
|
||||
// SubmissionVariableCatalogue returns the full variable catalogue for the
|
||||
// submission resolvers — every (key, DE/EN label, namespace) the sidebar
|
||||
// form and the authoring palette can offer. Built from the resolvers'
|
||||
// Keys() with no entity state, so it needs no DB call. This is the single
|
||||
// source of truth for variable labels, replacing the duplicated TS
|
||||
// VARIABLE_LABELS table (t-paliad-349 slice 5).
|
||||
func SubmissionVariableCatalogue() []docforge.VariableKey {
|
||||
return docforge.NewResolverSet(
|
||||
firmResolver{},
|
||||
todayResolver{},
|
||||
userResolver{},
|
||||
proceduralEventResolver{},
|
||||
projectResolver{},
|
||||
captionResolver{},
|
||||
partiesResolver{},
|
||||
deadlineResolver{},
|
||||
).Catalogue()
|
||||
}
|
||||
|
||||
// firmResolver populates firm.* from process-wide branding.
|
||||
type firmResolver struct{}
|
||||
|
||||
func (firmResolver) Namespace() string { return "firm" }
|
||||
func (firmResolver) Populate(bag PlaceholderMap) { addFirmVars(bag) }
|
||||
func (firmResolver) Keys() []docforge.VariableKey {
|
||||
return []docforge.VariableKey{
|
||||
vk("firm", "firm.name", "Kanzlei", "Firm"),
|
||||
vk("firm", "firm.signature_block", "Signatur-Block", "Signature block"),
|
||||
}
|
||||
}
|
||||
|
||||
// todayResolver populates today.* from the build-time clock.
|
||||
type todayResolver struct{ now time.Time }
|
||||
|
||||
func (todayResolver) Namespace() string { return "today" }
|
||||
func (r todayResolver) Populate(bag PlaceholderMap) { addTodayVars(bag, r.now) }
|
||||
func (todayResolver) Keys() []docforge.VariableKey {
|
||||
return []docforge.VariableKey{
|
||||
vk("today", "today", "Heute", "Today"),
|
||||
vk("today", "today.iso", "Heute (ISO)", "Today (ISO)"),
|
||||
vk("today", "today.long_de", "Heute (DE lang)", "Today (DE long)"),
|
||||
vk("today", "today.long_en", "Heute (EN lang)", "Today (EN long)"),
|
||||
}
|
||||
}
|
||||
|
||||
// userResolver populates user.* from the caller's row.
|
||||
type userResolver struct{ user *models.User }
|
||||
|
||||
func (userResolver) Namespace() string { return "user" }
|
||||
func (r userResolver) Populate(bag PlaceholderMap) { addUserVars(bag, r.user) }
|
||||
func (userResolver) Keys() []docforge.VariableKey {
|
||||
return []docforge.VariableKey{
|
||||
vk("user", "user.display_name", "Bearbeiter", "Author"),
|
||||
vk("user", "user.email", "E-Mail", "Email"),
|
||||
vk("user", "user.office", "Büro", "Office"),
|
||||
}
|
||||
}
|
||||
|
||||
// proceduralEventResolver populates procedural_event.* and the legacy
|
||||
// rule.* alias from the published deadline_rule.
|
||||
type proceduralEventResolver struct {
|
||||
rule *models.DeadlineRule
|
||||
lang string
|
||||
}
|
||||
|
||||
func (proceduralEventResolver) Namespace() string { return "procedural_event" }
|
||||
func (r proceduralEventResolver) Populate(bag PlaceholderMap) { addRuleVars(bag, r.rule, r.lang) }
|
||||
func (proceduralEventResolver) Keys() []docforge.VariableKey {
|
||||
return []docforge.VariableKey{
|
||||
vk("procedural_event", "procedural_event.code", "Code (Verfahrensschritt)", "Code (procedural event)"),
|
||||
vk("procedural_event", "procedural_event.name", "Verfahrensschritt", "Procedural event"),
|
||||
vk("procedural_event", "procedural_event.name_de", "Verfahrensschritt (DE)", "Procedural event (DE)"),
|
||||
vk("procedural_event", "procedural_event.name_en", "Verfahrensschritt (EN)", "Procedural event (EN)"),
|
||||
vk("procedural_event", "procedural_event.legal_source", "Rechtsgrundlage (Code)", "Legal source (code)"),
|
||||
vk("procedural_event", "procedural_event.legal_source_pretty", "Rechtsgrundlage", "Legal source"),
|
||||
vk("procedural_event", "procedural_event.primary_party", "Partei (typisch)", "Primary party"),
|
||||
vk("procedural_event", "procedural_event.event_kind", "Art des Verfahrensschritts", "Procedural-event kind"),
|
||||
// Legacy rule.* aliases — @deprecated, kept forever (m/paliad#93 Q7).
|
||||
vk("procedural_event", "rule.submission_code", "Schriftsatz-Code (legacy)", "Submission code (legacy)"),
|
||||
vk("procedural_event", "rule.name", "Schriftsatz (legacy)", "Submission (legacy)"),
|
||||
vk("procedural_event", "rule.name_de", "Schriftsatz (DE, legacy)", "Submission (DE, legacy)"),
|
||||
vk("procedural_event", "rule.name_en", "Schriftsatz (EN, legacy)", "Submission (EN, legacy)"),
|
||||
vk("procedural_event", "rule.legal_source", "Rechtsgrundlage (Code, legacy)", "Legal source (code, legacy)"),
|
||||
vk("procedural_event", "rule.legal_source_pretty", "Rechtsgrundlage (legacy)", "Legal source (legacy)"),
|
||||
vk("procedural_event", "rule.primary_party", "Partei (typisch, legacy)", "Primary party (legacy)"),
|
||||
vk("procedural_event", "rule.event_type", "Schriftsatz-Typ (legacy)", "Event type (legacy)"),
|
||||
}
|
||||
}
|
||||
|
||||
// projectResolver populates project.* from the project + its proceeding type.
|
||||
type projectResolver struct {
|
||||
project *models.Project
|
||||
pt *models.ProceedingType
|
||||
lang string
|
||||
}
|
||||
|
||||
func (projectResolver) Namespace() string { return "project" }
|
||||
func (r projectResolver) Populate(bag PlaceholderMap) { addProjectVars(bag, r.project, r.pt, r.lang) }
|
||||
func (projectResolver) Keys() []docforge.VariableKey {
|
||||
return []docforge.VariableKey{
|
||||
vk("project", "project.title", "Projekttitel", "Project title"),
|
||||
vk("project", "project.reference", "Aktenzeichen (intern)", "Internal reference"),
|
||||
vk("project", "project.case_number", "Aktenzeichen (Gericht)", "Court case number"),
|
||||
vk("project", "project.court", "Gericht", "Court"),
|
||||
vk("project", "project.patent_number", "Patentnummer", "Patent number"),
|
||||
vk("project", "project.patent_number_upc", "Patentnummer (UPC-Format)", "Patent number (UPC format)"),
|
||||
vk("project", "project.filing_date", "Anmeldedatum", "Filing date"),
|
||||
vk("project", "project.grant_date", "Erteilungsdatum", "Grant date"),
|
||||
vk("project", "project.our_side", "Unsere Seite", "Our side"),
|
||||
vk("project", "project.our_side_de", "Unsere Seite (DE)", "Our side (DE)"),
|
||||
vk("project", "project.our_side_en", "Unsere Seite (EN)", "Our side (EN)"),
|
||||
vk("project", "project.instance_level", "Instanz", "Instance"),
|
||||
vk("project", "project.client_number", "Mandantennummer", "Client number"),
|
||||
vk("project", "project.matter_number", "Matter-Nummer", "Matter number"),
|
||||
vk("project", "project.proceeding.code", "Verfahrenstyp (Code)", "Proceeding type (code)"),
|
||||
vk("project", "project.proceeding.jurisdiction", "Gerichtsbarkeit", "Jurisdiction"),
|
||||
vk("project", "project.proceeding.name", "Verfahrenstyp", "Proceeding type"),
|
||||
vk("project", "project.proceeding.name_de", "Verfahrenstyp (DE)", "Proceeding type (DE)"),
|
||||
vk("project", "project.proceeding.name_en", "Verfahrenstyp (EN)", "Proceeding type (EN)"),
|
||||
}
|
||||
}
|
||||
|
||||
// captionResolver populates caption.* — the parametric case-caption (Rubrum)
|
||||
// pieces shared across every render path (merge fallback skeleton, per-code
|
||||
// .docx templates, Composer caption seeds) so the wording stays unified
|
||||
// (t-paliad-358 A-S2). Needs the project (instance level) + proceeding type
|
||||
// (jurisdiction, code, role-label overrides); see addCaptionVars.
|
||||
type captionResolver struct {
|
||||
project *models.Project
|
||||
pt *models.ProceedingType
|
||||
lang string
|
||||
}
|
||||
|
||||
func (captionResolver) Namespace() string { return "caption" }
|
||||
func (r captionResolver) Populate(bag PlaceholderMap) {
|
||||
addCaptionVars(bag, r.project, r.pt, r.lang)
|
||||
}
|
||||
func (captionResolver) Keys() []docforge.VariableKey {
|
||||
return []docforge.VariableKey{
|
||||
vk("caption", "caption.heading", "Rubrum-Überschrift", "Caption heading"),
|
||||
vk("caption", "caption.claimant_designation", "Bezeichnung Klägerseite", "Claimant designation"),
|
||||
vk("caption", "caption.defendant_designation", "Bezeichnung Beklagtenseite", "Defendant designation"),
|
||||
vk("caption", "caption.versus", "Gegen-Konnektor", "Versus connector"),
|
||||
vk("caption", "caption.subject", "Streitgegenstand (wegen)", "Subject matter (re)"),
|
||||
}
|
||||
}
|
||||
|
||||
// partiesResolver populates parties.* from the (already filtered) party list.
|
||||
type partiesResolver struct{ parties []models.Party }
|
||||
|
||||
func (partiesResolver) Namespace() string { return "parties" }
|
||||
func (r partiesResolver) Populate(bag PlaceholderMap) { addPartyVars(bag, r.parties) }
|
||||
|
||||
// Keys returns the flat, user-facing party forms (the power-user override
|
||||
// rows the sidebar shows). The indexed (parties.claimant.0.name) and
|
||||
// joined (parties.claimants) forms Populate also emits are not catalogue
|
||||
// entries — they're resolved into the bag but not offered in the palette.
|
||||
func (partiesResolver) Keys() []docforge.VariableKey {
|
||||
return []docforge.VariableKey{
|
||||
vk("parties", "parties.claimant.name", "Klägerin", "Claimant"),
|
||||
vk("parties", "parties.claimant.representative", "Klägerin-Vertreter", "Claimant representative"),
|
||||
vk("parties", "parties.defendant.name", "Beklagte", "Defendant"),
|
||||
vk("parties", "parties.defendant.representative", "Beklagten-Vertreter", "Defendant representative"),
|
||||
vk("parties", "parties.other.name", "Weitere Partei", "Other party"),
|
||||
vk("parties", "parties.other.representative", "Weitere-Partei-Vertreter", "Other party representative"),
|
||||
}
|
||||
}
|
||||
|
||||
// deadlineResolver populates deadline.* from the next pending deadline.
|
||||
type deadlineResolver struct {
|
||||
deadline *models.Deadline
|
||||
project *models.Project
|
||||
lang string
|
||||
}
|
||||
|
||||
func (deadlineResolver) Namespace() string { return "deadline" }
|
||||
func (r deadlineResolver) Populate(bag PlaceholderMap) {
|
||||
addDeadlineVars(bag, r.deadline, r.project, r.lang)
|
||||
}
|
||||
func (deadlineResolver) Keys() []docforge.VariableKey {
|
||||
return []docforge.VariableKey{
|
||||
vk("deadline", "deadline.due_date", "Frist (ISO)", "Deadline (ISO)"),
|
||||
vk("deadline", "deadline.due_date_long_de", "Frist (DE lang)", "Deadline (DE long)"),
|
||||
vk("deadline", "deadline.due_date_long_en", "Frist (EN lang)", "Deadline (EN long)"),
|
||||
vk("deadline", "deadline.original_due_date", "Ursprüngliche Frist", "Original deadline"),
|
||||
vk("deadline", "deadline.computed_from", "Frist berechnet aus", "Deadline computed from"),
|
||||
vk("deadline", "deadline.title", "Frist-Titel", "Deadline title"),
|
||||
vk("deadline", "deadline.source", "Frist-Quelle", "Deadline source"),
|
||||
}
|
||||
}
|
||||
397
internal/services/template_store.go
Normal file
397
internal/services/template_store.go
Normal file
@@ -0,0 +1,397 @@
|
||||
package services
|
||||
|
||||
// PgTemplateStore — paliad's Postgres implementation of
|
||||
// docforge.TemplateStore (t-paliad-349 slice 4). The carrier .docx bytes
|
||||
// live in a bytea column (paliad.template_versions.carrier_blob); the
|
||||
// stylemap is jsonb; slots are rows in paliad.template_slots. Versioning is
|
||||
// snapshot-at-create: Create makes version 1 and pins it as current,
|
||||
// AddVersion inserts the next version and re-points current.
|
||||
//
|
||||
// docforge owns the interface + the neutral types; this is the paliad-side
|
||||
// data binding. No handler wires this yet — the authoring surface (slice 6)
|
||||
// and generation-on-templates (slice 7) are the consumers.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
|
||||
"mgit.msbls.de/m/paliad/pkg/docforge"
|
||||
)
|
||||
|
||||
// PgTemplateStore implements docforge.TemplateStore against Postgres.
|
||||
type PgTemplateStore struct {
|
||||
db *sqlx.DB
|
||||
}
|
||||
|
||||
// compile-time conformance.
|
||||
var _ docforge.TemplateStore = (*PgTemplateStore)(nil)
|
||||
|
||||
// NewPgTemplateStore wires the store.
|
||||
func NewPgTemplateStore(db *sqlx.DB) *PgTemplateStore {
|
||||
return &PgTemplateStore{db: db}
|
||||
}
|
||||
|
||||
// templateMetaRow scans the catalog metadata + the current version number
|
||||
// (via LEFT JOIN, 0 when no version pinned yet).
|
||||
type templateMetaRow struct {
|
||||
ID uuid.UUID `db:"id"`
|
||||
Slug *string `db:"slug"`
|
||||
NameDE string `db:"name_de"`
|
||||
NameEN string `db:"name_en"`
|
||||
Kind string `db:"kind"`
|
||||
SourceFormat string `db:"source_format"`
|
||||
Firm *string `db:"firm"`
|
||||
IsActive bool `db:"is_active"`
|
||||
Version int `db:"version"`
|
||||
VersionID *uuid.UUID `db:"version_id"`
|
||||
}
|
||||
|
||||
func (r templateMetaRow) toMeta() docforge.TemplateMeta {
|
||||
m := docforge.TemplateMeta{
|
||||
ID: r.ID.String(),
|
||||
Slug: derefString(r.Slug),
|
||||
NameDE: r.NameDE,
|
||||
NameEN: r.NameEN,
|
||||
Kind: r.Kind,
|
||||
SourceFormat: r.SourceFormat,
|
||||
Firm: derefString(r.Firm),
|
||||
IsActive: r.IsActive,
|
||||
Version: r.Version,
|
||||
}
|
||||
if r.VersionID != nil {
|
||||
m.VersionID = r.VersionID.String()
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
const templateMetaColumns = `t.id, t.slug, t.name_de, t.name_en, t.kind,
|
||||
t.source_format, t.firm, t.is_active,
|
||||
COALESCE(v.version, 0) AS version,
|
||||
v.id AS version_id`
|
||||
|
||||
const templateMetaFrom = `FROM paliad.templates t
|
||||
LEFT JOIN paliad.template_versions v
|
||||
ON v.id = t.current_version_id`
|
||||
|
||||
// List returns catalog metadata for matching templates, without carrier
|
||||
// bytes.
|
||||
func (s *PgTemplateStore) List(ctx context.Context, f docforge.TemplateFilter) ([]docforge.TemplateMeta, error) {
|
||||
q := `SELECT ` + templateMetaColumns + ` ` + templateMetaFrom + ` WHERE 1=1`
|
||||
var args []any
|
||||
if f.ActiveOnly {
|
||||
q += ` AND t.is_active`
|
||||
}
|
||||
if f.Firm != "" {
|
||||
args = append(args, f.Firm)
|
||||
q += fmt.Sprintf(` AND (t.firm = $%d OR t.firm IS NULL)`, len(args))
|
||||
}
|
||||
if f.Kind != "" {
|
||||
args = append(args, f.Kind)
|
||||
q += fmt.Sprintf(` AND t.kind = $%d`, len(args))
|
||||
}
|
||||
q += ` ORDER BY COALESCE(t.firm, ''), t.name_de`
|
||||
|
||||
var rows []templateMetaRow
|
||||
if err := s.db.SelectContext(ctx, &rows, q, args...); err != nil {
|
||||
return nil, fmt.Errorf("list templates: %w", err)
|
||||
}
|
||||
out := make([]docforge.TemplateMeta, len(rows))
|
||||
for i := range rows {
|
||||
out[i] = rows[i].toMeta()
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Get resolves a template to its current version.
|
||||
func (s *PgTemplateStore) Get(ctx context.Context, id string) (*docforge.Template, error) {
|
||||
tid, err := uuid.Parse(id)
|
||||
if err != nil {
|
||||
return nil, docforge.ErrTemplateNotFound
|
||||
}
|
||||
var meta templateMetaRow
|
||||
err = s.db.GetContext(ctx, &meta,
|
||||
`SELECT `+templateMetaColumns+` `+templateMetaFrom+` WHERE t.id = $1`, tid)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, docforge.ErrTemplateNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get template: %w", err)
|
||||
}
|
||||
tmpl := &docforge.Template{TemplateMeta: meta.toMeta()}
|
||||
if meta.Version == 0 {
|
||||
// No version pinned yet — return metadata only (carrier empty).
|
||||
return tmpl, nil
|
||||
}
|
||||
if err := s.loadCurrentVersionContent(ctx, tid, tmpl); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return tmpl, nil
|
||||
}
|
||||
|
||||
// GetVersion resolves a template to a specific version id — the path a
|
||||
// draft uses to render its pinned snapshot.
|
||||
func (s *PgTemplateStore) GetVersion(ctx context.Context, versionID string) (*docforge.Template, error) {
|
||||
vid, err := uuid.Parse(versionID)
|
||||
if err != nil {
|
||||
return nil, docforge.ErrTemplateNotFound
|
||||
}
|
||||
var vr struct {
|
||||
TemplateID uuid.UUID `db:"template_id"`
|
||||
Version int `db:"version"`
|
||||
Carrier []byte `db:"carrier_blob"`
|
||||
Stylemap []byte `db:"stylemap"`
|
||||
}
|
||||
err = s.db.GetContext(ctx, &vr,
|
||||
`SELECT template_id, version, carrier_blob, stylemap
|
||||
FROM paliad.template_versions WHERE id = $1`, vid)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, docforge.ErrTemplateNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get template version: %w", err)
|
||||
}
|
||||
var meta templateMetaRow
|
||||
err = s.db.GetContext(ctx, &meta,
|
||||
`SELECT t.id, t.slug, t.name_de, t.name_en, t.kind, t.source_format,
|
||||
t.firm, t.is_active, $2 AS version
|
||||
FROM paliad.templates t WHERE t.id = $1`, vr.TemplateID, vr.Version)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, docforge.ErrTemplateNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get template version meta: %w", err)
|
||||
}
|
||||
tmpl := &docforge.Template{TemplateMeta: meta.toMeta(), CarrierBytes: vr.Carrier}
|
||||
tmpl.VersionID = vid.String() // the resolved version is the one requested
|
||||
tmpl.Stylemap = decodeStylemap(vr.Stylemap)
|
||||
slots, err := s.loadSlots(ctx, vid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tmpl.Slots = slots
|
||||
return tmpl, nil
|
||||
}
|
||||
|
||||
// loadCurrentVersionContent fills carrier + stylemap + slots from the
|
||||
// template's current_version_id.
|
||||
func (s *PgTemplateStore) loadCurrentVersionContent(ctx context.Context, templateID uuid.UUID, tmpl *docforge.Template) error {
|
||||
var vr struct {
|
||||
ID uuid.UUID `db:"id"`
|
||||
Carrier []byte `db:"carrier_blob"`
|
||||
Stylemap []byte `db:"stylemap"`
|
||||
}
|
||||
err := s.db.GetContext(ctx, &vr,
|
||||
`SELECT v.id, v.carrier_blob, v.stylemap
|
||||
FROM paliad.template_versions v
|
||||
JOIN paliad.templates t ON t.current_version_id = v.id
|
||||
WHERE t.id = $1`, templateID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return docforge.ErrTemplateNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("load current version: %w", err)
|
||||
}
|
||||
tmpl.CarrierBytes = vr.Carrier
|
||||
tmpl.Stylemap = decodeStylemap(vr.Stylemap)
|
||||
slots, err := s.loadSlots(ctx, vr.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpl.Slots = slots
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadSlots returns the slots placed in a version, ordered.
|
||||
func (s *PgTemplateStore) loadSlots(ctx context.Context, versionID uuid.UUID) ([]docforge.TemplateSlot, error) {
|
||||
var rows []struct {
|
||||
SlotKey string `db:"slot_key"`
|
||||
Anchor string `db:"anchor"`
|
||||
Label *string `db:"label"`
|
||||
OrderIndex int `db:"order_index"`
|
||||
}
|
||||
err := s.db.SelectContext(ctx, &rows,
|
||||
`SELECT slot_key, anchor, label, order_index
|
||||
FROM paliad.template_slots
|
||||
WHERE template_version_id = $1
|
||||
ORDER BY order_index, slot_key`, versionID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load template slots: %w", err)
|
||||
}
|
||||
out := make([]docforge.TemplateSlot, len(rows))
|
||||
for i, r := range rows {
|
||||
out[i] = docforge.TemplateSlot{
|
||||
Key: r.SlotKey,
|
||||
Anchor: r.Anchor,
|
||||
Label: derefString(r.Label),
|
||||
OrderIndex: r.OrderIndex,
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Create inserts a new template + its first version (version 1) and pins
|
||||
// that version as current.
|
||||
func (s *PgTemplateStore) Create(ctx context.Context, meta docforge.TemplateMetaInput, first docforge.TemplateVersionInput) (*docforge.Template, error) {
|
||||
createdBy, err := uuid.Parse(meta.CreatedBy)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create template: invalid created_by: %w", err)
|
||||
}
|
||||
kind := meta.Kind
|
||||
if kind == "" {
|
||||
kind = "submission"
|
||||
}
|
||||
format := meta.SourceFormat
|
||||
if format == "" {
|
||||
format = "docx"
|
||||
}
|
||||
|
||||
var versionID uuid.UUID
|
||||
err = s.inTx(ctx, func(tx *sqlx.Tx) error {
|
||||
var templateID uuid.UUID
|
||||
if err := tx.GetContext(ctx, &templateID,
|
||||
`INSERT INTO paliad.templates
|
||||
(slug, name_de, name_en, kind, source_format, firm, created_by)
|
||||
VALUES (NULLIF($1, ''), $2, $3, $4, $5, NULLIF($6, ''), $7)
|
||||
RETURNING id`,
|
||||
meta.Slug, meta.NameDE, meta.NameEN, kind, format, meta.Firm, createdBy); err != nil {
|
||||
return fmt.Errorf("insert template: %w", err)
|
||||
}
|
||||
var verr error
|
||||
versionID, verr = insertTemplateVersion(ctx, tx, templateID, 1, first)
|
||||
if verr != nil {
|
||||
return verr
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE paliad.templates SET current_version_id = $1, updated_at = now() WHERE id = $2`,
|
||||
versionID, templateID); err != nil {
|
||||
return fmt.Errorf("pin current version: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.GetVersion(ctx, versionID.String())
|
||||
}
|
||||
|
||||
// AddVersion inserts the next version for an existing template and
|
||||
// re-points current_version to it.
|
||||
func (s *PgTemplateStore) AddVersion(ctx context.Context, templateID string, v docforge.TemplateVersionInput) (*docforge.Template, error) {
|
||||
tid, err := uuid.Parse(templateID)
|
||||
if err != nil {
|
||||
return nil, docforge.ErrTemplateNotFound
|
||||
}
|
||||
|
||||
var versionID uuid.UUID
|
||||
err = s.inTx(ctx, func(tx *sqlx.Tx) error {
|
||||
var exists bool
|
||||
if err := tx.GetContext(ctx, &exists,
|
||||
`SELECT EXISTS(SELECT 1 FROM paliad.templates WHERE id = $1)`, tid); err != nil {
|
||||
return fmt.Errorf("check template exists: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
return docforge.ErrTemplateNotFound
|
||||
}
|
||||
var nextVersion int
|
||||
if err := tx.GetContext(ctx, &nextVersion,
|
||||
`SELECT COALESCE(MAX(version), 0) + 1 FROM paliad.template_versions WHERE template_id = $1`,
|
||||
tid); err != nil {
|
||||
return fmt.Errorf("next version: %w", err)
|
||||
}
|
||||
var verr error
|
||||
versionID, verr = insertTemplateVersion(ctx, tx, tid, nextVersion, v)
|
||||
if verr != nil {
|
||||
return verr
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE paliad.templates SET current_version_id = $1, updated_at = now() WHERE id = $2`,
|
||||
versionID, tid); err != nil {
|
||||
return fmt.Errorf("pin current version: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.GetVersion(ctx, versionID.String())
|
||||
}
|
||||
|
||||
// insertTemplateVersion inserts a version row + its slots inside tx and
|
||||
// returns the new version id.
|
||||
func insertTemplateVersion(ctx context.Context, tx *sqlx.Tx, templateID uuid.UUID, version int, v docforge.TemplateVersionInput) (uuid.UUID, error) {
|
||||
createdBy, err := uuid.Parse(v.CreatedBy)
|
||||
if err != nil {
|
||||
return uuid.Nil, fmt.Errorf("insert template version: invalid created_by: %w", err)
|
||||
}
|
||||
stylemap := v.Stylemap
|
||||
if stylemap == nil {
|
||||
stylemap = map[string]string{}
|
||||
}
|
||||
smJSON, err := json.Marshal(stylemap)
|
||||
if err != nil {
|
||||
return uuid.Nil, fmt.Errorf("marshal stylemap: %w", err)
|
||||
}
|
||||
var versionID uuid.UUID
|
||||
if err := tx.GetContext(ctx, &versionID,
|
||||
`INSERT INTO paliad.template_versions
|
||||
(template_id, version, carrier_blob, stylemap, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id`,
|
||||
templateID, version, v.CarrierBytes, smJSON, createdBy); err != nil {
|
||||
return uuid.Nil, fmt.Errorf("insert template version: %w", err)
|
||||
}
|
||||
for i, slot := range v.Slots {
|
||||
order := slot.OrderIndex
|
||||
if order == 0 {
|
||||
order = i
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO paliad.template_slots
|
||||
(template_version_id, slot_key, anchor, label, order_index)
|
||||
VALUES ($1, $2, $3, NULLIF($4, ''), $5)`,
|
||||
versionID, slot.Key, slot.Anchor, slot.Label, order); err != nil {
|
||||
return uuid.Nil, fmt.Errorf("insert template slot %q: %w", slot.Key, err)
|
||||
}
|
||||
}
|
||||
return versionID, nil
|
||||
}
|
||||
|
||||
// inTx runs fn inside a transaction, committing on success and rolling
|
||||
// back on error or panic.
|
||||
func (s *PgTemplateStore) inTx(ctx context.Context, fn func(tx *sqlx.Tx) error) error {
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if p := recover(); p != nil {
|
||||
_ = tx.Rollback()
|
||||
panic(p)
|
||||
}
|
||||
}()
|
||||
if err := fn(tx); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit tx: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// decodeStylemap unmarshals the stylemap jsonb; an empty/invalid value
|
||||
// yields an empty map so callers never deref nil.
|
||||
func decodeStylemap(raw []byte) map[string]string {
|
||||
out := map[string]string{}
|
||||
if len(raw) == 0 {
|
||||
return out
|
||||
}
|
||||
_ = json.Unmarshal(raw, &out)
|
||||
return out
|
||||
}
|
||||
146
internal/services/template_store_live_test.go
Normal file
146
internal/services/template_store_live_test.go
Normal file
@@ -0,0 +1,146 @@
|
||||
package services
|
||||
|
||||
// Live-DB integration tests for PgTemplateStore (t-paliad-349 slice 4).
|
||||
// Skipped when TEST_DATABASE_URL is unset, mirroring the other live-DB
|
||||
// tests. Exercises the full round-trip: Create (version 1) → Get →
|
||||
// GetVersion → AddVersion (version 2, current re-pointed) → List, asserting
|
||||
// the carrier bytes, stylemap, and slots persist and resolve intact.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/lib/pq"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/db"
|
||||
"mgit.msbls.de/m/paliad/pkg/docforge"
|
||||
)
|
||||
|
||||
func TestPgTemplateStore_RoundTrip(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()
|
||||
store := NewPgTemplateStore(pool)
|
||||
author := uuid.NewString()
|
||||
|
||||
carrierV1 := []byte("PK\x03\x04 fake docx carrier v1")
|
||||
tmpl, err := store.Create(ctx,
|
||||
docforge.TemplateMetaInput{
|
||||
NameDE: "Test-Vorlage",
|
||||
NameEN: "Test template",
|
||||
Firm: "HLC",
|
||||
CreatedBy: author,
|
||||
},
|
||||
docforge.TemplateVersionInput{
|
||||
CarrierBytes: carrierV1,
|
||||
Stylemap: map[string]string{"paragraph": "Normal", "heading_1": "Heading 1"},
|
||||
Slots: []docforge.TemplateSlot{
|
||||
{Key: "project.case_number", Anchor: "{{project.case_number}}", Label: "Aktenzeichen", OrderIndex: 0},
|
||||
{Key: "parties.claimant.0.name", Anchor: "{{parties.claimant.0.name}}", OrderIndex: 1},
|
||||
},
|
||||
CreatedBy: author,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
// Clean up the row (cascades to versions + slots) regardless of outcome.
|
||||
defer func() {
|
||||
_, _ = pool.ExecContext(ctx, `DELETE FROM paliad.templates WHERE id = $1`, tmpl.ID)
|
||||
}()
|
||||
|
||||
// --- Create assertions: version 1, defaults applied, content intact.
|
||||
if tmpl.Version != 1 {
|
||||
t.Errorf("Create version = %d; want 1", tmpl.Version)
|
||||
}
|
||||
if tmpl.Kind != "submission" || tmpl.SourceFormat != "docx" {
|
||||
t.Errorf("defaults: kind=%q format=%q; want submission/docx", tmpl.Kind, tmpl.SourceFormat)
|
||||
}
|
||||
if !bytes.Equal(tmpl.CarrierBytes, carrierV1) {
|
||||
t.Errorf("carrier round-trip mismatch: got %q", tmpl.CarrierBytes)
|
||||
}
|
||||
if tmpl.Stylemap["heading_1"] != "Heading 1" {
|
||||
t.Errorf("stylemap[heading_1] = %q; want 'Heading 1'", tmpl.Stylemap["heading_1"])
|
||||
}
|
||||
if len(tmpl.Slots) != 2 {
|
||||
t.Fatalf("len(slots) = %d; want 2", len(tmpl.Slots))
|
||||
}
|
||||
if tmpl.Slots[0].Key != "project.case_number" || tmpl.Slots[0].Label != "Aktenzeichen" {
|
||||
t.Errorf("slot[0] = %+v; want project.case_number/Aktenzeichen", tmpl.Slots[0])
|
||||
}
|
||||
|
||||
// --- Get by template id resolves the current version.
|
||||
got, err := store.Get(ctx, tmpl.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
if got.Version != 1 || !bytes.Equal(got.CarrierBytes, carrierV1) || len(got.Slots) != 2 {
|
||||
t.Errorf("Get current version mismatch: v=%d slots=%d", got.Version, len(got.Slots))
|
||||
}
|
||||
|
||||
// --- AddVersion bumps to 2 and re-points current.
|
||||
carrierV2 := []byte("PK\x03\x04 fake docx carrier v2 edited")
|
||||
v2, err := store.AddVersion(ctx, tmpl.ID, docforge.TemplateVersionInput{
|
||||
CarrierBytes: carrierV2,
|
||||
Stylemap: map[string]string{"paragraph": "HLpat-Body-B0"},
|
||||
Slots: []docforge.TemplateSlot{{Key: "today", Anchor: "{{today}}", OrderIndex: 0}},
|
||||
CreatedBy: author,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("AddVersion: %v", err)
|
||||
}
|
||||
if v2.Version != 2 {
|
||||
t.Errorf("AddVersion version = %d; want 2", v2.Version)
|
||||
}
|
||||
if !bytes.Equal(v2.CarrierBytes, carrierV2) || len(v2.Slots) != 1 || v2.Slots[0].Key != "today" {
|
||||
t.Errorf("AddVersion content mismatch: carrier/slots wrong")
|
||||
}
|
||||
|
||||
// Get now resolves version 2 (current re-pointed).
|
||||
cur, err := store.Get(ctx, tmpl.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Get after AddVersion: %v", err)
|
||||
}
|
||||
if cur.Version != 2 || !bytes.Equal(cur.CarrierBytes, carrierV2) {
|
||||
t.Errorf("Get after AddVersion = v%d; want v2 with new carrier", cur.Version)
|
||||
}
|
||||
|
||||
// --- List reflects the current version number, filtered by firm.
|
||||
metas, err := store.List(ctx, docforge.TemplateFilter{Firm: "HLC", ActiveOnly: true})
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
var found *docforge.TemplateMeta
|
||||
for i := range metas {
|
||||
if metas[i].ID == tmpl.ID {
|
||||
found = &metas[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
t.Fatalf("List did not return the created template")
|
||||
}
|
||||
if found.Version != 2 {
|
||||
t.Errorf("List version = %d; want 2 (current)", found.Version)
|
||||
}
|
||||
|
||||
// --- Unknown id → ErrTemplateNotFound.
|
||||
if _, err := store.Get(ctx, uuid.NewString()); !errors.Is(err, docforge.ErrTemplateNotFound) {
|
||||
t.Errorf("Get(unknown) err = %v; want ErrTemplateNotFound", err)
|
||||
}
|
||||
}
|
||||
24
pkg/docforge/doc.go
Normal file
24
pkg/docforge/doc.go
Normal file
@@ -0,0 +1,24 @@
|
||||
// Package docforge is paliad's modular document-generator engine — the
|
||||
// format-neutral core that turns templates + variables into rendered
|
||||
// documents, with format-specific adapters living in sub-packages.
|
||||
//
|
||||
// The package is being extracted from the in-tree submission generator
|
||||
// (internal/services/submission_*.go) per the PRD in
|
||||
// docs/plans/prd-docforge-2026-05-29.md (t-paliad-349 / m/paliad#157).
|
||||
// The extraction follows the same packaging discipline as
|
||||
// pkg/litigationplanner: docforge owns its types and exposes interfaces
|
||||
// for the stateful inputs (variable resolution, template storage); the
|
||||
// consuming application (paliad) implements those interfaces against its
|
||||
// own database, and a future second consumer reaches the engine over an
|
||||
// HTTP veneer rather than importing it.
|
||||
//
|
||||
// Slice 1 (this commit) relocates the .docx adapter — the Markdown→OOXML
|
||||
// walker, the placeholder substitution engine, and the .dotm→.docx
|
||||
// converter — into pkg/docforge/docx with no behaviour change. paliad's
|
||||
// internal/services package keeps thin type-alias + forwarder shims so
|
||||
// the submission generator and its HTTP surface compile and behave
|
||||
// identically. Later slices introduce the neutral document model,
|
||||
// hoist the format-neutral placeholder grammar up to this root package,
|
||||
// and add the VariableResolver interface, the TemplateStore, the
|
||||
// authoring surface, and the pluggable Exporter.
|
||||
package docforge
|
||||
172
pkg/docforge/docx/authoring.go
Normal file
172
pkg/docforge/docx/authoring.go
Normal file
@@ -0,0 +1,172 @@
|
||||
package docx
|
||||
|
||||
// Authoring support — the .docx side of the docforge authoring surface
|
||||
// (t-paliad-349 slice 6). Two operations back the "upload a base .docx →
|
||||
// place variable slots" flow:
|
||||
//
|
||||
// ImportForAuthoring — parse an uploaded .docx into a run-addressable
|
||||
// preview (one <span data-run="N"> per <w:t>, in document order) plus
|
||||
// the slots already present in the carrier.
|
||||
// InjectSlot — replace a selected piece of text inside run N with a
|
||||
// {{slot_key}} placeholder, returning the new carrier bytes. The
|
||||
// placeholder is the sentinel that locates the slot (PRD §5 lean) and
|
||||
// the same token the generation-time renderer substitutes.
|
||||
//
|
||||
// Both walk runs in the same order (paragraphs, then <w:t> within), so the
|
||||
// data-run indices the preview emits address exactly the runs InjectSlot
|
||||
// targets. Injection keys on the selected text
|
||||
// (not a byte/UTF-16 offset) so umlauts in German prose can't desync the
|
||||
// client's selection from the server's slice.
|
||||
//
|
||||
// v1 scope (PRD §2.1): text-level slots inside body paragraphs. A run is a
|
||||
// <w:t> within a <w:p>; selections spanning runs or sitting in
|
||||
// headers/footers/tables are out of scope and surface as an error the UI
|
||||
// turns into "select within a single text span".
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"mgit.msbls.de/m/paliad/pkg/docforge"
|
||||
)
|
||||
|
||||
// AuthoringView is the parsed, run-addressable form of an uploaded
|
||||
// template, ready for the authoring editor.
|
||||
type AuthoringView struct {
|
||||
// PreviewHTML is the body rendered as paragraphs of run spans:
|
||||
// <p>…<span class="docforge-run" data-run="N">text</span>…</p>.
|
||||
// The client attaches selection handling to the run spans; data-run
|
||||
// is the index InjectSlot expects.
|
||||
PreviewHTML string
|
||||
// Slots are the {{placeholder}} tokens already present in the
|
||||
// carrier (so re-opening a saved template shows its slots).
|
||||
Slots []docforge.TemplateSlot
|
||||
}
|
||||
|
||||
// ImportForAuthoring parses carrierBytes (any .docx/.dotm/...) into an
|
||||
// AuthoringView. Runs the .dotm→.docx pre-pass so macro templates import
|
||||
// cleanly.
|
||||
func ImportForAuthoring(carrierBytes []byte) (*AuthoringView, error) {
|
||||
clean, err := ConvertDotmToDocx(carrierBytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("authoring import: convert: %w", err)
|
||||
}
|
||||
documentXML, _, err := splitBaseZip(clean)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("authoring import: %w", err)
|
||||
}
|
||||
return &AuthoringView{
|
||||
PreviewHTML: authoringPreviewHTML(documentXML),
|
||||
Slots: detectSlots(documentXML),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// authoringPreviewHTML renders the body as run-addressable HTML. One <p>
|
||||
// per <w:p>; one <span class="docforge-run" data-run="N"> per <w:t>, with
|
||||
// the decoded run text HTML-escaped. N is the global run index in
|
||||
// document-then-paragraph order — the same order InjectSlot walks.
|
||||
func authoringPreviewHTML(documentXML []byte) string {
|
||||
var out bytes.Buffer
|
||||
runIdx := 0
|
||||
paras := wParagraphRegex.FindAll(documentXML, -1)
|
||||
for _, para := range paras {
|
||||
out.WriteString("<p>")
|
||||
for _, m := range wTextNodeRegex.FindAllSubmatch(para, -1) {
|
||||
text := xmlDecode(string(m[2]))
|
||||
out.WriteString(`<span class="docforge-run" data-run="`)
|
||||
out.WriteString(strconv.Itoa(runIdx))
|
||||
out.WriteString(`">`)
|
||||
out.WriteString(htmlEscape(text))
|
||||
out.WriteString(`</span>`)
|
||||
runIdx++
|
||||
}
|
||||
out.WriteString("</p>\n")
|
||||
}
|
||||
if out.Len() == 0 {
|
||||
return "<p></p>"
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
// detectSlots returns the distinct {{placeholder}} tokens present in the
|
||||
// document body, in first-appearance order.
|
||||
func detectSlots(documentXML []byte) []docforge.TemplateSlot {
|
||||
seen := map[string]bool{}
|
||||
var slots []docforge.TemplateSlot
|
||||
// Match against decoded text so a placeholder split by an entity is
|
||||
// still found the same way the renderer would substitute it.
|
||||
for _, m := range wTextNodeRegex.FindAllSubmatch(documentXML, -1) {
|
||||
text := xmlDecode(string(m[2]))
|
||||
for _, pm := range placeholderRegex.FindAllStringSubmatch(text, -1) {
|
||||
key := pm[1]
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
slots = append(slots, docforge.TemplateSlot{
|
||||
Key: key,
|
||||
Anchor: "{{" + key + "}}",
|
||||
OrderIndex: len(slots),
|
||||
})
|
||||
}
|
||||
}
|
||||
return slots
|
||||
}
|
||||
|
||||
// InjectSlot replaces the first occurrence of selectedText inside run
|
||||
// runIndex with a {{slotKey}} placeholder and returns the new carrier
|
||||
// bytes. Errors when the run is out of range or selectedText isn't found
|
||||
// in that run (a render/selection desync, or a cross-run selection).
|
||||
func InjectSlot(carrierBytes []byte, runIndex int, selectedText, slotKey string) ([]byte, error) {
|
||||
if selectedText == "" {
|
||||
return nil, fmt.Errorf("authoring inject: empty selection")
|
||||
}
|
||||
if !placeholderRegex.MatchString("{{" + slotKey + "}}") {
|
||||
return nil, fmt.Errorf("authoring inject: invalid slot key %q", slotKey)
|
||||
}
|
||||
clean, err := ConvertDotmToDocx(carrierBytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("authoring inject: convert: %w", err)
|
||||
}
|
||||
documentXML, parts, err := splitBaseZip(clean)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("authoring inject: %w", err)
|
||||
}
|
||||
|
||||
runIdx := 0
|
||||
injected := false
|
||||
newDoc := wParagraphRegex.ReplaceAllFunc(documentXML, func(para []byte) []byte {
|
||||
return wTextNodeRegex.ReplaceAllFunc(para, func(tnode []byte) []byte {
|
||||
idx := runIdx
|
||||
runIdx++
|
||||
if injected || idx != runIndex {
|
||||
return tnode
|
||||
}
|
||||
sub := wTextNodeRegex.FindSubmatch(tnode)
|
||||
attrs := string(sub[1])
|
||||
content := xmlDecode(string(sub[2]))
|
||||
before, after, found := strings.Cut(content, selectedText)
|
||||
if !found {
|
||||
return tnode // not found here — reported after the walk
|
||||
}
|
||||
newContent := before + "{{" + slotKey + "}}" + after
|
||||
if !strings.Contains(attrs, "xml:space") &&
|
||||
(strings.HasPrefix(newContent, " ") || strings.HasSuffix(newContent, " ")) {
|
||||
attrs += ` xml:space="preserve"`
|
||||
}
|
||||
injected = true
|
||||
return []byte(`<w:t` + attrs + `>` + xmlEncode(newContent) + `</w:t>`)
|
||||
})
|
||||
})
|
||||
if !injected {
|
||||
return nil, fmt.Errorf("authoring inject: selection %q not found in run %d", selectedText, runIndex)
|
||||
}
|
||||
|
||||
repacked, err := repackBaseZip(parts, newDoc)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("authoring inject: %w", err)
|
||||
}
|
||||
return repacked, nil
|
||||
}
|
||||
111
pkg/docforge/docx/authoring_test.go
Normal file
111
pkg/docforge/docx/authoring_test.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package docx
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// docBody wraps a <w:body> inner string into a full document.xml.
|
||||
func docBody(inner string) string {
|
||||
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
|
||||
`<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">` +
|
||||
`<w:body>` + inner + `</w:body></w:document>`
|
||||
}
|
||||
|
||||
func TestImportForAuthoring_PreviewIsRunAddressable(t *testing.T) {
|
||||
body := docBody(
|
||||
`<w:p><w:r><w:t>Az. 4c O 12/23</w:t></w:r></w:p>` +
|
||||
`<w:p><w:r><w:t>Klägerin</w:t></w:r><w:r><w:t xml:space="preserve"> GmbH</w:t></w:r></w:p>`)
|
||||
view, err := ImportForAuthoring(minimalMergeDOCX(t, body))
|
||||
if err != nil {
|
||||
t.Fatalf("ImportForAuthoring: %v", err)
|
||||
}
|
||||
// Three <w:t> → three run spans, indexed 0,1,2 in document order.
|
||||
for i, want := range []string{`data-run="0"`, `data-run="1"`, `data-run="2"`} {
|
||||
if !strings.Contains(view.PreviewHTML, want) {
|
||||
t.Errorf("preview missing %s (run %d); html=%s", want, i, view.PreviewHTML)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(view.PreviewHTML, "Az. 4c O 12/23") {
|
||||
t.Errorf("preview missing run text; html=%s", view.PreviewHTML)
|
||||
}
|
||||
// Two paragraphs.
|
||||
if n := strings.Count(view.PreviewHTML, "<p>"); n != 2 {
|
||||
t.Errorf("paragraph count = %d; want 2", n)
|
||||
}
|
||||
if len(view.Slots) != 0 {
|
||||
t.Errorf("fresh doc should have no slots; got %v", view.Slots)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportForAuthoring_DetectsExistingSlots(t *testing.T) {
|
||||
body := docBody(`<w:p><w:r><w:t>Az. {{project.case_number}} vor {{project.court}}</w:t></w:r></w:p>`)
|
||||
view, err := ImportForAuthoring(minimalMergeDOCX(t, body))
|
||||
if err != nil {
|
||||
t.Fatalf("ImportForAuthoring: %v", err)
|
||||
}
|
||||
if len(view.Slots) != 2 {
|
||||
t.Fatalf("slots = %d; want 2 (%v)", len(view.Slots), view.Slots)
|
||||
}
|
||||
if view.Slots[0].Key != "project.case_number" || view.Slots[0].Anchor != "{{project.case_number}}" {
|
||||
t.Errorf("slot[0] = %+v; want project.case_number", view.Slots[0])
|
||||
}
|
||||
if view.Slots[1].Key != "project.court" {
|
||||
t.Errorf("slot[1].Key = %q; want project.court", view.Slots[1].Key)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectSlot_ReplacesSelectionWithPlaceholder(t *testing.T) {
|
||||
body := docBody(`<w:p><w:r><w:t>Az. 4c O 12/23 vor dem LG</w:t></w:r></w:p>`)
|
||||
out, err := InjectSlot(minimalMergeDOCX(t, body), 0, "4c O 12/23", "project.case_number")
|
||||
if err != nil {
|
||||
t.Fatalf("InjectSlot: %v", err)
|
||||
}
|
||||
doc := readMergeDocumentXML(t, out)
|
||||
if !strings.Contains(doc, "Az. {{project.case_number}} vor dem LG") {
|
||||
t.Errorf("injected doc wrong; got %s", doc)
|
||||
}
|
||||
// Round-trips: re-importing finds the new slot.
|
||||
view, err := ImportForAuthoring(out)
|
||||
if err != nil {
|
||||
t.Fatalf("re-import: %v", err)
|
||||
}
|
||||
if len(view.Slots) != 1 || view.Slots[0].Key != "project.case_number" {
|
||||
t.Errorf("re-imported slots = %v; want [project.case_number]", view.Slots)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectSlot_TargetsTheNamedRun(t *testing.T) {
|
||||
// "GmbH" appears in run 1 only; "Müller" (with umlaut) in run 0.
|
||||
body := docBody(
|
||||
`<w:p><w:r><w:t>Müller</w:t></w:r><w:r><w:t xml:space="preserve"> GmbH</w:t></w:r></w:p>`)
|
||||
out, err := InjectSlot(minimalMergeDOCX(t, body), 0, "Müller", "parties.claimant.name")
|
||||
if err != nil {
|
||||
t.Fatalf("InjectSlot: %v", err)
|
||||
}
|
||||
doc := readMergeDocumentXML(t, out)
|
||||
if !strings.Contains(doc, "{{parties.claimant.name}}") {
|
||||
t.Errorf("umlaut selection not replaced; got %s", doc)
|
||||
}
|
||||
if !strings.Contains(doc, " GmbH") {
|
||||
t.Errorf("run 1 should be untouched; got %s", doc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectSlot_ErrorsWhenSelectionNotInRun(t *testing.T) {
|
||||
body := docBody(`<w:p><w:r><w:t>Hello</w:t></w:r></w:p>`)
|
||||
if _, err := InjectSlot(minimalMergeDOCX(t, body), 0, "Goodbye", "firm.name"); err == nil {
|
||||
t.Error("expected error when selection absent from run; got nil")
|
||||
}
|
||||
// Out-of-range run index.
|
||||
if _, err := InjectSlot(minimalMergeDOCX(t, body), 9, "Hello", "firm.name"); err == nil {
|
||||
t.Error("expected error for out-of-range run index; got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectSlot_RejectsInvalidSlotKey(t *testing.T) {
|
||||
body := docBody(`<w:p><w:r><w:t>Hello</w:t></w:r></w:p>`)
|
||||
if _, err := InjectSlot(minimalMergeDOCX(t, body), 0, "Hello", "9bad-key!"); err == nil {
|
||||
t.Error("expected error for invalid slot key; got nil")
|
||||
}
|
||||
}
|
||||
636
pkg/docforge/docx/compose.go
Normal file
636
pkg/docforge/docx/compose.go
Normal file
@@ -0,0 +1,636 @@
|
||||
package docx
|
||||
|
||||
// Composer render pipeline — t-paliad-313 Slice B (design doc §9.1 +
|
||||
// §9.2). Assembles a base .docx and a draft's section rows into a
|
||||
// merged .docx ready for export.
|
||||
//
|
||||
// Pipeline (high-level):
|
||||
//
|
||||
// 1. ConvertDotmToDocx pre-pass on the base bytes (idempotent on .docx).
|
||||
// 2. Locate `word/document.xml` inside the zip; pull the body XML.
|
||||
// 3. For each section in the draft (order_index ASC, included=true):
|
||||
// render content_md_<lang> → OOXML via RenderMarkdownToOOXML using
|
||||
// base.section_spec.stylemap.paragraph.
|
||||
// 4. Splice the rendered OOXML into the base body. Two splice modes:
|
||||
// - Anchor mode: when the body carries `{{#section:KEY}}` /
|
||||
// `{{/section:KEY}}` marker pairs, replace the slot's content
|
||||
// (including the anchor paragraphs themselves) with the rendered
|
||||
// section.
|
||||
// - Append mode: when no anchor pair is found for a section, the
|
||||
// rendered OOXML appends at the end of the body, just before any
|
||||
// `<w:sectPr>` element. Sections with `included=false` are
|
||||
// dropped silently.
|
||||
// 5. Strip any leftover unmatched anchor paragraphs.
|
||||
// 6. Re-pack the document.xml into the zip, leaving every other part
|
||||
// untouched.
|
||||
// 7. Run the v1 SubmissionRenderer placeholder pass over the assembly
|
||||
// so `{{path}}` placeholders inside section content (and inside
|
||||
// the base's untouched chrome) get substituted by the merged bag.
|
||||
// Cross-run merge in pass 2 handles autocorrect-fragmented
|
||||
// placeholders the same as v1.
|
||||
//
|
||||
// Result: a fully-merged .docx. No new third-party Go dep — reuses
|
||||
// archive/zip + the existing SubmissionRenderer.
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"mgit.msbls.de/m/paliad/pkg/docforge"
|
||||
)
|
||||
|
||||
// Composer assembles base + sections into a final .docx.
|
||||
// Stateless; safe for concurrent use.
|
||||
type Composer struct {
|
||||
renderer *SubmissionRenderer
|
||||
}
|
||||
|
||||
// NewComposer wires the composer. The renderer is required —
|
||||
// a nil renderer is a programmer error and the composer panics at
|
||||
// construction.
|
||||
func NewComposer(renderer *SubmissionRenderer) *Composer {
|
||||
if renderer == nil {
|
||||
panic("submission composer: renderer required")
|
||||
}
|
||||
return &Composer{renderer: renderer}
|
||||
}
|
||||
|
||||
// Carrier is the opaque base document the composer splices rendered
|
||||
// content into. Its bytes are preserved verbatim outside the regions the
|
||||
// splice touches — the {{#section:KEY}} anchor paragraphs and the
|
||||
// {{placeholder}} tokens — so the firm's letterhead, styles, headers, and
|
||||
// footers survive a compose byte-for-byte. This is the docforge "carrier"
|
||||
// for the .docx format: the lossless host for editable content.
|
||||
type Carrier struct {
|
||||
// Bytes is the raw base .docx. May be a .dotm/.docm/.dotx; Compose
|
||||
// runs ConvertDotmToDocx on it first (idempotent on a plain .docx).
|
||||
Bytes []byte
|
||||
|
||||
// Stylemap maps a logical block kind (paragraph, heading_1/2/3,
|
||||
// list_bullet, list_numbered, blockquote) to the Word paragraph
|
||||
// style name the base defines for it. Drives the Markdown walker's
|
||||
// <w:pStyle>. Missing entries fall back to the "paragraph" style.
|
||||
Stylemap map[string]string
|
||||
}
|
||||
|
||||
// Section is one editable content block the composer renders and splices.
|
||||
// It is the format-neutral input the docforge engine consumes; the
|
||||
// consuming application maps its own row type onto it (paliad maps
|
||||
// SubmissionSection → Section).
|
||||
type Section struct {
|
||||
// Key matches a {{#section:KEY}} anchor in the carrier, or — when no
|
||||
// anchor matches — marks an append-mode section.
|
||||
Key string
|
||||
// OrderIndex sets append-mode ordering (ascending).
|
||||
OrderIndex int
|
||||
// Included=false drops the section entirely.
|
||||
Included bool
|
||||
// ContentMDDE / ContentMDEN are the bilingual Markdown sources; Lang
|
||||
// selects which one renders.
|
||||
ContentMDDE string
|
||||
ContentMDEN string
|
||||
}
|
||||
|
||||
// ComposeOptions carries the per-call composition inputs.
|
||||
type ComposeOptions struct {
|
||||
// Sections are the draft's section rows in display order. The
|
||||
// composer renders included sections; excluded rows are dropped.
|
||||
// Caller is responsible for visibility — by the time the composer
|
||||
// runs, the section rows have already been gated by the caller.
|
||||
Sections []Section
|
||||
|
||||
// Carrier is the base .docx chrome plus its stylemap. Required.
|
||||
Carrier Carrier
|
||||
|
||||
// Lang ('de' or 'en') selects which content_md_* column the
|
||||
// composer reads per section. Defaults to 'de' if empty.
|
||||
Lang string
|
||||
|
||||
// Vars is the merged placeholder bag the v1 renderer pass
|
||||
// substitutes after the composer assembly. Passed straight through
|
||||
// to SubmissionRenderer.Render.
|
||||
Vars docforge.PlaceholderMap
|
||||
|
||||
// Missing translates an unbound placeholder key into the marker
|
||||
// the lawyer sees in Word. Passed straight to the renderer.
|
||||
Missing docforge.MissingPlaceholderFn
|
||||
}
|
||||
|
||||
// Compose runs the full pipeline and returns the merged .docx bytes.
|
||||
func (c *Composer) Compose(ctx context.Context, opts ComposeOptions) ([]byte, error) {
|
||||
_ = ctx // reserved for cancellation propagation in later slices
|
||||
sections := opts.Sections
|
||||
|
||||
// Pre-pass: strip macros so the base reads as a plain .docx zip.
|
||||
cleanBytes, err := ConvertDotmToDocx(opts.Carrier.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("submission compose: convert base: %w", err)
|
||||
}
|
||||
|
||||
// Locate + extract word/document.xml so we can splice in-place.
|
||||
documentXML, otherParts, err := splitBaseZip(cleanBytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Per-compose hyperlink allocator. Each unique URL gets a fresh
|
||||
// rId outside the base's existing namespace. The post-pass
|
||||
// (patchDocumentXMLRels) writes the matching Relationship rows
|
||||
// before the zip is repacked. Slice D adds inline `[label](url)`
|
||||
// hyperlink support.
|
||||
linkAlloc := newComposerLinkAllocator()
|
||||
|
||||
// Build the rendered-section map: section_key → OOXML span.
|
||||
stylemap := opts.Carrier.Stylemap
|
||||
rendered := make(map[string]string, len(sections))
|
||||
keptSections := make([]Section, 0, len(sections))
|
||||
for _, sec := range sections {
|
||||
if !sec.Included {
|
||||
continue
|
||||
}
|
||||
md := sec.ContentMDDE
|
||||
if strings.EqualFold(opts.Lang, "en") {
|
||||
md = sec.ContentMDEN
|
||||
}
|
||||
rendered[sec.Key] = RenderMarkdownToOOXMLWithStyles(md, stylemap, linkAlloc.Alloc)
|
||||
keptSections = append(keptSections, sec)
|
||||
}
|
||||
// Stable order — already sorted ascending by ListForDraft, but
|
||||
// belt-and-braces in case the caller swaps the ordering policy
|
||||
// later.
|
||||
sort.SliceStable(keptSections, func(i, j int) bool {
|
||||
return keptSections[i].OrderIndex < keptSections[j].OrderIndex
|
||||
})
|
||||
|
||||
assembledBody := spliceSections(documentXML, rendered, keptSections, sections)
|
||||
|
||||
// Slice D hyperlink patch: when the walker emitted hyperlink rIds
|
||||
// for inline `[label](url)` links, the base's
|
||||
// word/_rels/document.xml.rels needs matching <Relationship>
|
||||
// entries so Word can resolve the rIds. Mutates one zip part in
|
||||
// otherParts (or appends if missing).
|
||||
if linkAlloc.HasLinks() {
|
||||
updatedParts, err := patchDocumentXMLRels(otherParts, linkAlloc.Pairs())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
otherParts = updatedParts
|
||||
}
|
||||
|
||||
// Re-pack into a zip with the assembled document.xml. All other
|
||||
// parts (styles, fonts, headers, footers, theme, settings) pass
|
||||
// through bit-for-bit at their original mtime + compression.
|
||||
repacked, err := repackBaseZip(otherParts, assembledBody)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Final pass: substitute placeholders against the merged bag. The
|
||||
// existing renderer handles cross-run fragmentation, the `{{rule.X}}`
|
||||
// alias contract, and the missing-marker emission. Reusing it
|
||||
// guarantees v1's placeholder grammar stays intact inside section
|
||||
// content + base chrome.
|
||||
merged, err := c.renderer.Render(repacked, opts.Vars, opts.Missing)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("submission compose: placeholder pass: %w", err)
|
||||
}
|
||||
return merged, nil
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Section splicing
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Anchor markers as they appear inside a <w:t> text node. We don't
|
||||
// need a full XML parse — finding the marker text inside the body is
|
||||
// sufficient because:
|
||||
// - {{ and }} are never legitimate document content (placeholders
|
||||
// follow the same convention everywhere else in paliad).
|
||||
// - The anchor key grammar [A-Za-z0-9_]+ rules out any HTML/XML
|
||||
// special characters.
|
||||
// - Each anchor lives in exactly one <w:t>...<w:t>, which lives in
|
||||
// exactly one <w:r>...</w:r>, which lives in exactly one
|
||||
// <w:p>...</w:p>. We expand from the marker outward to find the
|
||||
// enclosing <w:p> span and drop the entire paragraph as part of
|
||||
// the splice.
|
||||
//
|
||||
// RE2 has no lookahead, so the "find enclosing <w:p>" logic is
|
||||
// implemented as manual byte-index search around the marker hit
|
||||
// (anchorParagraphSpan below) rather than a single regex pattern.
|
||||
|
||||
const (
|
||||
anchorOpenPrefix = "{{#section:"
|
||||
anchorClosePrefix = "{{/section:"
|
||||
anchorSuffix = "}}"
|
||||
)
|
||||
|
||||
// anchorKeyRegex validates that the captured anchor key is a clean
|
||||
// identifier. Keys that include other characters (which can't actually
|
||||
// appear in our authored .docx) are treated as no match.
|
||||
var anchorKeyRegex = regexp.MustCompile(`^[A-Za-z0-9_]+$`)
|
||||
|
||||
// anchorPair records the byte span of one matched anchor pair inside
|
||||
// the body — from the start of the opening anchor's <w:p> element
|
||||
// through the end of the closing anchor's </w:p>.
|
||||
type anchorPair struct {
|
||||
key string
|
||||
openStart int // start of <w:p> for the opening anchor
|
||||
closeEnd int // index just past </w:p> for the closing anchor
|
||||
}
|
||||
|
||||
// findAllAnchorPairs scans the body for matched open/close anchor
|
||||
// pairs. Unbalanced markers (open without close, or vice versa) are
|
||||
// dropped from the result. Returns pairs in body-order; each pair's
|
||||
// span is non-overlapping.
|
||||
func findAllAnchorPairs(body string) []anchorPair {
|
||||
type marker struct {
|
||||
key string
|
||||
paraStart int
|
||||
paraEnd int
|
||||
isOpen bool
|
||||
}
|
||||
var markers []marker
|
||||
|
||||
collect := func(prefix string, isOpen bool) {
|
||||
offset := 0
|
||||
for {
|
||||
idx := strings.Index(body[offset:], prefix)
|
||||
if idx < 0 {
|
||||
return
|
||||
}
|
||||
start := offset + idx
|
||||
suffixIdx := strings.Index(body[start+len(prefix):], anchorSuffix)
|
||||
if suffixIdx < 0 {
|
||||
return
|
||||
}
|
||||
key := body[start+len(prefix) : start+len(prefix)+suffixIdx]
|
||||
if !anchorKeyRegex.MatchString(key) {
|
||||
offset = start + len(prefix)
|
||||
continue
|
||||
}
|
||||
markerEnd := start + len(prefix) + suffixIdx + len(anchorSuffix)
|
||||
pStart, pEnd, ok := paragraphSpanAround(body, start, markerEnd)
|
||||
if !ok {
|
||||
offset = markerEnd
|
||||
continue
|
||||
}
|
||||
markers = append(markers, marker{key: key, paraStart: pStart, paraEnd: pEnd, isOpen: isOpen})
|
||||
offset = pEnd
|
||||
}
|
||||
}
|
||||
collect(anchorOpenPrefix, true)
|
||||
collect(anchorClosePrefix, false)
|
||||
|
||||
// Walk markers in body-order, matching each open with the next
|
||||
// close that carries the same key.
|
||||
sort.SliceStable(markers, func(i, j int) bool {
|
||||
return markers[i].paraStart < markers[j].paraStart
|
||||
})
|
||||
var pairs []anchorPair
|
||||
openStack := map[string]marker{}
|
||||
for _, m := range markers {
|
||||
if m.isOpen {
|
||||
openStack[m.key] = m
|
||||
continue
|
||||
}
|
||||
o, ok := openStack[m.key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
pairs = append(pairs, anchorPair{
|
||||
key: m.key,
|
||||
openStart: o.paraStart,
|
||||
closeEnd: m.paraEnd,
|
||||
})
|
||||
delete(openStack, m.key)
|
||||
}
|
||||
return pairs
|
||||
}
|
||||
|
||||
// paragraphSpanAround returns the byte span of the smallest `<w:p>...</w:p>`
|
||||
// element that fully contains the byte range [markerStart, markerEnd).
|
||||
// Returns false when the byte range doesn't sit inside a single
|
||||
// paragraph (which would mean the marker survived a cross-paragraph
|
||||
// edit — defensive guard, shouldn't happen in well-formed input).
|
||||
func paragraphSpanAround(body string, markerStart, markerEnd int) (int, int, bool) {
|
||||
// Walk backwards to find the nearest unclosed <w:p ... > opening.
|
||||
// Since <w:p> doesn't nest, the nearest <w:p before markerStart is
|
||||
// the enclosing paragraph's opening tag.
|
||||
pStart := -1
|
||||
cursor := markerStart
|
||||
for cursor > 0 {
|
||||
idx := strings.LastIndex(body[:cursor], "<w:p")
|
||||
if idx < 0 {
|
||||
break
|
||||
}
|
||||
// Confirm this is a paragraph open, not a different
|
||||
// w:p-prefixed tag (e.g. <w:pPr>).
|
||||
if idx+4 <= len(body) {
|
||||
after := body[idx+4]
|
||||
if after == ' ' || after == '>' || after == '/' {
|
||||
// <w:p ...> or <w:p>; not <w:pPr>.
|
||||
close := strings.Index(body[idx:], ">")
|
||||
if close < 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
pStart = idx
|
||||
break
|
||||
}
|
||||
}
|
||||
cursor = idx
|
||||
}
|
||||
if pStart < 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
// Walk forward to find the matching </w:p>. <w:p> doesn't nest so
|
||||
// the next </w:p> after the marker is the close.
|
||||
pEndIdx := strings.Index(body[markerEnd:], "</w:p>")
|
||||
if pEndIdx < 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
pEnd := markerEnd + pEndIdx + len("</w:p>")
|
||||
return pStart, pEnd, true
|
||||
}
|
||||
|
||||
// spliceSections replaces anchor slots with rendered sections and
|
||||
// appends any unanchored sections before sectPr. Returns the assembled
|
||||
// document.xml body.
|
||||
func spliceSections(documentXML []byte, rendered map[string]string, kept []Section, all []Section) []byte {
|
||||
body := string(documentXML)
|
||||
pairs := findAllAnchorPairs(body)
|
||||
|
||||
// Build a lookup of kept section keys for quick membership tests.
|
||||
keptByKey := map[string]int{}
|
||||
for i, sec := range kept {
|
||||
keptByKey[sec.Key] = i
|
||||
}
|
||||
allByKey := map[string]int{}
|
||||
for i, sec := range all {
|
||||
allByKey[sec.Key] = i
|
||||
}
|
||||
|
||||
matchedKeys := map[string]bool{}
|
||||
|
||||
// Walk pairs in REVERSE body-order so slice mutations don't shift
|
||||
// later offsets.
|
||||
sort.SliceStable(pairs, func(i, j int) bool {
|
||||
return pairs[i].openStart > pairs[j].openStart
|
||||
})
|
||||
for _, p := range pairs {
|
||||
replacement := ""
|
||||
if idx, ok := keptByKey[p.key]; ok {
|
||||
replacement = rendered[p.key]
|
||||
matchedKeys[p.key] = true
|
||||
_ = idx
|
||||
} else if _, isOnDraft := allByKey[p.key]; isOnDraft {
|
||||
// Anchor matches an excluded section on the draft — drop
|
||||
// the entire slot.
|
||||
replacement = ""
|
||||
} else {
|
||||
// Anchor doesn't match any section on this draft — drop
|
||||
// to leave the base's chrome unbroken.
|
||||
replacement = ""
|
||||
}
|
||||
body = body[:p.openStart] + replacement + body[p.closeEnd:]
|
||||
}
|
||||
|
||||
// Append unanchored sections before sectPr in order_index ASC.
|
||||
var unanchored strings.Builder
|
||||
for _, sec := range kept {
|
||||
if matchedKeys[sec.Key] {
|
||||
continue
|
||||
}
|
||||
unanchored.WriteString(rendered[sec.Key])
|
||||
}
|
||||
if unanchored.Len() > 0 {
|
||||
body = appendBeforeSectPr(body, unanchored.String())
|
||||
}
|
||||
|
||||
return []byte(body)
|
||||
}
|
||||
|
||||
// appendBeforeSectPr inserts content immediately before the first
|
||||
// `<w:sectPr` element in the body, or at the end of the body if there
|
||||
// is none. Word documents conventionally close the body with a sectPr
|
||||
// describing page setup; we want to land sections before that element
|
||||
// so they show up on the actual pages.
|
||||
var sectPrRegex = regexp.MustCompile(`<w:sectPr\b`)
|
||||
|
||||
func appendBeforeSectPr(body, content string) string {
|
||||
loc := sectPrRegex.FindStringIndex(body)
|
||||
if loc == nil {
|
||||
// No sectPr → append before `</w:body>` if present, else at
|
||||
// the very end.
|
||||
idx := strings.LastIndex(body, "</w:body>")
|
||||
if idx < 0 {
|
||||
return body + content
|
||||
}
|
||||
return body[:idx] + content + body[idx:]
|
||||
}
|
||||
return body[:loc[0]] + content + body[loc[0]:]
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Zip plumbing
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
// baseZipPart captures one zip entry we kept aside while extracting
|
||||
// document.xml.
|
||||
type baseZipPart struct {
|
||||
name string
|
||||
method uint16
|
||||
modTime int64 // wall seconds; converted back to time.Time on repack
|
||||
body []byte
|
||||
}
|
||||
|
||||
// splitBaseZip extracts document.xml and returns it alongside every
|
||||
// other zip entry, ready for repacking.
|
||||
func splitBaseZip(cleanBytes []byte) ([]byte, []baseZipPart, error) {
|
||||
zr, err := zip.NewReader(bytes.NewReader(cleanBytes), int64(len(cleanBytes)))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("submission compose: open base zip: %w", err)
|
||||
}
|
||||
var documentXML []byte
|
||||
parts := make([]baseZipPart, 0, len(zr.File))
|
||||
for _, f := range zr.File {
|
||||
body, err := readZipEntry(f)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("submission compose: read %s: %w", f.Name, err)
|
||||
}
|
||||
if f.Name == "word/document.xml" {
|
||||
documentXML = body
|
||||
parts = append(parts, baseZipPart{name: f.Name, method: f.Method, modTime: f.Modified.Unix(), body: nil})
|
||||
continue
|
||||
}
|
||||
parts = append(parts, baseZipPart{name: f.Name, method: f.Method, modTime: f.Modified.Unix(), body: body})
|
||||
}
|
||||
if documentXML == nil {
|
||||
return nil, nil, fmt.Errorf("submission compose: base zip missing word/document.xml")
|
||||
}
|
||||
return documentXML, parts, nil
|
||||
}
|
||||
|
||||
// repackBaseZip rebuilds the zip, swapping document.xml for the
|
||||
// assembled body and leaving every other part untouched.
|
||||
func repackBaseZip(parts []baseZipPart, assembledBody []byte) ([]byte, error) {
|
||||
var out bytes.Buffer
|
||||
zw := zip.NewWriter(&out)
|
||||
for _, p := range parts {
|
||||
hdr := &zip.FileHeader{
|
||||
Name: p.name,
|
||||
Method: p.method,
|
||||
}
|
||||
if p.modTime > 0 {
|
||||
hdr.Modified = time.Unix(p.modTime, 0)
|
||||
}
|
||||
w, err := zw.CreateHeader(hdr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("submission compose: write header %s: %w", p.name, err)
|
||||
}
|
||||
body := p.body
|
||||
if p.name == "word/document.xml" {
|
||||
body = assembledBody
|
||||
}
|
||||
if _, err := w.Write(body); err != nil {
|
||||
return nil, fmt.Errorf("submission compose: write body %s: %w", p.name, err)
|
||||
}
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
return nil, fmt.Errorf("submission compose: finalise zip: %w", err)
|
||||
}
|
||||
return out.Bytes(), nil
|
||||
}
|
||||
|
||||
func readZipEntry(f *zip.File) ([]byte, error) {
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rc.Close()
|
||||
return io.ReadAll(rc)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Slice D — hyperlink wiring
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
// composerLinkAllocator hands out fresh rIds for inline hyperlink
|
||||
// targets discovered by the MD walker. Each unique URL gets one rId
|
||||
// (deduped — repeated links to the same URL share one Relationship).
|
||||
// Allocations land outside the base's rId namespace by prefixing with
|
||||
// "rIdComposer" so they can't collide with existing relationships.
|
||||
type composerLinkAllocator struct {
|
||||
next int
|
||||
byURL map[string]string
|
||||
order []string // URLs in allocation order
|
||||
}
|
||||
|
||||
func newComposerLinkAllocator() *composerLinkAllocator {
|
||||
return &composerLinkAllocator{byURL: map[string]string{}}
|
||||
}
|
||||
|
||||
// Alloc returns the rId for url, allocating one on first sight.
|
||||
func (a *composerLinkAllocator) Alloc(url string) string {
|
||||
if rid, ok := a.byURL[url]; ok {
|
||||
return rid
|
||||
}
|
||||
a.next++
|
||||
rid := fmt.Sprintf("rIdComposer%d", a.next)
|
||||
a.byURL[url] = rid
|
||||
a.order = append(a.order, url)
|
||||
return rid
|
||||
}
|
||||
|
||||
// HasLinks reports whether any links were allocated during this compose.
|
||||
func (a *composerLinkAllocator) HasLinks() bool {
|
||||
return len(a.order) > 0
|
||||
}
|
||||
|
||||
// Pairs returns the (rId, URL) pairs in allocation order. The
|
||||
// document.xml.rels patcher consumes this to emit <Relationship>
|
||||
// elements.
|
||||
func (a *composerLinkAllocator) Pairs() [][2]string {
|
||||
pairs := make([][2]string, 0, len(a.order))
|
||||
for _, url := range a.order {
|
||||
pairs = append(pairs, [2]string{a.byURL[url], url})
|
||||
}
|
||||
return pairs
|
||||
}
|
||||
|
||||
// patchDocumentXMLRels mutates the word/_rels/document.xml.rels entry
|
||||
// in `parts` to append the given (rId, URL) pairs as hyperlink
|
||||
// relationships. If the rels part doesn't exist (some bases omit it
|
||||
// when the body has no relationships), this function appends a fresh
|
||||
// part with the minimal Relationships wrapper.
|
||||
//
|
||||
// Idempotent on (rId, URL) pairs already present (e.g. when a base
|
||||
// already references the URL for some other reason).
|
||||
//
|
||||
// Returns the (possibly extended) parts slice — callers must overwrite
|
||||
// their reference because the append in the no-rels-yet case grows the
|
||||
// backing array.
|
||||
func patchDocumentXMLRels(parts []baseZipPart, pairs [][2]string) ([]baseZipPart, error) {
|
||||
const path = "word/_rels/document.xml.rels"
|
||||
const hyperlinkType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink"
|
||||
|
||||
existingIdx := -1
|
||||
for i := range parts {
|
||||
if parts[i].name == path {
|
||||
existingIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
var body string
|
||||
if existingIdx >= 0 {
|
||||
body = string(parts[existingIdx].body)
|
||||
} else {
|
||||
body = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
|
||||
`<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"></Relationships>`
|
||||
}
|
||||
|
||||
var inserts strings.Builder
|
||||
for _, p := range pairs {
|
||||
rid := p[0]
|
||||
url := p[1]
|
||||
if strings.Contains(body, `Id="`+rid+`"`) {
|
||||
continue
|
||||
}
|
||||
inserts.WriteString(`<Relationship Id="`)
|
||||
inserts.WriteString(xmlAttrEscape(rid))
|
||||
inserts.WriteString(`" Type="`)
|
||||
inserts.WriteString(hyperlinkType)
|
||||
inserts.WriteString(`" Target="`)
|
||||
inserts.WriteString(xmlAttrEscape(url))
|
||||
inserts.WriteString(`" TargetMode="External"/>`)
|
||||
}
|
||||
|
||||
if inserts.Len() == 0 {
|
||||
return parts, nil
|
||||
}
|
||||
|
||||
closeIdx := strings.LastIndex(body, "</Relationships>")
|
||||
if closeIdx < 0 {
|
||||
return parts, fmt.Errorf("submission compose: malformed document.xml.rels (no closing tag)")
|
||||
}
|
||||
patched := body[:closeIdx] + inserts.String() + body[closeIdx:]
|
||||
|
||||
if existingIdx >= 0 {
|
||||
parts[existingIdx].body = []byte(patched)
|
||||
return parts, nil
|
||||
}
|
||||
parts = append(parts, baseZipPart{
|
||||
name: path,
|
||||
method: zip.Deflate,
|
||||
modTime: time.Now().Unix(),
|
||||
body: []byte(patched),
|
||||
})
|
||||
return parts, nil
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user