Compare commits

..

10 Commits

Author SHA1 Message Date
mAi
af073f87da fix(print): default to portrait, opt-in landscape for wide surfaces (t-paliad-233)
The smart-timeline-chart block in global.css declared @page { size: A4
landscape } inside @media print. @page rules are global even when nested
in selectors, so this leaked landscape onto every printed surface in
paliad — not just the chart.

Switch to named-page strategy:

- Default @page { size: A4 portrait; margin: 1.5cm 1.2cm }
- @page paliad-landscape { size: A4 landscape; margin: 1.5cm }
- @media print: body.<surface> { page: paliad-landscape } opts surfaces
  that need width into landscape via per-page body classes

Landscape opt-ins:
- body.page-kostenrechner — wide fee-tier tables
- body.page-projects-chart — horizontal Smart Timeline chart
- body.events-view-calendar — /events Kalender tab (month grid)
- body.views-shape-active-calendar / -timeline — Custom Views shapes
- body.verfahrensablauf-view-timeline — horizontal procedure timeline

Body classes:
- kostenrechner.tsx, projects-chart.tsx, verfahrensablauf.tsx now set
  page-<slug> on body
- verfahrensablauf.ts toggles verfahrensablauf-view-(timeline|columns)
  in initViewToggle
- views.ts toggles views-shape-active-<shape> in setActiveShape (mirrors
  the existing events.ts events-view-* pattern)

General print polish in the universal block (the catch-all at the bottom
of global.css):
- Hide .fab / .fab-button / .edit-mode-handle / .paliadin-widget /
  [data-print-hide] in print
- thead { display: table-header-group } so headers repeat across pages
- tr/th/td page-break-inside: avoid so rows don't split mid-cell
- h1-h6 page-break-after: avoid, orphans/widows: 3 for p/h*/li
- print-color-adjust: exact on brand-coloured headers + status pills
- a[href^="http"]::after content: " (" attr(href) ")" prints external
  URLs after their link text (opt-out via data-print-url="hide")
- body font-size: 11pt for print readability

Verified via Playwright on static dist build that:
- Default surfaces (dashboard, projects, fristenrechner, agenda, admin)
  match no page: rule → portrait
- kostenrechner, projects-chart match the landscape rule
- verfahrensablauf-view-columns → portrait, -view-timeline → landscape
- views-shape-active-list/-cards → portrait, -calendar/-timeline →
  landscape
- /events default (events-view-cards) → portrait, calendar toggle →
  landscape

go build ./... + go test ./internal/... + bun test (99 pass) + bun
run build all clean.
2026-05-21 22:01:46 +02:00
mAi
f22e918048 Merge: hotfix compose env — SUPABASE_SERVICE_ROLE_KEY 2026-05-21 21:50:43 +02:00
mAi
79d98cfeb8 hotfix(compose): declare SUPABASE_SERVICE_ROLE_KEY in web env block
m reported "Add-User-Pfad ist nicht konfiguriert (SUPABASE_SERVICE_ROLE_KEY
fehlt am Server)" when trying to add a user account on /admin/team.

Root cause: the value was provisioned in Dokploy's compose env block
(I confirmed it via compose.one API), but docker-compose.yml's
`environment:` section never declared the variable. Docker compose
only forwards env vars that are listed in `environment:` — Dokploy's
project-level env is just a source of `${…}` interpolation, not an
automatic injection.

Fix: add `- SUPABASE_SERVICE_ROLE_KEY=${SUPABASE_SERVICE_ROLE_KEY:-}`
alongside the other Supabase keys. The `:-` default keeps the compose
parseable on deployments that haven't provisioned the key (those still
get the existing /admin/team 503 fallback log line).

After the auto-deploy, cmd/server/main.go:139 will log
"supabase admin API configured — /admin/team Add-User path active"
instead of "SUPABASE_SERVICE_ROLE_KEY not set".
2026-05-21 21:50:43 +02:00
mAi
19d95d6f5b Merge: hotfix paliadin /chat/turn user_id 2026-05-21 21:21:37 +02:00
mAi
17d149c09e hotfix(paliadin): ship user_id on /chat/turn (aichat tenant-DB requirement)
m reported "ai chat seems not to be wired anymore" + the frontend
showed "Verbindung verloren. Antwort wird nachgereicht…".

Root cause: aichat on mRiver added a tenant-DB layer that demands
`user_id` on every /chat/turn request:

  {"error":{"code":"bad_request",
            "message":"user_id is required when a tenant DB is
                       configured","retryable":false}}

aichat itself is healthy (/chat/health 200, paliadin session ok:true,
last successful turn was ~2.6h ago). The paliad side built and shipped
an aichatTurnRequest without user_id, so every turn since the tenant-DB
flip 400s; paliad's SSE relay receives no upstream data and closes
empty, producing the user-visible "Verbindung verloren".

Fix: add UserID to aichatTurnRequest (json: user_id, mandatory now),
populate from req.UserID.String() at the call site. The userID was
already in scope (used for JWT mint + username lookup); the struct just
wasn't shipping it.

Regression test in TestRunTurn_HappyPath_ViaCallHTTP asserts
captured.UserID == request UUID so a future struct edit that drops the
field fails CI instead of production.
2026-05-21 21:21:32 +02:00
mAi
7c7030c5bf Merge: t-paliad-232 — Verfahrenstyp picker + Schriftsätze CTA 2026-05-21 15:45:59 +02:00
mAi
da8389b6e3 feat(projects): t-paliad-232 Verfahrenstyp picker + Schriftsätze CTA
Two-part fix from m's 2026-05-21 finding that the Schriftsätze tab
told users "Bitte zuerst einen Verfahrenstyp setzen" while the
project form had no field to set it. The `proceeding_type_id`
column was already on `paliad.projects` and accepted by the API.

  Part 1 — Verfahrenstyp picker on the case-fields block

    * frontend/src/components/ProjectFormFields.tsx — new optional
      <select id="project-proceeding-type-id"> rendered between
      Aktenzeichen and Mandantenrolle inside the type=case block.
      First option is "(nicht gesetzt)" / "(unset)".
    * frontend/src/client/project-form.ts — shared
      loadProceedingTypes() + populateProceedingTypeSelect()
      helpers. Options sorted by `code` (de.* → dpma.* → epa.* →
      upc.*). readPayload sends `proceeding_type_id` only when the
      user picked a value; prefillForm restores the saved id via
      dataset.preselect to survive the async populate race.
    * frontend/src/client/projects-new.ts — kicks off populate on
      DOMContentLoaded.
    * frontend/src/client/projects-detail.ts — edit-modal preload
      now awaits populate; the local loadProceedingTypes duplicate
      (used by the counterclaim modal) is replaced by the shared
      helper so both surfaces hit the same cache.

  Part 2 — Actionable empty-state on the Schriftsätze tab

    * frontend/src/projects-detail.tsx — the static <p> empty-state
      becomes a div with a "Projekt bearbeiten" button.
    * frontend/src/client/projects-detail.ts — openEditModal now
      accepts an optional focusFieldID; the new
      #project-submissions-edit-cta click handler calls it with
      "project-proceeding-type-id" so the picker is scrolled into
      view and focused right after the modal opens.

  i18n: new keys projects.field.proceeding_type{,.unset,.hint} and
  projects.detail.submissions.empty.no_proceeding.cta; reworded
  no_proceeding copy to match the new "edit the project" CTA.

  Backend already validates via validateProceedingTypeCategory
  (mig 087/088 fristenrechner-category guard). Added
  TestProjectService_CaseProceedingTypePicker exercising both the
  happy and reject paths through a `case`-typed Create.

Manual test path: open any case project → Edit → the Verfahrenstyp
picker shows below Aktenzeichen → save → the Schriftsätze tab now
lists the submission codes. Clicking the empty-state CTA jumps
straight to the picker.
2026-05-21 15:45:19 +02:00
mAi
7967839f78 Merge: t-paliad-230 — submission generator format-only convert (.dotm → .docx) 2026-05-21 15:26:31 +02:00
mAi
d86cac0b53 feat(submissions): t-paliad-230 format-only .dotm→.docx convert
m's 2026-05-21 scope reduction of the t-paliad-215 submission generator:
ship a demo that hands the lawyer the firm style template as a clean
.docx. No variable-merge engine, no per-submission template registry,
no fallback chain — the merge slice is deferred to a future task.

Replaces the previous engine (template registry + variable bag +
{{placeholder}} renderer + dual project_events/documents writes) with:

* services.ConvertDotmToDocx — single-function .dotm/.docm/.dotx → .docx
  format converter that strips word/vbaProject.bin, word/vbaData.xml,
  word/customizations.xml, and word/_rels/vbaProject.bin.rels, rewrites
  [Content_Types].xml (demotes the macro/template main type to plain
  docx, drops the .bin Default Extension and the macro Overrides), and
  rewrites word/_rels/document.xml.rels to drop the vbaProject +
  keyMapCustomizations relationships. Idempotent on a plain .docx.
  archive/zip + regex stdlib only — no new third-party dependencies.

* handlers/submissions.go — POST /api/projects/{id}/submissions/{code}
  /generate fetches the cached HL Patents Style .dotm (via a new
  fetchHLPatentsStyleBytes accessor on files.go that shares the same
  cache as /files/{slug}), converts, writes one paliad.system_audit_log
  row (event_type='submission.generated', metadata={submission_code,
  rule_name, filename}), and streams the .docx as an attachment. GET
  /api/projects/{id}/submissions still lists filing rules but
  has_template is unconditionally true (one universal template).

* Filename per design §7: {rule.name}-{project.case_number}-{YYYY-MM-DD}
  .docx, with Umlauts ASCII-folded and slashes → underscores.

Drops services/submission_templates.go, services/submission_vars.go,
and the wiring in cmd/server/main.go + handlers/handlers.go that bound
them together. Frontend client switched to POST.

Verified the converter against the real HL Patents Style.dotm (361 KB
input → 243 KB output, 46 parts in output zip):

  unzip -tq /tmp/hl-patents-style.converted.docx   → No errors
  python3 -c "import zipfile, xml.etree.ElementTree as ET; \
              z=zipfile.ZipFile('/tmp/hl-patents-style.converted.docx'); \
              [ET.fromstring(z.read(p)) for p in z.namelist() if p.endswith('.xml')]"
  uv run --with python-docx python3 -c "import docx; \
              d=docx.Document('/tmp/hl-patents-style.converted.docx'); \
              print(len(d.paragraphs), 'paragraphs', len(d.styles), 'styles')"
              → 236 paragraphs, 168 styles, 1 section

All assertions passed: every Override in [Content_Types].xml resolves
to a real part, every internal Target in document.xml.rels resolves,
zero macro-related residue, and the document body + styles + theme
survive untouched.

go test -run TestBootSmoke ./cmd/server/... clean (route additions
register without conflict on the Go ServeMux).
2026-05-21 15:23:24 +02:00
mAi
69f45893a3 Merge: t-paliad-231 — mailto: team selection on project Team tab 2026-05-21 15:18:42 +02:00
26 changed files with 919 additions and 1880 deletions

View File

@@ -229,19 +229,10 @@ func main() {
// Nil-safe: empty firm row falls back to the factory layout.
svcBundle.DashboardLayout.SetFirmDefaultService(svcBundle.FirmDashboardDefault)
// t-paliad-215 Slice 1 — submission generator. Three services
// stitched together by handlers/submissions.go: registry pulls
// templates from Gitea (reuses GITEA_TOKEN env), vars builds
// the placeholder map from project + parties + rule, renderer
// merges {{placeholder}} tokens into the .docx.
svcBundle.SubmissionRegistry = services.NewTemplateRegistry(giteaToken, branding.Name)
svcBundle.SubmissionVars = services.NewSubmissionVarsService(
pool,
svcBundle.Project,
svcBundle.Party,
svcBundle.Users,
)
svcBundle.SubmissionRenderer = services.NewSubmissionRenderer()
// t-paliad-230 — submission generator (format-only). No
// service wiring needed: handlers/submissions.go reuses the
// existing files.go HL Patents Style cache and calls
// services.ConvertDotmToDocx (stateless function).
// Paliadin backend selection.
//

View File

@@ -8,6 +8,7 @@ services:
- SUPABASE_URL=${SUPABASE_URL}
- SUPABASE_ANON_KEY=${SUPABASE_ANON_KEY}
- SUPABASE_JWT_SECRET=${SUPABASE_JWT_SECRET}
- SUPABASE_SERVICE_ROLE_KEY=${SUPABASE_SERVICE_ROLE_KEY:-}
- GITEA_TOKEN=${GITEA_TOKEN}
- DATABASE_URL=${DATABASE_URL}
- CALDAV_ENCRYPTION_KEY=${CALDAV_ENCRYPTION_KEY}

View File

@@ -1335,7 +1335,10 @@ const translations: Record<Lang, Record<string, string>> = {
"projects.field.grant_date": "Erteilungstag",
"projects.field.court": "Gericht",
"projects.field.case_number": "Aktenzeichen (Gericht)",
"projects.field.proceeding_type_id": "Verfahrensart",
"projects.field.proceeding_type_id": "Verfahrenstyp",
"projects.field.proceeding_type": "Verfahrenstyp",
"projects.field.proceeding_type.unset": "(nicht gesetzt)",
"projects.field.proceeding_type.hint": "Bestimmt, welche Schriftsätze-Vorlagen für dieses Verfahren angezeigt werden.",
"projects.field.our_side": "Wir vertreten",
"projects.field.our_side.hint": "Bestimmt die Voreinstellung der Perspektive im Fristenrechner-Determinator. Lässt sich dort jederzeit überschreiben.",
"projects.field.our_side.unset": "Unbekannt / nicht gesetzt",
@@ -1425,7 +1428,8 @@ const translations: Record<Lang, Record<string, string>> = {
"projects.detail.export.button": "Daten exportieren",
"projects.detail.export.tooltip": "Daten dieses Projekts (mit Unter-Projekten) als Excel + JSON + CSV herunterladen.",
"projects.detail.submissions.empty": "Für dieses Verfahren sind keine Schriftsätze hinterlegt.",
"projects.detail.submissions.empty.no_proceeding": "Bitte zuerst einen Verfahrenstyp setzen.",
"projects.detail.submissions.empty.no_proceeding": "Für dieses Projekt ist noch kein Verfahrenstyp gesetzt. Bitte im Projekt bearbeiten.",
"projects.detail.submissions.empty.no_proceeding.cta": "Projekt bearbeiten",
"projects.detail.submissions.col.name": "Schriftsatz",
"projects.detail.submissions.col.party": "Partei",
"projects.detail.submissions.col.source": "Rechtsgrundlage",
@@ -4199,6 +4203,9 @@ const translations: Record<Lang, Record<string, string>> = {
"projects.field.court": "Court",
"projects.field.case_number": "Case number (court)",
"projects.field.proceeding_type_id": "Proceeding type",
"projects.field.proceeding_type": "Proceeding type",
"projects.field.proceeding_type.unset": "(unset)",
"projects.field.proceeding_type.hint": "Determines which submission templates show up on this proceeding.",
"projects.field.our_side": "We represent",
"projects.field.our_side.hint": "Pre-selects the perspective chip in the Fristenrechner Determinator. Always overridable from there.",
"projects.field.our_side.unset": "Unknown / not set",
@@ -4288,7 +4295,8 @@ const translations: Record<Lang, Record<string, string>> = {
"projects.detail.export.button": "Export data",
"projects.detail.export.tooltip": "Download this project's data (including sub-projects) as Excel + JSON + CSV.",
"projects.detail.submissions.empty": "No submissions are configured for this proceeding.",
"projects.detail.submissions.empty.no_proceeding": "Please set a proceeding type first.",
"projects.detail.submissions.empty.no_proceeding": "No proceeding type is set for this project yet. Edit the project to choose one.",
"projects.detail.submissions.empty.no_proceeding.cta": "Edit project",
"projects.detail.submissions.col.name": "Submission",
"projects.detail.submissions.col.party": "Party",
"projects.detail.submissions.col.source": "Legal basis",

View File

@@ -1,8 +1,37 @@
import { t, tDyn } from "./i18n";
import { t, tDyn, getLang } from "./i18n";
// Shared logic for the Project form rendered by ProjectFormFields.tsx.
// Used by /projects/new and the edit modal on /projects/{id}.
export interface ProceedingTypeRow {
id: number;
code: string;
name: string;
name_en: string;
jurisdiction?: string;
is_active: boolean;
}
let proceedingTypesCache: ProceedingTypeRow[] | null = null;
// loadProceedingTypes fetches active fristenrechner-category proceeding
// types — the only set a project may bind to (mig 087/088 + service
// validation guard `validateProceedingTypeCategory`). Cached at module
// level so the page only pays for one fetch even when both the new-
// project page and the edit modal exercise the picker.
export async function loadProceedingTypes(): Promise<ProceedingTypeRow[]> {
if (proceedingTypesCache) return proceedingTypesCache;
try {
const resp = await fetch("/api/proceeding-types-db?category=fristenrechner");
if (!resp.ok) return [];
const rows = ((await resp.json()) ?? []) as ProceedingTypeRow[];
proceedingTypesCache = rows.filter((r) => r.is_active);
return proceedingTypesCache;
} catch {
return [];
}
}
export interface ProjectMini {
id: string;
title: string;
@@ -136,6 +165,34 @@ export function wireTypeChange() {
typeSel.addEventListener("change", () => showFieldsForType(typeSel.value));
}
// populateProceedingTypeSelect fills #project-proceeding-type-id with one
// option per fristenrechner-category proceeding type, ordered by `code`
// (so the user scans `de.*`, `dpma.*`, `epa.*`, `upc.*` in stable
// jurisdiction-grouped order). The first option is the empty "unset"
// choice already in the markup; this helper only appends rows below it.
// Idempotent — clearing rows[1..] on re-call so a re-open of the edit
// modal doesn't double-render the list.
export async function populateProceedingTypeSelect(): Promise<void> {
const sel = tryGet("project-proceeding-type-id") as HTMLSelectElement | null;
if (!sel) return;
const rows = await loadProceedingTypes();
rows.sort((a, b) => a.code.localeCompare(b.code));
while (sel.options.length > 1) sel.remove(1);
const isEN = getLang() === "en";
for (const row of rows) {
const opt = document.createElement("option");
opt.value = String(row.id);
const label = isEN && row.name_en ? row.name_en : row.name;
opt.textContent = `${label} (${row.code})`;
sel.appendChild(opt);
}
// Honour a pre-selection value that prefillForm wrote before the
// option set existed. dataset.preselect is set to "" or the saved id;
// restoring it here keeps the edit modal's saved value visible.
const preselect = sel.dataset.preselect;
if (preselect !== undefined) sel.value = preselect;
}
// readPayload collects the form's current values into a CreateProjectInput /
// UpdateProjectInput compatible JSON payload. Returns null + sets msg when
// title is missing.
@@ -208,6 +265,22 @@ export function readPayload(
stringField("project-court", "court");
stringField("project-case-number", "case_number");
// Proceeding type — optional picker. Per t-paliad-232, an empty
// pick simply omits the key from the payload (create: column stays
// NULL; edit: server's `omitempty` skips the SET). Clearing a
// previously-set value isn't supported in this slice; once bound,
// a project's proceeding type can be swapped but not unset from
// the form. The server's validateProceedingTypeCategory backs the
// selected id with a category check.
const ptSel = tryGet("project-proceeding-type-id") as HTMLSelectElement | null;
if (ptSel) {
const v = ptSel.value.trim();
if (v) {
const n = parseInt(v, 10);
if (!isNaN(n)) payload.proceeding_type_id = n;
}
}
// Client Role (DB column: our_side) — case-only after t-paliad-222.
// The select uses "" for the unset option; the service maps empty
// string to NULL via nullableOurSide.
@@ -259,6 +332,16 @@ export function prefillForm(p: Record<string, unknown>) {
if (osSel) osSel.value = String(p.our_side ?? "");
const ocEl = tryGet("project-opponent-code") as HTMLInputElement | null;
if (ocEl) ocEl.value = String(p.opponent_code ?? "");
// Proceeding-type picker — populated lazily by populateProceedingTypeSelect.
// Set the value here even if the options haven't arrived yet; the post-
// populate render runs ApplyProceedingTypeValue to re-select the saved id
// once the option exists.
const ptSel = tryGet("project-proceeding-type-id") as HTMLSelectElement | null;
if (ptSel) {
const v = p.proceeding_type_id == null ? "" : String(p.proceeding_type_id);
ptSel.dataset.preselect = v;
ptSel.value = v;
}
getTA("project-description").value = String(p.description ?? "");
getSel("project-status").value = String(p.status ?? "active");
}

View File

@@ -8,6 +8,8 @@ import {
wireTypeChange,
prefillForm,
readPayload,
populateProceedingTypeSelect,
loadProceedingTypes as loadProceedingTypesShared,
} from "./project-form";
import { mountFilterBar, type BarHandle } from "./filter-bar";
import type { FilterSpec, RenderSpec } from "./views/types";
@@ -1450,36 +1452,9 @@ function initSmartTimelineAddModal(id: string) {
initCounterclaimRoute(id, modal, choices, form);
}
interface ProceedingTypeRow {
id: number;
code: string;
name: string;
name_en: string;
jurisdiction?: string;
is_active: boolean;
}
let proceedingTypesCache: ProceedingTypeRow[] | null = null;
// loadProceedingTypes fetches active proceeding types for the project
// picker. Phase 3 Slice 5 (t-paliad-186) restricts project-binding to
// fristenrechner-category codes (design §3.F + m's Q2 ruling), so the
// picker only ever shows those — never the 7 legacy litigation codes
// (INF / REV / CCR / APM / APP / AMD / ZPO_CIVIL). The matching
// server-side service validation + DB trigger (mig 088) are the
// defence-in-depth backstops for any non-UI writer.
async function loadProceedingTypes(): Promise<ProceedingTypeRow[]> {
if (proceedingTypesCache) return proceedingTypesCache;
try {
const resp = await fetch("/api/proceeding-types-db?category=fristenrechner");
if (!resp.ok) return [];
const rows = ((await resp.json()) ?? []) as ProceedingTypeRow[];
proceedingTypesCache = rows.filter((r) => r.is_active);
return proceedingTypesCache;
} catch {
return [];
}
}
// loadProceedingTypes is shared from ./project-form so the counterclaim
// modal here and the project-edit picker hit the same cache.
const loadProceedingTypes = loadProceedingTypesShared;
function initCounterclaimRoute(
id: string,
@@ -1760,9 +1735,14 @@ async function prepareEditForm() {
// as the new parent (server would reject anyway).
await loadParentCandidates(project?.id);
initParentPicker();
await populateProceedingTypeSelect();
}
function openEditModal() {
// openEditModal opens the project-edit modal, optionally scrolling +
// focusing a specific field after the form is prefilled. Callers like
// the Schriftsätze empty-state CTA pass focusFieldID="project-proceeding-
// type-id" to land the user directly on the picker they came to set.
function openEditModal(focusFieldID?: string) {
if (!project) return;
const modal = document.getElementById("project-edit-modal");
const msg = document.getElementById("project-edit-msg");
@@ -1798,6 +1778,19 @@ function openEditModal() {
};
}
renderTypeChangeWarning();
if (focusFieldID) {
// Wait a tick so the modal has laid out before scrolling — the
// wrapping flex container is display:flex so the field's offset
// height is only reliable after the next animation frame.
requestAnimationFrame(() => {
const target = document.getElementById(focusFieldID);
if (!target) return;
target.scrollIntoView({ behavior: "smooth", block: "center" });
if (target instanceof HTMLSelectElement || target instanceof HTMLInputElement) {
target.focus();
}
});
}
});
msg.textContent = "";
msg.className = "form-msg";
@@ -1876,13 +1869,26 @@ function initEditModal() {
const msg = document.getElementById("project-edit-msg") as HTMLParagraphElement | null;
if (!editBtn || !modal || !closeBtn || !cancelBtn || !form || !msg) return;
editBtn.addEventListener("click", openEditModal);
editBtn.addEventListener("click", () => openEditModal());
closeBtn.addEventListener("click", closeEditModal);
cancelBtn.addEventListener("click", closeEditModal);
modal.addEventListener("click", (e) => {
if (e.target === e.currentTarget) closeEditModal();
});
// Schriftsätze empty-state CTA — when the panel reports "no proceeding
// set", clicking the button opens the edit modal directly on the
// Verfahrenstyp picker so the lawyer can resolve the gap in one step
// (t-paliad-232).
const submissionsCTA = document.getElementById(
"project-submissions-edit-cta",
) as HTMLButtonElement | null;
if (submissionsCTA) {
submissionsCTA.addEventListener("click", () => {
openEditModal("project-proceeding-type-id");
});
}
form.addEventListener("submit", async (e) => {
e.preventDefault();
if (!project) return;

View File

@@ -6,6 +6,7 @@ import {
wireTypeChange,
showFieldsForType,
readPayload,
populateProceedingTypeSelect,
} from "./project-form";
// /projects/new client. Posts v2 CreateProjectInput shape using the shared
@@ -106,5 +107,8 @@ document.addEventListener("DOMContentLoaded", async () => {
await loadParentCandidates();
initParentPicker();
await applyParentFromQueryString();
// Fire-and-forget — the picker is hidden until type=case, so no need
// to block initial render on the fetch.
void populateProceedingTypeSelect();
submitForm();
});

View File

@@ -159,7 +159,7 @@ async function onGenerateClick(btn: HTMLButtonElement): Promise<void> {
try {
const url = `/api/projects/${projectID}/submissions/${encodeURIComponent(code)}/generate`;
const resp = await fetch(url, { method: "GET" });
const resp = await fetch(url, { method: "POST" });
if (!resp.ok) {
let detail = "";
try {

View File

@@ -283,18 +283,30 @@ function selectProceeding(btn: HTMLButtonElement) {
scheduleCalc(0);
}
function applyVerfahrensablaufViewBodyClass(view: ProcedureView) {
// Mirrors the events.ts pattern (body.events-view-*). The print
// stylesheet keys `body.verfahrensablauf-view-timeline` to
// `@page paliad-landscape`, so flipping this class is what lets a
// user print the horizontal timeline in landscape without affecting
// the columns view (which stays portrait).
document.body.classList.toggle("verfahrensablauf-view-timeline", view === "timeline");
document.body.classList.toggle("verfahrensablauf-view-columns", view === "columns");
}
function initViewToggle() {
const toggle = document.getElementById("fristen-view-toggle");
if (!toggle) return;
const initial = new URLSearchParams(window.location.search).get("view");
if (initial === "timeline") procedureView = "timeline";
applyVerfahrensablaufViewBodyClass(procedureView);
toggle.querySelectorAll<HTMLInputElement>("input[name=fristen-view]").forEach((input) => {
input.checked = input.value === procedureView;
input.addEventListener("change", () => {
if (!input.checked) return;
procedureView = input.value === "columns" ? "columns" : "timeline";
applyVerfahrensablaufViewBodyClass(procedureView);
const url = new URL(window.location.href);
if (procedureView === "columns") {
url.searchParams.delete("view");

View File

@@ -204,6 +204,12 @@ function setActiveShape(shape: RenderShape | null): void {
document.querySelectorAll<HTMLButtonElement>("#views-shape-chips [data-shape]").forEach((btn) => {
btn.classList.toggle("active", btn.dataset.shape === shape);
});
// Mirror the active shape on <body> so the print stylesheet can opt
// calendar / timeline into landscape (`@page paliad-landscape`) while
// list / cards stay portrait — t-paliad-233.
for (const s of ["list", "cards", "calendar", "timeline"]) {
document.body.classList.toggle(`views-shape-active-${s}`, shape === s);
}
}
let timelineHandle: ChartHandle | null = null;

View File

@@ -171,6 +171,16 @@ export function ProjectFormFields(): string {
</div>
</div>
<div className="form-field">
<label htmlFor="project-proceeding-type-id" data-i18n="projects.field.proceeding_type">Verfahrenstyp</label>
<select id="project-proceeding-type-id">
<option value="" data-i18n="projects.field.proceeding_type.unset">(nicht gesetzt)</option>
</select>
<p className="form-hint" data-i18n="projects.field.proceeding_type.hint">
Bestimmt, welche Schriftsätze-Vorlagen für dieses Verfahren angezeigt werden.
</p>
</div>
<div className="form-field">
<label htmlFor="project-our-side" data-i18n="projects.field.client_role">Mandantenrolle</label>
<select id="project-our-side">

View File

@@ -2258,6 +2258,7 @@ export type I18nKey =
| "projects.detail.submissions.col.source"
| "projects.detail.submissions.empty"
| "projects.detail.submissions.empty.no_proceeding"
| "projects.detail.submissions.empty.no_proceeding.cta"
| "projects.detail.submissions.hint"
| "projects.detail.tab.checklisten"
| "projects.detail.tab.fristen"
@@ -2354,6 +2355,9 @@ export type I18nKey =
| "projects.field.parent.hint"
| "projects.field.parent.placeholder"
| "projects.field.patent_number"
| "projects.field.proceeding_type"
| "projects.field.proceeding_type.hint"
| "projects.field.proceeding_type.unset"
| "projects.field.proceeding_type_id"
| "projects.field.ref"
| "projects.field.ref.placeholder"

View File

@@ -104,7 +104,7 @@ export function renderKostenrechner(): string {
<title data-i18n="kosten.title">Prozesskostenrechner &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<body className="has-sidebar page-kostenrechner">
<Sidebar currentPath="/tools/kostenrechner" />
<BottomNav currentPath="/tools/kostenrechner" />

View File

@@ -27,7 +27,7 @@ export function renderProjectsChart(): string {
<title data-i18n="projects.chart.title">Projekt-Chart &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<body className="has-sidebar page-projects-chart">
<Sidebar currentPath="/projects" />
<BottomNav currentPath="/projects" />

View File

@@ -627,9 +627,18 @@ export function renderProjectsDetail(): string {
proceeding bound; otherwise enumerates every active
filing rule for the proceeding. */}
<section className="entity-tab-panel" id="tab-submissions" style="display:none">
<p id="project-submissions-no-proceeding" className="entity-events-empty" style="display:none" data-i18n="projects.detail.submissions.empty.no_proceeding">
Bitte zuerst einen Verfahrenstyp setzen.
</p>
<div id="project-submissions-no-proceeding" className="entity-events-empty" style="display:none">
<p data-i18n="projects.detail.submissions.empty.no_proceeding">
Für dieses Projekt ist noch kein Verfahrenstyp gesetzt. Bitte im Projekt bearbeiten.
</p>
<button
type="button"
id="project-submissions-edit-cta"
className="btn-primary btn-small"
data-i18n="projects.detail.submissions.empty.no_proceeding.cta">
Projekt bearbeiten
</button>
</div>
<p id="project-submissions-empty" className="entity-events-empty" style="display:none" data-i18n="projects.detail.submissions.empty">
Für dieses Verfahren sind keine Schriftsätze hinterlegt.
</p>

View File

@@ -4902,11 +4902,6 @@ dialog.modal::backdrop {
font-size: 11pt;
color: #000;
}
@page {
margin: 2cm;
size: A4;
}
}
/* --- Geb\u00fchrentabellen --- */
@@ -12185,8 +12180,38 @@ dialog.quick-add-sheet::backdrop {
gericht cards, entity tables, glossar entries, checklist items) print as-is.
Per-page print rules above this block handle their specific tweaks; this
block is the catch-all for chrome that those rules miss.
Orientation strategy (t-paliad-233):
- Default `@page` is A4 portrait. The CSS `@page` rule is *global*
even when nested inside `@media print` — so prior to t-paliad-233
a stray `@page { size: A4 landscape }` in the smart-timeline-chart
block was leaking landscape onto every printed surface.
- Surfaces that genuinely need width opt into the named
`paliad-landscape` page via a `page: paliad-landscape` declaration
on a per-page body class. Wired below: Kostenrechner, projects
chart, Custom Views calendar / timeline, /events Kalender,
Verfahrensablauf timeline view.
============================================================================ */
@page {
size: A4 portrait;
margin: 1.5cm 1.2cm;
}
@page paliad-landscape {
size: A4 landscape;
margin: 1.5cm;
}
@media print {
body.page-kostenrechner,
body.page-projects-chart,
body.events-view-calendar,
body.views-shape-active-calendar,
body.views-shape-active-timeline,
body.verfahrensablauf-view-timeline {
page: paliad-landscape;
}
.header,
.footer,
.sidebar,
@@ -12222,6 +12247,11 @@ dialog.quick-add-sheet::backdrop {
.event-picker-row,
.date-input-group,
.wizard-step-hint,
.fab,
.fab-button,
.edit-mode-handle,
.paliadin-widget,
[data-print-hide],
#step-1,
#step-2,
#event-step-1,
@@ -12274,14 +12304,64 @@ dialog.quick-add-sheet::backdrop {
break-inside: avoid;
}
/* Tables: repeat headers on each printed page, keep rows intact. */
thead {
display: table-header-group;
}
tfoot {
display: table-footer-group;
}
tr,
th,
td {
page-break-inside: avoid;
break-inside: avoid;
}
/* Orphans / widows defensive defaults. */
p, h1, h2, h3, h4, h5, h6, li {
orphans: 3;
widows: 3;
}
h1, h2, h3, h4, h5, h6 {
page-break-after: avoid;
break-after: avoid;
}
body {
background: #fff !important;
color: #000 !important;
font-size: 11pt;
}
@page {
size: A4;
margin: 1.5cm;
/* Brand-coloured headers and status pills keep their fill in print
instead of losing background colour to the default print bleach. */
.print-header,
.checklist-regime,
.gebuehren-table th,
.entity-status-pill,
.fr-col-header,
[data-print-color="exact"] {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
/* External hyperlinks: print the URL after the link text so the
printed page remains traceable. Skip same-page fragment anchors
and javascript: pseudo-links; skip links whose text already *is*
the URL (avoids duplicates like "https://… (https://…)"). */
a[href^="http"]:not([href*="#"])::after {
content: " (" attr(href) ")";
font-size: 9pt;
color: #555;
word-break: break-all;
}
a[href^="http"][data-print-url="hide"]::after {
content: none;
}
}
@@ -16066,13 +16146,10 @@ dialog.quick-add-sheet::backdrop {
- Force the print palette regardless of the user's screen choice
(B&W shows nothing the user didn't intend, redactable).
- Hide chrome (sidebar, footer, header, bottom-nav, control chips).
- Let the chart fill landscape A4 width.
- Let the chart fill landscape A4 width via the named `paliad-landscape`
page declared in the universal print block (t-paliad-233).
- Add a printed header with project meta on the chart page. */
@media print {
@page {
size: A4 landscape;
margin: 1.5cm;
}
body.has-sidebar > aside.sidebar,
body.has-sidebar > .bottom-nav,
body.has-sidebar > footer,

View File

@@ -84,7 +84,7 @@ export function renderVerfahrensablauf(): string {
<title data-i18n="tools.verfahrensablauf.title">Verfahrensablauf &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<body className="has-sidebar page-verfahrensablauf">
<Sidebar currentPath="/tools/verfahrensablauf" />
<BottomNav currentPath="/tools/verfahrensablauf" />

View File

@@ -1,6 +1,7 @@
package handlers
import (
"context"
"encoding/json"
"fmt"
"io"
@@ -117,6 +118,45 @@ func handleFileRefresh(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"ok": "true", "message": "Cache cleared"})
}
// fetchHLPatentsStyleBytes returns the cached HL Patents Style .dotm
// bytes. Shared accessor used by both the /files/{slug} download path
// (Word auto-update channel) and the submission generator
// (handlers/submissions.go) so a refresh through one path is visible to
// the other. First call warms the cache from Gitea synchronously;
// subsequent calls are sub-millisecond. A stale-but-present cache is
// returned immediately while a background refresh runs.
func fetchHLPatentsStyleBytes(ctx context.Context) ([]byte, error) {
entry, ok := fileRegistry[hlPatentsStyleSlug]
if !ok {
return nil, fmt.Errorf("file proxy: %s not registered", hlPatentsStyleSlug)
}
ce := getCacheEntry(hlPatentsStyleSlug)
ce.mu.RLock()
hasData := len(ce.data) > 0
needsCheck := time.Since(ce.lastChecked) >= checkInterval
ce.mu.RUnlock()
if !hasData {
if err := fileFetch(ce, entry); err != nil {
return nil, err
}
} else if needsCheck {
go fileCheckAndRefresh(ce, entry)
}
ce.mu.RLock()
defer ce.mu.RUnlock()
if len(ce.data) == 0 {
return nil, fmt.Errorf("file proxy: %s cache empty after fetch", hlPatentsStyleSlug)
}
out := make([]byte, len(ce.data))
copy(out, ce.data)
_ = ctx // ctx reserved for future timeout pass-through; fileFetch
// uses the package httpClient timeout today.
return out, nil
}
// fileFetch downloads the file synchronously (first request).
func fileFetch(ce *cacheEntry, entry fileEntry) error {
sha, _ := giteaLatestSHA(entry)

View File

@@ -98,15 +98,6 @@ type Services struct {
Projection *services.ProjectionService
Export *services.ExportService
// Submission generator (t-paliad-215) — Klageerwiderung &
// friends. Three coordinated services: registry fetches templates
// from Gitea; vars builds the placeholder map from project +
// parties + rule; renderer merges the .docx. Wired together in
// cmd/server/main.go; nil here when DATABASE_URL is unset.
SubmissionRegistry *services.TemplateRegistry
SubmissionVars *services.SubmissionVarsService
SubmissionRenderer *services.SubmissionRenderer
// 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
@@ -123,14 +114,6 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
paliadinSvc = svc.Paliadin
}
// Submission generator singletons (t-paliad-215). All three or
// none — the handler short-circuits with 503 when any is nil.
if svc != nil {
submissionRegistry = svc.SubmissionRegistry
submissionVars = svc.SubmissionVars
submissionRenderer = svc.SubmissionRenderer
}
if svc != nil {
dbSvc = &dbServices{
projects: svc.Project,
@@ -323,11 +306,13 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
protected.HandleFunc("POST /api/projects/{id}/timeline/milestone", handleCreateProjectTimelineMilestone)
protected.HandleFunc("POST /api/projects/{id}/timeline/anchor", handleProjectTimelineAnchor)
protected.HandleFunc("POST /api/projects/{id}/timeline/skip", handleProjectTimelineSkip)
// t-paliad-215 Slice 1 — submission generator. /submissions lists
// the project's filing-type rules with template-availability flags;
// /submissions/{code}/generate streams the rendered .docx.
// t-paliad-230 — submission generator (format-only). /submissions
// lists the project's published filing rules; /generate fetches the
// universal HL Patents Style .dotm, strips the macro project, and
// streams a clean .docx attachment. POST because each generation
// writes an audit row.
protected.HandleFunc("GET /api/projects/{id}/submissions", handleListProjectSubmissions)
protected.HandleFunc("GET /api/projects/{id}/submissions/{code}/generate", handleGenerateProjectSubmission)
protected.HandleFunc("POST /api/projects/{id}/submissions/{code}/generate", handleGenerateProjectSubmission)
// /counterclaim creates a CCR sub-project linked via the new
// paliad.projects.counterclaim_of FK (t-paliad-174 Slice 3).
protected.HandleFunc("POST /api/projects/{id}/counterclaim", handleCreateProjectCounterclaim)

View File

@@ -1,24 +1,32 @@
package handlers
// Submission generator HTTP layer (t-paliad-215 Slice 1).
// Submission generator HTTP layer (t-paliad-230 — format-only scope
// reduction of t-paliad-215).
//
// Endpoints:
//
// GET /api/projects/{id}/submissions
// Lists the project's proceeding-relevant submission codes
// and reports template availability for each. Powers the
// SubmissionsPanel on the project detail page.
// Lists the project's proceeding-relevant filing rules.
// has_template is unconditionally true: every project gets
// offered the universal HL Patents Style template.
//
// GET /api/projects/{id}/submissions/{code}/generate
// Renders the .docx and streams it as an attachment download.
// Writes one paliad.system_audit_log row and one
// paliad.project_events row per generation. No server-side
// binary persistence (design §3, m's Q3 pick).
// POST /api/projects/{id}/submissions/{code}/generate
// Fetches the cached HL Patents Style .dotm (same proxy used
// by /files/hl-patents-style.dotm), converts it to a clean
// .docx via services.ConvertDotmToDocx, writes one
// paliad.system_audit_log row, and streams the result as an
// attachment download.
//
// No variable substitution, no per-submission templates, no
// project_events/documents writes. Those layers are deferred to a
// future "merge engine" slice; today's generator hands the lawyer a
// clean .docx of the firm style and lets them edit and save under
// their own filename.
//
// Visibility: every endpoint runs through ProjectService.GetByID
// (paliad.can_see_project gate). Unauthorised callers get 404, never
// 403 — same convention as the rest of the project surfaces (avoids
// project-existence enumeration).
// (paliad.can_see_project gate). Unauthorised callers get 404 — same
// convention as the rest of the project surfaces (no project-existence
// enumeration).
import (
"context"
@@ -33,29 +41,26 @@ import (
"github.com/google/uuid"
"mgit.msbls.de/m/paliad/internal/branding"
"mgit.msbls.de/m/paliad/internal/models"
"mgit.msbls.de/m/paliad/internal/services"
)
// submissionRenderer + registry + vars are package-level singletons
// wired by Register() once at boot. Stateless rendering + thread-safe
// caches inside the registry mean no per-request construction.
var (
submissionRenderer *services.SubmissionRenderer
submissionRegistry *services.TemplateRegistry
submissionVars *services.SubmissionVarsService
)
// submissionRenderTimeout caps a single generate request. Template
// fetch (cache-miss) + rendering of a typical pleading takes well
// under a second; the timeout exists to surface "Gitea is unreachable"
// quickly rather than letting the browser spin.
// submissionRenderTimeout caps a single generate request. .dotm fetch
// is from the in-process cache (sub-millisecond) and the convert step
// is a single zip round-trip; the timeout exists so a cold cache miss
// against Gitea surfaces quickly rather than letting the browser spin.
const submissionRenderTimeout = 30 * time.Second
// docxMime is the .docx Content-Type per the OOXML spec.
const docxMime = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
// submissionListEntry is one row in the SubmissionsPanel.
// hlPatentsStyleSlug names the universal style template inside the
// fileRegistry in files.go. Both surfaces (the /files download for
// Word's auto-update channel and this generator) share the same
// cache entry so a refresh through one path is visible to the other.
const hlPatentsStyleSlug = "hl-patents-style.dotm"
// submissionListEntry is one row in the Schriftsätze panel.
type submissionListEntry struct {
SubmissionCode string `json:"submission_code"`
Name string `json:"name"`
@@ -73,8 +78,10 @@ type submissionListResponse struct {
Entries []submissionListEntry `json:"entries"`
}
// handleListProjectSubmissions returns the filing-type rules for the
// project's proceeding, annotated with template availability.
// handleListProjectSubmissions returns the published filing rules for
// the project's proceeding_type. has_template is true for every row —
// Slice 1 (t-paliad-230) ships one universal template, so the only
// "no template" case is a project that has no proceeding_type bound.
func handleListProjectSubmissions(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
@@ -83,9 +90,6 @@ func handleListProjectSubmissions(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
if !requireSubmissionsWired(w) {
return
}
projectID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid project id"})
@@ -123,8 +127,6 @@ func handleListProjectSubmissions(w http.ResponseWriter, r *http.Request) {
continue
}
if rule.EventType == nil || *rule.EventType != "filing" {
// Hearings + decisions don't generate submissions. The
// "Schriftsätze" panel only lists filings.
continue
}
if rule.LifecycleState != "published" {
@@ -134,7 +136,7 @@ func handleListProjectSubmissions(w http.ResponseWriter, r *http.Request) {
SubmissionCode: *rule.SubmissionCode,
Name: rule.Name,
NameEN: rule.NameEN,
HasTemplate: submissionRegistry.HasTemplate(ctx, *rule.SubmissionCode),
HasTemplate: true,
}
if rule.EventType != nil {
entry.EventType = *rule.EventType
@@ -151,9 +153,10 @@ func handleListProjectSubmissions(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, resp)
}
// handleGenerateProjectSubmission renders the .docx and streams it
// back to the browser. Audits the generation; never persists the
// rendered bytes server-side.
// handleGenerateProjectSubmission fetches the universal HL Patents
// Style .dotm, converts it to a clean .docx, writes one audit row, and
// streams the result. No variable substitution; the bytes that go down
// the wire are the firm style template with macros stripped.
func handleGenerateProjectSubmission(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
@@ -162,9 +165,6 @@ func handleGenerateProjectSubmission(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
if !requireSubmissionsWired(w) {
return
}
projectID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid project id"})
@@ -179,209 +179,162 @@ func handleGenerateProjectSubmission(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), submissionRenderTimeout)
defer cancel()
varsResult, err := submissionVars.Build(ctx, services.SubmissionVarsContext{
UserID: uid,
ProjectID: projectID,
SubmissionCode: submissionCode,
})
project, err := dbSvc.projects.GetByID(ctx, uid, projectID)
if err != nil {
if errors.Is(err, services.ErrSubmissionRuleNotFound) {
writeServiceError(w, err)
return
}
rule, err := loadPublishedRuleByCode(ctx, submissionCode)
if err != nil {
if errors.Is(err, errRuleNotFound) {
writeJSON(w, http.StatusNotFound, map[string]string{
"error": fmt.Sprintf("no published rule for submission_code %q", submissionCode),
})
return
}
writeServiceError(w, err)
log.Printf("submissions: load rule %q: %v", submissionCode, err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "rule lookup failed"})
return
}
tmpl, err := submissionRegistry.Resolve(ctx, submissionCode)
dotm, err := fetchHLPatentsStyleBytes(ctx)
if err != nil {
if errors.Is(err, services.ErrNoTemplate) {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
"error": "no template available for this submission",
"hint": "ask an admin to upload a .docx template under templates/_base/ in mWorkRepo",
})
return
}
log.Printf("submissions: template resolve for %s: %v", submissionCode, err)
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
"error": "template repository unreachable",
log.Printf("submissions: fetch HL Patents Style .dotm: %v", err)
writeJSON(w, http.StatusBadGateway, map[string]string{
"error": "template upstream unreachable",
})
return
}
missing := services.DefaultMissingMarker(varsResult.Lang)
rendered, err := submissionRenderer.Render(tmpl.Bytes, varsResult.Placeholders, missing)
docx, err := services.ConvertDotmToDocx(dotm)
if err != nil {
log.Printf("submissions: render %s for project %s: %v", submissionCode, projectID, err)
log.Printf("submissions: convert dotm for project %s code %s: %v", projectID, submissionCode, err)
writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": "render failed",
"error": "convert failed",
})
return
}
filename := submissionFileName(varsResult, projectID)
user, err := dbSvc.users.GetByID(ctx, uid)
if err != nil {
log.Printf("submissions: load user %s: %v", uid, err)
}
lang := "de"
if user != nil && user.Lang != "" {
lang = user.Lang
}
// Audit + Verlauf writes. Best-effort with a background context so
// the user still receives the download even if the audit insert
// races a slow DB.
filename := submissionFileName(rule, project, lang)
// Audit write is best-effort with a background context so the
// download still succeeds if the DB races. Audit failure here only
// affects the system_audit_log feed — never the user's response.
bgCtx, cancelBG := context.WithTimeout(context.Background(), 10*time.Second)
defer cancelBG()
if err := writeSubmissionAuditRow(bgCtx, varsResult, tmpl, submissionCode); err != nil {
if err := writeSubmissionAuditRow(bgCtx, user, project.ID, submissionCode, rule.Name, filename); err != nil {
log.Printf("submissions: audit insert failed (project=%s code=%s): %v", projectID, submissionCode, err)
}
if err := writeSubmissionProjectEvent(bgCtx, varsResult, tmpl, submissionCode); err != nil {
log.Printf("submissions: project_events insert failed (project=%s code=%s): %v", projectID, submissionCode, err)
}
if err := writeSubmissionDocumentRow(bgCtx, varsResult, tmpl, submissionCode); err != nil {
log.Printf("submissions: documents insert failed (project=%s code=%s): %v", projectID, submissionCode, err)
}
w.Header().Set("Content-Type", docxMime)
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename=%q`, filename))
w.Header().Set("Content-Length", strconv.Itoa(len(rendered)))
w.Header().Set("X-Paliad-Template-Sha", tmpl.SHA)
w.Header().Set("X-Paliad-Template-Tier", tmpl.FirmTier)
if _, err := w.Write(rendered); err != nil {
w.Header().Set("Content-Length", strconv.Itoa(len(docx)))
if _, err := w.Write(docx); err != nil {
log.Printf("submissions: response write failed (project=%s code=%s): %v", projectID, submissionCode, err)
}
}
// requireSubmissionsWired returns false (and writes 503) when the
// generator wasn't constructed at boot. Happens in DATABASE_URL-less
// deployments — knowledge-platform-only stacks don't ship the
// submission engine.
func requireSubmissionsWired(w http.ResponseWriter) bool {
if submissionRenderer == nil || submissionRegistry == nil || submissionVars == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
"error": "submission generator not configured",
})
return false
// errRuleNotFound is the sentinel for "no published rule with that
// submission_code" — distinguished from a generic DB error so the
// handler returns 404 instead of 500.
var errRuleNotFound = errors.New("submission rule not found")
// loadPublishedRuleByCode fetches the rule the user requested. Only
// published+active rows resolve; drafts and archived rules never feed
// a real submission.
func loadPublishedRuleByCode(ctx context.Context, submissionCode string) (*models.DeadlineRule, error) {
if submissionCode == "" {
return nil, errRuleNotFound
}
return true
var rule models.DeadlineRule
err := dbSvc.projects.DB().GetContext(ctx, &rule,
`SELECT id, proceeding_type_id, parent_id, submission_code, name, name_en,
description, primary_party, event_type, duration_value, duration_unit,
timing, rule_code, deadline_notes, deadline_notes_en, sequence_order,
alt_duration_value, alt_duration_unit, alt_rule_code, anchor_alt,
concept_id, legal_source, is_spawn, spawn_label, is_active,
created_at, updated_at, lifecycle_state
FROM paliad.deadline_rules
WHERE submission_code = $1
AND lifecycle_state = 'published'
AND is_active = true
ORDER BY sequence_order
LIMIT 1`, submissionCode)
if err != nil {
if strings.Contains(err.Error(), "no rows") {
return nil, errRuleNotFound
}
return nil, err
}
return &rule, nil
}
// submissionFileName builds the user-facing filename per design §7:
//
// {rule.name}-{project.case_number}-{YYYY-MM-DD}.docx
//
// Slashes and backslashes in case_number sanitise to underscores so
// the file saves cleanly across Windows + macOS + Linux. Missing
// case_number falls back to an 8-hex-char stable id from the project
// UUID so the file still has a deterministic handle.
func submissionFileName(vars *services.SubmissionVarsResult, projectID uuid.UUID) string {
// 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)
}
ruleName := strings.TrimSpace(vars.Rule.Name)
if strings.EqualFold(vars.Lang, "en") {
ruleName = strings.TrimSpace(vars.Rule.NameEN)
ruleName := strings.TrimSpace(rule.Name)
if strings.EqualFold(lang, "en") && strings.TrimSpace(rule.NameEN) != "" {
ruleName = strings.TrimSpace(rule.NameEN)
}
if ruleName == "" {
ruleName = "submission"
}
parts := []string{services.SanitiseSubmissionFileName(ruleName)}
caseNo := ""
if vars.Project != nil && vars.Project.CaseNumber != nil {
caseNo = strings.TrimSpace(*vars.Project.CaseNumber)
if project != nil && project.CaseNumber != nil {
caseNo = strings.TrimSpace(*project.CaseNumber)
}
if caseNo == "" {
caseNo = projectID.String()[:8]
if caseNo != "" {
parts = append(parts, services.SanitiseSubmissionFileName(caseNo))
}
caseNo = strings.ReplaceAll(caseNo, "/", "_")
caseNo = strings.ReplaceAll(caseNo, `\`, "_")
return fmt.Sprintf("%s-%s-%s.docx", ruleName, caseNo, day.Format("2006-01-02"))
parts = append(parts, day.Format("2006-01-02"))
return strings.Join(parts, "-") + ".docx"
}
// writeSubmissionAuditRow files the org-wide audit entry. Reuses the
// system_audit_log convention (event_type='submission.generated')
// established in t-paliad-214's mig 102.
func writeSubmissionAuditRow(ctx context.Context, vars *services.SubmissionVarsResult, tmpl *services.ResolvedTemplate, code string) error {
// writeSubmissionAuditRow files one row in paliad.system_audit_log per
// generation. event_type='submission.generated', scope='project',
// scope_root=project_id. Metadata is intentionally small per Slice 1:
// {submission_code, rule_name, filename} — enough for a reviewer to
// reconstruct which template was offered to which project without
// over-baking the audit shape.
func writeSubmissionAuditRow(ctx context.Context, user *models.User, projectID uuid.UUID, submissionCode, ruleName, filename string) error {
meta := map[string]any{
"submission_code": code,
"template_path": tmpl.Path,
"template_sha": tmpl.SHA,
"template_tier": tmpl.FirmTier,
"project_id": vars.Project.ID.String(),
"rule_id": vars.Rule.ID.String(),
"firm": branding.Name,
"submission_code": submissionCode,
"rule_name": ruleName,
"filename": filename,
}
body, _ := json.Marshal(meta)
var (
actorID any
actorEmail string
)
if user != nil {
actorID = user.ID
actorEmail = user.Email
}
_, err := dbSvc.projects.DB().ExecContext(ctx,
`INSERT INTO paliad.system_audit_log
(event_type, actor_id, actor_email, scope, scope_root, metadata)
VALUES ('submission.generated', $1, $2, 'project', $3, $4::jsonb)`,
vars.User.ID, vars.User.Email, vars.Project.ID.String(), string(body),
)
return err
}
// writeSubmissionProjectEvent surfaces the generation in the project
// Verlauf / SmartTimeline. event_type stays free-text (no CHECK on
// paliad.project_events.event_type per Slice 2 of SmartTimeline) so we
// don't need a migration to introduce 'submission_generated'.
func writeSubmissionProjectEvent(ctx context.Context, vars *services.SubmissionVarsResult, tmpl *services.ResolvedTemplate, code string) error {
ruleName := strings.TrimSpace(vars.Rule.Name)
if strings.EqualFold(vars.Lang, "en") {
ruleName = strings.TrimSpace(vars.Rule.NameEN)
}
title := fmt.Sprintf("%s generiert", ruleName)
if strings.EqualFold(vars.Lang, "en") {
title = fmt.Sprintf("%s generated", ruleName)
}
meta := map[string]any{
"submission_code": code,
"template_path": tmpl.Path,
"template_sha": tmpl.SHA,
"template_tier": tmpl.FirmTier,
"rule_id": vars.Rule.ID.String(),
}
body, _ := json.Marshal(meta)
now := time.Now().UTC()
_, err := dbSvc.projects.DB().ExecContext(ctx,
`INSERT INTO paliad.project_events
(id, project_id, event_type, title, description, event_date,
created_by, metadata, created_at, updated_at)
VALUES ($1, $2, 'submission_generated', $3, NULL, $4, $5, $6::jsonb, $4, $4)`,
uuid.New(), vars.Project.ID, title, now, vars.User.ID, string(body),
)
return err
}
// writeSubmissionDocumentRow files the audit-only paliad.documents
// row. file_path stays NULL — the bytes are regenerable from inputs
// (m's Q3 pick: no server-side binary). doc_type='generated_submission'
// is the additive marker; no CHECK constraint exists on doc_type, so
// this requires no migration.
func writeSubmissionDocumentRow(ctx context.Context, vars *services.SubmissionVarsResult, tmpl *services.ResolvedTemplate, code string) error {
ruleName := strings.TrimSpace(vars.Rule.Name)
if strings.EqualFold(vars.Lang, "en") {
ruleName = strings.TrimSpace(vars.Rule.NameEN)
}
day := time.Now()
if loc, err := time.LoadLocation("Europe/Berlin"); err == nil {
day = day.In(loc)
}
title := fmt.Sprintf("%s (generiert %s)", ruleName, day.Format("2006-01-02"))
if strings.EqualFold(vars.Lang, "en") {
title = fmt.Sprintf("%s (generated %s)", ruleName, day.Format("2006-01-02"))
}
provenance := map[string]any{
"submission_code": code,
"template_path": tmpl.Path,
"template_sha": tmpl.SHA,
"template_tier": tmpl.FirmTier,
"firm": branding.Name,
"rule_id": vars.Rule.ID.String(),
}
body, _ := json.Marshal(provenance)
_, err := dbSvc.projects.DB().ExecContext(ctx,
`INSERT INTO paliad.documents
(id, project_id, title, doc_type, file_path, file_size, mime_type,
ai_extracted, uploaded_by, created_at, updated_at)
VALUES ($1, $2, $3, 'generated_submission', NULL, NULL, $4, $5::jsonb, $6, now(), now())`,
uuid.New(), vars.Project.ID, title, docxMime, string(body), vars.User.ID,
actorID, actorEmail, projectID.String(), string(body),
)
return err
}

View File

@@ -217,6 +217,7 @@ func (s *AichatPaliadinService) RunTurn(ctx context.Context, req TurnRequest) (*
body := aichatTurnRequest{
Persona: s.cfg.Persona,
Username: username,
UserID: req.UserID.String(),
SessionID: req.SessionID,
Message: sanitiseForTmux(req.UserMessage),
JWT: jwt,
@@ -611,8 +612,13 @@ func (s *AichatPaliadinService) clearPrimed(session string) {
// =============================================================================
type aichatTurnRequest struct {
Persona string `json:"persona"`
Username string `json:"username"`
Persona string `json:"persona"`
Username string `json:"username"`
// UserID is the paliad user UUID, required by aichat now that a
// tenant DB is configured ("user_id is required when a tenant DB
// is configured"). Without it /chat/turn 400s and the SSE relay
// closes empty → "Verbindung verloren" on the frontend.
UserID string `json:"user_id"`
SessionID string `json:"session_id,omitempty"`
Message string `json:"message"`
JWT string `json:"jwt,omitempty"`

View File

@@ -501,6 +501,7 @@ func TestRunTurn_HappyPath_ViaCallHTTP(t *testing.T) {
body := aichatTurnRequest{
Persona: s.cfg.Persona,
Username: s.usernameFor(context.Background(), uid),
UserID: uid.String(),
Message: "Hello",
JWT: jwtTok,
Meta: buildAichatMeta(TurnRequest{PageOrigin: "/dashboard"}),
@@ -516,6 +517,12 @@ func TestRunTurn_HappyPath_ViaCallHTTP(t *testing.T) {
if captured.Username != "user-aaaaaaaa" {
t.Errorf("username = %q; want user-aaaaaaaa (nil DB fallback)", captured.Username)
}
// Regression for the 2026-05-21 outage: aichat now requires user_id
// when a tenant DB is configured; missing → 400 → SSE drop on the
// frontend ("Verbindung verloren"). The struct must carry it.
if captured.UserID != uid.String() {
t.Errorf("user_id = %q; want %q", captured.UserID, uid.String())
}
if captured.Message != "Hello" {
t.Errorf("message = %q; want Hello", captured.Message)
}

View File

@@ -253,3 +253,95 @@ func TestProjectService_InstanceLevel_Roundtrip(t *testing.T) {
t.Errorf("want ErrInvalidInput, got %v", err)
}
}
// TestProjectService_CaseProceedingTypePicker covers the t-paliad-232
// data path for the new project-form Verfahrenstyp picker:
//
// 1. Creating a `case`-typed project with a fristenrechner-category
// proceeding_type_id round-trips the column.
// 2. The same code path rejects a non-fristenrechner-category id with
// ErrInvalidProceedingTypeCategory (mirror of the guard test above,
// this time exercised through a 'case' shape).
//
// Skipped when TEST_DATABASE_URL is unset.
func TestProjectService_CaseProceedingTypePicker(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()
var fristenrechnerID int
if err := pool.GetContext(ctx, &fristenrechnerID,
`SELECT id FROM paliad.proceeding_types
WHERE category = 'fristenrechner' AND code = $1 AND is_active = true`,
CodeUPCInfringement); err != nil {
t.Fatalf("look up %s id: %v", CodeUPCInfringement, err)
}
var nonFristenrechnerID int
if err := pool.GetContext(ctx, &nonFristenrechnerID,
`SELECT id FROM paliad.proceeding_types
WHERE category <> 'fristenrechner'
ORDER BY id
LIMIT 1`); err != nil {
t.Fatalf("look up non-fristenrechner id: %v", err)
}
users := NewUserService(pool)
svc := NewProjectService(pool, users)
userID := uuid.New()
cleanup := func() {
pool.ExecContext(ctx, `DELETE FROM paliad.projects 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, 't-paliad-232-test@hlc.com')`,
userID); err != nil {
t.Fatalf("seed auth.users: %v", err)
}
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.users (id, email, display_name, office, role, lang)
VALUES ($1, 't-paliad-232-test@hlc.com', 'Picker Test', 'munich', 'associate', 'de')`,
userID); err != nil {
t.Fatalf("seed paliad.users: %v", err)
}
// 1. Case-typed create with a fristenrechner id succeeds.
created, err := svc.Create(ctx, userID, CreateProjectInput{
Type: ProjectTypeCase,
Title: "t-paliad-232 — case with proceeding_type_id",
ProceedingTypeID: &fristenrechnerID,
})
if err != nil {
t.Fatalf("Create case with fristenrechner id: %v", err)
}
if created.ProceedingTypeID == nil || *created.ProceedingTypeID != fristenrechnerID {
t.Errorf("created proceeding_type_id = %v, want %d", created.ProceedingTypeID, fristenrechnerID)
}
// 2. Case-typed create with a non-fristenrechner id is rejected.
_, err = svc.Create(ctx, userID, CreateProjectInput{
Type: ProjectTypeCase,
Title: "t-paliad-232 — case with non-fristenrechner id",
ProceedingTypeID: &nonFristenrechnerID,
})
if err == nil {
t.Error("Create case with non-fristenrechner proceeding_type_id should fail, but succeeded")
} else if !errors.Is(err, ErrInvalidProceedingTypeCategory) {
t.Errorf("expected ErrInvalidProceedingTypeCategory, got %v", err)
}
}

View File

@@ -1,27 +1,33 @@
package services
// Submission template renderer — in-house engine for the submission
// generator (t-paliad-215, design doc
// docs/design-submission-generator-2026-05-19.md §6).
// Submission .dotm → .docx converter (t-paliad-230, "format-only" scope
// reduction of the original t-paliad-215 submission generator).
//
// Design choice — why not lukasjarosch/go-docx:
// The library's "nested placeholder" guard treats sibling placeholders
// inside the same <w:t> run (e.g. "{{a}} ./. {{b}}") as nested and
// refuses to replace either. Patent submissions routinely have multiple
// placeholders per paragraph (party blocks especially), so the library
// is a non-starter without a custom fork. The in-house renderer below
// is ~150 LoC and handles both the single-run common case and the
// cross-run case (where Word may split a placeholder across runs after
// editing).
// Word .dotm (macro-enabled template), .docm (macro-enabled document),
// .dotx (template, no macros), and .docx (document, no macros) are all
// OOXML zip containers. The macro-bearing variants carry an extra set
// of parts:
//
// Placeholder grammar: {{[A-Za-z][A-Za-z0-9_.]*}} with optional
// whitespace inside braces ({{ project.case_number }} ≡
// {{project.case_number}}).
// word/vbaProject.bin — the VBA project binary
// word/_rels/vbaProject.bin.rels — auxiliary relationships
// word/vbaData.xml — VBA support data
// word/customizations.xml — keyMapCustomizations
//
// Missing-value behaviour: when a placeholder has no binding in the
// PlaceholderMap, the renderer emits a marker token so the lawyer sees
// the gap in Word rather than failing the request. See §6.3 of the
// design doc.
// plus a Content-Types override for each of those, a Default extension
// declaring all .bin files as vbaProject, and a different "main" content
// type for word/document.xml itself.
//
// ConvertDotmToDocx walks the zip, drops the macro parts, rewrites
// [Content_Types].xml and word/_rels/document.xml.rels to remove every
// reference to them, and switches the main document content type to
// the plain .docx form. Every other part — styles, fonts, theme,
// settings, document body, header/footer/numbering, glossary, custom
// XML — passes through bit-for-bit at the original compression method
// and modification time.
//
// No variable substitution. Today's slice hands the lawyer the firm
// style template as a clean .docx so they can edit and save under
// their own filename. The merge-engine slice is deferred.
import (
"archive/zip"
@@ -32,110 +38,132 @@ import (
"strings"
)
// PlaceholderMap is the variable bag built by SubmissionVarsService.
// Keys are dotted paths without braces (e.g. "project.case_number").
// Values are the substituted text — already locale-aware, pretty-
// printed, and sanitised by the caller.
type PlaceholderMap map[string]string
// The four OOXML "main" content types we may see on word/document.xml.
// Anything other than docxMainContentType gets rewritten so the output
// reads as a plain document.
const (
dotmMainContentType = "application/vnd.ms-word.template.macroEnabledTemplate.main+xml"
docmMainContentType = "application/vnd.ms-word.document.macroEnabled.main+xml"
dotxMainContentType = "application/vnd.openxmlformats-officedocument.wordprocessingml.template.main+xml"
docxMainContentType = "application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"
)
// MissingPlaceholderFn translates an unbound placeholder key into the
// in-document marker token. The default in DefaultMissingMarker is
// "[KEIN WERT: <key>]" / "[NO VALUE: <key>]" depending on lang.
type MissingPlaceholderFn func(key string) string
// DefaultMissingMarker returns the standard missing-value marker for
// the given UI language.
func DefaultMissingMarker(lang string) MissingPlaceholderFn {
prefix := "KEIN WERT"
if strings.EqualFold(lang, "en") {
prefix = "NO VALUE"
}
return func(key string) string {
return "[" + prefix + ": " + key + "]"
}
// Macro-related parts dropped wholesale from the output zip.
var macroParts = map[string]bool{
"word/vbaProject.bin": true,
"word/_rels/vbaProject.bin.rels": true,
"word/vbaData.xml": true,
"word/customizations.xml": true,
}
// placeholderRegex matches a single placeholder. The capture group
// extracts the key name without braces or surrounding whitespace.
//
// Restricted to [A-Za-z][A-Za-z0-9_.]* so that stray "{{" sequences in
// legal prose (extremely rare in DE/EN court briefs but possible)
// don't get mistaken for placeholders. A genuine placeholder always
// starts with an ASCII letter.
var placeholderRegex = regexp.MustCompile(`\{\{\s*([A-Za-z][A-Za-z0-9_.]*)\s*\}\}`)
const (
contentTypesPath = "[Content_Types].xml"
documentRelsPath = "word/_rels/document.xml.rels"
)
// SubmissionRenderer renders a .docx template into a .docx output by
// substituting {{placeholder}} tokens with values from a PlaceholderMap.
// Stateless; safe for concurrent use.
type SubmissionRenderer struct{}
// vbaDefaultExtensionRegex matches the `<Default Extension="bin"
// ContentType=".../vbaProject"/>` row in [Content_Types].xml. After
// vbaProject.bin is dropped, the Default is dead weight (and Word will
// flag the file as macro-bearing if it survives).
var vbaDefaultExtensionRegex = regexp.MustCompile(
`\s*<Default\b[^>]*\bExtension\s*=\s*"bin"[^>]*\bContentType\s*=\s*"application/vnd\.ms-office\.vbaProject"[^>]*/>`,
)
// NewSubmissionRenderer constructs the renderer.
func NewSubmissionRenderer() *SubmissionRenderer {
return &SubmissionRenderer{}
}
// macroOverridePartRegex matches any <Override PartName="…"/> element
// whose PartName is one of the dropped macro parts. The /word/
// prefix is the OOXML convention for the absolute part path in
// [Content_Types].xml — file paths in the zip itself omit the leading
// slash.
var macroOverridePartRegex = regexp.MustCompile(
`\s*<Override\b[^>]*\bPartName\s*=\s*"/word/(?:vbaProject\.bin|vbaData\.xml|customizations\.xml)"[^>]*/>`,
)
// Render reads the .docx template at templateBytes, substitutes every
// placeholder from vars (or emits the missing-marker token), and writes
// the result to the returned byte slice. Unknown placeholders never
// fail the render — the lawyer sees the marker in Word and fixes it.
func (r *SubmissionRenderer) Render(templateBytes []byte, vars PlaceholderMap, missing MissingPlaceholderFn) ([]byte, error) {
if missing == nil {
missing = DefaultMissingMarker("de")
}
zr, err := zip.NewReader(bytes.NewReader(templateBytes), int64(len(templateBytes)))
// macroRelTypeRegex matches the two macro-related relationship Types
// in word/_rels/document.xml.rels: vbaProject (binds to vbaProject.bin)
// and keyMapCustomizations (binds to customizations.xml). After both
// targets are dropped, leaving the relationships in would make Word
// flag the file as corrupt.
var macroRelTypeRegex = regexp.MustCompile(
`\s*<Relationship\b[^>]*\bType\s*=\s*"http://schemas\.microsoft\.com/office/2006/relationships/(?:vbaProject|keyMapCustomizations)"[^>]*/>`,
)
// ConvertDotmToDocx rewrites a .dotm (or .docm, or .dotx) zip into a
// clean .docx zip. Idempotent on a zip that is already a plain .docx.
// Returns an error if the input is not a valid zip.
func ConvertDotmToDocx(dotmBytes []byte) ([]byte, error) {
zr, err := zip.NewReader(bytes.NewReader(dotmBytes), int64(len(dotmBytes)))
if err != nil {
return nil, fmt.Errorf("submission template: open zip: %w", err)
return nil, fmt.Errorf("dotm→docx: open zip: %w", err)
}
var out bytes.Buffer
zw := zip.NewWriter(&out)
defer zw.Close()
for _, entry := range zr.File {
body, err := readZipEntry(entry)
if macroParts[entry.Name] {
continue
}
body, err := readZipFile(entry)
if err != nil {
return nil, fmt.Errorf("submission template: read %s: %w", entry.Name, err)
return nil, fmt.Errorf("dotm→docx: read %s: %w", entry.Name, err)
}
if isWordXMLEntry(entry.Name) {
body = substituteInDocumentXML(body, vars, missing)
switch entry.Name {
case contentTypesPath:
body = rewriteContentTypes(body)
case documentRelsPath:
body = rewriteDocumentRels(body)
}
w, err := zw.CreateHeader(&zip.FileHeader{
Name: entry.Name,
Method: entry.Method,
Modified: entry.Modified,
})
if err != nil {
return nil, fmt.Errorf("submission template: write header %s: %w", entry.Name, err)
return nil, fmt.Errorf("dotm→docx: write header %s: %w", entry.Name, err)
}
if _, err := w.Write(body); err != nil {
return nil, fmt.Errorf("submission template: write %s: %w", entry.Name, err)
return nil, fmt.Errorf("dotm→docx: write body %s: %w", entry.Name, err)
}
}
if err := zw.Close(); err != nil {
return nil, fmt.Errorf("submission template: finalise zip: %w", err)
return nil, fmt.Errorf("dotm→docx: finalise zip: %w", err)
}
return out.Bytes(), nil
}
// isWordXMLEntry returns true for the .docx parts that contain
// substitutable text. We touch document.xml plus header*.xml and
// footer*.xml (templates may put firm letterhead in a header) but
// skip styles, theme, settings, comments, footnotes — none of which
// should carry merge placeholders in a well-formed template.
func isWordXMLEntry(name string) bool {
switch {
case name == "word/document.xml":
return true
case strings.HasPrefix(name, "word/header") && strings.HasSuffix(name, ".xml"):
return true
case strings.HasPrefix(name, "word/footer") && strings.HasSuffix(name, ".xml"):
return true
}
return false
// rewriteContentTypes demotes any of the three non-docx "main" content
// types to plain docx, drops the bin Default-Extension entry, and
// drops every Override that targeted a dropped macro part.
//
// String-level substitution rather than encoding/xml: round-tripping
// through Go's XML marshaller would re-emit the document with
// canonical namespace declarations on every child, which Word reads
// but which makes the binary diff unnecessarily large. Direct
// substitution preserves the file's original shape.
func rewriteContentTypes(body []byte) []byte {
body = bytes.ReplaceAll(body, []byte(dotmMainContentType), []byte(docxMainContentType))
body = bytes.ReplaceAll(body, []byte(docmMainContentType), []byte(docxMainContentType))
body = bytes.ReplaceAll(body, []byte(dotxMainContentType), []byte(docxMainContentType))
body = vbaDefaultExtensionRegex.ReplaceAll(body, nil)
body = macroOverridePartRegex.ReplaceAll(body, nil)
return body
}
// readZipEntry slurps a zip entry's bytes.
func readZipEntry(f *zip.File) ([]byte, error) {
// rewriteDocumentRels drops the two macro-related relationships from
// word/_rels/document.xml.rels (vbaProject + keyMapCustomizations) so
// the manifest no longer points at parts the zip no longer carries.
// Every other relationship — styles, settings, numbering, theme,
// headers/footers, customXml — passes through untouched.
func rewriteDocumentRels(body []byte) []byte {
return macroRelTypeRegex.ReplaceAll(body, nil)
}
// readZipFile slurps a zip entry's bytes.
func readZipFile(f *zip.File) ([]byte, error) {
rc, err := f.Open()
if err != nil {
return nil, err
@@ -144,172 +172,33 @@ func readZipEntry(f *zip.File) ([]byte, error) {
return io.ReadAll(rc)
}
// substituteInDocumentXML walks document XML and replaces every
// {{placeholder}} occurrence inside <w:t> text nodes. Handles both
// single-run placeholders (the common case for freshly authored
// templates) and cross-run placeholders (where Word's autocorrect or
// manual editing has split a placeholder across runs).
//
// Two-pass strategy:
//
// 1. Pass 1: replace placeholders that fit entirely within one
// <w:t>…</w:t>. This is the 99% case and preserves all run-level
// formatting (bold, italic, font runs).
// 2. Pass 2: for paragraphs that still contain orphan "{{" or "}}"
// markers after pass 1, merge the text of every <w:t> inside the
// paragraph, run the replacement on the merged text, and rewrite
// the paragraph's runs as a single <w:r><w:t>…</w:t></w:r> using
// the formatting properties of the first run. Loses intra-paragraph
// formatting on the affected paragraph — but only on paragraphs
// where Word genuinely fragmented a placeholder.
func substituteInDocumentXML(body []byte, vars PlaceholderMap, missing MissingPlaceholderFn) []byte {
replaced := substituteInTextNodes(body, vars, missing)
if !needsCrossRunMerge(replaced) {
return replaced
}
return substituteAcrossRuns(replaced, vars, missing)
}
// wTextNodeRegex matches one <w:t …>contents</w:t> element, capturing
// the contents. Attributes on <w:t> (xml:space="preserve") are preserved
// because the entire match is rewritten.
var wTextNodeRegex = regexp.MustCompile(`<w:t(\s[^>]*)?>([^<]*)</w:t>`)
// substituteInTextNodes runs the placeholder replacement inside each
// <w:t> text node independently. Format-preserving for single-run
// placeholders.
func substituteInTextNodes(body []byte, vars PlaceholderMap, missing MissingPlaceholderFn) []byte {
return wTextNodeRegex.ReplaceAllFunc(body, func(match []byte) []byte {
sub := wTextNodeRegex.FindSubmatch(match)
attrs := string(sub[1])
contents := xmlDecode(string(sub[2]))
replaced := replacePlaceholders(contents, vars, missing)
if replaced == contents {
return match
// SanitiseSubmissionFileName cleans a string for use inside a download
// filename — strips path separators and quote characters that would
// break Content-Disposition or confuse browsers across OSes. ASCII-folds
// the small set of German umlaut letters that show up in submission
// names today (Klageerwiderung, Berufungsbegründung, …) so the file
// lands cleanly on legacy SMB shares whose layer is still cp1252.
// Other Unicode is preserved so non-DE/EN names still produce a
// recognisable file.
func SanitiseSubmissionFileName(s string) string {
s = strings.TrimSpace(s)
s = umlautFolder.Replace(s)
s = strings.Map(func(r rune) rune {
switch r {
case '/', '\\':
return '_'
case '"', '\'':
return -1
}
// xml:space="preserve" stays attached whenever the original
// content had leading/trailing whitespace; ensure it's still
// declared after replacement to avoid Word collapsing spaces.
if !strings.Contains(attrs, "xml:space") &&
(strings.HasPrefix(replaced, " ") || strings.HasSuffix(replaced, " ")) {
attrs += ` xml:space="preserve"`
}
return []byte(`<w:t` + attrs + `>` + xmlEncode(replaced) + `</w:t>`)
})
}
// needsCrossRunMerge returns true when the body still contains an
// unmatched "{{" or "}}" after pass 1 — a sign that Word fragmented
// the placeholder across runs and pass 1 couldn't touch it.
func needsCrossRunMerge(body []byte) bool {
// Cheap heuristic: count "{{" vs "}}" inside <w:t> nodes. If we have
// either marker present in the text-node space, pass 2 will handle
// it. (Inside attributes or other XML, the markers don't matter.)
for _, m := range wTextNodeRegex.FindAllSubmatch(body, -1) {
t := string(m[2])
if strings.Contains(t, "{{") || strings.Contains(t, "}}") {
return true
}
}
return false
}
// wParagraphRegex matches one <w:p>…</w:p> paragraph block. Greedy
// inner-content match is safe here because <w:p> elements do not nest
// in WordprocessingML — a paragraph is the leaf container for text.
var wParagraphRegex = regexp.MustCompile(`(?s)<w:p\b[^>]*>.*?</w:p>`)
// wRunPropsRegex pulls the first <w:rPr>…</w:rPr> block from a
// paragraph so we can reuse it as the formatting of the merged run.
var wRunPropsRegex = regexp.MustCompile(`(?s)<w:rPr>.*?</w:rPr>`)
// wParagraphPropsRegex pulls the optional <w:pPr>…</w:pPr> that sits
// at the top of a paragraph (alignment, spacing, etc.). Preserved.
var wParagraphPropsRegex = regexp.MustCompile(`(?s)<w:pPr>.*?</w:pPr>`)
// substituteAcrossRuns is pass 2: for any paragraph that still has a
// split placeholder, concatenate every text node, run replacement, and
// rewrite the paragraph as a single run using the first run's
// properties. Paragraphs without orphan markers are left untouched so
// run-level formatting survives wherever pass 1 already resolved the
// placeholders.
func substituteAcrossRuns(body []byte, vars PlaceholderMap, missing MissingPlaceholderFn) []byte {
return wParagraphRegex.ReplaceAllFunc(body, func(para []byte) []byte {
textNodes := wTextNodeRegex.FindAllSubmatch(para, -1)
if len(textNodes) == 0 {
return para
}
var merged strings.Builder
for _, m := range textNodes {
merged.WriteString(xmlDecode(string(m[2])))
}
original := merged.String()
if !strings.Contains(original, "{{") {
// No fragmented placeholder in this paragraph; leave it
// alone so pass 1's run-level edits survive.
return para
}
replaced := replacePlaceholders(original, vars, missing)
if replaced == original {
return para
}
// Preserve paragraph properties (alignment, spacing) and the
// first run's properties (font, bold/italic).
pPr := wParagraphPropsRegex.Find(para)
rPr := wRunPropsRegex.Find(para)
var rebuilt bytes.Buffer
rebuilt.WriteString(`<w:p>`)
if pPr != nil {
rebuilt.Write(pPr)
}
rebuilt.WriteString(`<w:r>`)
if rPr != nil {
rebuilt.Write(rPr)
}
rebuilt.WriteString(`<w:t xml:space="preserve">`)
rebuilt.WriteString(xmlEncode(replaced))
rebuilt.WriteString(`</w:t></w:r></w:p>`)
return rebuilt.Bytes()
})
}
// replacePlaceholders performs the actual substitution on a plain
// string. Unbound placeholders render the missing marker.
func replacePlaceholders(s string, vars PlaceholderMap, missing MissingPlaceholderFn) string {
return placeholderRegex.ReplaceAllStringFunc(s, func(match string) string {
sub := placeholderRegex.FindStringSubmatch(match)
if len(sub) < 2 {
return match
}
key := sub[1]
if value, ok := vars[key]; ok {
return value
}
return missing(key)
})
}
// xmlDecode reverses the small set of escapes used in WordprocessingML
// text content. We don't need a full XML parser — text nodes carry only
// the standard five entities, and Word never emits numeric-character
// references inside <w:t> for printable content.
func xmlDecode(s string) string {
s = strings.ReplaceAll(s, "&lt;", "<")
s = strings.ReplaceAll(s, "&gt;", ">")
s = strings.ReplaceAll(s, "&quot;", `"`)
s = strings.ReplaceAll(s, "&apos;", "'")
s = strings.ReplaceAll(s, "&amp;", "&")
return r
}, s)
return s
}
// xmlEncode escapes a substituted value for safe insertion back into a
// WordprocessingML text node. & must be replaced first to avoid double
// encoding the entity prefixes we introduce on the other characters.
func xmlEncode(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
s = strings.ReplaceAll(s, `"`, "&quot;")
s = strings.ReplaceAll(s, "'", "&apos;")
return s
}
// umlautFolder turns the four DE umlaut letters (both cases) into ASCII
// digraphs; ß → ss.
var umlautFolder = strings.NewReplacer(
"ä", "ae", "ö", "oe", "ü", "ue",
"Ä", "Ae", "Ö", "Oe", "Ü", "Ue",
"ß", "ss",
)

View File

@@ -6,392 +6,249 @@ import (
"io"
"strings"
"testing"
"time"
)
// minimalDOCX builds a tiny .docx zip with one document.xml that
// contains the given body. Just enough to exercise the renderer
// without depending on Word's full OOXML scaffolding.
func minimalDOCX(t *testing.T, documentBody string) []byte {
// minimalDOTM builds a small .dotm zip whose shape mirrors the real
// HL Patents Style template: macro-enabled main content type, Default
// extension declaring .bin as vbaProject, Overrides for vbaData.xml +
// customizations.xml, document.xml.rels with vbaProject +
// keyMapCustomizations relationships, and the four macro parts on
// disk (vbaProject.bin + auxiliary rels + vbaData.xml +
// customizations.xml).
//
// In-memory so the test is self-contained (no checked-in binary).
// Word and LibreOffice would reject this minimal file as incomplete
// (no _rels/.rels root manifest); the tests work at the byte level
// and assert structural properties of the converted output.
func minimalDOTM(t *testing.T) []byte {
t.Helper()
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
w, err := zw.Create("word/document.xml")
if err != nil {
t.Fatalf("create document.xml: %v", err)
}
if _, err := io.WriteString(w, documentBody); err != nil {
t.Fatalf("write document.xml: %v", err)
}
// Drop in a stub Content-Types so the bytes look more like a real
// .docx for any downstream sanity checks; Word doesn't care about
// the content during our unit tests but the shape stays honest.
w2, err := zw.Create("[Content_Types].xml")
if err != nil {
t.Fatalf("create content types: %v", err)
}
if _, err := io.WriteString(w2, `<?xml version="1.0"?><Types/>`); err != nil {
t.Fatalf("write content types: %v", err)
add := func(name, body string) {
t.Helper()
w, err := zw.CreateHeader(&zip.FileHeader{
Name: name,
Method: zip.Deflate,
Modified: time.Date(2026, 5, 21, 12, 0, 0, 0, time.UTC),
})
if err != nil {
t.Fatalf("zip header %s: %v", name, err)
}
if _, err := io.WriteString(w, body); err != nil {
t.Fatalf("write %s: %v", name, err)
}
}
add(contentTypesPath, `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`+
`<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">`+
`<Default Extension="bin" ContentType="application/vnd.ms-office.vbaProject"/>`+
`<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>`+
`<Default Extension="xml" ContentType="application/xml"/>`+
`<Override PartName="/word/document.xml" ContentType="`+dotmMainContentType+`"/>`+
`<Override PartName="/word/customizations.xml" ContentType="application/vnd.ms-word.keyMapCustomizations+xml"/>`+
`<Override PartName="/word/vbaData.xml" ContentType="application/vnd.ms-word.vbaData+xml"/>`+
`<Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+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><w:p><w:r><w:t>Hello Paliad</w:t></w:r></w:p></w:body></w:document>`)
add(documentRelsPath,
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`+
`<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">`+
`<Relationship Id="rId1" Type="http://schemas.microsoft.com/office/2006/relationships/vbaProject" Target="vbaProject.bin"/>`+
`<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>`+
`<Relationship Id="rId3" Type="http://schemas.microsoft.com/office/2006/relationships/keyMapCustomizations" Target="customizations.xml"/>`+
`</Relationships>`)
add("word/styles.xml", `<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"/>`)
add("word/vbaProject.bin", "PRETEND-VBA-BINARY-PAYLOAD")
add("word/_rels/vbaProject.bin.rels", `<?xml version="1.0"?><Relationships/>`)
add("word/vbaData.xml", `<?xml version="1.0"?><wne:vbaSuppData xmlns:wne="http://schemas.microsoft.com/office/word/2006/wordml"/>`)
add("word/customizations.xml", `<?xml version="1.0"?><wne:tcg xmlns:wne="http://schemas.microsoft.com/office/word/2006/wordml"/>`)
if err := zw.Close(); err != nil {
t.Fatalf("close zip: %v", err)
}
return buf.Bytes()
}
// readDocumentXML pulls word/document.xml out of a rendered .docx.
func readDocumentXML(t *testing.T, b []byte) string {
func unzipEntries(t *testing.T, data []byte) map[string]string {
t.Helper()
zr, err := zip.NewReader(bytes.NewReader(b), int64(len(b)))
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
t.Fatalf("open rendered zip: %v", err)
t.Fatalf("open output zip: %v", err)
}
out := make(map[string]string, len(zr.File))
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)
t.Fatalf("open %s: %v", f.Name, err)
}
defer rc.Close()
body, err := io.ReadAll(rc)
rc.Close()
if err != nil {
t.Fatalf("read document.xml: %v", err)
t.Fatalf("read %s: %v", f.Name, err)
}
return string(body)
out[f.Name] = string(body)
}
t.Fatal("rendered .docx had no word/document.xml")
return ""
return out
}
// TestRender_SingleRunPlaceholder covers the 99% case: a placeholder
// that sits inside a single <w:t> text node.
func TestRender_SingleRunPlaceholder(t *testing.T) {
doc := `<w:document><w:body><w:p><w:r><w:t>{{firm.name}}</w:t></w:r></w:p></w:body></w:document>`
tmpl := minimalDOCX(t, doc)
r := NewSubmissionRenderer()
out, err := r.Render(tmpl, PlaceholderMap{"firm.name": "HLC"}, nil)
func TestConvertDotmToDocx_StripsMacroParts(t *testing.T) {
dotm := minimalDOTM(t)
out, err := ConvertDotmToDocx(dotm)
if err != nil {
t.Fatalf("render: %v", err)
t.Fatalf("ConvertDotmToDocx: %v", err)
}
body := readDocumentXML(t, out)
if !strings.Contains(body, ">HLC<") {
t.Errorf("expected HLC in body, got %q", body)
}
if strings.Contains(body, "{{") {
t.Errorf("unreplaced placeholder marker in body: %q", body)
}
}
// TestRender_MultiplePlaceholdersPerRun is the case go-docx fails on
// — sibling placeholders inside the same <w:t> run. The in-house
// renderer must handle them.
func TestRender_MultiplePlaceholdersPerRun(t *testing.T) {
doc := `<w:document><w:body><w:p><w:r><w:t>{{parties.claimant.name}}, vertreten durch {{parties.claimant.representative}}</w:t></w:r></w:p></w:body></w:document>`
tmpl := minimalDOCX(t, doc)
r := NewSubmissionRenderer()
out, err := r.Render(tmpl, PlaceholderMap{
"parties.claimant.name": "Acme Inc.",
"parties.claimant.representative": "Kanzlei Müller",
}, nil)
if err != nil {
t.Fatalf("render: %v", err)
}
body := readDocumentXML(t, out)
if !strings.Contains(body, "Acme Inc.") || !strings.Contains(body, "Kanzlei Müller") {
t.Errorf("expected both party values, got %q", body)
}
if strings.Contains(body, "{{") {
t.Errorf("unreplaced placeholder marker in body: %q", body)
}
}
entries := unzipEntries(t, out)
// TestRender_MissingMarker confirms unbound placeholders render the
// missing-value marker instead of failing the request.
func TestRender_MissingMarker(t *testing.T) {
doc := `<w:document><w:body><w:p><w:r><w:t>{{project.case_number}}</w:t></w:r></w:p></w:body></w:document>`
tmpl := minimalDOCX(t, doc)
r := NewSubmissionRenderer()
out, err := r.Render(tmpl, PlaceholderMap{}, DefaultMissingMarker("de"))
if err != nil {
t.Fatalf("render: %v", err)
}
body := readDocumentXML(t, out)
if !strings.Contains(body, "[KEIN WERT: project.case_number]") {
t.Errorf("expected KEIN WERT marker, got %q", body)
}
outEN, err := r.Render(tmpl, PlaceholderMap{}, DefaultMissingMarker("en"))
if err != nil {
t.Fatalf("render en: %v", err)
}
bodyEN := readDocumentXML(t, outEN)
if !strings.Contains(bodyEN, "[NO VALUE: project.case_number]") {
t.Errorf("expected NO VALUE marker, got %q", bodyEN)
}
}
// TestRender_CrossRunPlaceholder simulates Word fragmenting a
// placeholder across runs (autocorrect or post-edit run-split).
// Pass 2 must catch it.
func TestRender_CrossRunPlaceholder(t *testing.T) {
doc := `<w:document><w:body><w:p><w:r><w:t>Hello {{</w:t></w:r><w:r><w:t>project</w:t></w:r><w:r><w:t>.case_number}}!</w:t></w:r></w:p></w:body></w:document>`
tmpl := minimalDOCX(t, doc)
r := NewSubmissionRenderer()
out, err := r.Render(tmpl, PlaceholderMap{"project.case_number": "7 O 1234/26"}, nil)
if err != nil {
t.Fatalf("render: %v", err)
}
body := readDocumentXML(t, out)
if !strings.Contains(body, "7 O 1234/26") {
t.Errorf("expected case number after cross-run merge, got %q", body)
}
if strings.Contains(body, "{{") {
t.Errorf("orphan placeholder marker remained: %q", body)
}
}
// TestRender_XMLEscaping verifies special characters in placeholder
// values are escaped so they don't corrupt the document XML.
func TestRender_XMLEscaping(t *testing.T) {
doc := `<w:document><w:body><w:p><w:r><w:t>{{user.display_name}}</w:t></w:r></w:p></w:body></w:document>`
tmpl := minimalDOCX(t, doc)
r := NewSubmissionRenderer()
out, err := r.Render(tmpl, PlaceholderMap{
"user.display_name": `Müller & Söhne <GmbH> "Special"`,
}, nil)
if err != nil {
t.Fatalf("render: %v", err)
}
body := readDocumentXML(t, out)
if !strings.Contains(body, "Müller &amp; Söhne &lt;GmbH&gt; &quot;Special&quot;") {
t.Errorf("expected escaped value, got %q", body)
}
}
// TestRender_PreservesNonWordEntries leaves the rest of the .docx
// untouched so any styles / theme / settings parts come through bit-
// for-bit.
func TestRender_PreservesNonWordEntries(t *testing.T) {
doc := `<w:document><w:body><w:p><w:r><w:t>{{firm.name}}</w:t></w:r></w:p></w:body></w:document>`
tmpl := minimalDOCX(t, doc)
r := NewSubmissionRenderer()
out, err := r.Render(tmpl, PlaceholderMap{"firm.name": "HLC"}, nil)
if err != nil {
t.Fatalf("render: %v", err)
}
zr, err := zip.NewReader(bytes.NewReader(out), int64(len(out)))
if err != nil {
t.Fatalf("open rendered: %v", err)
}
var sawTypes bool
for _, f := range zr.File {
if f.Name == "[Content_Types].xml" {
sawTypes = true
for _, name := range []string{
"word/vbaProject.bin",
"word/_rels/vbaProject.bin.rels",
"word/vbaData.xml",
"word/customizations.xml",
} {
if _, ok := entries[name]; ok {
t.Errorf("output still contains %s", name)
}
}
if !sawTypes {
t.Error("rendered .docx lost [Content_Types].xml")
if doc, ok := entries["word/document.xml"]; !ok {
t.Error("output is missing word/document.xml")
} else if !strings.Contains(doc, "Hello Paliad") {
t.Errorf("document body lost during conversion: %q", doc)
}
if _, ok := entries["word/styles.xml"]; !ok {
t.Error("output lost unrelated word/styles.xml")
}
ctypes, ok := entries[contentTypesPath]
if !ok {
t.Fatal("output is missing [Content_Types].xml")
}
if strings.Contains(ctypes, "macroEnabled") {
t.Errorf("output [Content_Types].xml still references a macro-enabled type: %q", ctypes)
}
if !strings.Contains(ctypes, docxMainContentType) {
t.Errorf("output is missing plain docx main content type: %q", ctypes)
}
if strings.Contains(ctypes, "vbaProject") {
t.Errorf("output [Content_Types].xml still references vbaProject: %q", ctypes)
}
if strings.Contains(ctypes, "vbaData") {
t.Errorf("output [Content_Types].xml still overrides vbaData: %q", ctypes)
}
if strings.Contains(ctypes, "keyMapCustomizations") {
t.Errorf("output [Content_Types].xml still overrides customizations: %q", ctypes)
}
if !strings.Contains(ctypes, "wordprocessingml.styles") {
t.Errorf("output lost unrelated styles Override: %q", ctypes)
}
rels, ok := entries[documentRelsPath]
if !ok {
t.Fatal("output is missing word/_rels/document.xml.rels")
}
if strings.Contains(rels, "vbaProject") {
t.Errorf("output rels still references vbaProject: %q", rels)
}
if strings.Contains(rels, "keyMapCustomizations") {
t.Errorf("output rels still references keyMapCustomizations: %q", rels)
}
if !strings.Contains(rels, "styles.xml") {
t.Errorf("output rels lost unrelated styles relationship: %q", rels)
}
}
// TestPlaceholderRegex_Boundaries pins the placeholder grammar.
func TestPlaceholderRegex_Boundaries(t *testing.T) {
tests := []struct {
in string
matches []string
}{
{"plain text", nil},
{"{{foo}}", []string{"{{foo}}"}},
{"{{ foo }}", []string{"{{ foo }}"}},
{"{{foo.bar}}", []string{"{{foo.bar}}"}},
{"{{ foo.bar_baz }}", []string{"{{ foo.bar_baz }}"}},
{"{{1bad}}", nil}, // must start with a letter
{"{{ foo }} and {{ bar }}", []string{"{{ foo }}", "{{ bar }}"}},
func TestConvertDotmToDocx_IdempotentOnPlainDocx(t *testing.T) {
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
add := func(name, body string) {
w, err := zw.Create(name)
if err != nil {
t.Fatalf("create %s: %v", name, err)
}
if _, err := io.WriteString(w, body); err != nil {
t.Fatalf("write %s: %v", name, err)
}
}
for _, tc := range tests {
t.Run(tc.in, func(t *testing.T) {
got := placeholderRegex.FindAllString(tc.in, -1)
if len(got) != len(tc.matches) {
t.Fatalf("got %d matches, want %d (in=%q)", len(got), len(tc.matches), tc.in)
add(contentTypesPath, `<?xml version="1.0"?>`+
`<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">`+
`<Override PartName="/word/document.xml" ContentType="`+docxMainContentType+`"/>`+
`</Types>`)
add("word/document.xml", `<w:document/>`)
if err := zw.Close(); err != nil {
t.Fatalf("close: %v", err)
}
out, err := ConvertDotmToDocx(buf.Bytes())
if err != nil {
t.Fatalf("ConvertDotmToDocx: %v", err)
}
entries := unzipEntries(t, out)
if _, ok := entries["word/vbaProject.bin"]; ok {
t.Error("plain docx grew a vbaProject during conversion")
}
if ctypes := entries[contentTypesPath]; !strings.Contains(ctypes, docxMainContentType) {
t.Errorf("plain docx lost its content type: %q", ctypes)
}
}
func TestConvertDotmToDocx_AcceptsDocmAndDotx(t *testing.T) {
for _, mainType := range []string{docmMainContentType, dotxMainContentType} {
t.Run(mainType, func(t *testing.T) {
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
add := func(name, body string) {
w, _ := zw.Create(name)
_, _ = io.WriteString(w, body)
}
for i := range got {
if got[i] != tc.matches[i] {
t.Errorf("match %d: got %q, want %q", i, got[i], tc.matches[i])
}
add(contentTypesPath, `<?xml version="1.0"?>`+
`<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">`+
`<Override PartName="/word/document.xml" ContentType="`+mainType+`"/>`+
`</Types>`)
add("word/document.xml", `<w:document/>`)
zw.Close()
out, err := ConvertDotmToDocx(buf.Bytes())
if err != nil {
t.Fatalf("ConvertDotmToDocx: %v", err)
}
ctypes := unzipEntries(t, out)[contentTypesPath]
if strings.Contains(ctypes, mainType) {
t.Errorf("non-docx main type survived conversion: %q", ctypes)
}
if !strings.Contains(ctypes, docxMainContentType) {
t.Errorf("docx main type not present: %q", ctypes)
}
})
}
}
// TestFamilyOf covers the proceeding-family extraction used by the
// template registry's fallback chain.
func TestFamilyOf(t *testing.T) {
tests := map[string]string{
"de.inf.lg.erwidg": "de.inf.lg",
"upc.inf.cfi.soc": "upc.inf.cfi",
"dpma.opp.dpma": "", // only three segments → no family
"de.inf.lg": "",
"": "",
func TestConvertDotmToDocx_RejectsNonZip(t *testing.T) {
_, err := ConvertDotmToDocx([]byte("not a zip file"))
if err == nil {
t.Fatal("expected error for non-zip input, got nil")
}
for in, want := range tests {
}
func TestSanitiseSubmissionFileName(t *testing.T) {
cases := map[string]string{
"Klageerwiderung": "Klageerwiderung",
"Berufungsbegründung": "Berufungsbegruendung",
"Schriftsatz/Anlage": "Schriftsatz_Anlage",
`Statement of "Defence"`: "Statement of Defence",
` Klage `: "Klage",
"Größe": "Groesse",
}
for in, want := range cases {
t.Run(in, func(t *testing.T) {
got := familyOf(in)
if got != want {
t.Errorf("familyOf(%q) = %q, want %q", in, got, want)
}
})
}
}
// TestLegalSourcePretty covers the prefix table.
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"},
// Unknown prefix → pass-through unchanged.
{"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)
}
})
}
}
// TestOurSideTranslations pins the our_side enum → DE/EN prose
// mapping used by addProjectVars. Post t-paliad-222: seven sub-role
// values + the gender-neutral "-Seite" / "-Partei" suffix shape on
// DE. Legacy 'court' / 'both' yield "" (the column no longer accepts
// them after mig 112, but the function defensively handles stale
// in-memory values from older callers).
func TestOurSideTranslations(t *testing.T) {
cases := []struct {
in, wantDE, wantEN string
}{
{"claimant", "Klägerseite", "Claimant"},
{"defendant", "Beklagtenseite", "Defendant"},
{"applicant", "Antragstellerseite", "Applicant"},
{"appellant", "Berufungsklägerseite", "Appellant"},
{"respondent", "Antragsgegnerseite", "Respondent"},
{"third_party", "Drittpartei", "Third Party"},
{"other", "sonstige Verfahrensbeteiligte", "other party"},
{"court", "", ""},
{"both", "", ""},
{"", "", ""},
{"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)
}
})
}
}
// TestTemplateRegistry_Candidates verifies the fallback-chain order
// matches the m-locked Q4 decision (firm → base/code → base/family →
// skeleton).
func TestTemplateRegistry_Candidates(t *testing.T) {
r := NewTemplateRegistry("", "HLC")
got := r.candidates("de.inf.lg.erwidg")
want := []string{
"templates/HLC/de.inf.lg.erwidg.docx",
"templates/_base/de.inf.lg.erwidg.docx",
"templates/_base/de.inf.lg.docx",
"templates/_base/_skeleton.docx",
}
if len(got) != len(want) {
t.Fatalf("candidates = %v, want %v", got, want)
}
for i := range got {
if got[i] != want[i] {
t.Errorf("candidate[%d] = %q, want %q", i, got[i], want[i])
}
}
}
// TestTemplateRegistry_Candidates_NoFamily covers submission codes
// without a family suffix (only three dot-segments).
func TestTemplateRegistry_Candidates_NoFamily(t *testing.T) {
r := NewTemplateRegistry("", "HLC")
got := r.candidates("dpma.opp.dpma")
want := []string{
"templates/HLC/dpma.opp.dpma.docx",
"templates/_base/dpma.opp.dpma.docx",
"templates/_base/_skeleton.docx",
}
if len(got) != len(want) {
t.Fatalf("candidates = %v, want %v", got, want)
}
for i := range got {
if got[i] != want[i] {
t.Errorf("candidate[%d] = %q, want %q", i, got[i], want[i])
}
}
}
// TestTemplateRegistry_Tiers labels each candidate slot. Must stay
// 1:1 with candidates().
func TestTemplateRegistry_Tiers(t *testing.T) {
r := NewTemplateRegistry("", "HLC")
codes := []string{"de.inf.lg.erwidg", "dpma.opp.dpma"}
for _, code := range codes {
c := r.candidates(code)
ts := r.tiers(code)
if len(c) != len(ts) {
t.Fatalf("candidate/tier mismatch for %q: %d vs %d", code, len(c), len(ts))
}
}
}
// TestPatentNumberUPC covers the kind-code parenthesisation that UPC
// briefs use (t-paliad-215 Slice 2, design §22 Q-S2-4).
func TestPatentNumberUPC(t *testing.T) {
tests := []struct {
in, want string
}{
// EP variants — the common case.
{"EP 1 234 567 B1", "EP 1 234 567 (B1)"},
{"EP 4 056 049 A1", "EP 4 056 049 (A1)"},
// DE national number with kind code.
{"DE 10 2020 123 456 A1", "DE 10 2020 123 456 (A1)"},
// No kind code → pass-through unchanged.
{"EP 1 234 567", "EP 1 234 567"},
// Leading + trailing whitespace trimmed.
{" EP 1 234 567 B1 ", "EP 1 234 567 (B1)"},
// Empty input.
{"", ""},
// Slash-separated forms (WO publication numbers) don't match
// the kind-code shape → pass through.
{"WO/2023/123456", "WO/2023/123456"},
// Two-digit kind code (e.g. B12) doesn't match the single-digit
// pattern; pass through. This is intentional — real EP kind
// codes are single-letter + single-digit.
{"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)
if got := SanitiseSubmissionFileName(in); got != want {
t.Errorf("SanitiseSubmissionFileName(%q) = %q, want %q", in, got, want)
}
})
}

View File

@@ -1,442 +0,0 @@
package services
// Submission template registry — Gitea-backed .docx template loader for
// the submission generator (t-paliad-215, design doc
// docs/design-submission-generator-2026-05-19.md §5).
//
// Layout in mWorkRepo:
//
// templates/{FIRM_NAME}/{submission_code}.docx firm-specific override
// templates/_base/{submission_code}.docx cross-firm baseline
// templates/_base/{family}.docx proceeding-family fallback
// templates/_base/_skeleton.docx ultra-generic fallback
//
// Lookup is first-match-wins down the chain; this is the m-locked Q4
// decision. Templates fetched via Gitea's raw URL endpoint, cached
// in-process with a 5-minute SHA refresh check — identical pattern to
// the HL Patents Style proxy in internal/handlers/files.go (which the
// design doc §1 verified is in production and works).
//
// Slice 1 ships one template at templates/_base/de.inf.lg.erwidg.docx
// (committed to HL/mWorkRepo at SHA 7f97b7f9, the bootstrap demo
// authored by the engine for end-to-end testing — HLC ships the
// polished version per §14 follow-up).
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"net/url"
"strings"
"sync"
"time"
)
const (
templatesGiteaBaseURL = "https://mgit.msbls.de"
templatesGiteaRepoOwn = "HL"
templatesGiteaRepoName = "mWorkRepo"
templatesGiteaBranch = "main"
templatesCheckInterval = 5 * time.Minute
templatesSkeleton = "_skeleton"
)
// ErrNoTemplate is returned when no template resolves anywhere in the
// fallback chain (firm/code → base/code → base/family → skeleton).
// Caller maps to 503 + a clear UI hint.
var ErrNoTemplate = errors.New("submission template: no template resolved in fallback chain")
// ErrTemplateUpstream wraps Gitea-side failures (network, 5xx).
// Distinct from ErrNoTemplate so the handler can render different UI:
// "no template configured" vs "template repo unreachable".
var ErrTemplateUpstream = errors.New("submission template: upstream Gitea unreachable")
// ResolvedTemplate is the result of a fallback-chain lookup: the
// template bytes plus the metadata the audit row + UI need.
type ResolvedTemplate struct {
// Path is the Gitea-relative path that resolved (e.g.
// "templates/HLC/de.inf.lg.erwidg.docx"). Persisted in the
// system_audit_log row so an admin can trace which template was
// used for a given generation.
Path string
// SHA is the commit SHA the template was fetched at. Pinning this
// lets audit consumers reproduce the exact bytes that went into
// the lawyer's download.
SHA string
// FirmTier reports which level of the fallback chain fired:
// "firm", "base_code", "base_family", or "skeleton". Useful for
// the variable-contract sidebar (Slice 3) and for ops monitoring
// of how often each firm is actually overriding.
FirmTier string
// Bytes is the .docx content; only populated for callers that
// need to render (i.e. SubmissionRenderer.Render). Resolve()
// returns it populated; Probe() leaves it nil.
Bytes []byte
}
// templateCacheEntry mirrors the per-file cache shape used by
// internal/handlers/files.go. Each cached entry tracks its bytes, the
// commit SHA, the last upstream check, and a checking flag so two
// concurrent refresh goroutines don't double-fetch.
type templateCacheEntry struct {
mu sync.RWMutex
data []byte
sha string
lastChecked time.Time
checking bool
missing bool // true when Gitea returned 404 — short-circuits subsequent lookups
}
// TemplateRegistry resolves submission templates from Gitea using the
// fallback chain. Process-wide cache; single-replica deployment (per
// docs/design-submission-generator-2026-05-19.md §1) makes in-process
// caching sufficient — a future multi-replica rollout would swap this
// for a shared cache. Same trade-off the HL Patents Style proxy makes.
type TemplateRegistry struct {
cache map[string]*templateCacheEntry
cacheMu sync.Mutex
giteaToken string
httpClient *http.Client
firmName string
}
// NewTemplateRegistry constructs the registry. firmName is read once
// at process start from internal/branding.Name so a runtime FIRM_NAME
// rebrand cuts in on the next deploy, not mid-request.
func NewTemplateRegistry(giteaToken, firmName string) *TemplateRegistry {
return &TemplateRegistry{
cache: make(map[string]*templateCacheEntry),
giteaToken: giteaToken,
firmName: firmName,
httpClient: &http.Client{Timeout: 30 * time.Second},
}
}
// HasTemplate reports whether any template resolves for the given
// submission code, without fetching the bytes. Used by the
// SubmissionsPanel to decide which "Generate" buttons to enable.
//
// Cheap path: walks the same fallback chain as Resolve, but stops at
// the SHA-probe step (Gitea's contents endpoint, single round-trip per
// candidate). The probe results land in the same cache as Resolve so a
// subsequent Resolve call reuses the SHA.
func (r *TemplateRegistry) HasTemplate(ctx context.Context, submissionCode string) bool {
for _, candidate := range r.candidates(submissionCode) {
if r.probe(ctx, candidate) {
return true
}
}
return false
}
// Resolve walks the fallback chain and returns the first template that
// fetches successfully, with bytes loaded. Returns ErrNoTemplate when
// no candidate (including the ultra-generic skeleton) resolves.
func (r *TemplateRegistry) Resolve(ctx context.Context, submissionCode string) (*ResolvedTemplate, error) {
candidates := r.candidates(submissionCode)
tiers := r.tiers(submissionCode)
if len(candidates) != len(tiers) {
return nil, fmt.Errorf("template registry: candidate/tier mismatch (%d vs %d)", len(candidates), len(tiers))
}
for i, candidate := range candidates {
entry := r.cacheGet(candidate)
entry.mu.RLock()
hasData := !entry.missing && len(entry.data) > 0
needsCheck := time.Since(entry.lastChecked) >= templatesCheckInterval
isMissing := entry.missing
entry.mu.RUnlock()
if isMissing && !needsCheck {
continue
}
if !hasData {
if err := r.fetchInto(ctx, candidate, entry); err != nil {
if errors.Is(err, errTemplate404) {
continue
}
return nil, fmt.Errorf("%w: %v", ErrTemplateUpstream, err)
}
} else if needsCheck {
go r.refresh(context.Background(), candidate, entry)
}
entry.mu.RLock()
out := &ResolvedTemplate{
Path: candidate,
SHA: entry.sha,
FirmTier: tiers[i],
Bytes: append([]byte(nil), entry.data...),
}
entry.mu.RUnlock()
return out, nil
}
return nil, ErrNoTemplate
}
// candidates returns the ordered Gitea-relative paths the registry
// walks for the given submission code. The order is the m-locked Q4
// decision: firm → base/code → base/family → skeleton.
func (r *TemplateRegistry) candidates(submissionCode string) []string {
family := familyOf(submissionCode)
out := []string{
fmt.Sprintf("templates/%s/%s.docx", r.firmName, submissionCode),
fmt.Sprintf("templates/_base/%s.docx", submissionCode),
}
if family != "" && family != submissionCode {
out = append(out, fmt.Sprintf("templates/_base/%s.docx", family))
}
out = append(out, fmt.Sprintf("templates/_base/%s.docx", templatesSkeleton))
return out
}
// tiers labels each candidate with its fallback tier. Order is locked
// to candidates(); both functions evolve together.
func (r *TemplateRegistry) tiers(submissionCode string) []string {
family := familyOf(submissionCode)
out := []string{"firm", "base_code"}
if family != "" && family != submissionCode {
out = append(out, "base_family")
}
out = append(out, "skeleton")
return out
}
// familyOf extracts the proceeding-family prefix from a submission
// code. The convention (docs/design-proceeding-code-taxonomy-2026-05-18.md)
// is jurisdiction.substantive.forum.submission, so the family is the
// first three dot-segments.
//
// de.inf.lg.erwidg → de.inf.lg
// upc.inf.cfi.soc → upc.inf.cfi
// dpma.opp.dpma → "" (only three segments — no submission suffix)
//
// Returns "" when the code doesn't carry a submission segment (no
// family-level fallback is meaningful).
func familyOf(submissionCode string) string {
parts := strings.Split(submissionCode, ".")
if len(parts) < 4 {
return ""
}
return strings.Join(parts[:3], ".")
}
// cacheGet returns the cache entry for a Gitea path, creating an empty
// entry on first lookup.
func (r *TemplateRegistry) cacheGet(path string) *templateCacheEntry {
r.cacheMu.Lock()
defer r.cacheMu.Unlock()
entry, ok := r.cache[path]
if !ok {
entry = &templateCacheEntry{}
r.cache[path] = entry
}
return entry
}
// errTemplate404 is an internal sentinel: candidate doesn't exist in
// Gitea, walk the chain. Distinguished from network/5xx errors so the
// registry doesn't wrap every fallback miss as ErrTemplateUpstream.
var errTemplate404 = errors.New("template not found in gitea")
// fetchInto downloads a candidate and populates the cache entry. On
// 404 it marks the entry missing so subsequent lookups short-circuit
// without hitting the network.
func (r *TemplateRegistry) fetchInto(ctx context.Context, path string, entry *templateCacheEntry) error {
sha, err := r.giteaSHA(ctx, path)
if err != nil {
if errors.Is(err, errTemplate404) {
entry.mu.Lock()
entry.missing = true
entry.lastChecked = time.Now()
entry.mu.Unlock()
}
return err
}
data, err := r.giteaDownload(ctx, path)
if err != nil {
return err
}
entry.mu.Lock()
entry.data = data
entry.sha = sha
entry.lastChecked = time.Now()
entry.missing = false
entry.mu.Unlock()
return nil
}
// refresh runs in the background after a stale-but-present cache hit.
// SHA-checks the candidate; re-downloads on change. Mirrors the same
// goroutine pattern as internal/handlers/files.go.
func (r *TemplateRegistry) refresh(ctx context.Context, path string, entry *templateCacheEntry) {
entry.mu.Lock()
if entry.checking {
entry.mu.Unlock()
return
}
entry.checking = true
entry.mu.Unlock()
defer func() {
entry.mu.Lock()
entry.checking = false
entry.mu.Unlock()
}()
latestSHA, err := r.giteaSHA(ctx, path)
if err != nil {
log.Printf("submission template: SHA check for %s failed: %v", path, err)
entry.mu.Lock()
entry.lastChecked = time.Now()
entry.mu.Unlock()
return
}
entry.mu.RLock()
unchanged := latestSHA == entry.sha && entry.sha != ""
entry.mu.RUnlock()
if unchanged {
entry.mu.Lock()
entry.lastChecked = time.Now()
entry.mu.Unlock()
return
}
data, err := r.giteaDownload(ctx, path)
if err != nil {
log.Printf("submission template: download %s failed: %v", path, err)
entry.mu.Lock()
entry.lastChecked = time.Now()
entry.mu.Unlock()
return
}
entry.mu.Lock()
entry.data = data
entry.sha = latestSHA
entry.lastChecked = time.Now()
entry.mu.Unlock()
log.Printf("submission template: updated %s (SHA: %.8s)", path, latestSHA)
}
// probe is the cheap existence-check used by HasTemplate. Reuses the
// cache but only fetches the SHA (not the bytes), so the
// SubmissionsPanel's per-row HasTemplate calls don't pull a megabyte
// of .docx data the user might never download.
func (r *TemplateRegistry) probe(ctx context.Context, path string) bool {
entry := r.cacheGet(path)
entry.mu.RLock()
hasData := !entry.missing && len(entry.data) > 0
hasSHA := !entry.missing && entry.sha != ""
isMissing := entry.missing
needsCheck := time.Since(entry.lastChecked) >= templatesCheckInterval
entry.mu.RUnlock()
if isMissing && !needsCheck {
return false
}
if hasData || hasSHA {
return true
}
sha, err := r.giteaSHA(ctx, path)
if err != nil {
if errors.Is(err, errTemplate404) {
entry.mu.Lock()
entry.missing = true
entry.lastChecked = time.Now()
entry.mu.Unlock()
}
return false
}
entry.mu.Lock()
entry.sha = sha
entry.lastChecked = time.Now()
entry.missing = false
entry.mu.Unlock()
return true
}
// giteaSHA returns the SHA of the latest commit that touched the
// template path. Returns errTemplate404 when Gitea responds with 404 —
// the registry distinguishes "no such template" from "Gitea is down".
func (r *TemplateRegistry) giteaSHA(ctx context.Context, path string) (string, error) {
apiURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/commits?path=%s&limit=1&sha=%s",
templatesGiteaBaseURL,
templatesGiteaRepoOwn,
templatesGiteaRepoName,
url.QueryEscape(path),
templatesGiteaBranch,
)
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
if err != nil {
return "", err
}
if r.giteaToken != "" {
req.Header.Set("Authorization", "token "+r.giteaToken)
}
resp, err := r.httpClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return "", errTemplate404
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("gitea sha lookup returned %d", resp.StatusCode)
}
var commits []struct {
SHA string `json:"sha"`
}
if err := json.NewDecoder(resp.Body).Decode(&commits); err != nil {
return "", err
}
if len(commits) == 0 {
return "", errTemplate404
}
return commits[0].SHA, nil
}
// giteaDownload fetches the raw template bytes.
func (r *TemplateRegistry) giteaDownload(ctx context.Context, path string) ([]byte, error) {
rawURL := fmt.Sprintf("%s/%s/%s/raw/branch/%s/%s",
templatesGiteaBaseURL,
templatesGiteaRepoOwn,
templatesGiteaRepoName,
templatesGiteaBranch,
path,
)
req, err := http.NewRequestWithContext(ctx, "GET", rawURL, nil)
if err != nil {
return nil, err
}
if r.giteaToken != "" {
req.Header.Set("Authorization", "token "+r.giteaToken)
}
resp, err := r.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return nil, errTemplate404
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("gitea raw returned %d", resp.StatusCode)
}
return io.ReadAll(resp.Body)
}
// ClearCache drops every cached entry. Exposed for an admin-side
// "refresh templates" affordance — paliad's existing /api/files/refresh
// has the same shape for the HL Patents Style proxy.
func (r *TemplateRegistry) ClearCache() {
r.cacheMu.Lock()
defer r.cacheMu.Unlock()
for k := range r.cache {
r.cache[k] = &templateCacheEntry{}
}
}

View File

@@ -1,559 +0,0 @@
package services
// Submission variable bag — builds the PlaceholderMap that
// SubmissionRenderer fills into a template (t-paliad-215, design doc
// docs/design-submission-generator-2026-05-19.md §6.2).
//
// Variables span six namespaces:
//
// firm.* process-wide (branding.Name)
// user.* caller's user row
// today.* server time in Europe/Berlin, locale-aware
// project.* paliad.projects + joined proceeding type
// parties.* paliad.parties grouped by role
// rule.* paliad.deadline_rules row keyed by submission_code
// deadline.* next open paliad.deadlines row for (project, rule), if any
//
// Locale handling: every long-form date string is computed in both DE
// and EN; the renderer picks based on the user's lang preference. The
// rule pretty-printer (legalSourcePretty) also has DE/EN variants.
//
// Visibility: caller passes userID; ProjectService.GetByID enforces
// paliad.can_see_project — unauthorised callers get the standard
// ErrNotFound before any variable construction runs.
import (
"context"
"database/sql"
"errors"
"fmt"
"regexp"
"strings"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"mgit.msbls.de/m/paliad/internal/branding"
"mgit.msbls.de/m/paliad/internal/models"
)
// SubmissionVarsService assembles the placeholder map.
type SubmissionVarsService struct {
db *sqlx.DB
projects *ProjectService
parties *PartyService
users *UserService
}
// NewSubmissionVarsService wires the service.
func NewSubmissionVarsService(db *sqlx.DB, projects *ProjectService, parties *PartyService, users *UserService) *SubmissionVarsService {
return &SubmissionVarsService{
db: db,
projects: projects,
parties: parties,
users: users,
}
}
// SubmissionVarsContext is the input bundle that produces a render.
type SubmissionVarsContext struct {
UserID uuid.UUID
ProjectID uuid.UUID
SubmissionCode string
}
// SubmissionVarsResult bundles the placeholder map with the lookup
// values the handler needs for the audit row + file naming
// (rule.Name, project.case_number, etc.).
type SubmissionVarsResult struct {
Placeholders PlaceholderMap
// Resolved entities for audit + naming.
User *models.User
Project *models.Project
Rule *models.DeadlineRule
ProceedingType *models.ProceedingType
Parties []models.Party
NextDeadline *models.Deadline
// Lang is the user's UI language used to pick locale-aware values
// during the build. Returned so the renderer can use the matching
// missing-marker function.
Lang string
}
// ErrSubmissionRuleNotFound is returned when no published deadline_rule
// matches the requested submission_code. Maps to 404 in the handler.
var ErrSubmissionRuleNotFound = errors.New("submission generator: no rule found for submission_code")
// Build resolves every entity and assembles the placeholder map.
func (s *SubmissionVarsService) Build(ctx context.Context, in SubmissionVarsContext) (*SubmissionVarsResult, error) {
if s.projects == nil || s.users == nil {
return nil, fmt.Errorf("submission vars: required services not wired")
}
user, err := s.users.GetByID(ctx, in.UserID)
if err != nil {
return nil, err
}
if user == nil {
return nil, ErrNotVisible
}
// Visibility gate — GetByID returns ErrNotFound when the user
// can't see the project, which is exactly the 404 the handler
// wants to propagate.
project, err := s.projects.GetByID(ctx, in.UserID, in.ProjectID)
if err != nil {
return nil, err
}
rule, err := s.loadPublishedRule(ctx, in.SubmissionCode)
if err != nil {
return nil, err
}
pt, err := s.loadProceedingType(ctx, project.ProceedingTypeID)
if err != nil {
return nil, err
}
parties, err := s.parties.ListForProject(ctx, in.UserID, in.ProjectID)
if err != nil {
return nil, err
}
next, err := s.nextOpenDeadline(ctx, in.ProjectID, rule.ID)
if err != nil {
return nil, err
}
lang := user.Lang
if lang == "" {
lang = "de"
}
bag := PlaceholderMap{}
addFirmVars(bag)
addTodayVars(bag, time.Now())
addUserVars(bag, user)
addProjectVars(bag, project, pt, lang)
addPartyVars(bag, parties)
addRuleVars(bag, rule, lang)
addDeadlineVars(bag, next, project, lang)
return &SubmissionVarsResult{
Placeholders: bag,
User: user,
Project: project,
Rule: rule,
ProceedingType: pt,
Parties: parties,
NextDeadline: next,
Lang: lang,
}, nil
}
// loadPublishedRule fetches the deadline_rule that owns the given
// submission_code. Restricts to lifecycle_state='published' so drafts
// never end up shaping a real submission.
func (s *SubmissionVarsService) loadPublishedRule(ctx context.Context, submissionCode string) (*models.DeadlineRule, error) {
if submissionCode == "" {
return nil, ErrSubmissionRuleNotFound
}
var rule models.DeadlineRule
err := s.db.GetContext(ctx, &rule,
`SELECT `+ruleColumns+`
FROM paliad.deadline_rules
WHERE submission_code = $1
AND lifecycle_state = 'published'
AND is_active = true
ORDER BY sequence_order
LIMIT 1`, submissionCode)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrSubmissionRuleNotFound
}
if err != nil {
return nil, fmt.Errorf("load rule by submission_code %q: %w", submissionCode, err)
}
return &rule, nil
}
// loadProceedingType fetches the proceeding type row for the project's
// proceeding_type_id. Tolerates a nil id (returns nil, nil) so projects
// without a bound proceeding still render a meaningful template — the
// {{project.proceeding.*}} placeholders just resolve to the missing
// marker.
func (s *SubmissionVarsService) loadProceedingType(ctx context.Context, id *int) (*models.ProceedingType, error) {
if id == nil {
return nil, nil
}
var pt models.ProceedingType
err := s.db.GetContext(ctx, &pt,
`SELECT `+proceedingTypeColumns+`
FROM paliad.proceeding_types
WHERE id = $1`, *id)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("load proceeding type %d: %w", *id, err)
}
return &pt, nil
}
// nextOpenDeadline finds the earliest pending paliad.deadlines row on
// the given project that maps to the chosen rule. Returns (nil, nil)
// when no matching deadline exists — common when the lawyer is drafting
// the submission before the system has computed its deadline row.
func (s *SubmissionVarsService) nextOpenDeadline(ctx context.Context, projectID, ruleID uuid.UUID) (*models.Deadline, error) {
var d models.Deadline
err := s.db.GetContext(ctx, &d,
`SELECT id, project_id, title, description, due_date, original_due_date,
warning_date, source, rule_id, rule_code, status, completed_at,
caldav_uid, caldav_etag, notes, created_by, created_at, updated_at,
approval_status, pending_request_id, approved_by, approved_at
FROM paliad.deadlines
WHERE project_id = $1
AND rule_id = $2
AND status = 'pending'
ORDER BY due_date ASC
LIMIT 1`, projectID, ruleID)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("load next deadline (project=%s rule=%s): %w", projectID, ruleID, err)
}
return &d, nil
}
// addFirmVars populates the firm.* namespace.
func addFirmVars(bag PlaceholderMap) {
bag["firm.name"] = branding.Name
// firm.signature_block is reserved for Phase 2; emit empty so
// templates that already reference it don't render the missing
// marker (less noisy for the lawyer).
bag["firm.signature_block"] = ""
}
// addTodayVars populates today.* in both DE and EN long forms. ISO
// short form is the default {{today}}.
func addTodayVars(bag PlaceholderMap, now time.Time) {
loc, _ := time.LoadLocation("Europe/Berlin")
if loc != nil {
now = now.In(loc)
}
bag["today"] = now.Format("2006-01-02")
bag["today.iso"] = now.Format("2006-01-02")
bag["today.long_de"] = formatLongDateDE(now)
bag["today.long_en"] = formatLongDateEN(now)
}
// addUserVars populates user.*.
func addUserVars(bag PlaceholderMap, u *models.User) {
bag["user.display_name"] = u.DisplayName
bag["user.email"] = u.Email
bag["user.office"] = u.Office
}
// addProjectVars populates project.* — title / case_number / court /
// patent_number / dates / our_side / proceeding metadata.
func addProjectVars(bag PlaceholderMap, p *models.Project, pt *models.ProceedingType, lang string) {
bag["project.title"] = p.Title
bag["project.reference"] = derefString(p.Reference)
// project.code is the auto-derived (or override) dotted project
// code computed by services.BuildProjectCode. Populated upstream
// by the service projection; templates that want the explicit
// override should read project.reference instead.
bag["project.code"] = p.Code
bag["project.case_number"] = derefString(p.CaseNumber)
bag["project.court"] = derefString(p.Court)
bag["project.patent_number"] = derefString(p.PatentNumber)
// project.patent_number_upc is the UPC-brief convention — kind code
// parenthesised ("EP 1 234 567 (B1)") instead of the DE form
// ("EP 1 234 567 B1"). Pure-function rewrite; pass-through when no
// kind code is present so the lawyer's draft never sees a worse
// number than the source value.
bag["project.patent_number_upc"] = patentNumberUPC(derefString(p.PatentNumber))
bag["project.filing_date"] = formatDatePtr(p.FilingDate, "2006-01-02")
bag["project.grant_date"] = formatDatePtr(p.GrantDate, "2006-01-02")
bag["project.our_side"] = derefString(p.OurSide)
bag["project.our_side_de"] = ourSideDE(derefString(p.OurSide))
bag["project.our_side_en"] = ourSideEN(derefString(p.OurSide))
bag["project.instance_level"] = derefString(p.InstanceLevel)
bag["project.client_number"] = derefString(p.ClientNumber)
bag["project.matter_number"] = derefString(p.MatterNumber)
if pt != nil {
bag["project.proceeding.code"] = pt.Code
if strings.EqualFold(lang, "en") {
bag["project.proceeding.name"] = pt.NameEN
} else {
bag["project.proceeding.name"] = pt.Name
}
bag["project.proceeding.name_de"] = pt.Name
bag["project.proceeding.name_en"] = pt.NameEN
}
}
// addPartyVars populates parties.* using the first row of each role.
// Multi-claimant / multi-defendant suits use the first row in Slice 1
// per design §13.6; expanded grouping is Phase 2.
func addPartyVars(bag PlaceholderMap, parties []models.Party) {
var claimant, defendant, other *models.Party
for i := range parties {
role := strings.ToLower(strings.TrimSpace(derefString(parties[i].Role)))
switch role {
case "claimant", "kläger", "klaeger":
if claimant == nil {
claimant = &parties[i]
}
case "defendant", "beklagter", "beklagte":
if defendant == nil {
defendant = &parties[i]
}
default:
if other == nil {
other = &parties[i]
}
}
}
if claimant != nil {
bag["parties.claimant.name"] = claimant.Name
bag["parties.claimant.representative"] = derefString(claimant.Representative)
}
if defendant != nil {
bag["parties.defendant.name"] = defendant.Name
bag["parties.defendant.representative"] = derefString(defendant.Representative)
}
if other != nil {
bag["parties.other.name"] = other.Name
bag["parties.other.representative"] = derefString(other.Representative)
}
}
// addRuleVars populates rule.* — submission_code, name(_en),
// legal_source (+ pretty form), primary_party, event_type.
func addRuleVars(bag PlaceholderMap, r *models.DeadlineRule, lang string) {
bag["rule.submission_code"] = derefString(r.SubmissionCode)
if strings.EqualFold(lang, "en") {
bag["rule.name"] = r.NameEN
} else {
bag["rule.name"] = r.Name
}
bag["rule.name_de"] = r.Name
bag["rule.name_en"] = r.NameEN
bag["rule.legal_source"] = derefString(r.LegalSource)
bag["rule.legal_source_pretty"] = legalSourcePretty(derefString(r.LegalSource), lang)
bag["rule.primary_party"] = derefString(r.PrimaryParty)
bag["rule.event_type"] = derefString(r.EventType)
}
// addDeadlineVars populates deadline.* from the next pending row. When
// no row exists the values fall through to the missing marker — the
// lawyer sees [KEIN WERT: deadline.due_date] in Word and knows to fix.
func addDeadlineVars(bag PlaceholderMap, d *models.Deadline, p *models.Project, lang string) {
if d == nil {
return
}
bag["deadline.due_date"] = d.DueDate.Format("2006-01-02")
bag["deadline.due_date_long_de"] = formatLongDateDE(d.DueDate)
bag["deadline.due_date_long_en"] = formatLongDateEN(d.DueDate)
if d.OriginalDueDate != nil {
bag["deadline.original_due_date"] = d.OriginalDueDate.Format("2006-01-02")
}
// computed_from carries the human-readable anchor description
// (e.g. "Klagezustellung am 14.05.2026 + 6 Wochen"). Notes is
// the closest existing field — the calculator stores anchor
// metadata there. If empty we leave the placeholder unresolved.
if d.Notes != nil && strings.TrimSpace(*d.Notes) != "" {
bag["deadline.computed_from"] = strings.TrimSpace(*d.Notes)
}
bag["deadline.title"] = d.Title
bag["deadline.source"] = d.Source
_ = p // reserved for future shape decisions where the deadline
// var depends on project context.
_ = lang
}
// derefString returns *s or "" when s is nil.
func derefString(s *string) string {
if s == nil {
return ""
}
return *s
}
// formatDatePtr formats a *time.Time, returning "" for nil.
func formatDatePtr(t *time.Time, layout string) string {
if t == nil {
return ""
}
return t.Format(layout)
}
// ourSideDE returns the German legal-prose form of an our_side value.
//
// t-paliad-222: unified on the gender-neutral "-Seite" / "-Partei"
// suffix shape to match the form labels and to avoid implying the
// firm represents a single (female) natural person — a B2B patent
// practice almost always represents companies. The seven sub-roles
// map onto the post-mig-110 schema; legacy 'court' / 'both' no
// longer exist in the column.
func ourSideDE(side string) string {
switch strings.ToLower(side) {
case "claimant":
return "Klägerseite"
case "defendant":
return "Beklagtenseite"
case "applicant":
return "Antragstellerseite"
case "appellant":
return "Berufungsklägerseite"
case "respondent":
return "Antragsgegnerseite"
case "third_party":
return "Drittpartei"
case "other":
return "sonstige Verfahrensbeteiligte"
}
return ""
}
// ourSideEN returns the English legal-prose form of an our_side value.
func ourSideEN(side string) string {
switch strings.ToLower(side) {
case "claimant":
return "Claimant"
case "defendant":
return "Defendant"
case "applicant":
return "Applicant"
case "appellant":
return "Appellant"
case "respondent":
return "Respondent"
case "third_party":
return "Third Party"
case "other":
return "other party"
}
return ""
}
// formatLongDateDE renders a date in the German long form
// ("19. Mai 2026"). Pure function for unit testing.
func formatLongDateDE(t time.Time) string {
months := []string{
"Januar", "Februar", "März", "April", "Mai", "Juni",
"Juli", "August", "September", "Oktober", "November", "Dezember",
}
idx := int(t.Month()) - 1
if idx < 0 || idx >= len(months) {
return t.Format("2006-01-02")
}
return fmt.Sprintf("%d. %s %d", t.Day(), months[idx], t.Year())
}
// formatLongDateEN renders a date in the English long form
// ("19 May 2026").
func formatLongDateEN(t time.Time) string {
return t.Format("2 January 2006")
}
// legalSourcePretty rewrites the shorthand stored on deadline_rules
// (DE.ZPO.276.1, UPC.RoP.23.1, …) into the form a lawyer would type
// in a brief ("§ 276 Abs. 1 ZPO", "Rule 23.1 RoP UPC"). Unknown
// prefixes pass through unchanged — preferring the raw shorthand over
// an incorrect prettification.
//
// Lang controls the language of connective words (Abs / Section,
// Regel / Rule, …). The pretty table covers the prefixes used by the
// 254 published rules in the corpus today; new prefixes default to
// pass-through and a follow-up CL extends the table.
func legalSourcePretty(src, lang string) string {
src = strings.TrimSpace(src)
if src == "" {
return ""
}
parts := strings.Split(src, ".")
en := strings.EqualFold(lang, "en")
switch {
case len(parts) == 4 && parts[0] == "DE" && parts[1] == "ZPO":
if en {
return fmt.Sprintf("Section %s(%s) ZPO", parts[2], parts[3])
}
return fmt.Sprintf("§ %s Abs. %s ZPO", parts[2], parts[3])
case len(parts) == 3 && parts[0] == "DE" && parts[1] == "ZPO":
if en {
return fmt.Sprintf("Section %s ZPO", parts[2])
}
return fmt.Sprintf("§ %s ZPO", parts[2])
case len(parts) == 4 && parts[0] == "UPC" && parts[1] == "RoP":
if en {
return fmt.Sprintf("Rule %s.%s RoP UPC", parts[2], parts[3])
}
return fmt.Sprintf("Regel %s.%s VerfO UPC", parts[2], parts[3])
case len(parts) == 3 && parts[0] == "UPC" && parts[1] == "RoP":
if en {
return fmt.Sprintf("Rule %s RoP UPC", parts[2])
}
return fmt.Sprintf("Regel %s VerfO UPC", parts[2])
case len(parts) >= 3 && parts[0] == "DE" && parts[1] == "PatG":
if en {
return fmt.Sprintf("Section %s PatG", parts[2])
}
return fmt.Sprintf("§ %s PatG", parts[2])
case len(parts) == 2 && parts[0] == "EPC":
if en {
return fmt.Sprintf("Art. %s EPC", parts[1])
}
return fmt.Sprintf("Art. %s EPÜ", parts[1])
}
return src
}
// patentNumberKindCodeRegex matches a trailing kind code on a patent
// number: a whitespace-separated single uppercase letter followed by
// a single digit (B1, A1, A2, B2, B9, C1, T2, U1, …). Capturing
// groups split the base from the kind code so the formatter can
// parenthesise the kind without touching the rest of the number.
var patentNumberKindCodeRegex = regexp.MustCompile(`^(.*?)\s+([A-Z]\d)$`)
// patentNumberUPC reformats a patent number from the DE convention
// ("EP 1 234 567 B1") to the UPC-brief convention
// ("EP 1 234 567 (B1)"). The kind code is parenthesised; everything
// else is preserved verbatim. Numbers without a recognised trailing
// kind code pass through unchanged so a lawyer's draft never sees a
// number worse than the source value.
//
// Recognised inputs:
//
// "EP 1 234 567 B1" → "EP 1 234 567 (B1)"
// "EP 4 056 049 A1" → "EP 4 056 049 (A1)"
// "DE 10 2020 123 456 A1" → "DE 10 2020 123 456 (A1)"
// " EP 1 234 567 B1 " → "EP 1 234 567 (B1)" (trimmed)
//
// Pass-through:
//
// "EP 1 234 567" → "EP 1 234 567"
// "WO/2023/123456" → "WO/2023/123456" (no kind code shape)
// "" → ""
//
// Pure function; unit-tested in submission_vars_test.go.
func patentNumberUPC(s string) string {
s = strings.TrimSpace(s)
if s == "" {
return ""
}
if m := patentNumberKindCodeRegex.FindStringSubmatch(s); m != nil {
base := strings.TrimSpace(m[1])
kind := m[2]
if base == "" {
return s
}
return base + " (" + kind + ")"
}
return s
}