Compare commits

...

29 Commits

Author SHA1 Message Date
mAi
cdd27d674e feat(paliadin): stream + honest late-recovery (t-paliad-235)
m's 14:56 observation: long Paliadin turns showed "Verbindung verloren —
Antwort wird nachgereicht …" but never delivered. The aichat backend
finished the turn upstream; paliad's HTTP client had given up at 130 s
and the legacy filesystem janitor never ran for the aichat path.

Three intertwined fixes, all shipped together because they share the
same wire shape and the same UI states:

1. Switch the aichat backend to /chat/turn/stream
   - new AichatPaliadinService.RunTurnStream relays incremental chunks
   - SSE parser handles default `data:` frames (chunk/meta/done/error)
     and named `event: heartbeat` frames per the upstream contract
   - no more 130 s hard ceiling — stream stays open as long as data or
     heartbeats flow; silenceTimeout (90 s) catches a true upstream
     stall instead

2. Proof-of-life thinking events
   - handler emits `event: thinking` every 5 s while the upstream is
     silent (synthesised locally) AND relays aichat's `heartbeat`
     events as thinking pings
   - frontend renders a lime-dot pulse + monospace counter inside the
     assistant bubble — the user can SEE the chat is still working

3. Honest disconnect copy + real late-recovery
   - new dispatching endpoint GET /api/paliadin/turns/{id}/recover
   - aichat backend: asks aichat via GET /chat/conversations and
     /chat/conversations/{id}/turns whether the turn actually finished
   - legacy backend: falls through to the local row read (janitor)
   - frontend swaps "wird nachgereicht" → "Lade frische Antwort …"
     while the recovery polls; on confirmed "lost" swaps to
     "Antwort konnte nicht zugestellt werden — bitte erneut stellen"
   - migration 118 adds aichat_conversation_id to paliadin_turns so
     the recovery has a fast path when the done frame arrived before
     the drop

Streaming + recovery are a no-op for PALIADIN_BACKEND=legacy: the
StreamingPaliadin interface is detected via type assertion, the
LocalPaliadinService stays on the one-shot RunTurn + filesystem
janitor path.

13 new unit tests cover the SSE parser, the conversation-API client,
and the match-assistant-response helper.

go build ./... + go test ./internal/... + go test ./cmd/server/...
+ bun run build all clean.
2026-05-22 15:17:24 +02:00
mAi
28de2e56d0 Merge: t-paliad-233 — print views default portrait + landscape opt-ins 2026-05-21 22:03:18 +02:00
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
mAi
9f339747e5 feat(team): mailto: selection on project-detail Team tab (t-paliad-231)
Non-admins can now select team members directly on the project detail
Team tab and open a mailto: link in their local mail client with every
selected member queued in the To: line. No server call, no audit row —
the existing /admin/team server-SMTP broadcast (t-paliad-147) stays
admin-only and untouched.

Behaviour:
- Checkbox column on every team-body row (direct + ancestor-inherited).
  Rows for users without an email render a disabled checkbox so the
  column geometry stays uniform.
- Tri-state master checkbox in the header row toggles every visible,
  email-bearing row.
- Single "Mail an Auswahl" button above the table, disabled while the
  selection is empty. When one or more rows are selected the label
  picks up "(N)" and the title attribute spells out the count.
- Click composes mailto:a@x,b@y via the existing buildMailtoHref
  helper from broadcast.ts (RFC 6068 comma join + encodeURIComponent
  per address) and sets window.location.href. Pure client side.
- Selection is pruned to currently-rendered, email-bearing user_ids
  on every renderTeam call so removed members or members who lose
  their email drop out automatically.
2026-05-21 15:17:52 +02:00
mAi
7c3c84454d Merge: t-paliad-229 — changelog catch-up May 2026 2026-05-21 15:03:44 +02:00
mAi
61210943d9 content(changelog): drop submission-generator entry per task scope
t-paliad-229 hard rule: "Don't write release notes for things still
in design phase (submission generator, etc.)". Klageerwiderung
shipped end-to-end via t-paliad-215 Slice 1, but m flagged the whole
submission generator as too early for the public changelog — only
one template, more to follow. Removing the 2026-05-19 entry; the 9
other entries remain unchanged.
2026-05-21 15:01:54 +02:00
mAi
74783e7a89 content(changelog): t-paliad-229 — catch up changelog for May 2026
Adds 10 user-visible entries covering everything shipped since the
2026-04-30 entries. Newest first, voice and length match the
established pattern.

- 2026-05-21 Configurable dashboard (drag/drop edit mode, resize,
  per-widget options, widget catalog, firm-wide admin default,
  collision-aware placement — bundles t-paliad-219 Slice A+B+C +
  m/paliad#69 + #70)
- 2026-05-20 User-authored checklists (Wizard + explicit sharing +
  admin firm-wide promotion + template versioning — t-paliad-225
  Slice A+B+C)
- 2026-05-20 Approvals: suggest changes (third inbox action,
  counter-proposal modal, Verlauf integration — t-paliad-216 +
  t-paliad-217)
- 2026-05-20 Client role + auto-derived project codes (t-paliad-222 =
  m/paliad#47 + #50)
- 2026-05-19 Submissions: Klageerwiderung als Word-Datei (Schriftsätze
  tab + first .docx template — t-paliad-215)
- 2026-05-19 Personal data export (xlsx/csv/json on /settings +
  per-project subtree export — t-paliad-214)
- 2026-05-15 Custom Views (Meine Sichten + list/cards/calendar/timeline
  + exports — t-paliad-144 + t-paliad-177 + t-paliad-211)
- 2026-05-07 Projects page redesign (tree + chips + pin + search + Cards
  view — t-paliad-149 PR 1+2)
- 2026-05-06 Four-eyes approvals (dual-control on deadline/appointment
  CRUD + admin policies UI — t-paliad-138 + t-paliad-154)
- 2026-05-05 Fristenrechner v3 (Pathway A/B + decision tree + concept
  layer + DE/EPA/DPMA expansion — t-paliad-131 / 133 / 134 / 136)

go build ./... + go test ./internal/changelog/... clean.
2026-05-21 15:00:53 +02:00
mAi
062afb6cc5 Merge: hotfix project tree ltree-on-text outage 2026-05-21 14:52:56 +02:00
mAi
47b869dddf hotfix(projects): drop ltree operators on text path — production outage
Production-down: project tree returned the
"Projektverwaltung zurzeit nicht verfügbar" message because every
PopulateProjectCodes call raised:

  ERROR service: populate project codes: bulk fetch:
  pq: operator does not exist: text @> text at position 13:38 (42883)

Root cause: paliad.projects.path is stored as TEXT (dot-separated
UUIDs), not as the ltree extension type. The rest of the codebase
treats it accordingly — can_see_project uses
string_to_array(path, '.')::uuid[]; export_service.go uses LIKE
patterns; export_service.go even spells it out:
"Subtree-aware queries via paliad.projects.path (ltree as text)."

The new project-code helper (t-paliad-222 / m/paliad#50) was the only
caller using ltree operators (@>, nlevel) against this text column.
Postgres correctly rejected text @> text — no such operator exists.

Fix: rewrite both queries (BuildProjectCode + PopulateProjectCodes) to
walk ancestors via string_to_array(path, '.')::uuid[], consistent with
the existing visibility predicate. Ordering uses array_position
instead of nlevel. Query shape validated against the live DB.

Pure-function tests (assemble + segment) untouched and passing. The
gap that let this ship: no integration test exercises the actual SQL
— it only tests the pure assembler. Filing a follow-up issue for a
real-DB regression test.
2026-05-21 14:52:50 +02:00
mAi
c4c4fa267f Merge: fix dashboard deadline link query preservation 2026-05-21 14:23:07 +02:00
mAi
d555d5f679 fix(dashboard): preserve query string on /deadlines → /events redirect
m's 2026-05-21 14:20 report: dashboard "Diese Woche" card linked to
/deadlines?status=this_week but the 301 to /events?type=deadline dropped
the query string, landing on the default Pending filter instead of the
This-Week bucket.

Two-part fix:

1. handleDeadlinesListRedirect now appends r.URL.RawQuery to the
   target so any filter (status, project_id, event_type, …) survives
   the redirect. Regression test pins all three shapes (no query,
   single param, multi param).

2. Dashboard summary cards point at the canonical
   /events?type=deadline&status=… URL directly — saves the 301 bounce
   and matches the URL the events page itself reads on load.

The five card values (overdue/today/this_week/next_week/later) are all
in STATUS_OPTIONS_DEADLINE in frontend/src/client/events.ts, so the
events page filter chip picks them up natively.
2026-05-21 14:23:04 +02:00
mAi
875d0c149a Merge: m/paliad#70 — collision-aware widget placement (dashboard overlap fix)
Follow-up to m/paliad#69. Mixed-size rows (e.g. 2-col widget next to 1-col)
no longer visually overlap because:

- Grid occupancy map now accounts for each widget's full colspan footprint,
  not just its origin cell.
- Drop-target hit detection excludes cells covered by another widget's
  colspan.
- Resize-grow shifts conflicting siblings to the next free cell (m's
  recommended behaviour per the issue body).

Tesla stays persistent on mai/tesla/dashboard-overlap for follow-up
dashboard tweaks per m's continuity ask.
2026-05-21 10:49:45 +02:00
mAi
92d0340d74 fix(dashboard): t-paliad-228 — collision-aware widget placement (m/paliad#70)
After m/paliad#69's edit-mode overhaul, widgets visually overlapped on
mixed-size rows: a 12-col + 6-col swap, an auto-flow widget landing on
an explicit blocker, or a resize-grow into a sibling all produced
layouts that ignored colspan footprints when computing occupancy.

Extracts placement math from dashboard.ts into a pure ./dashboard-grid
module and adds an occupancy bitmap. Every visible widget is placed
once; explicit-position collisions are resolved by searching downward
from the requested row for the first w×h block that fits, preferring
the requested column. Resize-grow + drag-drop swap now reliably
produce no-overlap layouts because the placer cleans up after them.

x+w > GRID_COLUMNS is clamped in the placer instead of rendered as an
overflow — matches the validator's hard rule on the wire.

Adds 14 dashboard-grid.test.ts regressions covering the mixed-width
swap, resize-grow shifting siblings, multi-row widgets, and the
overflow clamp. Pure tests — no DOM.
2026-05-21 10:48:10 +02:00
mAi
f8c6206afe Merge: m/paliad#69 — dashboard edit-mode overhaul (drag/drop + resize + per-widget options)
Three regressions / gaps on newton's just-shipped Slice B+C addressed.

- **Drag/drop reorder**: rebuilt on a single proper 12-col grid (newton's
  implementation had per-row containers which blocked cross-row drops + the
  swap heuristic only handled adjacent same-size cells). Drop hit detection
  now works across the entire grid; recalc step uses real grid coordinates;
  any widget moves anywhere, autosaves.
- **Resize**: bottom-right resize handle added (visible only in edit mode).
  Snaps to valid 1x1 / 2x1 / 2x2 grid sizes; sibling widgets reflow on
  resize; autosave via the same PUT /api/user/dashboard path.
- **Per-widget options expansion**: widget catalog entries now carry an
  option schema (limits, position, content/view-type). Settings pane
  renders the right controls dynamically per schema. Deadlines widget
  exposes list / calendar / timeline-strip view picker; activity widget
  full / compact toggle; etc.

No schema migration — option schema rides on the existing user_dashboard_layouts
jsonb. Backward-compat: legacy layouts (without per-widget options) hydrate
with catalog defaults.
2026-05-21 09:56:08 +02:00
mAi
f8245a06a6 fix(dashboard): t-paliad-227 — rebuild edit mode on a single 12-col grid (m/paliad#69)
Three issues from Slice B were entangled in the same root cause:

1. **Drag/drop reorder only swapped the first two same-size widgets.**
   Widgets lived in two parents (.container + .dashboard-columns); the
   old applyLayout used parent.appendChild per widget which physically
   moved every .container widget to the END of .container — past the
   .dashboard-columns row, edit-footer, and save-toast. Only the two
   columns inside .dashboard-columns swapped visibly because they
   shared a parent. Cross-row drags appeared to silently no-op.

2. **No resize affordance** — the design's per-widget sizing existed
   only on paper.

3. **Per-widget options were thin** — count + horizon dropdowns only.

This change rebuilds the whole layout primitive on a single 12-column
CSS grid:

Backend (internal/services/):
- DashboardWidgetRef gains x/y/w/h grid coordinates. Validator clamps
  against catalog MinW/MaxW/MinH/MaxH and rejects x+w > 12.
- WidgetDef gains DefaultW/H + MinW/MaxW/MinH/MaxH for the resize clamps.
- WidgetSettingsSchema gains Views ([{id,label_de,label_en}]), CountMax,
  HorizonMax. Validator accepts free-form ints inside [1,CountMax] in
  addition to dropdown presets, plus view-id against schema.
- WidgetCatalog wires views for upcoming-deadlines/-appointments (list,
  calendar), inline-agenda (timeline, list), recent-activity (full,
  compact), plus default sizes per widget.
- FactoryDefaultLayout greedy-packs visible widgets onto the grid,
  tracking row-max height so taller previous neighbours never overlap.

Frontend:
- dashboard.tsx: every widget moved into a single .dashboard-grid
  wrapper; matter-summary converted to a CollapsibleSection so it
  participates in the grid like everything else.
- applyLayout rewritten — never moves DOM nodes; writes inline
  grid-column / grid-row from computed placements. computePlacements
  trusts explicit positions and auto-flows the rest with the same
  rowMaxH-aware packer the backend uses.
- reorderViaDnd swaps (x, y) instead of array order; layout re-sorted
  by (y, x) so the persisted array matches visual order.
- Resize handles in edit mode: bottom-right pointer-drag, cellW/cellH
  derived from live grid metrics, snaps to grid + clamps to schema,
  autosaves on pointerup. Native HTML5 DnD suppressed during resize.
- afterLayoutMutation now materialises every visible widget's
  (x,y,w,h) so the spec stays self-describing — no mixed
  explicit/auto-flow on next render.
- Gear popover expanded: view segmented control, custom count/horizon
  numeric inputs alongside preset dropdowns, size (W/H) + position
  (X/Y) spinners. Every visible widget gets a gear in edit mode.
- View-aware renderers:
  - upcoming-deadlines / -appointments: list (default) or mini-month
    calendar with item dots.
  - inline-agenda: timeline (default) or flat list.
  - recent-activity: full (default) or compact (one-line per row).

CSS:
- .dashboard-grid (12 cols, dense auto-flow); collapses to single
  stack on narrow viewports.
- .dashboard-widget__resize handle (bottom-right diagonal stripes).
- .dashboard-widget__view-group segmented control.
- .dashboard-cal-* mini-calendar.
- .dashboard-activity-list--compact one-line variant.
- Grid items get card chrome via .dashboard-grid > .dashboard-section.

Tests:
- New: AcceptsCustomCountWithinMax, AcceptsValidView,
  RejectsUnknownView, RejectsViewOnNoViewWidget, GridPosition,
  GridSizeOutsideClamps, NoOverlap (greedy packer regression),
  AssignsPositions.
- Updated: BadSettings now asserts a value above CountMax (free-form
  values inside [1,CountMax] are valid; presets stay valid too).

Backwards-compatible: a stored layout without x/y/w/h still loads — the
client's auto-flow placer puts widgets into a clean single column until
the user customises. The first drag / resize / settings tweak
materialises all positions so subsequent renders are deterministic.
2026-05-21 09:54:23 +02:00
mAi
ca71162543 Merge: t-paliad-219 Slice C — catalog expansion + firm-wide admin default (m/paliad#46)
Final slice of the configurable dashboard. Catalog expansion + firm-wide
default propagation.

- mig 117 paliad.firm_dashboard_default — single-row firm-wide factory
  layout, editable by global_admin. New users hydrate from this; existing
  users get 'reset to firm default' option alongside the existing
  'reset to factory'.
- Catalog expansion: pinned-projects widget brought live (C0 pin-machinery
  prerequisite shipped inline); plus 2-3 high-value adds per design
  catalog (recent-deadlines-by-type, my-open-approvals, etc.).
- Frontend: admin '/admin/dashboard-default' page to edit the firm shape;
  user-side 'Reset auf Firmenstandard' link in the dashboard reset flow.

m/paliad#46 fully shipped (Slices A + B + C).
2026-05-20 19:30:20 +02:00
mAi
6b565be830 feat(dashboard): t-paliad-219 Slice C — catalog expansion + firm-wide admin default
Three additions on top of Slice B's edit-mode chrome.

**Catalog expansion (2 new widgets, default-hidden — opt-in via picker):**

- pinned-projects: surfaces a list of the user's pinned matters via the
  pre-existing PinService (mig 062/063, pre-dates t-paliad-219). New
  DashboardService.loadPinnedProjects joins paliad.user_pinned_projects
  to paliad.projects under the standard visibility predicate, preserves
  pinned-at-DESC order, capped at PinnedProjectsCap=20. PinnedProjects
  []PinnedProjectRef grows DashboardData; SetPinService wired
  post-construction to mirror the SetApprovalService pattern.

- quick-actions: pure UI affordance with three buttons linking to the
  existing /projects/new, /deadlines/new, /appointments/new routes. No
  backend payload, no settings schema.

Both default-hidden — m's brief asked for "high-value adds"; injecting
new widgets into every user's dashboard unannounced would be loud.
Factory test relaxed: visibility now matches catalog.DefaultVisible
instead of the previous "all-visible" invariant.

**Firm-wide admin default (mig 117 + new service + 4 endpoints):**

- paliad.firm_dashboard_default: single-row table (id smallint PK CHECK
  id=1) with layout_json + updated_by + updated_at. RLS: SELECT
  authenticated, no INSERT/UPDATE policy (writes go through the
  service-role connection behind the adminGate).
- FirmDashboardDefaultService Get/Set/Clear. Validates against the
  catalog on Set so an admin can't seed an invalid layout.
- DashboardLayoutService.SetFirmDefaultService wires in the firm
  source. Both GetOrSeed and ResetToDefault now prefer the firm
  default over the code-resident FactoryDefaultLayout when one is set.
  Nil-safe — empty firm row falls back to the factory layout, transient
  DB errors fall back too (a blip can't strand a user without a
  dashboard).
- HTTP: GET / PUT / DELETE /api/admin/firm-dashboard-default (admin-
  gated). POST /api/me/dashboard-layout/promote: admin convenience —
  reads the admin's own current layout and stashes it as the firm
  default (saves the JSON-editor step; admins edit via /dashboard's
  normal editor, then click Promote).

**Frontend (Slice B's edit-mode footer grew an admin button):**

- "Als Firmen-Standard speichern" button in the edit footer; hidden via
  CSS-inline until syncPromoteButtonVisibility unhides for
  global_admin. Confirm() → POST /promote → toast.
- The existing "Auf Standard zurücksetzen" copy stays the same — the
  semantics now "firm default if set, else factory", which is the
  desired surface: users see one canonical "Standard" link.

i18n: 13 new keys × DE+EN (dashboard.pinned.*, dashboard.quick.*,
dashboard.edit.promote*). i18n-keys.ts regenerated by build.

m/paliad#46.

go build ./... clean; go vet ./... clean
go test ./internal/... clean (Slice C catalog test + factory-default
   test relaxation; FirmDashboardDefault round-trip tests gated on
   TEST_DATABASE_URL)
Migration 117 dry-run: PASS (other dry-run failures are pre-existing
   local-DB collisions on origin/main; mig 117 itself clean)
bun run build clean: dashboard.html carries new section markup + admin
   button; dashboard.js bundles renderPinnedProjects + promote handler
   + all new i18n keys
2026-05-20 19:15:32 +02:00
mAi
0857c1c078 Merge: t-paliad-219 Slice B — dashboard edit mode (m/paliad#46)
Second slice of the configurable dashboard. Adds the user-facing edit-mode
on top of Slice A's storage + factory render.

- 'Anpassen' toggle button in the dashboard header — off by default.
- Drag handles + x + + buttons appear on widgets when edit mode is on;
  invisible otherwise so the reading-only path stays clean.
- Per-widget settings (counts + horizon dropdowns) per widget catalog.
- 12-col grid drag/drop reorder; mobile fallback to single column with
  drag-by-handle.
- Autosave 400ms debounced via PUT /api/user/dashboard.
- Reset-to-default link to revert layout to the factory shape.

Frontend-only slice. Net 5 files, +1027/-3 LoC (most of it in
client/dashboard.ts + the new CSS block).

Slice C (catalog expansion + admin firm-wide default) remaining.
2026-05-20 19:00:11 +02:00
mAi
4bf0a719b0 feat(dashboard): t-paliad-219 Slice B — edit mode + drag/drop + autosave
Adds the user-facing dashboard customization UI on top of Slice A's
backend (already shipped). Off by default — view-mode DOM and behavior
are byte-identical to the factory render.

Anpassen toggle in the dashboard header flips body.dashboard-editing.
When on, every [data-widget-key] grows a chrome strip with drag handle,
↑/↓ keyboard reorder buttons, hide/show button, and ⚙ gear for widgets
with a settings schema. An edit footer below the activity widget
surfaces "+ Widget hinzufügen" and "Auf Standard zurücksetzen".

Drag-and-drop uses native HTML5 DnD (dragstart / dragover / drop) on
the widget element itself. ↑/↓ buttons are the keyboard + touch
fallback. Hide flips Visible:false in the layout draft; re-showing via
the picker either un-hides in place or appends to the end if the
widget was never added.

Picker modal uses the unified openModal() helper (t-paliad-217). Each
catalog entry shows title + description + active/hidden/absent pill;
tapping an inactive entry mutates the layout and the list re-renders
in place so the user can multi-add.

Gear popover anchors absolutely inside the widget. Per-widget knobs
follow the catalog's WidgetSettingsSchema: count {1,3,5,10,20} for
list widgets, horizon_days {7,14,30,60} for upcoming-deadlines/-appoint-
ments, horizon-only {14,30,60} for inline-agenda, count {1,3,5,10} for
inbox. Selecting a value scheduleSave()s; close on outside-click / Esc.

Autosave: every layout mutation → snapshot rollback target +
400ms-debounced PUT /api/me/dashboard-layout. Success flashes a
"Gespeichert" toast (1.5s); failure rolls back, re-renders, and shows
"Speichern fehlgeschlagen". Reset link → confirm() → POST /api/me/
dashboard-layout/reset, replacing currentLayout with the factory
default returned by the service.

Mobile (≤32rem): toggle becomes full-width tappable, drag handle
hides in favor of ↑/↓ buttons (touch DnD is unreliable), picker uses
the existing modal full-screen breakpoint, toast spans the row.

Frontend-only — Slice A already shipped GET/PUT/POST /api/me/dashboard-
layout, GET /api/dashboard-widget-catalog, and the three-blob shell
hydration (data, layout, catalog). The client reads __PALIAD_DASHBOARD
_CATALOG__ inline; fetch fallback on hydration miss.

i18n: 23 new keys × 2 langs (DE + EN) for the toggle, picker, gear,
toast, and reset confirm. The i18n-keys.ts regenerates on every build.

m/paliad#46.

go build ./... clean
go vet ./... clean
go test ./internal/... clean (24 dashboard-layout/widget-catalog unit tests pass)
go test ./cmd/server/ -run TestBootSmoke: SKIPS without TEST_DATABASE_URL
   (CI's clean test DB runs the boot-smoke gate)
bun run build clean: dashboard.html still carries the three placeholder
   tokens; dashboard.js bundles the edit-mode code + i18n keys
2026-05-20 18:42:41 +02:00
mAi
15ce176ebd Merge: t-paliad-225 Slice C — checklist gallery + versioning (m/paliad#61)
Final slice. Discoverability + versioning on user-authored checklists.

- mig 116 paliad.checklists.version int NOT NULL DEFAULT 1 +
  paliad.checklist_instances.template_version int (snapshot column).
  Version bumps on template UPDATE; instance carries the version it was
  created from.
- 'Geteilte Vorlagen' tab on /tools/checklists surfacing templates the
  user can see via firm/global visibility + checklist_shares. Filter by
  author / tag / visibility level. Popularity sort optional (deferred).
- Outdated-template badge on instance detail when
  instance.template_version < template.version. Click → modal showing
  the diff (template's new sections / items vs the snapshot).
- audit events: checklist_template_versioned emitted on each UPDATE.

t-paliad-225 / m/paliad#61 fully shipped (Slices A + B + C).
2026-05-20 15:51:43 +02:00
54 changed files with 6865 additions and 2135 deletions

View File

@@ -201,8 +201,9 @@ func main() {
UserView: services.NewUserViewService(pool),
Broadcast: services.NewBroadcastService(pool, mailSvc, users, teamSvc, emailTemplateSvc),
Pin: services.NewPinService(pool, projectSvc),
CardLayout: services.NewCardLayoutService(pool),
DashboardLayout: services.NewDashboardLayoutService(pool),
CardLayout: services.NewCardLayoutService(pool),
DashboardLayout: services.NewDashboardLayoutService(pool),
FirmDashboardDefault: services.NewFirmDashboardDefaultService(pool),
Projection: services.NewProjectionService(pool, projectSvc, deadlineSvc, appointmentSvc, services.NewFristenrechnerService(rules, holidays, courts), rules),
// t-paliad-214 Slice 1 — personal-scope data export. firm name
// is captured into __meta of every export and printed in the
@@ -217,20 +218,21 @@ func main() {
// without approvals — so keeping this a setter keeps both
// constructors simple).
svcBundle.Dashboard.SetApprovalService(svcBundle.Approval)
// Slice C wires PinService into DashboardService for the
// pinned-projects widget. Pin pre-dates t-paliad-219; no new
// schema, no circular dependency (Pin doesn't know about the
// dashboard).
svcBundle.Dashboard.SetPinService(svcBundle.Pin)
// Slice C wires the firm-wide dashboard default into the
// per-user layout service so GetOrSeed/ResetToDefault prefer
// the admin-set firm default over the code-resident factory.
// 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

@@ -0,0 +1,226 @@
import { describe, expect, test } from "bun:test";
import {
GRID_COLUMNS,
clampH,
clampW,
placeWidgets,
type WidgetPlacementInput,
} from "./dashboard-grid";
// Regression suite for m/paliad#70 (t-paliad-228): the post-#69 edit
// mode produced overlapping widgets when a 2-col widget sat next to a
// 1-col widget on the same row, when a drag swapped widgets of
// different widths, and when a resize grew a widget into a sibling. The
// fix moved the placement math into ./dashboard-grid + made it
// collision-aware. These tests pin the no-overlap invariant.
function spec(
key: string,
x: number | undefined,
y: number | undefined,
w: number,
h = 1,
visible = true,
): WidgetPlacementInput {
return { key, visible, x, y, w, h };
}
// hasOverlap returns true if any placed pair shares a cell. O(n²) is
// fine — layouts cap at 32 widgets and the tests stay tiny.
function hasOverlap(rects: Map<string, { x: number; y: number; w: number; h: number }>): string | null {
const list = Array.from(rects.entries());
for (let i = 0; i < list.length; i++) {
const [ka, a] = list[i];
for (let j = i + 1; j < list.length; j++) {
const [kb, b] = list[j];
const xOverlap = a.x < b.x + b.w && b.x < a.x + a.w;
const yOverlap = a.y < b.y + b.h && b.y < a.y + a.h;
if (xOverlap && yOverlap) return `${ka}${kb} at (${a.x},${a.y},${a.w}x${a.h}) vs (${b.x},${b.y},${b.w}x${b.h})`;
}
}
return null;
}
describe("placeWidgets — basic auto-flow", () => {
test("places two 6-wide widgets side by side on row 0", () => {
const out = placeWidgets([
spec("a", undefined, undefined, 6),
spec("b", undefined, undefined, 6),
]);
expect(out.get("a")).toEqual({ x: 0, y: 0, w: 6, h: 1 });
expect(out.get("b")).toEqual({ x: 6, y: 0, w: 6, h: 1 });
expect(hasOverlap(out)).toBeNull();
});
test("wraps when row doesn't fit", () => {
const out = placeWidgets([
spec("a", undefined, undefined, 8),
spec("b", undefined, undefined, 8),
]);
expect(out.get("a")!.y).toBe(0);
expect(out.get("b")!.y).toBeGreaterThan(0);
expect(hasOverlap(out)).toBeNull();
});
test("hidden widgets are skipped and reserve no cells", () => {
const out = placeWidgets([
spec("hidden", 0, 0, 12, 1, false),
spec("visible", undefined, undefined, 6),
]);
expect(out.has("hidden")).toBe(false);
expect(out.get("visible")).toEqual({ x: 0, y: 0, w: 6, h: 1 });
});
});
describe("placeWidgets — explicit positions, no collision", () => {
test("trusts non-colliding explicit positions exactly", () => {
const out = placeWidgets([
spec("a", 0, 0, 6),
spec("b", 6, 0, 6),
spec("c", 0, 1, 12),
]);
expect(out.get("a")).toEqual({ x: 0, y: 0, w: 6, h: 1 });
expect(out.get("b")).toEqual({ x: 6, y: 0, w: 6, h: 1 });
expect(out.get("c")).toEqual({ x: 0, y: 1, w: 12, h: 1 });
expect(hasOverlap(out)).toBeNull();
});
});
describe("placeWidgets — mixed-width collision (m/paliad#70 regression)", () => {
test("1-col + 2-col on same row do not overlap when both explicit", () => {
// Half-width left + half-width right is the canonical 'two widgets per
// row' layout; pre-fix this was fine but the next regression below
// exercises the actual bug.
const out = placeWidgets([
spec("left", 0, 0, 6),
spec("right", 6, 0, 6),
]);
expect(hasOverlap(out)).toBeNull();
});
test("4-col + 8-col both claiming (0,0) end up non-overlapping", () => {
// Simulates a post-#69 layout where a 4-wide widget sits at (0, 0)
// and an 8-wide widget got accidentally placed at (0, 0) too (e.g.
// a buggy reset path or a stale spec from before #70). Placer must
// honour the first one's position and fit the second somewhere
// free — landing it on the same row at x=4 is acceptable (better
// density) as long as nothing overlaps.
const out = placeWidgets([
spec("first", 0, 0, 4),
spec("colliding", 0, 0, 8),
]);
expect(out.get("first")).toEqual({ x: 0, y: 0, w: 4, h: 1 });
expect(out.get("colliding")!.w).toBe(8);
expect(hasOverlap(out)).toBeNull();
});
test("drag-drop swap of 12-wide onto 6-wide does not overlap", () => {
// Setup before swap:
// A at (0, 0, w=12) — full width row 0
// B at (0, 1, w=6) — half row 1 left
// C at (6, 1, w=6) — half row 1 right
// User drags A onto B. reorderViaDnd swaps (x, y):
// A.x=0, A.y=1
// B.x=0, B.y=0
// Result must not overlap C.
const out = placeWidgets([
spec("a", 0, 1, 12),
spec("b", 0, 0, 6),
spec("c", 6, 1, 6),
]);
expect(hasOverlap(out)).toBeNull();
});
test("auto-flow widget steps past explicit blocker on same row", () => {
// Explicit widget at (6, 0, w=6); auto-flow widget would pack into
// (0, 0, w=6) which is fine — but the next auto-flow widget at w=6
// would want (6, 0) which is taken. Placer must wrap it.
const out = placeWidgets([
spec("flow-a", undefined, undefined, 6),
spec("anchored", 6, 0, 6),
spec("flow-b", undefined, undefined, 6),
]);
expect(out.get("flow-a")).toEqual({ x: 0, y: 0, w: 6, h: 1 });
expect(out.get("anchored")).toEqual({ x: 6, y: 0, w: 6, h: 1 });
expect(out.get("flow-b")!.y).toBeGreaterThan(0);
expect(hasOverlap(out)).toBeNull();
});
});
describe("placeWidgets — resize-grow shifts siblings", () => {
test("growing a 6-wide to 12-wide bumps the sibling on the same row", () => {
// Pre-resize state:
// A at (0, 0, w=6)
// B at (6, 0, w=6)
// User resizes A to w=12. resizeWidget() updates A.w but leaves B
// at (6, 0). Placer must shift B down.
const out = placeWidgets([
spec("a", 0, 0, 12),
spec("b", 6, 0, 6),
]);
expect(out.get("a")).toEqual({ x: 0, y: 0, w: 12, h: 1 });
expect(out.get("b")!.y).toBeGreaterThan(0);
expect(hasOverlap(out)).toBeNull();
});
test("growing widget pushes only the first colliding sibling", () => {
// A grows to 12-wide; B and C on row 0 are both colliding. Both must
// move; their relative order on row 0 is preserved (B at x=0, C at
// x=6) on row 1.
const out = placeWidgets([
spec("a", 0, 0, 12),
spec("b", 0, 0, 4),
spec("c", 4, 0, 4),
]);
expect(hasOverlap(out)).toBeNull();
expect(out.get("a")!.y).toBe(0);
expect(out.get("b")!.y).toBeGreaterThan(0);
expect(out.get("c")!.y).toBeGreaterThan(0);
});
});
describe("placeWidgets — explicit position overflow clamp", () => {
test("x+w > GRID_COLUMNS is clamped not rejected", () => {
// A 12-wide widget with x=6 would extend past col 11. Placer must
// clamp x to 0 (or wherever fits) so the widget renders inside the
// grid.
const out = placeWidgets([
spec("wide", 6, 0, 12),
]);
const r = out.get("wide")!;
expect(r.x + r.w).toBeLessThanOrEqual(GRID_COLUMNS);
expect(r.w).toBe(12);
});
});
describe("placeWidgets — vertical (multi-row) widgets", () => {
test("a 2-row-tall widget reserves both rows", () => {
const out = placeWidgets([
spec("tall", 0, 0, 6, 2),
spec("collides-on-row-1", 0, 1, 6, 1),
]);
expect(out.get("tall")).toEqual({ x: 0, y: 0, w: 6, h: 2 });
// The colliding widget must move because tall covers cols 0..5
// on both row 0 and row 1. The placer may shift it to the right
// half of row 1 (cols 6..11) or to a later row — either is fine
// as long as nothing overlaps.
const other = out.get("collides-on-row-1")!;
expect(other.x >= 6 || other.y >= 2).toBe(true);
expect(hasOverlap(out)).toBeNull();
});
});
describe("clamp helpers", () => {
test("clampW respects min/max bounds", () => {
expect(clampW(2, { min_w: 4, max_w: 12 })).toBe(4);
expect(clampW(20, { min_w: 4, max_w: 12 })).toBe(12);
expect(clampW(0, { default_w: 6 })).toBe(6);
expect(clampW(NaN, { default_w: 8 })).toBe(8);
});
test("clampH respects min/max bounds and MAX_ROW_SPAN", () => {
expect(clampH(0, { default_h: 2 })).toBe(2);
expect(clampH(99, undefined)).toBe(5); // MAX_ROW_SPAN
expect(clampH(1, { min_h: 3 })).toBe(3);
});
});

View File

@@ -0,0 +1,216 @@
// dashboard-grid — pure layout math for the dashboard widget grid.
//
// Lives outside dashboard.ts so the placement logic is importable from
// tests without dragging in the DOM-side rendering code. The grid is a
// 12-column CSS Grid matching internal/services/dashboard_layout_spec.go;
// rows grow vertically as widgets are placed.
//
// The core invariant is no-overlap: after placeWidgets() returns, every
// pair of widgets occupies disjoint cells. Pre-overhaul callers wrote
// computePlacements() to trust explicit (x, y) without checking — that
// produced visual overlap whenever a drag or resize landed a widget on
// cells another widget already covered (m/paliad#70). The collision-
// aware placer below shifts colliding widgets to the next free row so
// the rendered grid never overlaps regardless of the input spec.
export const GRID_COLUMNS = 12;
export const MAX_ROW_SPAN = 5;
// Hard cap on the row-scan depth in findFreeSlot. The widget cap on a
// single layout is 32 (LayoutWidgetCap on the Go side); each row holds
// at least one widget, so 256 rows is an order-of-magnitude buffer
// against runaway loops on pathological inputs.
const MAX_SCAN_ROWS = 256;
export interface PlacedRect {
x: number;
y: number;
w: number;
h: number;
}
// WidgetSizeBound captures the per-widget min/max/default clamps the
// catalog publishes. Optional fields keep callers from having to
// synthesize zeroes when the catalog entry is missing.
export interface WidgetSizeBound {
default_w?: number;
default_h?: number;
min_w?: number;
max_w?: number;
min_h?: number;
max_h?: number;
}
// WidgetPlacementInput is the per-widget data the placer consumes. The
// catalog bound is optional — when missing, defaults fall back to a
// full-width 1-row widget.
export interface WidgetPlacementInput {
key: string;
visible: boolean;
x?: number;
y?: number;
w?: number;
h?: number;
bound?: WidgetSizeBound;
}
export function clampW(w: number, bound: WidgetSizeBound | undefined): number {
let v = Math.round(w);
if (!Number.isFinite(v) || v <= 0) v = bound?.default_w ?? GRID_COLUMNS;
v = Math.max(1, Math.min(GRID_COLUMNS, v));
if (bound?.min_w && v < bound.min_w) v = bound.min_w;
if (bound?.max_w && v > bound.max_w) v = bound.max_w;
return v;
}
export function clampH(h: number, bound: WidgetSizeBound | undefined): number {
let v = Math.round(h);
if (!Number.isFinite(v) || v <= 0) v = bound?.default_h ?? 1;
v = Math.max(1, Math.min(MAX_ROW_SPAN, v));
if (bound?.min_h && v < bound.min_h) v = bound.min_h;
if (bound?.max_h && v > bound.max_h) v = bound.max_h;
return v;
}
// Occupancy bitmap: one row → Uint8Array of GRID_COLUMNS bits. Rows are
// created lazily so the map only stores rows the layout actually
// reaches. Cell value 1 = occupied.
class Occupancy {
private rows = new Map<number, Uint8Array>();
row(y: number): Uint8Array {
let r = this.rows.get(y);
if (!r) {
r = new Uint8Array(GRID_COLUMNS);
this.rows.set(y, r);
}
return r;
}
free(x: number, y: number, w: number, h: number): boolean {
if (x < 0 || y < 0 || x + w > GRID_COLUMNS) return false;
for (let yy = y; yy < y + h; yy++) {
const row = this.row(yy);
for (let xx = x; xx < x + w; xx++) {
if (row[xx]) return false;
}
}
return true;
}
mark(x: number, y: number, w: number, h: number): void {
for (let yy = y; yy < y + h; yy++) {
const row = this.row(yy);
for (let xx = x; xx < x + w; xx++) row[xx] = 1;
}
}
}
// findFreeSlot scans for the first (x, y) where a w×h block fits without
// collision, starting at row startY. At each row preferX is tried first
// — that keeps a widget close to its requested column when only the row
// is blocked. Falls back to left-to-right scan within the row, then to
// the next row. Caller guarantees w ≤ GRID_COLUMNS.
function findFreeSlot(
occ: Occupancy,
startY: number,
w: number,
h: number,
preferX: number,
): { x: number; y: number } {
for (let y = startY; y < startY + MAX_SCAN_ROWS; y++) {
if (preferX >= 0 && preferX + w <= GRID_COLUMNS && occ.free(preferX, y, w, h)) {
return { x: preferX, y };
}
for (let x = 0; x + w <= GRID_COLUMNS; x++) {
if (x === preferX) continue;
if (occ.free(x, y, w, h)) return { x, y };
}
}
// Pathological fallback — caller's widget cap (32) makes this
// unreachable in practice. Snap to the bottom-left so the widget at
// least renders somewhere visible instead of vanishing.
return { x: 0, y: startY + MAX_SCAN_ROWS };
}
// placeWidgets assigns no-overlap grid coordinates to every visible
// widget. Hidden widgets are skipped and contribute no placement.
//
// Algorithm: iterate widgets in input order. For each visible widget:
// 1. Clamp w/h against catalog bounds.
// 2. If the spec carries explicit x and y, try that slot. On a
// collision, search downward starting at the requested y for the
// first free w×h block (preferring the requested x).
// 3. If only x is explicit, search from y=0 at that x.
// 4. Otherwise auto-flow: pack left-to-right under a running cursor;
// when the row doesn't fit or is blocked by an explicitly-placed
// widget, wrap to the next free row.
//
// The mixed-spec case (some widgets explicit, others auto-flow) is the
// real-world layout — placing the explicit widgets first would change
// the visual order, so we keep input order and let auto-flow widgets
// step around any explicit blockers via the same collision search.
export function placeWidgets(
widgets: WidgetPlacementInput[],
): Map<string, PlacedRect> {
const out = new Map<string, PlacedRect>();
const occ = new Occupancy();
// Auto-flow cursor — advances as we place flowed widgets. cursorY
// tracks the row currently being filled; rowMaxH is the tallest
// widget in that row so wrapping advances past it (not just past the
// new widget's height — that would let taller previous neighbours
// overlap into the wrap row).
let cursorX = 0;
let cursorY = 0;
let rowMaxH = 0;
for (const w of widgets) {
if (!w.visible) continue;
const dw = clampW(w.w ?? w.bound?.default_w ?? GRID_COLUMNS, w.bound);
const dh = clampH(w.h ?? w.bound?.default_h ?? 1, w.bound);
const hasX = typeof w.x === "number";
const hasY = typeof w.y === "number";
let placed: { x: number; y: number };
if (hasX && hasY) {
// Clamp x so the widget never overflows the right edge — drag/
// resize gestures can produce x+w > GRID_COLUMNS otherwise.
const prefX = Math.max(0, Math.min(GRID_COLUMNS - dw, w.x as number));
const prefY = Math.max(0, w.y as number);
if (occ.free(prefX, prefY, dw, dh)) {
placed = { x: prefX, y: prefY };
} else {
placed = findFreeSlot(occ, prefY, dw, dh, prefX);
}
} else if (hasX) {
const prefX = Math.max(0, Math.min(GRID_COLUMNS - dw, w.x as number));
placed = findFreeSlot(occ, 0, dw, dh, prefX);
} else {
// Auto-flow. Wrap the cursor when the widget wouldn't fit in the
// remaining columns of the current row, then ask findFreeSlot to
// honour the cursor's preferred (x, y) — that lets it step past
// any explicit widget that already claimed cells under the
// cursor.
if (cursorX + dw > GRID_COLUMNS) {
cursorY += rowMaxH || 1;
cursorX = 0;
rowMaxH = 0;
}
placed = findFreeSlot(occ, cursorY, dw, dh, cursorX);
if (placed.y > cursorY) {
// Wrap was forced by a collision deeper than the current row.
cursorY = placed.y;
rowMaxH = 0;
}
cursorX = placed.x + dw;
if (dh > rowMaxH) rowMaxH = dh;
}
occ.mark(placed.x, placed.y, dw, dh);
out.set(w.key, { x: placed.x, y: placed.y, w: dw, h: dh });
}
return out;
}

File diff suppressed because it is too large Load Diff

View File

@@ -1006,6 +1006,50 @@ const translations: Record<Lang, Record<string, string>> = {
"dashboard.inbox.full_link": "Vollst\u00e4ndigen Posteingang \u00f6ffnen \u2192",
"dashboard.inbox.entity.deadline": "Frist",
"dashboard.inbox.entity.appointment": "Termin",
// Edit-mode chrome (t-paliad-219 Slice B). The toggle in the
// dashboard header flips body.dashboard-editing; the keys below
// power the in-page chrome (drag handle, \u2191/\u2193, hide, gear, picker,
// reset) plus the autosave toast.
"dashboard.edit.toggle": "Anpassen",
"dashboard.edit.exit": "Fertig",
"dashboard.edit.add_widget": "Widget hinzuf\u00fcgen",
"dashboard.edit.reset": "Auf Standard zur\u00fccksetzen",
"dashboard.edit.reset_confirm": "Layout auf Standard zur\u00fccksetzen? Diese Aktion kann nicht r\u00fcckg\u00e4ngig gemacht werden.",
// Slice C: admin promote \u2014 visible only when global_role==global_admin.
"dashboard.edit.promote": "Als Firmen-Standard speichern",
"dashboard.edit.promote_confirm": "Dein aktuelles Layout als Firmen-Standard speichern? Neue Nutzer:innen und 'Auf Standard zur\u00fccksetzen' verwenden danach diese Vorlage.",
"dashboard.edit.promoted": "Als Firmen-Standard gespeichert",
// Slice C: pinned-projects widget (reuses PinService).
"dashboard.pinned.heading": "Angepinnte Akten",
"dashboard.pinned.empty": "Noch keine Akten angepinnt.",
"dashboard.pinned.full_link": "Alle Akten \u00f6ffnen \u2192",
// Slice C: quick-actions widget \u2014 pure UI affordances.
"dashboard.quick.heading": "Schnellzugriff",
"dashboard.quick.new_project": "+ Akte",
"dashboard.quick.new_deadline": "+ Frist",
"dashboard.quick.new_appointment": "+ Termin",
"dashboard.edit.move_up": "Nach oben bewegen",
"dashboard.edit.move_down": "Nach unten bewegen",
"dashboard.edit.hide": "Ausblenden",
"dashboard.edit.settings": "Einstellungen",
"dashboard.edit.drag": "Ziehen, um neu zu ordnen",
"dashboard.edit.saved": "Gespeichert",
"dashboard.edit.save_failed": "Speichern fehlgeschlagen",
"dashboard.edit.setting.count": "Anzahl",
"dashboard.edit.setting.count.custom": "Eigener Wert (max. {n})",
"dashboard.edit.setting.horizon": "Zeitraum",
"dashboard.edit.setting.horizon.days": "{n} Tage",
"dashboard.edit.setting.horizon.custom": "Eigener Wert in Tagen (max. {n})",
"dashboard.edit.setting.view": "Ansicht",
"dashboard.edit.setting.size": "Größe",
"dashboard.edit.setting.position": "Position",
"dashboard.edit.resize": "Größe ändern",
"dashboard.picker.title": "Widget hinzuf\u00fcgen",
"dashboard.picker.status.active": "Aktiv",
"dashboard.picker.status.hidden": "Versteckt",
"dashboard.picker.status.absent": "Nicht hinzugef\u00fcgt",
"dashboard.picker.close": "Schlie\u00dfen",
"dashboard.picker.empty": "Alle Widgets sind hinzugef\u00fcgt.",
// Collapsible-section toggle a11y labels (t-paliad-162). Both states
// are needed because the aria-label flips with the expanded state.
"dashboard.section.collapse": "Abschnitt einklappen",
@@ -1291,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",
@@ -1381,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",
@@ -1551,6 +1599,14 @@ const translations: Record<Lang, Record<string, string>> = {
"projects.detail.team.invite.hint": "Benutzer nicht gefunden?",
"projects.detail.team.invite.hint_email": "Niemand mit dieser E-Mail.",
"projects.detail.team.invite.cta": "Einladen",
// t-paliad-231 — pure-client mailto: button on the Team tab. No
// server call; opens the local mail client with every selected
// member queued in the To: line.
"projects.team.mailto.label": "Mail an Auswahl",
"projects.team.mailto.empty": "Mindestens ein Mitglied auswählen",
"projects.team.mailto.count": "{n} ausgewählt",
"projects.team.mailto.select_all": "Alle sichtbaren auswählen",
"projects.team.mailto.select_row": "Mitglied auswählen",
"projects.view.tree": "Baumansicht",
"projects.tree.toggle": "Aufklappen / Zuklappen",
"projects.tree.loading": "Baum wird geladen\u2026",
@@ -1882,6 +1938,13 @@ const translations: Record<Lang, Record<string, string>> = {
"agenda.appointment_type.deadline_hearing": "Fristentermin",
"agenda.day.today": "Heute",
"agenda.day.tomorrow": "Morgen",
"agenda.day.mo": "Mo",
"agenda.day.di": "Di",
"agenda.day.mi": "Mi",
"agenda.day.do": "Do",
"agenda.day.fr": "Fr",
"agenda.day.sa": "Sa",
"agenda.day.so": "So",
"agenda.urgency.overdue": "Überfällig",
"agenda.urgency.today": "Heute",
"agenda.urgency.tomorrow": "Morgen",
@@ -1985,8 +2048,13 @@ const translations: Record<Lang, Record<string, string>> = {
"paliadin.error.timeout": "Paliadin antwortet nicht (Timeout 60s). Nochmal versuchen.",
"paliadin.error.connection_lost": "Verbindung verloren.",
"paliadin.error.upstream": "Fehler beim Senden.",
"paliadin.error.upstream_silence": "Paliadin meldet sich nicht mehr — Verbindung wird beendet.",
"paliadin.late.waiting": "Antwort wird nachgereicht, sobald sie eintrifft …",
"paliadin.late.checking": "Verbindung verloren — Paliadin denkt vielleicht noch. Lade frische Antwort …",
"paliadin.late.lost": "Antwort konnte nicht zugestellt werden — bitte Frage erneut stellen.",
"paliadin.late.marker": "verspätet",
"paliadin.thinking": "Paliadin denkt nach",
"paliadin.thinking.seconds": "{seconds}s",
"paliadin.widget.title": "Paliadin",
"paliadin.widget.trigger": "Paliadin (Cmd+J)",
"paliadin.widget.empty": "Was kann ich für dich tun?",
@@ -3825,6 +3893,43 @@ const translations: Record<Lang, Record<string, string>> = {
"dashboard.inbox.full_link": "Open full inbox →",
"dashboard.inbox.entity.deadline": "Deadline",
"dashboard.inbox.entity.appointment": "Appointment",
"dashboard.edit.toggle": "Customize",
"dashboard.edit.exit": "Done",
"dashboard.edit.add_widget": "Add widget",
"dashboard.edit.reset": "Reset to default",
"dashboard.edit.reset_confirm": "Reset layout to default? This cannot be undone.",
"dashboard.edit.promote": "Save as firm default",
"dashboard.edit.promote_confirm": "Save your current layout as the firm default? New users and 'Reset to default' will use this layout afterwards.",
"dashboard.edit.promoted": "Saved as firm default",
"dashboard.pinned.heading": "Pinned matters",
"dashboard.pinned.empty": "No pinned matters yet.",
"dashboard.pinned.full_link": "Open all matters →",
"dashboard.quick.heading": "Quick actions",
"dashboard.quick.new_project": "+ Matter",
"dashboard.quick.new_deadline": "+ Deadline",
"dashboard.quick.new_appointment": "+ Appointment",
"dashboard.edit.move_up": "Move up",
"dashboard.edit.move_down": "Move down",
"dashboard.edit.hide": "Hide",
"dashboard.edit.settings": "Settings",
"dashboard.edit.drag": "Drag to reorder",
"dashboard.edit.saved": "Saved",
"dashboard.edit.save_failed": "Save failed",
"dashboard.edit.setting.count": "Count",
"dashboard.edit.setting.count.custom": "Custom value (max {n})",
"dashboard.edit.setting.horizon": "Horizon",
"dashboard.edit.setting.horizon.days": "{n} days",
"dashboard.edit.setting.horizon.custom": "Custom horizon in days (max {n})",
"dashboard.edit.setting.view": "View",
"dashboard.edit.setting.size": "Size",
"dashboard.edit.setting.position": "Position",
"dashboard.edit.resize": "Resize",
"dashboard.picker.title": "Add widget",
"dashboard.picker.status.active": "Active",
"dashboard.picker.status.hidden": "Hidden",
"dashboard.picker.status.absent": "Not added",
"dashboard.picker.close": "Done",
"dashboard.picker.empty": "All widgets are already added.",
"dashboard.section.collapse": "Collapse section",
"dashboard.section.expand": "Expand section",
"dashboard.urgency.overdue": "Overdue",
@@ -4103,6 +4208,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",
@@ -4192,7 +4300,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",
@@ -4361,6 +4470,12 @@ const translations: Record<Lang, Record<string, string>> = {
"projects.detail.team.invite.hint": "User not found?",
"projects.detail.team.invite.hint_email": "No one with that email.",
"projects.detail.team.invite.cta": "Invite",
// t-paliad-231 — pure-client mailto: button on the Team tab.
"projects.team.mailto.label": "Mail to selection",
"projects.team.mailto.empty": "Select at least one member",
"projects.team.mailto.count": "{n} selected",
"projects.team.mailto.select_all": "Select all visible",
"projects.team.mailto.select_row": "Select member",
"projects.view.tree": "Tree view",
"projects.tree.toggle": "Expand / collapse",
"projects.tree.loading": "Loading tree…",
@@ -4689,6 +4804,13 @@ const translations: Record<Lang, Record<string, string>> = {
"agenda.appointment_type.deadline_hearing": "Deadline hearing",
"agenda.day.today": "Today",
"agenda.day.tomorrow": "Tomorrow",
"agenda.day.mo": "Mon",
"agenda.day.di": "Tue",
"agenda.day.mi": "Wed",
"agenda.day.do": "Thu",
"agenda.day.fr": "Fri",
"agenda.day.sa": "Sat",
"agenda.day.so": "Sun",
"agenda.urgency.overdue": "Overdue",
"agenda.urgency.today": "Today",
"agenda.urgency.tomorrow": "Tomorrow",
@@ -4790,8 +4912,13 @@ const translations: Record<Lang, Record<string, string>> = {
"paliadin.error.timeout": "Paliadin didn't respond in time (60s). Try again.",
"paliadin.error.connection_lost": "Connection lost.",
"paliadin.error.upstream": "Send failed.",
"paliadin.error.upstream_silence": "Paliadin went silent — closing the connection.",
"paliadin.late.waiting": "Will fill in the response when it arrives …",
"paliadin.late.checking": "Connection lost — Paliadin may still be thinking. Fetching fresh answer …",
"paliadin.late.lost": "Answer couldn't be delivered — please ask again.",
"paliadin.late.marker": "late",
"paliadin.thinking": "Paliadin is thinking",
"paliadin.thinking.seconds": "{seconds}s",
"paliadin.widget.title": "Paliadin",
"paliadin.widget.trigger": "Paliadin (Cmd+J)",
"paliadin.widget.empty": "What can I help you with?",

View File

@@ -1,15 +1,24 @@
// Late-response polling. The Go backend's pollForResponse window is
// 60 s; if Claude writes the response file after that (because the
// tmux pane was busy mid-turn when the message arrived), the SSE
// stream has already closed with an `error` event. The Janitor
// (services.LocalPaliadinService.runJanitor) then patches the
// paliadin_turns row when the file lands.
// Late-response polling (t-paliad-235 rewrite).
//
// This module is the FE half of that loop: after the bubble shows an
// error, the caller registers the turn here. We poll
// `/api/paliadin/turns/{id}` every 3 s for up to 10 minutes; once the
// row has a non-empty response, we hand it back so the caller can
// swap the bubble content in place.
// When the SSE stream closes mid-turn with an error event, the bubble
// can't tell from the wire whether (a) the upstream is still finishing
// the turn and we just lost transport, or (b) the upstream is truly
// dead.
//
// This module hits the dispatching recovery endpoint
// `/api/paliadin/turns/{id}/recover`, which knows the active backend:
//
// - aichat backend → asks aichat via its conversation API whether
// the turn actually completed upstream
// - legacy backend → reads the local row (paliad's filesystem
// janitor patches it when claude writes the
// response file late)
//
// The endpoint returns:
//
// recovery_state="recovered" → response is in the payload, render it
// recovery_state="pending" → keep polling
// recovery_state="lost" → upstream is truly gone, give up
export interface LateTurn {
turn_id: string;
@@ -28,6 +37,10 @@ export interface LatePollOptions {
intervalMs?: number; // default 3000
maxDurationMs?: number; // default 600000 (10 min)
onLateResponse: (turn: LateTurn) => void;
// onLost — backend confirmed the turn is unrecoverable. Caller should
// swap the bubble copy to the "verloren" string. Distinct from
// onGiveUp (which fires only on the local timeout).
onLost?: () => void;
onGiveUp?: () => void;
}
@@ -35,6 +48,20 @@ export interface LatePollHandle {
cancel: () => void;
}
interface RecoverResponse {
turn_id: string;
started_at: string;
response: string | null;
error_code: string | null;
finished_at: string | null;
duration_ms: number | null;
used_tools: string[];
rows_seen: number[];
chip_count: number;
classifier_tag: string | null;
recovery_state: "recovered" | "pending" | "lost";
}
export function pollForLateResponse(opts: LatePollOptions): LatePollHandle {
const interval = opts.intervalMs ?? 3000;
const maxDuration = opts.maxDurationMs ?? 10 * 60 * 1000;
@@ -50,18 +77,24 @@ export function pollForLateResponse(opts: LatePollOptions): LatePollHandle {
return;
}
try {
const r = await fetch(`/api/paliadin/turns/${opts.turnId}`, {
const r = await fetch(`/api/paliadin/turns/${opts.turnId}/recover`, {
credentials: "same-origin",
});
if (r.ok) {
const turn = (await r.json()) as LateTurn;
if (turn.response && turn.response.length > 0) {
opts.onLateResponse(turn);
const body = (await r.json()) as RecoverResponse;
if (body.recovery_state === "recovered" && body.response) {
opts.onLateResponse(toLateTurn(body));
return;
}
}
// 404: row gone (very unlikely) — give up.
if (r.status === 404) {
if (body.recovery_state === "lost") {
opts.onLost?.();
return;
}
// pending — keep polling
} else if (r.status === 404) {
// Row gone — give up. Different signal from `lost`: a missing row
// is a paliad-side bookkeeping problem; aichat may still have the
// answer but we can't surface it without the row.
opts.onGiveUp?.();
return;
}
@@ -72,7 +105,8 @@ export function pollForLateResponse(opts: LatePollOptions): LatePollHandle {
};
// First poll deliberately runs after one interval so we don't race
// the 60 s timeout on the very first tick.
// the dispatch endpoint on the very first tick (gives the upstream a
// moment to actually settle the row after the stream drop).
timer = window.setTimeout(tick, interval);
return {
@@ -82,3 +116,17 @@ export function pollForLateResponse(opts: LatePollOptions): LatePollHandle {
},
};
}
function toLateTurn(body: RecoverResponse): LateTurn {
return {
turn_id: body.turn_id,
response: body.response,
error_code: body.error_code,
finished_at: body.finished_at,
duration_ms: body.duration_ms,
used_tools: body.used_tools ?? [],
rows_seen: body.rows_seen ?? [],
chip_count: body.chip_count ?? 0,
classifier_tag: body.classifier_tag,
};
}

View File

@@ -381,11 +381,32 @@ async function sendTurn(): Promise<void> {
const es = new EventSource(turnRes.sse_url);
activeStream = es;
startWidgetThinking(placeholder);
let fullText = "";
es.addEventListener("thinking", (ev) => {
let elapsed = 0;
try {
const data = JSON.parse((ev as MessageEvent).data || "{}");
if (typeof data.elapsed_seconds === "number") elapsed = data.elapsed_seconds;
} catch {
/* ignore */
}
updateWidgetThinking(placeholder, elapsed);
});
es.addEventListener("content", (ev) => {
try {
const data = JSON.parse((ev as MessageEvent).data);
if (typeof data.delta === "string" && data.delta) {
// Streamed delta (aichat backend) — append.
stopWidgetThinking(placeholder);
fullText += data.delta;
setBubbleText(placeholder, fullText);
return;
}
// Legacy one-shot full-text payload.
fullText = String(data.text || "");
stopWidgetThinking(placeholder);
setBubbleText(placeholder, fullText);
} catch {
/* ignore parse error */
@@ -393,13 +414,15 @@ async function sendTurn(): Promise<void> {
});
es.addEventListener("end", () => {
placeholder.dataset.streaming = "false";
stopWidgetThinking(placeholder);
history.push({ role: "assistant", text: fullText || "", ts: new Date().toISOString() });
saveHistory();
cleanupStream();
});
es.addEventListener("error", () => {
stopWidgetThinking(placeholder);
const errText = t("paliadin.error.connection_lost");
setBubbleText(placeholder, errText + " " + t("paliadin.late.waiting"));
setBubbleText(placeholder, errText + " " + t("paliadin.late.checking"));
placeholder.classList.add("paliadin-widget-bubble--error");
placeholder.classList.add("paliadin-widget-bubble--late-pending");
placeholder.dataset.streaming = "false";
@@ -412,6 +435,39 @@ async function sendTurn(): Promise<void> {
});
}
function startWidgetThinking(bubble: HTMLElement): void {
if (bubble.querySelector(".paliadin-widget-thinking")) return;
// Clear the static placeholder text — the live pulse + counter is
// the canonical "denkt nach" signal.
const textNode = bubble.querySelector(".paliadin-widget-bubble-text");
if (textNode) textNode.textContent = "";
const node = document.createElement("div");
node.className = "paliadin-widget-thinking";
node.innerHTML = `
<span class="paliadin-widget-thinking-dot" aria-hidden="true"></span>
<span class="paliadin-widget-thinking-label"></span>
<span class="paliadin-widget-thinking-elapsed"></span>
`;
const label = node.querySelector(".paliadin-widget-thinking-label")!;
label.textContent = t("paliadin.thinking");
bubble.appendChild(node);
updateWidgetThinking(bubble, 0);
}
function updateWidgetThinking(bubble: HTMLElement, elapsedSeconds: number): void {
const node = bubble.querySelector(".paliadin-widget-thinking") as HTMLElement | null;
if (!node) return;
const elapsed = node.querySelector(".paliadin-widget-thinking-elapsed");
if (elapsed) {
const s = elapsedSeconds < 0 ? 0 : Math.round(elapsedSeconds);
elapsed.textContent = t("paliadin.thinking.seconds").replace("{seconds}", String(s));
}
}
function stopWidgetThinking(bubble: HTMLElement): void {
bubble.querySelector(".paliadin-widget-thinking")?.remove();
}
function cleanupStream(): void {
activeStream?.close();
activeStream = null;
@@ -427,13 +483,24 @@ function startWidgetLatePoll(turnId: string, bubble: HTMLElement): void {
lateWidgetPolls.delete(turnId);
applyWidgetLateResponse(bubble, turn);
},
onLost: () => {
lateWidgetPolls.delete(turnId);
applyWidgetLost(bubble);
},
onGiveUp: () => {
lateWidgetPolls.delete(turnId);
applyWidgetLost(bubble);
},
});
lateWidgetPolls.set(turnId, handle);
}
function applyWidgetLost(bubble: HTMLElement): void {
bubble.classList.remove("paliadin-widget-bubble--late-pending");
bubble.classList.add("paliadin-widget-bubble--lost");
setBubbleText(bubble, t("paliadin.late.lost"));
}
function applyWidgetLateResponse(bubble: HTMLElement, turn: LateTurn): void {
if (!turn.response) return;
bubble.classList.remove(

View File

@@ -3,16 +3,25 @@ import { initSidebar } from "./sidebar";
import { renderResponseHTML } from "./paliadin-render";
import { pollForLateResponse, type LateTurn, type LatePollHandle } from "./paliadin-late-poll";
// Paliadin chat panel client (t-paliad-146 PoC).
// Paliadin chat panel client (t-paliad-146 PoC, streaming upgrade
// t-paliad-235).
//
// State machine: empty → typing → sending → streaming → done.
// State machine: empty → typing → sending → thinking → streaming → done.
// History lives in localStorage under "paliadin:history:<sessionId>"
// — design §0.5.4 session-only persistence.
//
// SSE consumer subscribes to `event: meta`, `event: content`,
// `event: end`, `event: error`, `event: ping`. Backend currently
// emits one `content` blob per turn (real chunked streaming is
// production-v1; PoC simulates with a typewriter effect).
// `event: thinking`, `event: end`, `event: error`, `event: ping`.
//
// `content` events from the aichat backend arrive as incremental
// `{delta: "..."}` chunks; the bubble accumulates them in real time —
// no typewriter simulation needed. Legacy backends still emit a single
// `{text: "..."}` payload and we fall back to the typewriter for that
// shape.
//
// `thinking` events fire while the upstream is alive but hasn't
// produced content yet (or stalled mid-stream); the bubble renders a
// pulse + counter so the user can SEE the chat is still working.
interface HistoryEntry {
role: "user" | "assistant";
@@ -167,25 +176,53 @@ async function sendTurn(text: string): Promise<void> {
const es = new EventSource(turnRes.sse_url);
currentEventSource = es;
// Show the thinking pulse immediately — the placeholder text already
// says "denkt nach", but the visible pulse + counter is the live
// proof-of-life signal m needs to trust that the chat is working.
startThinkingIndicator(placeholder);
// Reset the streamed accumulator for this turn.
placeholder.dataset.fullText = "";
es.addEventListener("meta", () => {
// Could surface a "thinking" indicator; placeholder text already does.
});
es.addEventListener("thinking", (ev) => {
let elapsed = 0;
try {
const data = JSON.parse((ev as MessageEvent).data || "{}");
if (typeof data.elapsed_seconds === "number") {
elapsed = data.elapsed_seconds;
}
} catch {
/* ignore */
}
updateThinkingIndicator(placeholder, elapsed);
});
es.addEventListener("content", (ev) => {
const data = JSON.parse((ev as MessageEvent).data);
const delta = typeof data.delta === "string" ? data.delta : "";
if (delta) {
// Aichat streaming path — accumulate the delta into the bubble.
stopThinkingIndicator(placeholder);
const current = placeholder.dataset.fullText ?? "";
const next = current + delta;
placeholder.dataset.fullText = next;
writeStreamedText(placeholder, next);
return;
}
// Legacy one-shot path — full body in `text`.
const text = String(data.text || "");
// Cache the full text on the bubble so finishBubble can render the
// complete response even when the typewriter is mid-flight when end
// arrives. textContent reflects only what's been typed so far and
// would otherwise truncate the rendered Markdown (m, 2026-05-08 —
// saw "## Proje" instead of the full 1408-byte body).
placeholder.dataset.fullText = text;
stopThinkingIndicator(placeholder);
typewriter(placeholder, text);
});
es.addEventListener("end", (ev) => {
const data = JSON.parse((ev as MessageEvent).data);
placeholder.dataset.streaming = "false";
stopThinkingIndicator(placeholder);
finishBubble(placeholder, data);
history.push({
role: "assistant",
@@ -210,12 +247,12 @@ async function sendTurn(text: string): Promise<void> {
es.addEventListener("error", (ev) => {
const errText = friendlyErrorMessage((ev as MessageEvent).data);
// Annotate the error bubble with a "warten auf späte Antwort" hint
// so m knows the turn isn't dead; if Claude finishes after the
// 60 s window the Janitor (services.LocalPaliadinService.runJanitor)
// patches the row and pollForLateResponse swaps in the real reply.
stopThinkingIndicator(placeholder);
// Honest copy: we don't claim "nachgereicht" because the recovery
// path may report "lost". Frame it as "checking" while we ask the
// backend whether the turn actually completed upstream.
placeholder.querySelector(".paliadin-bubble-text")!.textContent =
errText + " " + t("paliadin.late.waiting");
errText + " " + t("paliadin.late.checking");
placeholder.classList.add("paliadin-bubble--error");
placeholder.classList.add("paliadin-bubble--late-pending");
placeholder.dataset.streaming = "false";
@@ -232,6 +269,65 @@ async function sendTurn(text: string): Promise<void> {
});
}
// =============================================================================
// thinking indicator — proof-of-life pulse + elapsed counter
// =============================================================================
function startThinkingIndicator(bubble: HTMLElement): void {
// Append a thinking node next to the bubble text (sibling, so the
// typewriter rewriting text content doesn't clobber it). The node
// shows a pulse dot + the elapsed counter.
let node = bubble.querySelector(".paliadin-thinking") as HTMLElement | null;
if (node) return; // already running
// Clear the static placeholder text — the live pulse + counter is
// now the canonical "denkt nach" signal. Leaving the text in place
// would render the same phrase twice.
const textNode = bubble.querySelector(".paliadin-bubble-text");
if (textNode) textNode.textContent = "";
node = document.createElement("div");
node.className = "paliadin-thinking";
node.innerHTML = `
<span class="paliadin-thinking-dot" aria-hidden="true"></span>
<span class="paliadin-thinking-label"></span>
<span class="paliadin-thinking-elapsed"></span>
`;
const label = node.querySelector(".paliadin-thinking-label")!;
label.textContent = t("paliadin.thinking");
bubble.appendChild(node);
// Initial 0s — replaced as soon as a thinking event arrives or our
// local ticker fires.
updateThinkingIndicator(bubble, 0);
}
function updateThinkingIndicator(bubble: HTMLElement, elapsedSeconds: number): void {
const node = bubble.querySelector(".paliadin-thinking") as HTMLElement | null;
if (!node) return;
const elapsed = node.querySelector(".paliadin-thinking-elapsed");
if (elapsed) {
elapsed.textContent = formatThinkingSeconds(elapsedSeconds);
}
}
function stopThinkingIndicator(bubble: HTMLElement): void {
bubble.querySelector(".paliadin-thinking")?.remove();
}
function formatThinkingSeconds(s: number): string {
if (s < 0) s = 0;
return t("paliadin.thinking.seconds").replace("{seconds}", String(Math.round(s)));
}
// writeStreamedText fills the bubble with raw text as it accumulates.
// Cheaper than the typewriter — we already have the real cadence from
// the wire, no need to simulate it.
function writeStreamedText(bubble: HTMLElement, text: string): void {
const node = bubble.querySelector(".paliadin-bubble-text");
if (!node) return;
node.textContent = text;
const stream = document.getElementById("paliadin-stream");
if (stream) stream.scrollTop = stream.scrollHeight;
}
// Server emits SSE error events as JSON `{code, message}`. Map known
// codes to localised, user-friendly text; fall through to a generic
// "connection lost" for anything we don't recognise (including raw
@@ -361,11 +457,12 @@ function finishBubble(bubble: HTMLElement, data: any): void {
}
// startLatePoll registers the Janitor-patched row poller for one
// errored turn. When the row gains a response we swap the bubble's
// content + drop the error class + retroactively replace the history
// entry (which was never written for the failed turn — append now so
// reload renders the late reply).
// startLatePoll registers the recovery-endpoint poller for one errored
// turn. When the row gains a response we swap the bubble's content +
// drop the error class + retroactively replace the history entry
// (which was never written for the failed turn — append now so reload
// renders the late reply). When the backend confirms the turn is
// "lost", we swap the bubble to the honest "verloren" copy.
function startLatePoll(turnId: string, bubble: HTMLElement): void {
// Avoid duplicate pollers for the same turn (e.g. SSE error fires
// twice in some browsers when the connection drops).
@@ -376,13 +473,25 @@ function startLatePoll(turnId: string, bubble: HTMLElement): void {
latePolls.delete(turnId);
applyLateResponse(bubble, turn);
},
onLost: () => {
latePolls.delete(turnId);
applyLostResponse(bubble);
},
onGiveUp: () => {
latePolls.delete(turnId);
applyLostResponse(bubble);
},
});
latePolls.set(turnId, handle);
}
function applyLostResponse(bubble: HTMLElement): void {
bubble.classList.remove("paliadin-bubble--late-pending");
bubble.classList.add("paliadin-bubble--lost");
const node = bubble.querySelector(".paliadin-bubble-text");
if (node) node.textContent = t("paliadin.late.lost");
}
function applyLateResponse(bubble: HTMLElement, turn: LateTurn): void {
if (!turn.response) return;
bubble.classList.remove("paliadin-bubble--error", "paliadin-bubble--late-pending");

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,11 +8,14 @@ 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";
import { renderSmartTimeline, type TimelineEvent as SmartTimelineEvent, type LaneInfo as SmartTimelineLane } from "./views/shape-timeline";
import { loadAndRenderSubmissions } from "./submissions";
import { buildMailtoHref, type BroadcastRecipient } from "./broadcast";
interface Project {
id: string;
@@ -236,6 +239,12 @@ let attachedUnits: AttachedUnit[] = [];
let allUnits: { id: string; name: string; office: string }[] = [];
let userOptions: { id: string; display_name: string; email: string; profession?: string }[] = [];
// t-paliad-231 — checkbox selection backing the "Mail an Auswahl"
// mailto: button on the Team tab. Pure client state, wiped on page
// navigation. Pruned to currently-visible user_ids on every renderTeam
// so removed/filtered-out members don't ride along in the next mailto.
const selectedMailUserIDs: Set<string> = new Set();
const EVENTS_PAGE_SIZE = 50;
let eventsHasMore = false;
let eventsLoadingMore = false;
@@ -1443,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,
@@ -1753,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");
@@ -1791,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";
@@ -1869,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;
@@ -2507,6 +2520,7 @@ async function loadUserList() {
function renderTeam() {
const body = document.getElementById("team-body")!;
const empty = document.getElementById("team-empty")!;
const mailtoControls = document.getElementById("team-mailto-controls") as HTMLDivElement | null;
// Existing team-body shows the direct + ancestor-inherited members
// returned by /api/projects/{id}/team. The derived + descendant
@@ -2517,12 +2531,21 @@ function renderTeam() {
if (totalRows === 0) {
body.innerHTML = "";
empty.style.display = "";
if (mailtoControls) mailtoControls.style.display = "none";
selectedMailUserIDs.clear();
syncMailtoButton();
syncMasterCheckbox();
renderDescendantStaffed();
renderDerivedMembers();
renderAttachedUnits();
return;
}
empty.style.display = "none";
if (mailtoControls) mailtoControls.style.display = teamMembers.length > 0 ? "" : "none";
// Prune the selection to whoever is actually rendered in team-body
// right now (e.g. a member just got removed). Invariant: selection ⊆
// currently-visible team-body rows.
pruneMailSelectionToVisible();
// t-paliad-223: callers with effective_project_admin authority see an
// inline <select> on the Rolle cell. Everyone else sees the read-only
@@ -2563,7 +2586,17 @@ function renderTeam() {
? renderResponsibilitySelect(m.user_id, responsibility)
: `<span class="projekt-team-responsibility">${esc(responsibilityLabel)}</span>`;
// t-paliad-231: per-row checkbox feeding selectedMailUserIDs. Only
// rows with a real email participate in the mailto: build; rows
// without are still rendered with a disabled checkbox so the
// column geometry stays uniform.
const hasEmail = !!(m.user_email && m.user_email.trim());
const checked = hasEmail && selectedMailUserIDs.has(m.user_id) ? " checked" : "";
const disabled = hasEmail ? "" : " disabled";
const checkboxCell = `<td class="team-col-select"><input type="checkbox" class="team-mail-select" data-user-id="${esc(m.user_id)}" data-email="${escAttr(m.user_email || "")}" aria-label="${escAttr(t("projects.team.mailto.select_row") || "Mitglied auswählen")}"${checked}${disabled} /></td>`;
return `<tr>
${checkboxCell}
<td><strong>${esc(m.user_display_name || m.user_email)}</strong>
<span class="form-hint">&middot; ${esc(m.user_email)}${officeLabel ? " &middot; " + esc(officeLabel) : ""}</span></td>
<td><span class="${profCls}" title="${escAttr(professionTitle)}">${esc(professionLabel)}</span></td>
@@ -2574,6 +2607,21 @@ function renderTeam() {
})
.join("");
// t-paliad-231 — wire row checkboxes + master + mailto button.
body.querySelectorAll<HTMLInputElement>(".team-mail-select").forEach((cb) => {
cb.addEventListener("change", () => {
const userID = cb.dataset.userId!;
if (cb.checked) selectedMailUserIDs.add(userID);
else selectedMailUserIDs.delete(userID);
syncMailtoButton();
syncMasterCheckbox();
});
});
wireMailtoMaster();
wireMailtoButton();
syncMailtoButton();
syncMasterCheckbox();
body.querySelectorAll<HTMLButtonElement>(".team-remove-btn").forEach((btn) => {
btn.addEventListener("click", async () => {
if (!project) return;
@@ -2860,6 +2908,113 @@ async function showTeamErrorToast(resp: Response): Promise<void> {
}, 5000);
}
// t-paliad-231 — mailto: selection helpers for the Team tab. The
// admin-only server SMTP broadcast (POST /api/team/broadcast) lives
// elsewhere; this is the non-admin / quick-CC variant that opens the
// user's local mail client. Pure client; no server call.
function pruneMailSelectionToVisible(): void {
const visible = new Set<string>();
for (const m of teamMembers) {
if (m.user_email && m.user_email.trim()) visible.add(m.user_id);
}
for (const id of Array.from(selectedMailUserIDs)) {
if (!visible.has(id)) selectedMailUserIDs.delete(id);
}
}
function selectedMailRecipients(): BroadcastRecipient[] {
const out: BroadcastRecipient[] = [];
for (const m of teamMembers) {
if (!selectedMailUserIDs.has(m.user_id)) continue;
if (!m.user_email || !m.user_email.trim()) continue;
out.push({
user_id: m.user_id,
email: m.user_email,
display_name: m.user_display_name || m.user_email,
first_name: (m.user_display_name || m.user_email).trim().split(/\s+/)[0] ?? "",
role_on_project: m.responsibility || "member",
});
}
return out;
}
function syncMailtoButton(): void {
const btn = document.getElementById("team-mailto-btn") as HTMLButtonElement | null;
const label = document.getElementById("team-mailto-label") as HTMLSpanElement | null;
if (!btn || !label) return;
const n = selectedMailRecipients().length;
const baseLabel = t("projects.team.mailto.label") || "Mail an Auswahl";
if (n === 0) {
btn.disabled = true;
label.textContent = baseLabel;
btn.title = t("projects.team.mailto.empty") || "Mindestens ein Mitglied auswählen";
} else {
btn.disabled = false;
label.textContent = `${baseLabel} (${n})`;
const tooltip = (t("projects.team.mailto.count") || "{n} ausgewählt").replace("{n}", String(n));
btn.title = tooltip;
}
}
function syncMasterCheckbox(): void {
const master = document.getElementById("team-select-master") as HTMLInputElement | null;
if (!master) return;
// Only count rows that actually rendered with an enabled checkbox —
// members without an email don't participate.
const checkboxes = document.querySelectorAll<HTMLInputElement>(
"#team-body .team-mail-select:not(:disabled)",
);
const total = checkboxes.length;
let selected = 0;
checkboxes.forEach((cb) => {
if (selectedMailUserIDs.has(cb.dataset.userId!)) selected++;
});
master.disabled = total === 0;
if (total === 0 || selected === 0) {
master.checked = false;
master.indeterminate = false;
} else if (selected === total) {
master.checked = true;
master.indeterminate = false;
} else {
master.checked = false;
master.indeterminate = true;
}
}
// wireMailtoMaster is idempotent — registers once via a sentinel data
// attr so re-renders don't stack click handlers.
function wireMailtoMaster(): void {
const master = document.getElementById("team-select-master") as HTMLInputElement | null;
if (!master || master.dataset.wired === "1") return;
master.dataset.wired = "1";
master.addEventListener("change", () => {
const turnOn = master.checked;
document
.querySelectorAll<HTMLInputElement>("#team-body .team-mail-select:not(:disabled)")
.forEach((cb) => {
const id = cb.dataset.userId!;
if (turnOn) selectedMailUserIDs.add(id);
else selectedMailUserIDs.delete(id);
cb.checked = turnOn;
});
syncMailtoButton();
syncMasterCheckbox();
});
}
function wireMailtoButton(): void {
const btn = document.getElementById("team-mailto-btn") as HTMLButtonElement | null;
if (!btn || btn.dataset.wired === "1") return;
btn.dataset.wired = "1";
btn.addEventListener("click", (e) => {
e.preventDefault();
const recipients = selectedMailRecipients();
if (recipients.length === 0) return;
window.location.href = buildMailtoHref(recipients);
});
}
function initTeamForm(id: string) {
const addBtn = document.getElementById("team-add-btn") as HTMLButtonElement | null;
const form = document.getElementById("team-form") as HTMLFormElement | null;

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

@@ -76,6 +76,17 @@ export function renderDashboard(): string {
<span className="dashboard-date" id="dashboard-date"></span>
</p>
</div>
{/* "Anpassen" toggle (t-paliad-219 Slice B). Off by
default — when on, body.dashboard-editing reveals
drag handles / ↑↓ / x / ⚙ chrome on each widget plus
the edit-footer below the widget stack. */}
<button
type="button"
id="dashboard-edit-toggle"
className="btn btn-ghost dashboard-edit-toggle"
aria-pressed="false"
data-i18n="dashboard.edit.toggle"
>Anpassen</button>
</div>
<div id="dashboard-unavailable" className="dashboard-unavailable" style="display:none">
@@ -90,66 +101,66 @@ export function renderDashboard(): string {
</p>
</div>
{/* Traffic-light deadline summary (4+1: Überfällig conditional + 4 universal — t-paliad-110) */}
<CollapsibleSection id="summary" widgetKey="deadline-summary" headingI18n="dashboard.summary.heading" headingDe="Fristen auf einen Blick">
<div className="dashboard-summary-grid">
<a href="/deadlines?status=overdue" className="dashboard-card dashboard-card-red" id="dashboard-card-overdue">
<div className="dashboard-card-count" id="dashboard-count-overdue">0</div>
<div className="dashboard-card-label" data-i18n="dashboard.summary.overdue">&Uuml;berf&auml;llig</div>
</a>
<a href="/deadlines?status=today" className="dashboard-card dashboard-card-today" id="dashboard-card-today">
<div className="dashboard-card-count" id="dashboard-count-today">0</div>
<div className="dashboard-card-label" data-i18n="dashboard.summary.today">Heute</div>
</a>
<a href="/deadlines?status=this_week" className="dashboard-card dashboard-card-amber" id="dashboard-card-thisweek">
<div className="dashboard-card-count" id="dashboard-count-this-week">0</div>
<div className="dashboard-card-label" data-i18n="dashboard.summary.this_week">Diese Woche</div>
</a>
<a href="/deadlines?status=next_week" className="dashboard-card dashboard-card-green" id="dashboard-card-nextweek">
<div className="dashboard-card-count" id="dashboard-count-next-week">0</div>
<div className="dashboard-card-label" data-i18n="dashboard.summary.next_week">N&auml;chste Woche</div>
</a>
<a href="/deadlines?status=later" className="dashboard-card dashboard-card-later" id="dashboard-card-later">
<div className="dashboard-card-count" id="dashboard-count-later">0</div>
<div className="dashboard-card-label" data-i18n="dashboard.summary.later">Sp&auml;ter</div>
</a>
</div>
</CollapsibleSection>
{/* Matter summary card — single tappable card, kept outside the
collapsible scaffold because its h3 is internal to the card
and doubles as the navigation affordance. */}
<section className="dashboard-matters" data-widget-key="matter-summary">
<a href="/projects" className="dashboard-matter-card">
<div className="dashboard-matter-header">
<h3 data-i18n="dashboard.matters.heading">Meine Akten</h3>
<span className="dashboard-matter-arrow" aria-hidden="true">&rarr;</span>
{/* Configurable widget grid (t-paliad-227 overhaul). All
widgets live as direct children of the single
.dashboard-grid container so applyLayout can place them
via grid-column/grid-row inline styles. Pre-overhaul
this stack had nested wrappers (.dashboard-columns,
standalone <section>s) that fought the layout engine
and made cross-row drags appear to fail. */}
<div className="dashboard-grid" id="dashboard-grid">
{/* Traffic-light deadline summary (4+1: Überfällig conditional + 4 universal — t-paliad-110) */}
<CollapsibleSection id="summary" widgetKey="deadline-summary" headingI18n="dashboard.summary.heading" headingDe="Fristen auf einen Blick">
<div className="dashboard-summary-grid">
<a href="/events?type=deadline&status=overdue" className="dashboard-card dashboard-card-red" id="dashboard-card-overdue">
<div className="dashboard-card-count" id="dashboard-count-overdue">0</div>
<div className="dashboard-card-label" data-i18n="dashboard.summary.overdue">&Uuml;berf&auml;llig</div>
</a>
<a href="/events?type=deadline&status=today" className="dashboard-card dashboard-card-today" id="dashboard-card-today">
<div className="dashboard-card-count" id="dashboard-count-today">0</div>
<div className="dashboard-card-label" data-i18n="dashboard.summary.today">Heute</div>
</a>
<a href="/events?type=deadline&status=this_week" className="dashboard-card dashboard-card-amber" id="dashboard-card-thisweek">
<div className="dashboard-card-count" id="dashboard-count-this-week">0</div>
<div className="dashboard-card-label" data-i18n="dashboard.summary.this_week">Diese Woche</div>
</a>
<a href="/events?type=deadline&status=next_week" className="dashboard-card dashboard-card-green" id="dashboard-card-nextweek">
<div className="dashboard-card-count" id="dashboard-count-next-week">0</div>
<div className="dashboard-card-label" data-i18n="dashboard.summary.next_week">N&auml;chste Woche</div>
</a>
<a href="/events?type=deadline&status=later" className="dashboard-card dashboard-card-later" id="dashboard-card-later">
<div className="dashboard-card-count" id="dashboard-count-later">0</div>
<div className="dashboard-card-label" data-i18n="dashboard.summary.later">Sp&auml;ter</div>
</a>
</div>
<div className="dashboard-matter-stats">
<div>
<div className="dashboard-matter-num" id="dashboard-matter-active">0</div>
<div className="dashboard-matter-lbl" data-i18n="dashboard.matters.active">Aktiv</div>
</div>
<div>
<div className="dashboard-matter-num" id="dashboard-matter-archived">0</div>
<div className="dashboard-matter-lbl" data-i18n="dashboard.matters.archived">Archiviert</div>
</div>
<div>
<div className="dashboard-matter-num" id="dashboard-matter-total">0</div>
<div className="dashboard-matter-lbl" data-i18n="dashboard.matters.total">Gesamt</div>
</div>
</div>
</a>
</section>
</CollapsibleSection>
{/* Matter summary — uses CollapsibleSection now so it
participates in the grid like every other widget. The
inner card heading was redundant with the section
heading; we keep the stats grid + the projects link. */}
<CollapsibleSection id="matters" widgetKey="matter-summary" headingI18n="dashboard.matters.heading" headingDe="Meine Akten">
<a href="/projects" className="dashboard-matter-card">
<div className="dashboard-matter-stats">
<div>
<div className="dashboard-matter-num" id="dashboard-matter-active">0</div>
<div className="dashboard-matter-lbl" data-i18n="dashboard.matters.active">Aktiv</div>
</div>
<div>
<div className="dashboard-matter-num" id="dashboard-matter-archived">0</div>
<div className="dashboard-matter-lbl" data-i18n="dashboard.matters.archived">Archiviert</div>
</div>
<div>
<div className="dashboard-matter-num" id="dashboard-matter-total">0</div>
<div className="dashboard-matter-lbl" data-i18n="dashboard.matters.total">Gesamt</div>
</div>
</div>
</a>
</CollapsibleSection>
{/* Two-column lists — each column is its own collapsible section
so users can hide deadlines or appointments independently.
The .dashboard-columns wrapper is preserved so the grid
layout still applies; collapse hides the body of each col
but leaves the heading row in the grid. */}
<div className="dashboard-columns">
<CollapsibleSection id="deadlines" widgetKey="upcoming-deadlines" headingI18n="dashboard.deadlines.heading" headingDe="Kommende Fristen">
<ul className="dashboard-list" id="dashboard-deadlines-list"></ul>
<div className="dashboard-calendar" id="dashboard-deadlines-calendar" style="display:none"></div>
<p className="dashboard-empty" id="dashboard-deadlines-empty" style="display:none" data-i18n="dashboard.deadlines.empty">
Keine Fristen in den n&auml;chsten 7 Tagen.
</p>
@@ -157,55 +168,117 @@ export function renderDashboard(): string {
<CollapsibleSection id="appointments" widgetKey="upcoming-appointments" headingI18n="dashboard.appointments.heading" headingDe="Kommende Termine">
<ul className="dashboard-list" id="dashboard-appointments-list"></ul>
<div className="dashboard-calendar" id="dashboard-appointments-calendar" style="display:none"></div>
<p className="dashboard-empty" id="dashboard-appointments-empty" style="display:none" data-i18n="dashboard.appointments.empty">
Keine Termine in den n&auml;chsten 7 Tagen.
</p>
</CollapsibleSection>
{/* Inline Agenda (t-paliad-162). Same item shape as the
standalone /agenda page, rendered via the shared
agenda-render module. The dashboard variant is read-only:
no chip filters, no URL state — a 30-day window of
upcoming items grouped by day. The standalone /agenda
route is unchanged for direct-link compatibility. */}
<CollapsibleSection id="agenda" widgetKey="inline-agenda" headingI18n="dashboard.agenda.heading" headingDe="Agenda">
<div className="dashboard-agenda">
<div className="agenda-timeline" id="dashboard-agenda-timeline" />
<ul className="dashboard-list" id="dashboard-agenda-list" style="display:none"></ul>
<p className="dashboard-empty" id="dashboard-agenda-empty" style="display:none" data-i18n="dashboard.agenda.empty">
Keine F&auml;lligkeiten in den n&auml;chsten 30 Tagen.
</p>
<p className="dashboard-agenda-link">
<a href="/agenda" data-i18n="dashboard.agenda.full_link">Vollst&auml;ndige Agenda &ouml;ffnen &rarr;</a>
</p>
</div>
</CollapsibleSection>
{/* Inbox-approvals widget (t-paliad-219 — new in v1). The
list mirrors /inbox's "Approver" axis but capped at the
widget's count setting. Renders the empty state when
the user has no open approvals to review. */}
<CollapsibleSection id="inbox-approvals" widgetKey="inbox-approvals" headingI18n="dashboard.inbox.heading" headingDe="Offene Freigaben">
<div className="dashboard-inbox">
<p className="dashboard-inbox-summary" id="dashboard-inbox-summary" style="display:none"></p>
<ul className="dashboard-list" id="dashboard-inbox-list"></ul>
<p className="dashboard-empty" id="dashboard-inbox-empty" style="display:none" data-i18n="dashboard.inbox.empty">
Keine offenen Freigaben.
</p>
<p className="dashboard-agenda-link">
<a href="/inbox" data-i18n="dashboard.inbox.full_link">Vollst&auml;ndigen Posteingang &ouml;ffnen &rarr;</a>
</p>
</div>
</CollapsibleSection>
{/* Activity feed — moved under Agenda per m's design call
(t-paliad-162). */}
<CollapsibleSection id="activity" widgetKey="recent-activity" headingI18n="dashboard.activity.heading" headingDe="Letzte Aktivität">
<ul className="dashboard-activity-list" id="dashboard-activity-list"></ul>
<p className="dashboard-empty" id="dashboard-activity-empty" style="display:none" data-i18n="dashboard.activity.empty">
Noch keine Aktivit&auml;t erfasst.
</p>
</CollapsibleSection>
{/* Pinned-projects widget (t-paliad-219 Slice C). Reads
PinService via DashboardData.pinned_projects (server-
joined to titles + refs). Default-hidden — users opt
in via the picker. */}
<CollapsibleSection id="pinned-projects" widgetKey="pinned-projects" headingI18n="dashboard.pinned.heading" headingDe="Angepinnte Akten">
<ul className="dashboard-list" id="dashboard-pinned-list"></ul>
<p className="dashboard-empty" id="dashboard-pinned-empty" style="display:none" data-i18n="dashboard.pinned.empty">
Noch keine Akten angepinnt.
</p>
<p className="dashboard-agenda-link">
<a href="/projects" data-i18n="dashboard.pinned.full_link">Alle Akten &ouml;ffnen &rarr;</a>
</p>
</CollapsibleSection>
{/* Quick-actions widget (t-paliad-219 Slice C). Pure UI;
no backend data path. Default-hidden — surfaced via the
picker. */}
<CollapsibleSection id="quick-actions" widgetKey="quick-actions" headingI18n="dashboard.quick.heading" headingDe="Schnellzugriff">
<div className="dashboard-quick-actions">
<a href="/projects/new" className="btn btn-primary dashboard-quick-btn" data-i18n="dashboard.quick.new_project">+ Akte</a>
<a href="/deadlines/new" className="btn btn-secondary dashboard-quick-btn" data-i18n="dashboard.quick.new_deadline">+ Frist</a>
<a href="/appointments/new" className="btn btn-secondary dashboard-quick-btn" data-i18n="dashboard.quick.new_appointment">+ Termin</a>
</div>
</CollapsibleSection>
</div>
{/* Inline Agenda (t-paliad-162). Same item shape as the
standalone /agenda page, rendered via the shared
agenda-render module. The dashboard variant is read-only:
no chip filters, no URL state — a 30-day window of
upcoming items grouped by day. The standalone /agenda
route is unchanged for direct-link compatibility. */}
<CollapsibleSection id="agenda" widgetKey="inline-agenda" headingI18n="dashboard.agenda.heading" headingDe="Agenda">
<div className="dashboard-agenda">
<div className="agenda-timeline" id="dashboard-agenda-timeline" />
<p className="dashboard-empty" id="dashboard-agenda-empty" style="display:none" data-i18n="dashboard.agenda.empty">
Keine F&auml;lligkeiten in den n&auml;chsten 30 Tagen.
</p>
<p className="dashboard-agenda-link">
<a href="/agenda" data-i18n="dashboard.agenda.full_link">Vollst&auml;ndige Agenda &ouml;ffnen &rarr;</a>
</p>
</div>
</CollapsibleSection>
{/* Edit-mode footer (t-paliad-219 Slice B). Hidden via CSS
unless body.dashboard-editing — see dashboard.ts.
Slice C added the admin "Promote to firm default"
button — it stays hidden unless data.user.global_role
is 'global_admin'; dashboard.ts toggles it. */}
<div id="dashboard-edit-footer" className="dashboard-edit-footer">
<button
type="button"
id="dashboard-edit-add"
className="btn btn-secondary dashboard-edit-add"
data-i18n="dashboard.edit.add_widget"
>Widget hinzuf&uuml;gen</button>
<button
type="button"
id="dashboard-edit-promote"
className="btn btn-ghost dashboard-edit-promote"
style="display:none"
data-i18n="dashboard.edit.promote"
>Als Firmen-Standard speichern</button>
<button
type="button"
id="dashboard-edit-reset"
className="dashboard-edit-reset-link"
data-i18n="dashboard.edit.reset"
>Auf Standard zur&uuml;cksetzen</button>
</div>
{/* Inbox-approvals widget (t-paliad-219 — new in v1). The
list mirrors /inbox's "Approver" axis but capped at the
widget's count setting. Renders the empty state when
the user has no open approvals to review. */}
<CollapsibleSection id="inbox-approvals" widgetKey="inbox-approvals" headingI18n="dashboard.inbox.heading" headingDe="Offene Freigaben">
<div className="dashboard-inbox">
<p className="dashboard-inbox-summary" id="dashboard-inbox-summary" style="display:none"></p>
<ul className="dashboard-list" id="dashboard-inbox-list"></ul>
<p className="dashboard-empty" id="dashboard-inbox-empty" style="display:none" data-i18n="dashboard.inbox.empty">
Keine offenen Freigaben.
</p>
<p className="dashboard-agenda-link">
<a href="/inbox" data-i18n="dashboard.inbox.full_link">Vollst&auml;ndigen Posteingang &ouml;ffnen &rarr;</a>
</p>
</div>
</CollapsibleSection>
{/* Activity feed — moved under Agenda per m's design call
(t-paliad-162). */}
<CollapsibleSection id="activity" widgetKey="recent-activity" headingI18n="dashboard.activity.heading" headingDe="Letzte Aktivität">
<ul className="dashboard-activity-list" id="dashboard-activity-list"></ul>
<p className="dashboard-empty" id="dashboard-activity-empty" style="display:none" data-i18n="dashboard.activity.empty">
Noch keine Aktivit&auml;t erfasst.
</p>
</CollapsibleSection>
{/* Save toast slot — managed by dashboard.ts. */}
<div
id="dashboard-save-toast"
className="dashboard-save-toast"
role="status"
aria-live="polite"
></div>
</div>
</section>
</main>

View File

@@ -502,6 +502,13 @@ export type I18nKey =
| "agenda.appointment_type.deadline_hearing"
| "agenda.appointment_type.hearing"
| "agenda.appointment_type.meeting"
| "agenda.day.di"
| "agenda.day.do"
| "agenda.day.fr"
| "agenda.day.mi"
| "agenda.day.mo"
| "agenda.day.sa"
| "agenda.day.so"
| "agenda.day.today"
| "agenda.day.tomorrow"
| "agenda.empty.hint"
@@ -1032,6 +1039,30 @@ export type I18nKey =
| "dashboard.appointments.heading"
| "dashboard.deadlines.empty"
| "dashboard.deadlines.heading"
| "dashboard.edit.add_widget"
| "dashboard.edit.drag"
| "dashboard.edit.exit"
| "dashboard.edit.hide"
| "dashboard.edit.move_down"
| "dashboard.edit.move_up"
| "dashboard.edit.promote"
| "dashboard.edit.promote_confirm"
| "dashboard.edit.promoted"
| "dashboard.edit.reset"
| "dashboard.edit.reset_confirm"
| "dashboard.edit.resize"
| "dashboard.edit.save_failed"
| "dashboard.edit.saved"
| "dashboard.edit.setting.count"
| "dashboard.edit.setting.count.custom"
| "dashboard.edit.setting.horizon"
| "dashboard.edit.setting.horizon.custom"
| "dashboard.edit.setting.horizon.days"
| "dashboard.edit.setting.position"
| "dashboard.edit.setting.size"
| "dashboard.edit.setting.view"
| "dashboard.edit.settings"
| "dashboard.edit.toggle"
| "dashboard.greeting.prefix"
| "dashboard.inbox.empty"
| "dashboard.inbox.entity.appointment"
@@ -1043,6 +1074,19 @@ export type I18nKey =
| "dashboard.matters.heading"
| "dashboard.matters.total"
| "dashboard.onboarding"
| "dashboard.picker.close"
| "dashboard.picker.empty"
| "dashboard.picker.status.absent"
| "dashboard.picker.status.active"
| "dashboard.picker.status.hidden"
| "dashboard.picker.title"
| "dashboard.pinned.empty"
| "dashboard.pinned.full_link"
| "dashboard.pinned.heading"
| "dashboard.quick.heading"
| "dashboard.quick.new_appointment"
| "dashboard.quick.new_deadline"
| "dashboard.quick.new_project"
| "dashboard.section.collapse"
| "dashboard.section.expand"
| "dashboard.summary.completed"
@@ -1941,8 +1985,11 @@ export type I18nKey =
| "paliadin.error.shim_error"
| "paliadin.error.timeout"
| "paliadin.error.upstream"
| "paliadin.error.upstream_silence"
| "paliadin.heading"
| "paliadin.input.placeholder"
| "paliadin.late.checking"
| "paliadin.late.lost"
| "paliadin.late.marker"
| "paliadin.late.waiting"
| "paliadin.reset"
@@ -1952,6 +1999,8 @@ export type I18nKey =
| "paliadin.starter.week"
| "paliadin.stop"
| "paliadin.tagline"
| "paliadin.thinking"
| "paliadin.thinking.seconds"
| "paliadin.title"
| "paliadin.widget.close"
| "paliadin.widget.context.on_page"
@@ -2214,6 +2263,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"
@@ -2310,6 +2360,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"
@@ -2355,6 +2408,11 @@ export type I18nKey =
| "projects.team.error.generic"
| "projects.team.error.last_admin"
| "projects.team.inherited.hint"
| "projects.team.mailto.count"
| "projects.team.mailto.empty"
| "projects.team.mailto.label"
| "projects.team.mailto.select_all"
| "projects.team.mailto.select_row"
| "projects.team.profession.associate"
| "projects.team.profession.hint"
| "projects.team.profession.none"

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

@@ -286,9 +286,33 @@ export function renderProjectsDetail(): string {
<p className="form-msg" id="team-msg" />
</form>
{/* t-paliad-231 — pure-client mailto: for non-admins.
Button stays disabled until at least one row is
selected; click opens a mailto: with every selected
member in the To: line. No server call. */}
<div className="party-controls team-mailto-controls" id="team-mailto-controls" style="display:none">
<button
type="button"
id="team-mailto-btn"
className="btn-secondary btn-small"
disabled
data-i18n-title="projects.team.mailto.empty"
title="Mindestens ein Mitglied ausw&auml;hlen"
>
<span id="team-mailto-label" data-i18n="projects.team.mailto.label">Mail an Auswahl</span>
</button>
</div>
<table className="party-table">
<thead>
<tr>
<th className="team-col-select">
<input
type="checkbox"
id="team-select-master"
aria-label="Alle sichtbaren ausw&auml;hlen"
/>
</th>
<th data-i18n="projects.detail.team.col.name">Name</th>
<th data-i18n="projects.detail.team.col.profession">Profession</th>
<th data-i18n="projects.detail.team.col.responsibility">Rolle</th>
@@ -603,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 --- */
@@ -7143,6 +7138,31 @@ dialog.modal::backdrop {
border-bottom: none;
}
/* t-paliad-231 — checkbox column for the project-detail Team tab's
"Mail an Auswahl" mailto: selection. Narrow, centred, accent-coloured. */
.party-table .team-col-select {
width: 2.4rem;
text-align: center;
padding-left: 0.6rem;
padding-right: 0.4rem;
}
.team-mail-select,
#team-select-master {
accent-color: var(--color-primary, #c6f41c);
cursor: pointer;
}
.team-mail-select:disabled {
cursor: not-allowed;
opacity: 0.4;
}
.team-mailto-controls {
justify-content: flex-end;
margin-bottom: 0.5rem;
}
.entity-col-actions {
text-align: right;
}
@@ -8076,19 +8096,13 @@ dialog.modal::backdrop {
.dashboard-matter-card {
display: block;
padding: 1.25rem 1.5rem;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius);
text-decoration: none;
color: var(--color-text);
box-shadow: var(--shadow);
transition: border-color 0.12s ease, box-shadow 0.12s ease;
transition: color 0.12s ease;
}
.dashboard-matter-card:hover {
border-color: var(--color-accent-light);
box-shadow: var(--shadow-md);
color: var(--color-accent-fg);
}
.dashboard-matter-header {
@@ -8131,7 +8145,34 @@ dialog.modal::backdrop {
color: var(--color-text-muted);
}
/* --- Two-column lists --- */
/* --- Configurable widget grid (t-paliad-227 overhaul) --- */
/* All dashboard widgets live as direct children of .dashboard-grid.
Inline grid-column / grid-row from applyLayout drive placement;
grid-auto-flow:dense fills any holes left by the explicit positions
so the grid stays compact even with sparse layouts. */
.dashboard-grid {
display: grid;
grid-template-columns: repeat(12, 1fr);
grid-auto-flow: dense;
grid-auto-rows: minmax(min-content, auto);
gap: 1.5rem;
margin-bottom: 2rem;
}
/* On narrow viewports the 12-col grid collapses to a single stack
regardless of the per-widget x/w/y/h — explicit positions are a
desktop affordance, not a mobile contract. The breakpoint matches
the existing .dashboard-columns rule below so the same screen
widths see consistent behaviour. */
@media (max-width: 720px) {
.dashboard-grid > [data-widget-key] {
grid-column: 1 / -1 !important;
grid-row: auto !important;
}
}
/* --- Two-column lists (legacy, kept for any non-dashboard usage) --- */
.dashboard-columns {
display: grid;
@@ -8353,6 +8394,47 @@ dialog.modal::backdrop {
margin-bottom: 2rem;
}
/* Inside the grid the gap handles inter-widget spacing — the legacy
per-section margin would double up and push every widget down. */
.dashboard-grid > .dashboard-section {
margin-bottom: 0;
}
/* Every grid widget gets the card chrome that .dashboard-columns >
.dashboard-section already gave the deadlines / appointments pair.
Without this, widgets like deadline-summary / matter-summary lose the
surface + border + padding that visually separated them from the
page background. */
.dashboard-grid > .dashboard-section {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius);
padding: 1.25rem 1.4rem;
box-shadow: var(--shadow);
min-width: 0;
/* Stretch widgets to the full row height of their grid row so two
neighbouring widgets with different content heights still align
to a tidy bottom edge. */
align-self: stretch;
display: flex;
flex-direction: column;
}
.dashboard-grid > .dashboard-section > .dashboard-section-body {
flex: 1;
min-height: 0;
}
/* Hide the legacy duplicate-card-chrome rule when the new grid is
active so we don't get a doubled border on the deadlines/appointments
pair (which legacy CSS targeted via .dashboard-columns >
.dashboard-section). */
.dashboard-grid .dashboard-columns > .dashboard-section {
box-shadow: none;
border: 0;
padding: 0;
}
.dashboard-section-toggle {
display: flex;
align-items: center;
@@ -8425,6 +8507,557 @@ dialog.modal::backdrop {
min-width: 0;
}
/* ---------------------------------------------------------------------------
Dashboard edit mode (t-paliad-219 Slice B)
---------------------------------------------------------------------------
The Anpassen toggle in the dashboard header flips body.dashboard-editing.
In view mode every .dashboard-widget__* selector is a no-op (chrome is
only injected dynamically by client/dashboard.ts on toggle-on). Footer
+ reset are CSS-hidden until editing.
--------------------------------------------------------------------------- */
.dashboard-edit-toggle {
align-self: flex-start;
padding: 0.4rem 0.9rem;
font-size: 0.85rem;
font-weight: 600;
line-height: 1.2;
}
.dashboard-edit-footer {
display: none;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin: 2rem 0 1rem;
padding: 1rem 1.25rem;
border: 1px dashed var(--color-border);
border-radius: var(--radius);
background: var(--color-surface-muted, var(--color-surface));
flex-wrap: wrap;
}
body.dashboard-editing .dashboard-edit-footer {
display: flex;
}
.dashboard-edit-add {
font-size: 0.9rem;
}
.dashboard-edit-reset-link {
background: none;
border: 0;
padding: 0.2rem 0.4rem;
color: var(--color-text-muted);
font-size: 0.85rem;
text-decoration: underline;
cursor: pointer;
}
.dashboard-edit-reset-link:hover,
.dashboard-edit-reset-link:focus-visible {
color: var(--color-text);
}
/* Every [data-widget-key] needs position:relative so the resize handle
(absolute bottom-right) and gear popover (absolute) anchor correctly.
Apply outside edit mode too — the resize handle only renders in edit
mode, but having a stable positioning context is cheap and keeps the
layout calculation consistent. */
[data-widget-key] {
position: relative;
}
/* Chrome strip injected on every [data-widget-key] when editing. */
body.dashboard-editing [data-widget-key].dashboard-widget {
outline: 1px dashed var(--color-border);
outline-offset: 4px;
border-radius: var(--radius);
transition: outline-color 0.12s ease;
}
body.dashboard-editing [data-widget-key].dashboard-widget--hidden {
opacity: 0.45;
}
body.dashboard-editing [data-widget-key].dashboard-widget--dragging {
opacity: 0.5;
}
body.dashboard-editing [data-widget-key].dashboard-widget--dragover {
outline-color: var(--color-accent-fg);
outline-style: solid;
outline-width: 2px;
}
.dashboard-widget__chrome {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.45rem 0.6rem;
margin: 0 0 0.6rem;
background: var(--color-surface-muted, var(--color-surface));
border: 1px solid var(--color-border);
border-radius: var(--radius);
font-size: 0.8rem;
color: var(--color-text-muted);
}
.dashboard-widget__handle {
cursor: grab;
color: var(--color-text-muted);
letter-spacing: -0.1em;
user-select: none;
}
.dashboard-widget__handle:active {
cursor: grabbing;
}
.dashboard-widget__label {
flex: 1;
font-weight: 600;
color: var(--color-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.dashboard-widget__actions {
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.dashboard-widget__btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.7rem;
height: 1.7rem;
padding: 0 0.4rem;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius);
color: var(--color-text);
font-size: 0.95rem;
line-height: 1;
cursor: pointer;
}
.dashboard-widget__btn:hover,
.dashboard-widget__btn:focus-visible {
border-color: var(--color-accent-fg);
color: var(--color-accent-fg);
}
.dashboard-widget__hide:hover {
color: var(--status-red-fg, #b91c1c);
border-color: currentColor;
}
/* Gear popover — absolutely positioned inside the .dashboard-widget. */
.dashboard-widget__gear-popover {
min-width: 180px;
padding: 0.75rem;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius);
box-shadow: var(--shadow);
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.dashboard-widget__gear-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
font-size: 0.85rem;
}
.dashboard-widget__gear-label {
color: var(--color-text-muted);
}
.dashboard-widget__gear-select {
min-width: 80px;
padding: 0.2rem 0.4rem;
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: var(--color-surface);
color: var(--color-text);
font-size: 0.85rem;
}
/* Combo row: label on the left, multiple controls on the right (e.g.
the count row's preset dropdown alongside the custom numeric input). */
.dashboard-widget__gear-row--combo {
flex-wrap: wrap;
}
.dashboard-widget__gear-controls {
display: inline-flex;
align-items: center;
gap: 0.35rem;
flex-wrap: wrap;
justify-content: flex-end;
}
.dashboard-widget__gear-mini {
color: var(--color-text-muted);
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.dashboard-widget__gear-number {
width: 4.2rem;
padding: 0.2rem 0.35rem;
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: var(--color-surface);
color: var(--color-text);
font-size: 0.85rem;
text-align: right;
}
/* View segmented control — one button per view option. The active
variant gets the accent fill so the current choice is obvious. */
.dashboard-widget__view-group {
display: inline-flex;
border: 1px solid var(--color-border);
border-radius: var(--radius);
overflow: hidden;
}
.dashboard-widget__view-btn {
padding: 0.25rem 0.55rem;
background: var(--color-surface);
border: 0;
color: var(--color-text-muted);
font-size: 0.8rem;
cursor: pointer;
border-right: 1px solid var(--color-border);
}
.dashboard-widget__view-btn:last-child {
border-right: 0;
}
.dashboard-widget__view-btn:hover,
.dashboard-widget__view-btn:focus-visible {
color: var(--color-text);
background: var(--color-surface-muted, var(--color-surface));
}
.dashboard-widget__view-btn--active {
background: var(--color-accent-fg, var(--color-text));
color: var(--color-surface);
font-weight: 600;
}
.dashboard-widget__view-btn--active:hover,
.dashboard-widget__view-btn--active:focus-visible {
background: var(--color-accent-fg, var(--color-text));
color: var(--color-surface);
}
/* Resize handle (bottom-right corner). Hidden in view mode; the
pointerdown handler in client/dashboard.ts converts pointer drag
into a snap-to-grid resize gesture. */
.dashboard-widget__resize {
position: absolute;
right: 0;
bottom: 0;
width: 18px;
height: 18px;
cursor: nwse-resize;
background:
linear-gradient(135deg, transparent 50%, var(--color-text-muted) 50%, var(--color-text-muted) 60%, transparent 60%, transparent 70%, var(--color-text-muted) 70%, var(--color-text-muted) 80%, transparent 80%);
border-bottom-right-radius: var(--radius);
opacity: 0.6;
touch-action: none;
z-index: 5;
}
.dashboard-widget__resize:hover {
opacity: 1;
}
body.dashboard-editing [data-widget-key].dashboard-widget--resizing {
outline-color: var(--color-accent-fg);
outline-style: solid;
outline-width: 2px;
user-select: none;
}
/* Mini calendar — used by the upcoming-deadlines / upcoming-
appointments widgets when view="calendar". Each month is a 7×N grid
of day cells with up to 3 colored dots per day plus a "+N" overflow. */
.dashboard-calendar {
display: flex;
flex-direction: column;
gap: 1.2rem;
}
.dashboard-cal-month {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.dashboard-cal-title {
margin: 0;
font-size: 0.95rem;
font-weight: 600;
color: var(--color-text);
}
.dashboard-cal-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 2px;
}
.dashboard-cal-day {
padding: 0.2rem 0.3rem;
font-size: 0.7rem;
font-weight: 600;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
text-align: center;
}
.dashboard-cal-cell {
min-height: 3.4rem;
padding: 0.25rem;
background: var(--color-surface-muted, var(--color-surface));
border: 1px solid var(--color-border);
border-radius: 4px;
display: flex;
flex-direction: column;
align-items: stretch;
gap: 0.15rem;
font-size: 0.75rem;
}
.dashboard-cal-cell--blank {
background: transparent;
border: 0;
}
.dashboard-cal-cell--today {
background: var(--color-surface);
border-color: var(--color-accent-fg);
box-shadow: 0 0 0 1px var(--color-accent-fg) inset;
}
.dashboard-cal-cell--has-items {
background: var(--color-surface);
}
.dashboard-cal-num {
font-size: 0.75rem;
color: var(--color-text);
}
.dashboard-cal-dots {
display: inline-flex;
flex-wrap: wrap;
gap: 0.15rem;
align-items: center;
}
.dashboard-cal-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--color-accent-fg, var(--color-text-muted));
display: inline-block;
}
.dashboard-cal-dot--overdue { background: var(--status-red-fg, #b91c1c); }
.dashboard-cal-dot--today { background: var(--status-amber-fg, #d97706); }
.dashboard-cal-dot--urgent { background: var(--status-amber-fg, #d97706); }
.dashboard-cal-dot--soon { background: var(--status-green-fg, #16a34a); }
.dashboard-cal-more {
font-size: 0.65rem;
color: var(--color-text-muted);
}
/* Compact activity view: collapse each row to a single line. */
.dashboard-activity-list--compact .dashboard-activity-detail {
display: none;
}
.dashboard-activity-list--compact .dashboard-activity-item {
padding-block: 0.3rem;
}
/* Widget picker modal body */
.widget-picker__list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.widget-picker__empty {
color: var(--color-text-muted);
font-style: italic;
padding: 1rem 0;
text-align: center;
}
.widget-picker__item {
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: var(--color-surface);
}
.widget-picker__btn {
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
column-gap: 0.75rem;
row-gap: 0.15rem;
width: 100%;
padding: 0.7rem 0.9rem;
background: transparent;
border: 0;
color: inherit;
cursor: pointer;
text-align: left;
}
.widget-picker__btn:hover:not([disabled]),
.widget-picker__btn:focus-visible:not([disabled]) {
background: var(--color-surface-muted, var(--color-surface));
}
.widget-picker__btn[disabled] {
cursor: default;
opacity: 0.7;
}
.widget-picker__title {
grid-column: 1;
font-weight: 600;
color: var(--color-text);
}
.widget-picker__desc {
grid-column: 1;
grid-row: 2;
font-size: 0.85rem;
color: var(--color-text-muted);
}
.widget-picker__pill {
grid-column: 2;
grid-row: 1 / span 2;
align-self: center;
padding: 0.15rem 0.55rem;
border-radius: 999px;
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.widget-picker__pill--active {
background: var(--status-green-bg, rgba(110, 220, 130, 0.18));
color: var(--status-green-fg, #166534);
}
.widget-picker__pill--hidden {
background: var(--status-amber-bg, rgba(255, 200, 110, 0.18));
color: var(--status-amber-fg, #92400e);
}
.widget-picker__pill--absent {
background: var(--color-surface-muted, rgba(0, 0, 0, 0.06));
color: var(--color-text-muted);
}
/* Autosave toast */
.dashboard-save-toast {
position: fixed;
right: 1.5rem;
bottom: calc(var(--bottom-nav-height, 1rem) + 1rem);
padding: 0.55rem 0.9rem;
border-radius: var(--radius);
background: var(--color-surface);
border: 1px solid var(--color-border);
color: var(--color-text);
font-size: 0.85rem;
box-shadow: var(--shadow);
opacity: 0;
transform: translateY(8px);
transition: opacity 0.18s ease, transform 0.18s ease;
pointer-events: none;
z-index: 60;
}
.dashboard-save-toast--show {
opacity: 1;
transform: translateY(0);
}
.dashboard-save-toast--err {
border-color: var(--status-red-fg, #b91c1c);
color: var(--status-red-fg, #b91c1c);
}
/* Mobile fallback (design §6.8) — single column already collapses via the
.dashboard-columns media query. The toggle becomes a wider tappable
button; drag handle is hidden in favor of the ↑/↓ buttons since
touch DnD is unreliable. The picker modal goes full-screen via the
modal primitive's existing @media (max-width: 32rem) rule. */
@media (max-width: 32rem) {
.dashboard-edit-toggle {
align-self: stretch;
width: 100%;
padding: 0.6rem 0.8rem;
}
body.dashboard-editing .dashboard-widget__handle {
display: none;
}
.dashboard-save-toast {
left: 1rem;
right: 1rem;
text-align: center;
}
}
/* ---------------------------------------------------------------------------
Slice C: pinned-projects + quick-actions widgets (t-paliad-219)
--------------------------------------------------------------------------- */
.dashboard-quick-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.dashboard-quick-btn {
flex: 1 1 auto;
text-align: center;
min-width: 8rem;
}
.dashboard-edit-promote {
font-size: 0.85rem;
padding: 0.4rem 0.8rem;
}
/* ---------------------------------------------------------------------------
Inline Agenda on the dashboard (t-paliad-162)
--------------------------------------------------------------------------- */
@@ -11547,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,
@@ -11584,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,
@@ -11636,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;
}
}
@@ -12635,6 +13353,48 @@ dialog.quick-add-sheet::backdrop {
font-style: italic;
}
/* lost: backend confirmed the turn is unrecoverable (t-paliad-235).
Different from error: the upstream had a chance to finish but the
conversation lookup didn't find a response — show the honest
"verloren" copy. */
.paliadin-bubble--lost {
color: var(--status-red-fg);
border-color: var(--status-red-border);
background: var(--status-red-bg);
opacity: 0.9;
}
/* Thinking indicator (t-paliad-235) — proof-of-life pulse + elapsed
counter while the upstream is alive but no content has streamed
yet. Lives as a sibling node inside the assistant bubble; removed
once the first chunk arrives. */
.paliadin-thinking {
display: inline-flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.5rem;
font-size: 0.8rem;
color: var(--color-text-muted);
font-family: monospace;
}
.paliadin-thinking-dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background: var(--color-bg-lime);
animation: paliadin-thinking-pulse 1.4s ease-in-out infinite;
}
.paliadin-thinking-elapsed {
font-variant-numeric: tabular-nums;
}
@keyframes paliadin-thinking-pulse {
0%, 100% { opacity: 0.4; transform: scale(0.9); }
50% { opacity: 1.0; transform: scale(1.1); }
}
.paliadin-bubble-role {
font-size: 0.75rem;
font-weight: 600;
@@ -14000,6 +14760,55 @@ dialog.quick-add-sheet::backdrop {
border: 1px solid var(--status-red-border, var(--color-border));
}
/* late-pending: stream dropped, recovery endpoint still polling. */
.paliadin-widget-bubble--late-pending {
opacity: 0.85;
}
/* late: response arrived after the stream closed. */
.paliadin-widget-bubble--late {
color: inherit;
background: var(--color-surface);
border: 1px solid var(--color-border);
}
.paliadin-widget-bubble-late-tag {
color: var(--color-text-muted);
font-style: italic;
margin-left: 0.25rem;
}
/* lost: backend confirmed the turn is unrecoverable (t-paliad-235). */
.paliadin-widget-bubble--lost {
background: var(--status-red-bg, var(--color-surface-2));
color: var(--status-red-fg, var(--color-text));
border: 1px solid var(--status-red-border, var(--color-border));
opacity: 0.9;
}
/* Thinking indicator inside widget bubbles (t-paliad-235). */
.paliadin-widget-thinking {
display: inline-flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.5rem;
font-size: 0.8rem;
color: var(--color-text-muted);
font-family: monospace;
}
.paliadin-widget-thinking-dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background: var(--color-bg-lime);
animation: paliadin-thinking-pulse 1.4s ease-in-out infinite;
}
.paliadin-widget-thinking-elapsed {
font-variant-numeric: tabular-nums;
}
.paliadin-widget-form {
display: flex;
align-items: flex-end;
@@ -15428,13 +16237,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

@@ -30,6 +30,78 @@ type Entry struct {
// Entries lists everything shipped so far, newest first. Append new rows
// at the top.
var Entries = []Entry{
{
Date: "2026-05-21",
Tag: TagFeature,
TitleDE: "Konfigurierbares Dashboard",
TitleEN: "Configurable dashboard",
BodyDE: "Das Dashboard lässt sich jetzt frei zusammenstellen: Widgets per Drag-and-drop verschieben, in der Größe ändern und einzeln konfigurieren. Der Katalog umfasst Fristen-Ampel, Termine, Agenda, Inbox-Übersicht, angepinnte Projekte und Schnellaktionen. Admins können eine kanzleiweite Standardanordnung festlegen, von der jeder Nutzer startet und sie nach Wunsch anpasst.",
BodyEN: "The dashboard can now be assembled freely: drag-and-drop widgets, resize them and configure each one individually. The catalog covers the deadline traffic-light, appointments, agenda, inbox summary, pinned projects and quick actions. Admins can set a firm-wide default layout that every user starts from and then tweaks to taste.",
},
{
Date: "2026-05-20",
Tag: TagFeature,
TitleDE: "Eigene Einreichungs-Checklisten",
TitleEN: "User-authored checklists",
BodyDE: "Eigene Checklisten lassen sich per Wizard anlegen und gezielt mit einzelnen Kolleg:innen, einem Büro, einer Partnereinheit oder einem Projekt teilen. Admins können besonders gute Vorlagen kanzleiweit unter „Geteilte Vorlagen\" freigeben. Wird eine Vorlage später überarbeitet, erscheint an laufenden Instanzen ein Hinweis-Badge auf die neuere Version.",
BodyEN: "Build your own filing checklists through a wizard and share them explicitly with individual colleagues, an office, a partner unit or a project. Admins can promote the best templates firm-wide under „Shared templates\". When a template is later revised, running instances surface a notice badge pointing at the newer version.",
},
{
Date: "2026-05-20",
Tag: TagFeature,
TitleDE: "Genehmigungen: Änderungen vorschlagen",
TitleEN: "Approvals: suggest changes",
BodyDE: "Im Inbox gibt es eine dritte Aktion neben „Genehmigen\" und „Ablehnen\": „Änderungen vorschlagen\". Ein Modal zeigt den ursprünglichen Wert, der Gegenvorschlag wandert mit einem Kommentar zurück an die Antragsteller:in. Der gesamte Austausch erscheint im Verlauf des Eintrags.",
BodyEN: "Inbox now offers a third action alongside „Approve\" and „Reject\": „Suggest changes\". A modal shows the original value, the counter-proposal travels back to the requester together with a comment. The full exchange shows up in the entry's Verlauf.",
},
{
Date: "2026-05-20",
Tag: TagFeature,
TitleDE: "Mandant:innen-Rolle und automatische Projekt-Codes",
TitleEN: "Client role and auto-derived project codes",
BodyDE: "Mandant:innen lassen sich jetzt als eigene Rolle in das Team eines Projekts aufnehmen — separat von HLC-Mitgliedern und mit eigenem Sichtbarkeitsumfang. Außerdem leitet Paliad pro Projekt einen kompakten Code aus dem Baum ab (etwa /9999-1-EP123-CFI) und zeigt ihn als zweites Badge im Header und in jedem Projekt-Picker.",
BodyEN: "Clients can now be added to a project's team as their own role — separate from HLC members and with their own visibility scope. In addition, Paliad derives a compact code per project from the ancestor tree (e.g. /9999-1-EP123-CFI) and shows it as a second badge in the header and in every project picker.",
},
{
Date: "2026-05-19",
Tag: TagFeature,
TitleDE: "Datenexport — Excel, CSV, JSON",
TitleEN: "Data export — Excel, CSV, JSON",
BodyDE: "Unter Einstellungen → Datenexport lassen sich alle sichtbaren Projekte, Fristen, Termine, Notizen und Checklisten als Excel-, CSV- oder JSON-Datei herunterladen. Auf jeder Projekt-Seite gibt es zusätzlich einen „Daten exportieren\"-Button, der nur den jeweiligen Teilbaum mitnimmt.",
BodyEN: "Settings → Data export lets you download every project, deadline, appointment, note and checklist you can see as an Excel, CSV or JSON file. Each project page additionally offers a „Daten exportieren\" button that exports just that subtree.",
},
{
Date: "2026-05-15",
Tag: TagFeature,
TitleDE: "Eigene Sichten — Liste, Karten, Kalender, Timeline",
TitleEN: "Custom views — list, cards, calendar, timeline",
BodyDE: "Eigene Filter über Fristen, Termine und Projekte lassen sich speichern und als Liste, Karten, Kalender oder Timeline rendern. Jede Sicht erhält einen permanenten Link, lässt sich als SVG, PNG, CSV, JSON oder iCal exportieren und erscheint in der Seitenleiste unter „Meine Sichten\".",
BodyEN: "Custom filters over deadlines, appointments and projects can be saved and rendered as list, cards, calendar or timeline. Each view gets a permalink, can be exported as SVG, PNG, CSV, JSON or iCal and shows up in the sidebar under „Meine Sichten\".",
},
{
Date: "2026-05-07",
Tag: TagFeature,
TitleDE: "Projekte-Seite mit Baum, Pinnungen und Karten-Ansicht",
TitleEN: "Projects page with tree, pins and cards view",
BodyDE: "Die Projekte-Seite öffnet jetzt mit einem zusammenklappbaren Baum, Volltextsuche und Chips für Mandant, Ort und Status. Häufig genutzte Projekte lassen sich oben anpinnen; die alternative Karten-Ansicht erlaubt frei per Drag-and-drop sortierbare Layouts pro Nutzer.",
BodyEN: "The Projects page now opens with a collapsible tree, full-text search and chips for client, location and status. Frequently used projects can be pinned to the top; the alternative cards view supports per-user drag-and-drop layouts.",
},
{
Date: "2026-05-06",
Tag: TagFeature,
TitleDE: "Vier-Augen-Prinzip für Fristen und Termine",
TitleEN: "Four-eyes principle for deadlines and appointments",
BodyDE: "Pro Projekt lässt sich festlegen, dass Anlegen, Ändern, Abhaken und Löschen von Fristen oder Terminen durch eine zweite Person freigegeben werden müssen. Anfragen erscheinen im Inbox, am Eintrag selbst und mit „PENDING\"-Vermerk im CalDAV-Kalender. Admins pflegen die Regeln zentral unter /admin/approval-policies.",
BodyEN: "Per project you can require that creating, editing, completing or deleting a deadline or appointment must be cleared by a second person. Requests show up in the inbox, on the entry itself and as a „PENDING\" marker in the CalDAV calendar. Admins maintain the rules centrally under /admin/approval-policies.",
},
{
Date: "2026-05-05",
Tag: TagFeature,
TitleDE: "Fristenrechner v3 — Entscheidungsbaum, Begriffe, DE/EPA/DPMA",
TitleEN: "Deadline calculator v3 — decision tree, concepts, DE/EPA/DPMA",
BodyDE: "Der Fristenrechner wurde grundlegend überarbeitet: ein Entscheidungsbaum führt durch Verfahren und Fristart, eine neue Begriffsebene fasst Wiedereinsetzung, Säumnis, Schriftsatznachreichung und Weiterbehandlung als wiederverwendbare Konzepte zusammen. Der Regelbestand wurde um deutsche Verfahren (PatG, BPatG, BGH), EPA- und DPMA-Strecken erweitert, mit aktuellen Werten und Querverweisen.",
BodyEN: "The deadline calculator has been overhauled from the ground up: a decision tree walks you through proceeding and deadline type, and a new concept layer treats Wiedereinsetzung, default, post-filing and further processing as reusable cross-cutting building blocks. The rule corpus has been extended with German proceedings (PatG, BPatG, BGH), EPO and DPMA tracks, with current values and cross-references.",
},
{
Date: "2026-04-30",
Tag: TagFeature,

View File

@@ -0,0 +1 @@
DROP TABLE IF EXISTS paliad.firm_dashboard_default;

View File

@@ -0,0 +1,33 @@
-- t-paliad-219 Slice C: firm-wide dashboard default layout.
--
-- Design: docs/design-dashboard-configurable-2026-05-20.md §8.2 (firm-wide
-- admin default, deferred to v1.1 — now activated by m's Slice C brief).
--
-- A single optional row that holds the firm's preferred dashboard layout.
-- DashboardLayoutService.GetOrSeed reads this on first call for a new user
-- (falling back to the code-resident FactoryDefaultLayout when null);
-- ResetToDefault similarly prefers the firm default. Admins promote their
-- own current layout into this row via POST /api/me/dashboard-layout/promote.
--
-- Single-row design via CHECK (id = 1) so there's no ambiguity about which
-- row is "the default". RLS lets any authenticated user SELECT (so the
-- service can read it during seed); only the application (service-role
-- connection) writes — the admin gate sits on the HTTP handler.
CREATE TABLE paliad.firm_dashboard_default (
id smallint PRIMARY KEY DEFAULT 1 CHECK (id = 1),
layout_json jsonb NOT NULL,
updated_by uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
updated_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE paliad.firm_dashboard_default ENABLE ROW LEVEL SECURITY;
-- All authenticated users can SELECT — the dashboard seed path needs to
-- read it for every new user. The HTTP handler enforces admin-only on the
-- PUT/DELETE paths; the service runs under service-role so writes bypass
-- RLS anyway. No INSERT/UPDATE policy means no Supabase-JWT-authenticated
-- client can write, which is the desired posture.
CREATE POLICY firm_dashboard_default_read
ON paliad.firm_dashboard_default FOR SELECT
USING (true);

View File

@@ -0,0 +1,2 @@
ALTER TABLE paliad.paliadin_turns
DROP COLUMN IF EXISTS aichat_conversation_id;

View File

@@ -0,0 +1,14 @@
-- t-paliad-235: track aichat conversation id on each paliadin turn so the
-- recovery endpoint can ask aichat for the late-arriving response when
-- paliad's stream connection drops mid-turn.
--
-- The PALIADIN_BACKEND=aichat path persists this from the upstream
-- /chat/turn/stream `done` frame's conversation_id. PALIADIN_BACKEND=legacy
-- turns leave it NULL — the filesystem janitor is still the recovery path
-- there.
ALTER TABLE paliad.paliadin_turns
ADD COLUMN IF NOT EXISTS aichat_conversation_id uuid;
COMMENT ON COLUMN paliad.paliadin_turns.aichat_conversation_id IS
'Aichat backend conversation id (t-paliad-235). Set when the streaming /chat/turn/stream done frame arrives, or when the recovery endpoint asks aichat to disambiguate which conversation this turn lives in. NULL for legacy backend turns and for aichat turns that errored before the conversation id was resolved.';

View File

@@ -11,8 +11,15 @@ import "net/http"
// to the canonical /events?type=deadline (t-paliad-115). Detail page
// /deadlines/{id} stays type-specific. Drop this redirect once we're
// confident no caches / bookmarks / external links still hit the old URL.
//
// Preserves the incoming query string so filter params (e.g. status=this_week
// from the dashboard summary cards) survive the redirect.
func handleDeadlinesListRedirect(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/events?type=deadline", http.StatusMovedPermanently)
target := "/events?type=deadline"
if r.URL.RawQuery != "" {
target += "&" + r.URL.RawQuery
}
http.Redirect(w, r, target, http.StatusMovedPermanently)
}
func handleDeadlinesNewPage(w http.ResponseWriter, r *http.Request) {

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

@@ -0,0 +1,139 @@
package handlers
// HTTP handlers for the firm-wide dashboard default layout (t-paliad-219
// Slice C). All four endpoints sit behind the adminGate so only
// global_admin can read or mutate. The per-user GetOrSeed/ResetToDefault
// path consumes the firm default via DashboardLayoutService — the read
// surface here is just the admin's read-back of the current row.
//
// GET /api/admin/firm-dashboard-default — current row, or 204
// PUT /api/admin/firm-dashboard-default — replace
// DELETE /api/admin/firm-dashboard-default — clear (revert to factory)
// POST /api/me/dashboard-layout/promote — promote caller's own
// current layout to firm
// default. Admin convenience.
import (
"encoding/json"
"errors"
"net/http"
"mgit.msbls.de/m/paliad/internal/services"
)
// GET /api/admin/firm-dashboard-default — returns the current firm-wide
// default layout, or 204 when none is set. Admins read this on the
// firm-default admin surface to verify the active layout.
func handleGetFirmDashboardDefault(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
if dbSvc.firmDashboardDefault == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "firm-dashboard-default service not configured"})
return
}
spec, ok, err := dbSvc.firmDashboardDefault.Get(r.Context())
if err != nil {
writeServiceError(w, err)
return
}
if !ok {
// Empty firm default — the caller can fall back to the factory
// shape via GET /api/dashboard-widget-catalog + FactoryDefault-
// Layout logic mirrored client-side. 204 is cheaper than
// shipping an "is_set: false" wrapper.
w.WriteHeader(http.StatusNoContent)
return
}
writeJSON(w, http.StatusOK, spec)
}
// PUT /api/admin/firm-dashboard-default — replace the firm-wide default.
// Body must be a complete DashboardLayoutSpec. The admin is recorded as
// updated_by for audit.
func handlePutFirmDashboardDefault(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
if dbSvc.firmDashboardDefault == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "firm-dashboard-default service not configured"})
return
}
var spec services.DashboardLayoutSpec
if err := json.NewDecoder(r.Body).Decode(&spec); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON body"})
return
}
out, err := dbSvc.firmDashboardDefault.Set(r.Context(), spec, uid)
if err != nil {
if errors.Is(err, services.ErrInvalidInput) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, out)
}
// DELETE /api/admin/firm-dashboard-default — clear the firm default so
// future seeds/resets revert to the code-resident FactoryDefaultLayout.
// Idempotent.
func handleDeleteFirmDashboardDefault(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
if dbSvc.firmDashboardDefault == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "firm-dashboard-default service not configured"})
return
}
if err := dbSvc.firmDashboardDefault.Clear(r.Context()); err != nil {
writeServiceError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// POST /api/me/dashboard-layout/promote — admin convenience. Takes the
// caller's current layout (whatever's in user_dashboard_layouts for
// them) and promotes it to the firm-wide default. Saves an admin the
// step of crafting a layout in a JSON editor — they edit their own
// dashboard via the standard /dashboard editor, then promote one click.
//
// Admin-only at the route level (handlers.go wires this under adminGate).
// The handler itself does not re-check admin — that's the gate's job.
func handlePromoteDashboardLayoutToFirmDefault(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
if dbSvc.dashboardLayout == nil || dbSvc.firmDashboardDefault == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "dashboard-layout service not configured"})
return
}
// Read the admin's own current layout (seeding the factory if they
// somehow lack a row — vanishingly unlikely for an admin who's
// logging in to promote, but the safety belt costs nothing).
spec, err := dbSvc.dashboardLayout.GetOrSeed(r.Context(), uid)
if err != nil {
writeServiceError(w, err)
return
}
out, err := dbSvc.firmDashboardDefault.Set(r.Context(), spec, uid)
if err != nil {
if errors.Is(err, services.ErrInvalidInput) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, out)
}

View File

@@ -90,18 +90,14 @@ type Services struct {
Pin *services.PinService
CardLayout *services.CardLayoutService
DashboardLayout *services.DashboardLayoutService
// FirmDashboardDefault is the firm-wide /dashboard default layout
// (Slice C). Admin-only writes; per-user seed/reset reads it via
// DashboardLayoutService.defaultLayout(). Nil-safe — falls back to
// the code-resident FactoryDefaultLayout.
FirmDashboardDefault *services.FirmDashboardDefaultService
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
@@ -118,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,
@@ -168,6 +156,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
pin: svc.Pin,
cardLayout: svc.CardLayout,
dashboardLayout: svc.DashboardLayout,
firmDashboardDefault: svc.FirmDashboardDefault,
projection: svc.Projection,
export: svc.Export,
}
@@ -317,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)
@@ -536,6 +527,12 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
protected.HandleFunc("PATCH /api/admin/users/{id}", adminGate(users, handleAdminUpdateUser))
protected.HandleFunc("DELETE /api/admin/users/{id}", adminGate(users, handleAdminDeleteUser))
protected.HandleFunc("GET /api/audit-log", adminGate(users, handleListAuditLog))
// t-paliad-219 Slice C — firm-wide dashboard default + admin promote.
protected.HandleFunc("GET /api/admin/firm-dashboard-default", adminGate(users, handleGetFirmDashboardDefault))
protected.HandleFunc("PUT /api/admin/firm-dashboard-default", adminGate(users, handlePutFirmDashboardDefault))
protected.HandleFunc("DELETE /api/admin/firm-dashboard-default", adminGate(users, handleDeleteFirmDashboardDefault))
protected.HandleFunc("POST /api/me/dashboard-layout/promote", adminGate(users, handlePromoteDashboardLayoutToFirmDefault))
protected.HandleFunc("GET /api/admin/email-templates", adminGate(users, handleAdminListEmailTemplates))
protected.HandleFunc("GET /api/admin/email-templates/{key}/variables", adminGate(users, handleAdminEmailTemplateVariables))
protected.HandleFunc("GET /api/admin/email-templates/{key}/{lang}", adminGate(users, handleAdminGetEmailTemplate))
@@ -652,6 +649,11 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
protected.HandleFunc("POST /api/paliadin/turn", handlePaliadinTurn)
protected.HandleFunc("GET /api/paliadin/stream/{id}", handlePaliadinStream)
protected.HandleFunc("GET /api/paliadin/turns/{id}", handlePaliadinTurnGet)
// Recovery endpoint (t-paliad-235): when the SSE stream drops mid-turn,
// the frontend hits this to ask whether aichat actually finished the
// turn upstream. Dispatches per backend — aichat hits the conversation
// API; legacy backends fall through to the local row read + janitor.
protected.HandleFunc("GET /api/paliadin/turns/{id}/recover", handlePaliadinTurnRecover)
// Crash-resistant history hydrate (t-paliad-161 follow-up): both
// Paliadin surfaces use this to seed their UI from the DB before
// consulting localStorage.

View File

@@ -185,16 +185,21 @@ func handlePaliadinTurn(w http.ResponseWriter, r *http.Request) {
}
// runPaliadinTurnAsync executes the turn and writes events into ch.
// Uses a 150 s hard timeout independently of the originating request,
// which leaves headroom over the shim's 120 s run-turn cap + SSH
// overhead (t-paliad-155: cold-start safety for skill + MCP discovery).
//
// Backend dispatch:
// - StreamingPaliadin (aichat) → drives runStreamingTurn which relays
// incremental chunks + upstream heartbeats. No hard ceiling on
// stream duration; falls back to silence_timeout (silenceTimeout)
// if the upstream goes dark.
// - Plain Paliadin (legacy local/remote) → one-shot RunTurn with the
// original 150 s ceiling (matches the shim's 120 s run-turn cap +
// SSH overhead per t-paliad-155).
func runPaliadinTurnAsync(turnID uuid.UUID, req services.TurnRequest, ch chan<- turnEvent) {
defer func() {
// Drain + close. The SSE handler reads until the channel closes.
close(ch)
}()
// Send a meta event so the client can show "Paliadin denkt nach …"
send(ch, turnEvent{
Kind: "meta",
Data: map[string]any{
@@ -203,6 +208,16 @@ func runPaliadinTurnAsync(turnID uuid.UUID, req services.TurnRequest, ch chan<-
},
})
if streamer, ok := paliadinSvc.(services.StreamingPaliadin); ok {
runStreamingTurn(turnID, req, ch, streamer)
return
}
runOneShotTurn(turnID, req, ch)
}
// runOneShotTurn drives the legacy synchronous backends (local-tmux PoC,
// remote ssh+paliadin-shim). Preserves the original 150 s ceiling.
func runOneShotTurn(turnID uuid.UUID, req services.TurnRequest, ch chan<- turnEvent) {
ctx, cancel := newDetachedContext(150 * time.Second)
defer cancel()
@@ -220,9 +235,7 @@ func runPaliadinTurnAsync(turnID uuid.UUID, req services.TurnRequest, ch chan<-
}
// One-shot content event with the full body. The frontend simulates
// streaming with a typewriter effect (cf. design §0.5.5: real
// chunked streaming would require Claude to write the response file
// progressively — out of PoC scope).
// streaming with a typewriter effect.
send(ch, turnEvent{
Kind: "content",
Data: map[string]any{"text": result.Response},
@@ -241,6 +254,224 @@ func runPaliadinTurnAsync(turnID uuid.UUID, req services.TurnRequest, ch chan<-
})
}
// silenceTimeout is the longest the aichat upstream may stay silent
// (no chunk, no heartbeat) before runStreamingTurn gives up and fires
// an error frame. 90 s comfortably exceeds aichat's 5 s heartbeat
// cadence so a transient stall (model wedge, GC pause) doesn't kill
// the turn, while still catching a hard upstream drop.
const silenceTimeout = 90 * time.Second
// streamingThinkingInterval is the cadence at which we emit a synthetic
// `thinking` event when the upstream has gone quiet but the connection
// is still alive. 5 s matches aichat's own heartbeat tick so the UI
// pulse never falls more than 5 s out of date.
const streamingThinkingInterval = 5 * time.Second
// streamingTurnDeadline is the upper bound for a single streaming turn.
// Far above any realistic Claude turn but finite so a runaway upstream
// (or a paliad bug that never closes the channel) can't leak forever.
const streamingTurnDeadline = 30 * time.Minute
// runStreamingTurn drives an incremental turn against the StreamingPaliadin
// backend. Relays chunks → content events, upstream heartbeats →
// thinking events, errors → error events. Adds its own silence-watch:
// if the upstream emits no event for silenceTimeout, fire an error
// frame so the client doesn't sit on a dead stream forever.
func runStreamingTurn(turnID uuid.UUID, req services.TurnRequest, ch chan<- turnEvent, streamer services.StreamingPaliadin) {
ctx, cancel := context.WithTimeout(context.Background(), streamingTurnDeadline)
defer cancel()
events := make(chan services.StreamEvent, 32)
startedAt := time.Now()
// streamerDone closes when the backend's RunTurnStream returns. We
// race the silence watcher and the event pump against it so the
// goroutine exit is clean either way.
type runResult struct {
result *services.TurnResult
err error
}
runCh := make(chan runResult, 1)
go func() {
res, err := streamer.RunTurnStream(ctx, req, events)
runCh <- runResult{res, err}
}()
var (
lastEventAt = time.Now()
usedTools []string
rowsSeen []int
classifierTag string
convID string
gotChunk bool
errorEmitted bool
)
silenceTicker := time.NewTicker(streamingThinkingInterval)
defer silenceTicker.Stop()
emitThinking := func(elapsedSeconds int) {
// Don't emit `thinking` after the first real chunk arrives —
// the frontend hides the pulse once content starts flowing
// anyway, but we save bandwidth by stopping emission.
send(ch, turnEvent{
Kind: "thinking",
Data: map[string]any{
"elapsed_seconds": elapsedSeconds,
"since_first": gotChunk,
},
})
}
for {
select {
case ev, more := <-events:
if !more {
events = nil // disable case
continue
}
lastEventAt = time.Now()
switch ev.Kind {
case services.StreamChunk:
gotChunk = true
send(ch, turnEvent{
Kind: "content",
Data: map[string]any{
"delta": ev.Content,
"streamed": true,
},
})
case services.StreamHeartbeat:
// Upstream is alive but no chunks yet (or a mid-stream
// stall). Pass through with our own thinking shape.
send(ch, turnEvent{
Kind: "thinking",
Data: map[string]any{
"elapsed_seconds": ev.ElapsedSeconds,
"since_first": gotChunk,
"upstream": true,
},
})
case services.StreamMeta:
usedTools = ev.UsedTools
rowsSeen = ev.RowsSeen
classifierTag = ev.ClassifierTag
case services.StreamConversation:
convID = ev.ConversationID
case services.StreamError:
errorEmitted = true
send(ch, turnEvent{
Kind: "error",
Data: map[string]any{
"code": ev.Code,
"message": ev.Message,
"retryable": ev.Retryable,
},
})
}
case <-silenceTicker.C:
elapsed := time.Since(lastEventAt)
if elapsed >= silenceTimeout {
send(ch, turnEvent{
Kind: "error",
Data: map[string]any{
"code": "upstream_silence",
"message": "aichat upstream went silent for over " + silenceTimeout.String(),
},
})
// Cancel the backend so it doesn't keep running.
cancel()
continue
}
emitThinking(int(time.Since(startedAt).Seconds()))
case res := <-runCh:
// Drain any remaining events the backend pushed before
// closing the channel.
if events != nil {
for ev := range events {
switch ev.Kind {
case services.StreamChunk:
gotChunk = true
send(ch, turnEvent{
Kind: "content",
Data: map[string]any{
"delta": ev.Content,
"streamed": true,
},
})
case services.StreamMeta:
usedTools = ev.UsedTools
rowsSeen = ev.RowsSeen
classifierTag = ev.ClassifierTag
case services.StreamConversation:
convID = ev.ConversationID
case services.StreamError:
errorEmitted = true
send(ch, turnEvent{
Kind: "error",
Data: map[string]any{
"code": ev.Code,
"message": ev.Message,
"retryable": ev.Retryable,
},
})
}
}
}
if res.err != nil {
if !errorEmitted {
send(ch, turnEvent{
Kind: "error",
Data: map[string]any{
"code": "upstream_error",
"message": res.err.Error(),
},
})
}
return
}
result := res.result
if result == nil {
// Shouldn't happen — backend contract returns either err
// or a result. Defensive bail.
if !errorEmitted {
send(ch, turnEvent{
Kind: "error",
Data: map[string]any{
"code": "upstream_error",
"message": "stream closed without result",
},
})
}
return
}
if result.UsedTools != nil {
usedTools = result.UsedTools
}
if result.RowsSeen != nil {
rowsSeen = result.RowsSeen
}
if classifierTag == "" && result.ClassifierTag != "" {
classifierTag = result.ClassifierTag
}
endData := map[string]any{
"turn_id": turnID.String(),
"used_tools": usedTools,
"rows_seen": rowsSeen,
"chip_count": result.ChipCount,
"classifier_tag": classifierTag,
"duration_ms": result.DurationMS,
"streamed": true,
}
if convID != "" {
endData["aichat_conversation_id"] = convID
}
send(ch, turnEvent{Kind: "end", Data: endData})
return
}
}
}
// handlePaliadinStream is the SSE endpoint the EventSource subscribes
// to. Reads from the per-turn channel + writes SSE-framed events.
func handlePaliadinStream(w http.ResponseWriter, r *http.Request) {
@@ -354,6 +585,114 @@ func handlePaliadinTurnGet(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, resp)
}
// handlePaliadinTurnRecover is the dispatching late-recovery endpoint
// (t-paliad-235). Replaces the legacy direct-row-read for the aichat
// backend. When the backend implements services.AichatRecoverer (the
// PALIADIN_BACKEND=aichat path), we ask aichat directly via its
// conversation API whether the turn actually completed upstream after
// our stream connection dropped. When it doesn't implement it (legacy
// local/remote backends), we fall back to reading the local row —
// services.LocalPaliadinService.runJanitor is still the recovery path
// there.
//
// Response shape mirrors handlePaliadinTurnGet so the frontend
// late-poll module doesn't need a backend-specific code path.
// Additional field `recovery_state` distinguishes:
//
// "recovered" — the response is in the row (already there, or freshly
// written from the upstream check)
// "pending" — still no response; caller should keep polling
// "lost" — backend confirms the turn is gone (aichat doesn't
// have it either). UI should degrade to "verloren".
func handlePaliadinTurnRecover(w http.ResponseWriter, r *http.Request) {
if !requirePaliadinOwner(w, r) {
return
}
uid, _ := requireUser(w, r)
turnID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
http.Error(w, "invalid turn_id", http.StatusBadRequest)
return
}
// Quick read first — gives us the row regardless of backend.
row, err := paliadinSvc.GetTurn(r.Context(), uid, turnID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
http.Error(w, "not found", http.StatusNotFound)
return
}
http.Error(w, "lookup failed", http.StatusInternalServerError)
return
}
state := recoveryStateFor(row)
// Aichat backend: when the row still has no response, ask aichat
// whether the turn actually finished upstream.
if state == "pending" {
if rec, ok := paliadinSvc.(services.AichatRecoverer); ok {
ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second)
defer cancel()
recovered, recErr := rec.RecoverTurn(ctx, uid, turnID)
if recErr != nil {
// Log + fall through to a plain pending response — a
// transient aichat hiccup shouldn't flip the UI to
// "lost".
_ = recErr
} else if recovered != nil {
row = recovered
state = recoveryStateFor(row)
} else {
// Aichat returned a clean "no, I don't have it either".
// Only mark as lost when the turn is older than the
// upstream's plausible turn budget — otherwise the
// recovery just hit the window between paliad's stream
// dropping and aichat finishing the run.
if recoveryShouldGiveUp(row) {
state = "lost"
}
}
} else if recoveryShouldGiveUp(row) {
// Legacy backends: rely on the janitor. If we're past the
// give-up threshold and still no response, surface "lost".
state = "lost"
}
}
resp := map[string]any{
"turn_id": row.TurnID.String(),
"started_at": row.StartedAt.Format(time.RFC3339),
"response": row.Response,
"error_code": row.ErrorCode,
"finished_at": row.FinishedAt,
"duration_ms": row.DurationMS,
"used_tools": []string(row.UsedTools),
"rows_seen": []int64(row.RowsSeen),
"chip_count": row.ChipCount,
"classifier_tag": row.ClassifierTag,
"recovery_state": state,
}
writeJSON(w, http.StatusOK, resp)
}
// recoveryStateFor returns the lifecycle state of a paliadin turn from
// the recovery endpoint's perspective.
func recoveryStateFor(row *services.PaliadinTurn) string {
if row.Response != nil && *row.Response != "" {
return "recovered"
}
return "pending"
}
// recoveryShouldGiveUp returns true when a turn has been pending long
// enough that we should surface "lost" rather than asking the user to
// keep waiting. 12 minutes is comfortably beyond the longest realistic
// Claude turn (cold-start + reasoning + tool calls all bundled).
func recoveryShouldGiveUp(row *services.PaliadinTurn) bool {
return time.Since(row.StartedAt) > 12*time.Minute
}
// handlePaliadinHistory returns the caller's prior turns for a given
// browser session id, oldest → newest. Both Paliadin surfaces (the
// inline drawer and the standalone /paliadin page) hit this on mount

View File

@@ -58,6 +58,7 @@ type dbServices struct {
pin *services.PinService
cardLayout *services.CardLayoutService
dashboardLayout *services.DashboardLayoutService
firmDashboardDefault *services.FirmDashboardDefaultService
projection *services.ProjectionService
export *services.ExportService
}

View File

@@ -57,3 +57,29 @@ func TestStandaloneCalendarHandlers_RedirectToEventsKalender(t *testing.T) {
}
}
}
// /deadlines list redirect must forward the incoming query string so legacy
// dashboard cards and external bookmarks like /deadlines?status=this_week
// land at /events?type=deadline&status=this_week instead of losing the
// filter. Regression for m's 2026-05-21 14:20 report.
func TestDeadlinesListRedirect_PreservesQueryString(t *testing.T) {
cases := []struct {
path string
want string
}{
{"/deadlines", "/events?type=deadline"},
{"/deadlines?status=this_week", "/events?type=deadline&status=this_week"},
{"/deadlines?status=overdue&project_id=abc", "/events?type=deadline&status=overdue&project_id=abc"},
}
for _, tc := range cases {
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
w := httptest.NewRecorder()
handleDeadlinesListRedirect(w, req)
if w.Code != http.StatusMovedPermanently {
t.Fatalf("%s: status = %d, want 301", tc.path, w.Code)
}
if got := w.Header().Get("Location"); got != tc.want {
t.Fatalf("%s: Location = %q, want %q", tc.path, got, tc.want)
}
}
}

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

@@ -112,6 +112,11 @@ type AichatPaliadinService struct {
// Hook for tests — when non-nil, callHTTP delegates here instead
// of hitting the wire. Production code never sets this.
httpHook func(ctx context.Context, method, path string, body any, out any) error
// Hook for tests — when non-nil, callStreamingHTTP delegates here
// instead of opening a real SSE connection. Production code never
// sets this.
streamHook func(ctx context.Context, path string, body any, emit func(streamFrame)) error
}
// ErrAichatAuthFailed signals the aichat service rejected the bearer
@@ -217,6 +222,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 +617,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

@@ -0,0 +1,654 @@
package services
// Streaming + recovery support for AichatPaliadinService (t-paliad-235).
//
// =============================================================================
// Upstream contract — /chat/turn/stream
// =============================================================================
//
// Source of truth: m/mAi internal/aichat/api/stream.go. Captured here as
// inline doc so future debugging doesn't require chasing across repos:
//
// Request body: same shape as POST /chat/turn (TurnRequest mirror in
// aichat_paliadin.go). Persona must support streaming; paliad's
// "paliadin" persona does.
//
// Response: text/event-stream. Two SSE event flavours:
//
// 1. The default unnamed `data:` event carries a discriminated-union
// JSON object keyed by `"type"`:
//
// {"type":"chunk","content":"…"}
// {"type":"meta","used_tools":[…],"rows_seen":[…],"classifier_tag":"…"}
// {"type":"done","turn_id":"…","conversation_id":"…",
// "duration_ms":1234,"pane_spawned":false,"resumed":false}
// {"type":"error","code":"…","message":"…","retryable":true}
//
// 2. The named `event: heartbeat` event carries:
//
// {"elapsed_seconds": N}
//
// Emitted every 5 s by the upstream while the runner has been
// silent (no content). aichat keeps emitting these for the lifetime
// of the runner so the client can render "Paliadin denkt nach
// (N s)" without conflating with actual content.
//
// Errors before the stream starts (auth failure, persona unknown,
// validation) come back as a normal JSON envelope with the appropriate
// HTTP status — not SSE. Those land in callHTTP via decodeAichatError.
//
// =============================================================================
// Conversation-based late recovery
// =============================================================================
//
// Aichat exposes:
//
// GET /chat/conversations?persona=…&username=…&user_id=…
// → list of ConversationSummary, ordered last_turn_at DESC
// GET /chat/conversations/{id}/turns
// → list of TurnRow (role=user|assistant, body, created_at)
//
// When paliad's stream drops mid-turn we:
// 1. Look up paliad.paliadin_turns.aichat_conversation_id for the row.
// 2. If unset (stream dropped before the `done` frame): list the user's
// conversations and take the most recent one for the persona —
// that's the pane our turn ran against (aichat owns one active
// conversation per persona+user, see m/mAi#243).
// 3. GET that conversation's turns. Find the latest assistant turn
// whose preceding user-role turn body matches our user_message.
// 4. Persist the response (completeTurnLate) and return it.
//
// If aichat returns no matching assistant turn → the turn is truly lost
// (transport drop + upstream crash). Recovery returns (nil, nil) and
// the handler degrades the UI to "verloren".
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"net/url"
"strings"
"time"
"github.com/google/uuid"
)
// =============================================================================
// Streaming RunTurnStream
// =============================================================================
// RunTurnStream drives one /chat/turn/stream turn against aichat and
// relays incremental events onto `events`. Closes `events` before
// returning. Implements StreamingPaliadin.
func (s *AichatPaliadinService) RunTurnStream(ctx context.Context, req TurnRequest, events chan<- StreamEvent) (*TurnResult, error) {
defer close(events)
s.turnMu.Lock()
defer s.turnMu.Unlock()
turnID := uuid.New()
startedAt := time.Now().UTC()
if err := s.insertTurnRow(ctx, &PaliadinTurn{
TurnID: turnID,
UserID: req.UserID,
SessionID: req.SessionID,
StartedAt: startedAt,
UserMessage: req.UserMessage,
PageOrigin: optionalString(req.PageOrigin),
}, req.Context); err != nil {
return nil, fmt.Errorf("paliadin: insert turn row: %w", err)
}
if err := s.healthGate(ctx); err != nil {
_ = s.markTurnError(ctx, turnID, "mriver_unreachable")
safeSendStream(ctx, events, StreamEvent{
Kind: StreamError,
Code: "mriver_unreachable",
Message: err.Error(),
})
return nil, err
}
username := s.usernameFor(ctx, req.UserID)
session := s.cfg.Persona + ":" + username
primer := s.buildPrimerExchanges(ctx, session, req)
jwt, err := s.mintJWTIfConfigured(req.UserID)
if err != nil {
_ = s.markTurnError(ctx, turnID, "jwt_mint_failed")
safeSendStream(ctx, events, StreamEvent{
Kind: StreamError,
Code: "shim_error",
Message: fmt.Sprintf("mint turn jwt: %v", err),
})
return nil, fmt.Errorf("paliadin: mint turn jwt: %w", err)
}
body := aichatTurnRequest{
Persona: s.cfg.Persona,
Username: username,
UserID: req.UserID.String(),
SessionID: req.SessionID,
Message: sanitiseForTmux(req.UserMessage),
JWT: jwt,
Primer: primer,
Meta: buildAichatMeta(req),
}
// Stream the upstream call. acc accumulates the full text so we can
// persist the row + return a TurnResult on success.
var (
acc strings.Builder
streamMeta trailerMeta
convID string
paneSpawned bool
upstreamDoneMs int64
)
streamErr := s.callStreamingHTTP(ctx, "/chat/turn/stream", body, func(frame streamFrame) {
switch {
case frame.event == "heartbeat":
safeSendStream(ctx, events, StreamEvent{
Kind: StreamHeartbeat,
ElapsedSeconds: frame.heartbeat.ElapsedSeconds,
})
case frame.data.Type == "chunk":
if frame.data.Content == "" {
return
}
acc.WriteString(frame.data.Content)
safeSendStream(ctx, events, StreamEvent{
Kind: StreamChunk,
Content: frame.data.Content,
})
case frame.data.Type == "meta":
streamMeta = trailerMeta{
UsedTools: append([]string(nil), frame.data.UsedTools...),
RowsSeen: coerceAichatRowsSeen(frame.data.RowsSeen),
ClassifierTag: frame.data.ClassifierTag,
}
safeSendStream(ctx, events, StreamEvent{
Kind: StreamMeta,
UsedTools: streamMeta.UsedTools,
RowsSeen: streamMeta.RowsSeen,
ClassifierTag: streamMeta.ClassifierTag,
})
case frame.data.Type == "done":
if frame.data.ConversationID != "" {
convID = frame.data.ConversationID
safeSendStream(ctx, events, StreamEvent{
Kind: StreamConversation,
ConversationID: convID,
})
}
paneSpawned = frame.data.PaneSpawned
upstreamDoneMs = frame.data.DurationMs
case frame.data.Type == "error":
// Forward as a stream error AND mark for non-nil err
// propagation via the streamErr captured below.
safeSendStream(ctx, events, StreamEvent{
Kind: StreamError,
Code: frame.data.Code,
Message: frame.data.Message,
Retryable: frame.data.Retryable,
})
}
})
cleanBody := acc.String()
tokens := approxTokenCount(cleanBody)
chipCount := countChips(cleanBody)
finished := time.Now().UTC()
durationMS := int(finished.Sub(startedAt) / time.Millisecond)
if upstreamDoneMs > 0 {
durationMS = int(upstreamDoneMs)
}
// Persist the conversation id we learned (best-effort — failure here
// just means recovery for THIS turn will have to list conversations
// rather than fast-path to a single id).
if convID != "" {
if err := s.setAichatConversationID(ctx, turnID, convID); err != nil {
log.Printf("paliadin: persist aichat conversation id %s: %v", convID, err)
}
}
if streamErr != nil {
// Don't overwrite an existing error_code we may have set above.
_ = s.markTurnError(ctx, turnID, classifyAichatError(streamErr))
return nil, streamErr
}
// Aichat is stateless on user content; the client owns the primer.
if paneSpawned {
s.clearPrimed(session)
} else {
s.markPrimed(session)
}
if cleanBody == "" {
// Upstream closed cleanly with no error event but no content
// either (unexpected — log + treat as upstream_error so the
// handler doesn't ship an empty bubble).
_ = s.markTurnError(ctx, turnID, "shim_error")
return nil, errors.New("aichat: stream closed with no content and no error")
}
if err := s.completeTurn(ctx, turnID, finished, durationMS, cleanBody, tokens, streamMeta, chipCount); err != nil {
log.Printf("paliadin: complete turn %s: %v", turnID, err)
}
return &TurnResult{
TurnID: turnID,
Response: cleanBody,
UsedTools: streamMeta.UsedTools,
RowsSeen: streamMeta.RowsSeen,
ChipCount: chipCount,
ClassifierTag: streamMeta.ClassifierTag,
DurationMS: durationMS,
}, nil
}
// streamFrame is one decoded SSE event.
type streamFrame struct {
event string // "" → default (data:) event
data streamDataFrame
heartbeat streamHeartbeatFrame
}
type streamDataFrame struct {
Type string `json:"type"`
Content string `json:"content,omitempty"`
UsedTools []string `json:"used_tools,omitempty"`
RowsSeen []string `json:"rows_seen,omitempty"`
ClassifierTag string `json:"classifier_tag,omitempty"`
TurnID string `json:"turn_id,omitempty"`
ConversationID string `json:"conversation_id,omitempty"`
DurationMs int64 `json:"duration_ms,omitempty"`
PaneSpawned bool `json:"pane_spawned,omitempty"`
Resumed bool `json:"resumed,omitempty"`
Code string `json:"code,omitempty"`
Message string `json:"message,omitempty"`
Retryable bool `json:"retryable,omitempty"`
}
type streamHeartbeatFrame struct {
ElapsedSeconds int `json:"elapsed_seconds"`
}
// callStreamingHTTP opens a streaming POST to aichat and invokes `emit`
// for each parsed SSE frame. Returns once the stream closes; surfaces
// non-2xx responses via decodeAichatError, transport errors via the
// underlying http.Client error.
//
// Tests can override the parsing path by setting streamHook (kept null
// in production).
func (s *AichatPaliadinService) callStreamingHTTP(ctx context.Context, path string, body any, emit func(streamFrame)) error {
if s.streamHook != nil {
return s.streamHook(ctx, path, body, emit)
}
buf, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("aichat: encode %s body: %w", path, err)
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, s.cfg.BaseURL+path, strings.NewReader(string(buf)))
if err != nil {
return fmt.Errorf("aichat: build %s request: %w", path, err)
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Accept", "text/event-stream")
if s.cfg.BearerToken != "" {
httpReq.Header.Set("Authorization", "Bearer "+s.cfg.BearerToken)
}
// Use a dedicated client without the short Timeout — for streaming
// we rely on the silence_timeout watch (no events for > 90 s ⇒ fail)
// rather than a hard ceiling on the whole turn. The aichat upstream
// keeps emitting heartbeats while it's alive, so a true upstream
// stall is observable here.
client := s.streamingClient()
resp, err := client.Do(httpReq)
if err != nil {
return fmt.Errorf("aichat: POST %s: %w", path, err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
respBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
return decodeAichatError(resp.StatusCode, respBytes)
}
return parseSSEStream(ctx, resp.Body, emit)
}
// streamingClient returns an HTTP client tuned for streaming — no
// per-request Timeout (kills mid-stream), but a long IdleConnTimeout so
// the connection stays usable for multi-minute turns.
func (s *AichatPaliadinService) streamingClient() *http.Client {
if s.cfg.HTTPClient == nil {
return &http.Client{Timeout: 0}
}
c := *s.cfg.HTTPClient
c.Timeout = 0
return &c
}
// parseSSEStream tokenises an SSE byte stream into streamFrame events
// and calls emit for each. Returns nil on clean EOF; returns the read
// error otherwise.
//
// Frame format (per https://html.spec.whatwg.org/multipage/server-sent-events.html):
//
// event: <name>\n
// data: <payload>\n
// <blank line>\n
//
// Multiple `data:` lines per event are concatenated with `\n`. Lines
// starting with `:` are comments and ignored.
func parseSSEStream(ctx context.Context, r io.Reader, emit func(streamFrame)) error {
br := bufio.NewReaderSize(r, 64<<10)
var (
eventName string
dataLines []string
)
flush := func() {
if len(dataLines) == 0 && eventName == "" {
return
}
payload := strings.Join(dataLines, "\n")
eventName = strings.TrimSpace(eventName)
dataLines = nil
eventOut := eventName
eventName = ""
if eventOut == "heartbeat" {
var hb streamHeartbeatFrame
if err := json.Unmarshal([]byte(payload), &hb); err != nil {
return
}
emit(streamFrame{event: "heartbeat", heartbeat: hb})
return
}
// Default event (unnamed) — discriminated by `type` field.
var d streamDataFrame
if err := json.Unmarshal([]byte(payload), &d); err != nil {
return
}
emit(streamFrame{event: "", data: d})
}
for {
if err := ctx.Err(); err != nil {
return err
}
line, err := br.ReadString('\n')
if err != nil {
if errors.Is(err, io.EOF) {
// Final frame may not be terminated by a blank line on
// abrupt close — flush whatever we accumulated.
if line != "" {
processSSELine(line, &eventName, &dataLines)
}
flush()
return nil
}
return fmt.Errorf("aichat: read sse: %w", err)
}
// Normalise line endings (some intermediaries send \r\n).
line = strings.TrimRight(line, "\r\n")
if line == "" {
flush()
continue
}
processSSELine(line, &eventName, &dataLines)
}
}
// processSSELine handles one line of the SSE wire format.
func processSSELine(line string, eventName *string, dataLines *[]string) {
if strings.HasPrefix(line, ":") {
return // comment / keep-alive
}
if idx := strings.IndexByte(line, ':'); idx >= 0 {
field := line[:idx]
value := line[idx+1:]
if strings.HasPrefix(value, " ") {
value = value[1:]
}
switch field {
case "event":
*eventName = value
case "data":
*dataLines = append(*dataLines, value)
}
return
}
// Field with no value (rare). Treat the whole line as field name
// per spec.
}
// =============================================================================
// AichatRecoverer — late recovery via the conversation API
// =============================================================================
// RecoverTurn asks aichat whether the given paliad turn has a response.
// Returns the up-to-date row on success (including a freshly persisted
// response when aichat had one), nil + nil when aichat doesn't know
// either, or an error on transport / DB failures.
func (s *AichatPaliadinService) RecoverTurn(ctx context.Context, callerID, turnID uuid.UUID) (*PaliadinTurn, error) {
row, err := s.GetTurn(ctx, callerID, turnID)
if err != nil {
return nil, err
}
// Fast path: the row already has a response (the janitor or a
// concurrent stream finished writing). Return it as-is.
if row.Response != nil && *row.Response != "" {
return row, nil
}
convID, err := s.resolveAichatConversationID(ctx, row)
if err != nil {
log.Printf("paliadin: recover %s: resolve conversation: %v", turnID, err)
return nil, nil
}
if convID == "" {
return nil, nil
}
turns, err := s.fetchAichatConversationTurns(ctx, convID)
if err != nil {
log.Printf("paliadin: recover %s: fetch turns: %v", turnID, err)
return nil, nil
}
assistantBody := matchAssistantResponse(turns, row.UserMessage)
if assistantBody == "" {
return nil, nil
}
finished := time.Now().UTC()
durationMS := int(finished.Sub(row.StartedAt) / time.Millisecond)
tokens := approxTokenCount(assistantBody)
chipCount := countChips(assistantBody)
if err := s.completeTurnLate(ctx, turnID, finished, durationMS, assistantBody, tokens, trailerMeta{}, chipCount); err != nil {
log.Printf("paliadin: recover %s: complete late: %v", turnID, err)
return nil, err
}
// Re-read so the caller gets a row that reflects the late-write.
return s.GetTurn(ctx, callerID, turnID)
}
// resolveAichatConversationID returns the conversation the turn lived
// in. Fast path: read the column on the row. Fallback: list aichat
// conversations for the user+persona and take the most recent.
func (s *AichatPaliadinService) resolveAichatConversationID(ctx context.Context, row *PaliadinTurn) (string, error) {
stored, err := s.getAichatConversationID(ctx, row.TurnID)
if err != nil {
return "", err
}
if stored != "" {
return stored, nil
}
username := s.usernameFor(ctx, row.UserID)
convs, err := s.listAichatConversations(ctx, username, row.UserID.String())
if err != nil {
return "", err
}
if len(convs) == 0 {
return "", nil
}
// Aichat orders by last_turn_at DESC; the head is the most recently
// active conversation, which is the pane the lost turn ran against.
return convs[0].ID, nil
}
// matchAssistantResponse walks the aichat turn list and returns the
// body of the latest assistant turn whose preceding user-role turn body
// matches `userMessage` (verbatim — aichat persists the raw message
// the same way paliad does).
//
// Falls back to "the last assistant body in the conversation" when no
// match is found but the conversation has assistant content. This
// covers cases where aichat persisted the user turn with envelope
// prefixes that don't exactly match our user_message (e.g. an embedded
// [ctx …] block).
func matchAssistantResponse(turns []aichatConversationTurn, userMessage string) string {
wantedNorm := normaliseForMatch(userMessage)
for i := 0; i < len(turns)-1; i++ {
t := turns[i]
if t.Role != "user" {
continue
}
if normaliseForMatch(t.Body) != wantedNorm {
continue
}
next := turns[i+1]
if next.Role == "assistant" && next.Body != "" {
return next.Body
}
}
for i := len(turns) - 1; i >= 0; i-- {
t := turns[i]
if t.Role == "assistant" && t.Body != "" {
return t.Body
}
}
return ""
}
// normaliseForMatch lowercases, strips surrounding whitespace, and
// collapses internal whitespace runs. Comparison only — no semantic
// meaning beyond "did aichat persist the same prompt we sent".
func normaliseForMatch(s string) string {
s = strings.TrimSpace(strings.ToLower(s))
for strings.Contains(s, " ") {
s = strings.ReplaceAll(s, " ", " ")
}
return s
}
// =============================================================================
// aichat conversation API client helpers
// =============================================================================
type aichatConversationSummary struct {
ID string `json:"id"`
Persona string `json:"persona"`
LastTurnAt string `json:"last_turn_at"`
}
type aichatListConversationsResponse struct {
Conversations []aichatConversationSummary `json:"conversations"`
}
type aichatConversationTurn struct {
ID string `json:"id"`
Seq int `json:"seq"`
Role string `json:"role"`
Body string `json:"body"`
CreatedAt string `json:"created_at"`
}
type aichatGetConversationTurnsResponse struct {
ConversationID string `json:"conversation_id"`
Turns []aichatConversationTurn `json:"turns"`
HasMore bool `json:"has_more"`
}
// listAichatConversations calls GET /chat/conversations for the user.
func (s *AichatPaliadinService) listAichatConversations(ctx context.Context, username, userID string) ([]aichatConversationSummary, error) {
q := url.Values{}
q.Set("persona", s.cfg.Persona)
q.Set("username", username)
q.Set("user_id", userID)
q.Set("limit", "5")
path := "/chat/conversations?" + q.Encode()
var resp aichatListConversationsResponse
if err := s.callHTTP(ctx, http.MethodGet, path, nil, &resp); err != nil {
return nil, err
}
return resp.Conversations, nil
}
// fetchAichatConversationTurns calls GET /chat/conversations/{id}/turns.
func (s *AichatPaliadinService) fetchAichatConversationTurns(ctx context.Context, convID string) ([]aichatConversationTurn, error) {
q := url.Values{}
q.Set("persona", s.cfg.Persona)
q.Set("limit", "20")
path := "/chat/conversations/" + url.PathEscape(convID) + "/turns?" + q.Encode()
var resp aichatGetConversationTurnsResponse
if err := s.callHTTP(ctx, http.MethodGet, path, nil, &resp); err != nil {
return nil, err
}
return resp.Turns, nil
}
// =============================================================================
// DB helpers for paliadin_turns.aichat_conversation_id (migration 118)
// =============================================================================
func (s *AichatPaliadinService) setAichatConversationID(ctx context.Context, turnID uuid.UUID, convID string) error {
if convID == "" {
return nil
}
convUUID, err := uuid.Parse(convID)
if err != nil {
return fmt.Errorf("invalid conversation id %q: %w", convID, err)
}
_, err = s.db.ExecContext(ctx, `
UPDATE paliad.paliadin_turns
SET aichat_conversation_id = $2
WHERE turn_id = $1
AND aichat_conversation_id IS DISTINCT FROM $2
`, turnID, convUUID)
return err
}
func (s *AichatPaliadinService) getAichatConversationID(ctx context.Context, turnID uuid.UUID) (string, error) {
var convID *uuid.UUID
err := s.db.QueryRowxContext(ctx,
`SELECT aichat_conversation_id FROM paliad.paliadin_turns WHERE turn_id = $1`,
turnID).Scan(&convID)
if err != nil {
return "", err
}
if convID == nil {
return "", nil
}
return convID.String(), nil
}
// Compile-time interface conformance — fail the build if a streaming
// method drifts off this backend.
var _ StreamingPaliadin = (*AichatPaliadinService)(nil)
var _ AichatRecoverer = (*AichatPaliadinService)(nil)

View File

@@ -0,0 +1,259 @@
package services
// Streaming + recovery tests for AichatPaliadinService (t-paliad-235).
//
// Like the sync-path tests next door, every test bypasses the HTTP wire
// (streamHook / httpHook). DB-write paths in RunTurnStream are out of
// scope here for the same reason — paliad has no sqlx mock. We focus
// on the SSE parser, the conversation-API client, and the
// match-assistant-response helper.
import (
"context"
"strings"
"testing"
)
// =============================================================================
// SSE parser
// =============================================================================
func TestParseSSEStream_DefaultEvents(t *testing.T) {
body := `data: {"type":"chunk","content":"Hello "}
data: {"type":"chunk","content":"world"}
data: {"type":"meta","used_tools":["search"],"rows_seen":["3"],"classifier_tag":"howto"}
data: {"type":"done","turn_id":"abc","conversation_id":"11111111-1111-1111-1111-111111111111","duration_ms":1234,"pane_spawned":false,"resumed":false}
`
var frames []streamFrame
err := parseSSEStream(context.Background(), strings.NewReader(body), func(f streamFrame) {
frames = append(frames, f)
})
if err != nil {
t.Fatalf("parseSSEStream: %v", err)
}
if len(frames) != 4 {
t.Fatalf("got %d frames; want 4 (%+v)", len(frames), frames)
}
if frames[0].data.Type != "chunk" || frames[0].data.Content != "Hello " {
t.Errorf("frame 0 = %+v; want chunk Hello ", frames[0])
}
if frames[1].data.Type != "chunk" || frames[1].data.Content != "world" {
t.Errorf("frame 1 = %+v; want chunk world", frames[1])
}
if frames[2].data.Type != "meta" || frames[2].data.ClassifierTag != "howto" {
t.Errorf("frame 2 = %+v; want meta howto", frames[2])
}
if frames[3].data.Type != "done" || frames[3].data.ConversationID == "" {
t.Errorf("frame 3 = %+v; want done with conversation_id", frames[3])
}
}
func TestParseSSEStream_HeartbeatEvent(t *testing.T) {
body := `event: heartbeat
data: {"elapsed_seconds": 5}
event: heartbeat
data: {"elapsed_seconds": 10}
data: {"type":"chunk","content":"Hi"}
`
var frames []streamFrame
err := parseSSEStream(context.Background(), strings.NewReader(body), func(f streamFrame) {
frames = append(frames, f)
})
if err != nil {
t.Fatalf("parseSSEStream: %v", err)
}
if len(frames) != 3 {
t.Fatalf("got %d frames; want 3", len(frames))
}
if frames[0].event != "heartbeat" || frames[0].heartbeat.ElapsedSeconds != 5 {
t.Errorf("frame 0 = %+v; want heartbeat 5s", frames[0])
}
if frames[1].event != "heartbeat" || frames[1].heartbeat.ElapsedSeconds != 10 {
t.Errorf("frame 1 = %+v; want heartbeat 10s", frames[1])
}
if frames[2].data.Type != "chunk" || frames[2].data.Content != "Hi" {
t.Errorf("frame 2 = %+v; want chunk Hi", frames[2])
}
}
func TestParseSSEStream_IgnoresComments(t *testing.T) {
body := `: keep-alive comment line
data: {"type":"chunk","content":"x"}
`
var frames []streamFrame
err := parseSSEStream(context.Background(), strings.NewReader(body), func(f streamFrame) {
frames = append(frames, f)
})
if err != nil {
t.Fatalf("parseSSEStream: %v", err)
}
if len(frames) != 1 || frames[0].data.Content != "x" {
t.Errorf("frames = %+v; want 1 chunk", frames)
}
}
func TestParseSSEStream_HandlesCRLF(t *testing.T) {
body := "data: {\"type\":\"chunk\",\"content\":\"crlf\"}\r\n\r\n"
var frames []streamFrame
err := parseSSEStream(context.Background(), strings.NewReader(body), func(f streamFrame) {
frames = append(frames, f)
})
if err != nil {
t.Fatalf("parseSSEStream: %v", err)
}
if len(frames) != 1 || frames[0].data.Content != "crlf" {
t.Errorf("frames = %+v; want crlf chunk", frames)
}
}
func TestParseSSEStream_MultilineData(t *testing.T) {
// Two data: lines for the same event must concatenate with \n.
body := `data: {"type":"chunk",
data: "content":"x"}
`
var frames []streamFrame
err := parseSSEStream(context.Background(), strings.NewReader(body), func(f streamFrame) {
frames = append(frames, f)
})
if err != nil {
t.Fatalf("parseSSEStream: %v", err)
}
if len(frames) != 1 || frames[0].data.Content != "x" {
t.Errorf("frames = %+v; want 1 chunk x", frames)
}
}
// =============================================================================
// matchAssistantResponse
// =============================================================================
func TestMatchAssistantResponse_PrefersUserPrecededAssistant(t *testing.T) {
turns := []aichatConversationTurn{
{Role: "user", Body: "first question"},
{Role: "assistant", Body: "first answer"},
{Role: "user", Body: "second question"},
{Role: "assistant", Body: "second answer"},
}
got := matchAssistantResponse(turns, "second question")
if got != "second answer" {
t.Errorf("got %q; want %q", got, "second answer")
}
}
func TestMatchAssistantResponse_NormaliseCase(t *testing.T) {
turns := []aichatConversationTurn{
{Role: "user", Body: "Hello World"},
{Role: "assistant", Body: "hi back"},
}
got := matchAssistantResponse(turns, " hello world ")
if got != "hi back" {
t.Errorf("got %q; want %q", got, "hi back")
}
}
func TestMatchAssistantResponse_FallbackToLastAssistant(t *testing.T) {
// User message doesn't match (aichat persisted with a different
// envelope or wrapper). Fallback: take the last assistant turn.
turns := []aichatConversationTurn{
{Role: "user", Body: "[ctx route=x] my question"},
{Role: "assistant", Body: "the answer"},
}
got := matchAssistantResponse(turns, "my question")
if got != "the answer" {
t.Errorf("got %q; want %q", got, "the answer")
}
}
func TestMatchAssistantResponse_NoAssistantTurns(t *testing.T) {
turns := []aichatConversationTurn{
{Role: "user", Body: "lonely"},
}
got := matchAssistantResponse(turns, "lonely")
if got != "" {
t.Errorf("got %q; want empty", got)
}
}
func TestMatchAssistantResponse_EmptyAssistantSkipped(t *testing.T) {
turns := []aichatConversationTurn{
{Role: "user", Body: "q1"},
{Role: "assistant", Body: ""},
{Role: "user", Body: "q2"},
{Role: "assistant", Body: "a2"},
}
got := matchAssistantResponse(turns, "q1")
if got != "a2" {
// q1's immediate next is the empty-body assistant — we skip it
// and fall back to the last non-empty assistant body.
t.Errorf("got %q; want %q (fallback)", got, "a2")
}
}
// =============================================================================
// Conversation-API HTTP client
// =============================================================================
func TestListAichatConversations_BuildsExpectedQuery(t *testing.T) {
var seenPath string
s := newAichatService(t, nil, func(ctx context.Context, method, path string, body any, out any) error {
seenPath = path
if dst, ok := out.(*aichatListConversationsResponse); ok {
dst.Conversations = []aichatConversationSummary{{ID: "11111111-1111-1111-1111-111111111111"}}
}
return nil
})
got, err := s.listAichatConversations(context.Background(), "alice", "00000000-0000-0000-0000-000000000001")
if err != nil {
t.Fatalf("listAichatConversations: %v", err)
}
if len(got) != 1 || got[0].ID == "" {
t.Errorf("got = %+v; want one conversation", got)
}
for _, want := range []string{"/chat/conversations?", "persona=paliadin", "username=alice", "user_id=00000000-0000-0000-0000-000000000001", "limit=5"} {
if !strings.Contains(seenPath, want) {
t.Errorf("path %q missing %q", seenPath, want)
}
}
}
func TestFetchAichatConversationTurns_BuildsPath(t *testing.T) {
var seenPath string
s := newAichatService(t, nil, func(ctx context.Context, method, path string, body any, out any) error {
seenPath = path
if dst, ok := out.(*aichatGetConversationTurnsResponse); ok {
dst.Turns = []aichatConversationTurn{{Role: "assistant", Body: "answer"}}
}
return nil
})
turns, err := s.fetchAichatConversationTurns(context.Background(), "11111111-1111-1111-1111-111111111111")
if err != nil {
t.Fatalf("fetchAichatConversationTurns: %v", err)
}
if len(turns) != 1 || turns[0].Body != "answer" {
t.Errorf("turns = %+v; want one assistant", turns)
}
for _, want := range []string{"/chat/conversations/11111111-1111-1111-1111-111111111111/turns", "persona=paliadin", "limit=20"} {
if !strings.Contains(seenPath, want) {
t.Errorf("path %q missing %q", seenPath, want)
}
}
}
// =============================================================================
// Interface conformance
// =============================================================================
func TestAichatPaliadinService_ImplementsStreaming(t *testing.T) {
var _ StreamingPaliadin = (*AichatPaliadinService)(nil)
var _ AichatRecoverer = (*AichatPaliadinService)(nil)
}

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

@@ -22,7 +22,8 @@ import (
// DashboardLayoutService manages paliad.user_dashboard_layouts.
type DashboardLayoutService struct {
db *sqlx.DB
db *sqlx.DB
firmDefault *FirmDashboardDefaultService
}
// NewDashboardLayoutService wires the service.
@@ -30,6 +31,29 @@ func NewDashboardLayoutService(db *sqlx.DB) *DashboardLayoutService {
return &DashboardLayoutService{db: db}
}
// SetFirmDefaultService wires the firm-wide default source (Slice C).
// When set and non-empty, GetOrSeed/ResetToDefault prefer it over the
// code-resident FactoryDefaultLayout. nil-safe — when unwired or when
// the table is empty, behavior falls back to the code-resident default.
func (s *DashboardLayoutService) SetFirmDefaultService(f *FirmDashboardDefaultService) {
s.firmDefault = f
}
// defaultLayout returns the firm-wide default if one is set, else the
// code-resident FactoryDefaultLayout. Used by the seed and reset paths.
// On any error reading the firm row, falls back to the factory default
// so a transient DB blip can't strand a user without a dashboard.
func (s *DashboardLayoutService) defaultLayout(ctx context.Context) DashboardLayoutSpec {
if s.firmDefault == nil {
return FactoryDefaultLayout()
}
spec, ok, err := s.firmDefault.Get(ctx)
if err != nil || !ok {
return FactoryDefaultLayout()
}
return spec
}
// GetOrSeed returns the caller's saved layout. On first call for a user
// (no row), it inserts and returns the factory default. The seed is
// idempotent — concurrent first-loads converge to the same row via the
@@ -73,9 +97,14 @@ func (s *DashboardLayoutService) Update(ctx context.Context, userID uuid.UUID, s
return out, nil
}
// ResetToDefault overwrites the user's layout with the factory default.
// ResetToDefault overwrites the user's layout with the firm-wide default
// when one is set, otherwise with the code-resident factory default
// (Slice C). The single user-facing "Auf Standard zurücksetzen" link
// always lands the user on whatever the firm considers default at the
// time of the click — admins can update it later and users get the new
// default on their next reset.
func (s *DashboardLayoutService) ResetToDefault(ctx context.Context, userID uuid.UUID) (DashboardLayoutSpec, error) {
def := FactoryDefaultLayout()
def := s.defaultLayout(ctx)
if err := s.upsert(ctx, userID, def); err != nil {
return DashboardLayoutSpec{}, err
}
@@ -106,11 +135,14 @@ func (s *DashboardLayoutService) fetch(ctx context.Context, userID uuid.UUID) (D
return spec, true, nil
}
// seedFactoryDefault inserts the factory layout for a brand-new user.
// ON CONFLICT DO NOTHING handles the race where two concurrent first
// loads both miss the SELECT and both try to insert.
// seedFactoryDefault inserts the firm-wide default (if set) or the
// code-resident factory layout for a brand-new user. ON CONFLICT DO
// NOTHING handles the race where two concurrent first loads both miss
// the SELECT and both try to insert. Function name kept as "Factory"
// even though it may now seed the firm default — renaming the call site
// would churn one file for no callsite benefit.
func (s *DashboardLayoutService) seedFactoryDefault(ctx context.Context, userID uuid.UUID) (DashboardLayoutSpec, error) {
def := FactoryDefaultLayout()
def := s.defaultLayout(ctx)
bytes, err := json.Marshal(def)
if err != nil {
return DashboardLayoutSpec{}, fmt.Errorf("seed dashboard layout marshal: %w", err)

View File

@@ -30,12 +30,35 @@ const LayoutSpecVersion = 1
// blobs.
const LayoutWidgetCap = 32
// DashboardGridColumns is the column count of the dashboard layout grid. The CSS
// `.dashboard-grid` template is `repeat(DashboardGridColumns, 1fr)` and the
// validator caps X+W ≤ DashboardGridColumns. Twelve is the industry-standard
// dashboard grain — supports halves, thirds, quarters, sixths.
const DashboardGridColumns = 12
// MaxGridRowSpan caps how tall a single widget can grow. Five vertical
// cells is enough for a fully-expanded calendar without letting a
// runaway resize fill the entire viewport.
const MaxGridRowSpan = 5
// DashboardWidgetRef is a single widget entry in the ordered widgets[] array.
// Visible=false entries are kept in the array so the picker can show them as
// "hidden" and re-adding restores their position.
//
// Position fields (X/Y/W/H) carry the widget's slot in the 12-column grid.
// X is 0-indexed column-start (0..DashboardGridColumns-1); Y is 0-indexed row-start.
// W is column span (1..DashboardGridColumns); H is row span (1..MaxGridRowSpan).
// When W=0 the widget is treated as full-width (W=DashboardGridColumns); H=0
// means H=1. This keeps pre-overhaul layouts (no positions on the wire)
// rendering sensibly under the new grid — they get auto-placed full-
// width in array order.
type DashboardWidgetRef struct {
Key WidgetKey `json:"key"`
Visible bool `json:"visible"`
X int `json:"x,omitempty"`
Y int `json:"y,omitempty"`
W int `json:"w,omitempty"`
H int `json:"h,omitempty"`
Settings json.RawMessage `json:"settings,omitempty"`
}
@@ -46,10 +69,12 @@ type DashboardLayoutSpec struct {
}
// FactoryDefaultLayout returns the Slice A1 baseline layout — every
// widget in KnownWidgetKeys, visible, in canonical order, with per-widget
// default settings drawn from the catalog. A user with no row sees this
// on first load and is byte-identical to today's dashboard plus the new
// inbox-approvals widget.
// widget in KnownWidgetKeys, in canonical order, with per-widget default
// settings + grid positions drawn from the catalog. Visible widgets get
// placed row-by-row using a greedy left-to-right packer (next widget
// goes into the leftmost slot wide enough on the current row, else
// wraps to a new row). Hidden widgets carry default sizes but no
// position — they get one when re-added via the picker.
func FactoryDefaultLayout() DashboardLayoutSpec {
catalog := WidgetCatalog()
byKey := make(map[WidgetKey]WidgetDef, len(catalog))
@@ -58,6 +83,13 @@ func FactoryDefaultLayout() DashboardLayoutSpec {
}
widgets := make([]DashboardWidgetRef, 0, len(KnownWidgetKeys))
// Greedy packer: place each visible widget left-to-right on the
// current row. When the widget doesn't fit, wrap to a new row at y
// = max-row-height-so-far. rowMaxH tracks the tallest widget in the
// row currently being filled — wrapping by only the new widget's
// height would let taller previous neighbours overlap. cursorX is
// the next free column on the current row.
cursorX, cursorY, rowMaxH := 0, 0, 0
for _, k := range KnownWidgetKeys {
def, ok := byKey[k]
if !ok {
@@ -67,6 +99,29 @@ func FactoryDefaultLayout() DashboardLayoutSpec {
if settings := defaultSettingsJSON(def); settings != nil {
ref.Settings = settings
}
w := def.DefaultW
if w <= 0 || w > DashboardGridColumns {
w = DashboardGridColumns
}
h := def.DefaultH
if h <= 0 {
h = 1
}
ref.W = w
ref.H = h
if def.DefaultVisible {
if cursorX+w > DashboardGridColumns {
cursorY += rowMaxH
cursorX = 0
rowMaxH = 0
}
ref.X = cursorX
ref.Y = cursorY
cursorX += w
if h > rowMaxH {
rowMaxH = h
}
}
widgets = append(widgets, ref)
}
@@ -129,6 +184,43 @@ func (s DashboardLayoutSpec) Validate() error {
if err := def.Settings.Validate(w.Settings); err != nil {
return fmt.Errorf("widgets[%d]: %w", i, err)
}
if err := validatePosition(i, w, def); err != nil {
return err
}
}
return nil
}
// validatePosition checks grid X/Y/W/H against schema clamps. Zero
// values are accepted (auto-flow + default size); non-zero values must
// fit the 12-column grid and the widget's MinW/MaxW/MinH/MaxH clamps.
func validatePosition(i int, w DashboardWidgetRef, def WidgetDef) error {
if w.X < 0 || w.X >= DashboardGridColumns {
return fmt.Errorf("%w: widgets[%d].x %d outside [0,%d)", ErrInvalidInput, i, w.X, DashboardGridColumns)
}
if w.Y < 0 {
return fmt.Errorf("%w: widgets[%d].y %d must be >= 0", ErrInvalidInput, i, w.Y)
}
if w.W < 0 || w.W > DashboardGridColumns {
return fmt.Errorf("%w: widgets[%d].w %d outside [0,%d]", ErrInvalidInput, i, w.W, DashboardGridColumns)
}
if w.W > 0 && w.X+w.W > DashboardGridColumns {
return fmt.Errorf("%w: widgets[%d] x+w (%d) overflows grid (%d)", ErrInvalidInput, i, w.X+w.W, DashboardGridColumns)
}
if w.H < 0 || w.H > MaxGridRowSpan {
return fmt.Errorf("%w: widgets[%d].h %d outside [0,%d]", ErrInvalidInput, i, w.H, MaxGridRowSpan)
}
if def.MinW > 0 && w.W > 0 && w.W < def.MinW {
return fmt.Errorf("%w: widgets[%d].w %d below MinW=%d", ErrInvalidInput, i, w.W, def.MinW)
}
if def.MaxW > 0 && w.W > def.MaxW {
return fmt.Errorf("%w: widgets[%d].w %d above MaxW=%d", ErrInvalidInput, i, w.W, def.MaxW)
}
if def.MinH > 0 && w.H > 0 && w.H < def.MinH {
return fmt.Errorf("%w: widgets[%d].h %d below MinH=%d", ErrInvalidInput, i, w.H, def.MinH)
}
if def.MaxH > 0 && w.H > def.MaxH {
return fmt.Errorf("%w: widgets[%d].h %d above MaxH=%d", ErrInvalidInput, i, w.H, def.MaxH)
}
return nil
}

View File

@@ -22,8 +22,18 @@ func TestFactoryDefaultLayout_AllKnownWidgetsPresent(t *testing.T) {
if def.Widgets[i].Key != k {
t.Errorf("widgets[%d].Key = %q; want %q", i, def.Widgets[i].Key, k)
}
if !def.Widgets[i].Visible {
t.Errorf("widgets[%d].Visible = false; factory default should be all-visible", i)
// Slice C: some catalog entries default-hidden (pinned-projects,
// quick-actions) — opt-in via the picker. Factory visibility
// must match the catalog declaration so a user can re-enable a
// widget without going through the picker every time.
catalogDef, ok := LookupWidgetDef(k)
if !ok {
t.Errorf("widgets[%d] %q has no catalog def", i, k)
continue
}
if def.Widgets[i].Visible != catalogDef.DefaultVisible {
t.Errorf("widgets[%d] %q: factory Visible=%v; catalog DefaultVisible=%v",
i, k, def.Widgets[i].Visible, catalogDef.DefaultVisible)
}
}
}
@@ -101,9 +111,12 @@ func TestDashboardLayoutSpec_Validate_DuplicateKey(t *testing.T) {
}
func TestDashboardLayoutSpec_Validate_BadSettings(t *testing.T) {
// count not in CountOptions for upcoming-deadlines (legal: 1,3,5,10,20)
// count over CountMax=50 for upcoming-deadlines must be rejected.
// (Values inside [1,CountMax] are accepted free-form per the gear
// pane's numeric input; values inside CountOptions are the curated
// presets. 100 is outside both.)
s := DashboardLayoutSpec{Version: 1, Widgets: []DashboardWidgetRef{
{Key: WidgetUpcomingDeadlines, Visible: true, Settings: json.RawMessage(`{"count": 7}`)},
{Key: WidgetUpcomingDeadlines, Visible: true, Settings: json.RawMessage(`{"count": 100}`)},
}}
err := s.Validate()
if !errors.Is(err, ErrInvalidInput) {
@@ -111,6 +124,120 @@ func TestDashboardLayoutSpec_Validate_BadSettings(t *testing.T) {
}
}
func TestDashboardLayoutSpec_Validate_AcceptsCustomCountWithinMax(t *testing.T) {
// Custom count not in CountOptions but inside CountMax — accepted.
// upcoming-deadlines: CountOptions {1,3,5,10,20}, CountMax 50.
s := DashboardLayoutSpec{Version: 1, Widgets: []DashboardWidgetRef{
{Key: WidgetUpcomingDeadlines, Visible: true, Settings: json.RawMessage(`{"count": 7}`)},
}}
if err := s.Validate(); err != nil {
t.Fatalf("Validate rejected custom count=7 within CountMax=50: %v", err)
}
}
func TestDashboardLayoutSpec_Validate_AcceptsValidView(t *testing.T) {
s := DashboardLayoutSpec{Version: 1, Widgets: []DashboardWidgetRef{
{Key: WidgetUpcomingDeadlines, Visible: true, Settings: json.RawMessage(`{"view":"calendar"}`)},
}}
if err := s.Validate(); err != nil {
t.Fatalf("Validate rejected legal view=calendar: %v", err)
}
}
func TestDashboardLayoutSpec_Validate_RejectsUnknownView(t *testing.T) {
s := DashboardLayoutSpec{Version: 1, Widgets: []DashboardWidgetRef{
{Key: WidgetUpcomingDeadlines, Visible: true, Settings: json.RawMessage(`{"view":"sankey"}`)},
}}
err := s.Validate()
if !errors.Is(err, ErrInvalidInput) {
t.Fatalf("Validate accepted unknown view; want ErrInvalidInput, got %v", err)
}
}
func TestDashboardLayoutSpec_Validate_RejectsViewOnNoViewWidget(t *testing.T) {
// deadline-summary has no Views — view knob must be rejected.
s := DashboardLayoutSpec{Version: 1, Widgets: []DashboardWidgetRef{
{Key: WidgetDeadlineSummary, Visible: true, Settings: json.RawMessage(`{"view":"list"}`)},
}}
err := s.Validate()
if !errors.Is(err, ErrInvalidInput) {
t.Fatalf("Validate accepted view on no-view widget; want ErrInvalidInput, got %v", err)
}
}
func TestDashboardLayoutSpec_Validate_GridPosition(t *testing.T) {
// X+W overflowing GridColumns must be rejected.
s := DashboardLayoutSpec{Version: 1, Widgets: []DashboardWidgetRef{
{Key: WidgetUpcomingDeadlines, Visible: true, X: 8, W: 6},
}}
err := s.Validate()
if !errors.Is(err, ErrInvalidInput) {
t.Fatalf("Validate accepted x+w overflow; want ErrInvalidInput, got %v", err)
}
}
func TestDashboardLayoutSpec_Validate_GridSizeOutsideClamps(t *testing.T) {
// upcoming-deadlines has MinW=4. W=2 must be rejected.
s := DashboardLayoutSpec{Version: 1, Widgets: []DashboardWidgetRef{
{Key: WidgetUpcomingDeadlines, Visible: true, X: 0, Y: 0, W: 2, H: 1},
}}
err := s.Validate()
if !errors.Is(err, ErrInvalidInput) {
t.Fatalf("Validate accepted W=2 below MinW=4; want ErrInvalidInput, got %v", err)
}
}
func TestFactoryDefaultLayout_AssignsPositions(t *testing.T) {
def := FactoryDefaultLayout()
// At least one visible widget must have a non-zero position OR
// W set (W=0 means "auto = full width" but factory should assign
// concrete sizes from the catalog).
anySized := false
for _, w := range def.Widgets {
if w.W > 0 {
anySized = true
break
}
}
if !anySized {
t.Fatal("FactoryDefaultLayout did not assign any W; every visible widget should carry a default size")
}
}
// TestFactoryDefaultLayout_NoOverlap verifies the greedy packer in
// FactoryDefaultLayout produces a non-overlapping arrangement. CSS Grid
// would render overlapping items stacked on top of each other — a
// regression here would mean every new paliad user sees a broken
// dashboard until they manually adjust positions.
func TestFactoryDefaultLayout_NoOverlap(t *testing.T) {
def := FactoryDefaultLayout()
type rect struct{ x, y, w, h int }
visible := []rect{}
for _, w := range def.Widgets {
if !w.Visible {
continue
}
if w.W <= 0 || w.H <= 0 {
t.Errorf("factory visible widget %q has non-positive size (w=%d, h=%d)", w.Key, w.W, w.H)
continue
}
visible = append(visible, rect{w.X, w.Y, w.W, w.H})
}
for i, a := range visible {
if a.x+a.w > DashboardGridColumns {
t.Errorf("widget %d overflows grid: x=%d w=%d", i, a.x, a.w)
}
for j, b := range visible {
if i == j {
continue
}
if a.x < b.x+b.w && b.x < a.x+a.w && a.y < b.y+b.h && b.y < a.y+a.h {
t.Errorf("widgets %d and %d overlap: %v vs %v", i, j, a, b)
}
}
}
}
func TestDashboardLayoutSpec_Validate_AcceptsValidSettings(t *testing.T) {
s := DashboardLayoutSpec{Version: 1, Widgets: []DashboardWidgetRef{
{Key: WidgetUpcomingDeadlines, Visible: true, Settings: json.RawMessage(`{"count": 5, "horizon_days": 14}`)},
@@ -210,6 +337,34 @@ func TestWidgetCatalog_AllKnownKeysHaveDef(t *testing.T) {
}
}
func TestWidgetCatalog_SliceC_HasPinnedAndQuickActions(t *testing.T) {
// Slice C activated pinned-projects + quick-actions in the catalog
// AND in KnownWidgetKeys. Lock both via this test so a future
// change can't accidentally remove them without thinking about the
// firm-default migration story.
if _, ok := LookupWidgetDef(WidgetPinnedProjects); !ok {
t.Errorf("WidgetCatalog missing pinned-projects entry")
}
if _, ok := LookupWidgetDef(WidgetQuickActions); !ok {
t.Errorf("WidgetCatalog missing quick-actions entry")
}
var hasPinned, hasQuick bool
for _, k := range KnownWidgetKeys {
if k == WidgetPinnedProjects {
hasPinned = true
}
if k == WidgetQuickActions {
hasQuick = true
}
}
if !hasPinned {
t.Errorf("KnownWidgetKeys missing pinned-projects")
}
if !hasQuick {
t.Errorf("KnownWidgetKeys missing quick-actions")
}
}
func TestWidgetCatalog_NoOrphanDefs(t *testing.T) {
known := make(map[WidgetKey]bool, len(KnownWidgetKeys))
for _, k := range KnownWidgetKeys {

View File

@@ -14,6 +14,7 @@ import (
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"github.com/lib/pq"
"mgit.msbls.de/m/paliad/internal/models"
)
@@ -24,6 +25,7 @@ type DashboardService struct {
db *sqlx.DB
users *UserService
approvals *ApprovalService
pins *PinService
}
func NewDashboardService(db *sqlx.DB, users *UserService) *DashboardService {
@@ -39,6 +41,14 @@ func (s *DashboardService) SetApprovalService(a *ApprovalService) {
s.approvals = a
}
// SetPinService wires the pinned-projects widget data source (Slice C).
// PinService pre-dates t-paliad-219 (mig 062/063) so no new schema is
// needed. Safe to leave nil — PinnedProjects then comes back empty and
// the widget renders its empty state.
func (s *DashboardService) SetPinService(p *PinService) {
s.pins = p
}
// DashboardData is the full payload returned to the frontend.
type DashboardData struct {
User *DashboardUser `json:"user"`
@@ -49,8 +59,23 @@ type DashboardData struct {
UpcomingAppointments []UpcomingAppointment `json:"upcoming_appointments"`
RecentActivity []ActivityEntry `json:"recent_activity"`
InboxSummary InboxSummary `json:"inbox_summary"`
PinnedProjects []PinnedProjectRef `json:"pinned_projects"`
}
// PinnedProjectRef is one row in DashboardData.PinnedProjects — the
// minimum needed to render a clickable entry in the pinned-projects
// widget. Order matches PinService.ListPinned (pinned_at DESC).
type PinnedProjectRef struct {
ProjectID uuid.UUID `json:"project_id" db:"id"`
ProjectTitle string `json:"project_title" db:"title"`
ProjectRef string `json:"project_reference" db:"reference"`
}
// PinnedProjectsCap caps the pinned-projects preview list. The widget
// count setting tops out at 20; we fetch the cap once and let the
// client trim further per the user's setting.
const PinnedProjectsCap = 20
// InboxSummary feeds the inbox-approvals widget on the configurable
// dashboard (t-paliad-219). PendingCount is the precise number of
// approval requests that await this user's approval; Top is a small
@@ -175,6 +200,7 @@ func (s *DashboardService) Get(ctx context.Context, userID uuid.UUID) (*Dashboar
UpcomingDeadlines: []UpcomingDeadline{},
UpcomingAppointments: []UpcomingAppointment{},
RecentActivity: []ActivityEntry{},
PinnedProjects: []PinnedProjectRef{},
}
if user == nil {
return data, nil
@@ -213,11 +239,77 @@ func (s *DashboardService) Get(ctx context.Context, userID uuid.UUID) (*Dashboar
if err := s.loadInboxSummary(ctx, data, user); err != nil {
return nil, err
}
if err := s.loadPinnedProjects(ctx, data, user); err != nil {
return nil, err
}
annotateUrgency(data.UpcomingDeadlines, now)
return data, nil
}
// loadPinnedProjects populates DashboardData.PinnedProjects (Slice C).
// Reads PinService.ListPinned for ordering, then materialises titles +
// references via a single visibility-filtered SELECT. When PinService
// is unwired or the user has no pins, the field comes back empty and
// the widget renders its empty state.
func (s *DashboardService) loadPinnedProjects(ctx context.Context, data *DashboardData, user *models.User) error {
if s.pins == nil {
return nil
}
ids, err := s.pins.ListPinned(ctx, user.ID)
if err != nil {
return fmt.Errorf("dashboard pinned ids: %w", err)
}
if len(ids) == 0 {
return nil
}
if len(ids) > PinnedProjectsCap {
ids = ids[:PinnedProjectsCap]
}
query := `
SELECT p.id,
p.title,
COALESCE(p.reference, '') AS reference
FROM paliad.projects p
WHERE p.id = ANY($2::uuid[])
AND ` + visibilityPredicatePositional("p", 1)
rows := []PinnedProjectRef{}
if err := s.db.SelectContext(ctx, &rows, query, user.ID, idsToArray(ids)); err != nil {
return fmt.Errorf("dashboard pinned projects: %w", err)
}
// Restore pinned-at order: PinService.ListPinned ordered DESC; the
// SELECT above doesn't preserve that. Build a position map and
// re-sort.
pos := make(map[uuid.UUID]int, len(ids))
for i, id := range ids {
pos[id] = i
}
out := make([]PinnedProjectRef, 0, len(rows))
for _, r := range rows {
out = append(out, r)
}
// Tiny n (cap=20); a simple insertion-style swap is enough.
for i := 0; i < len(out); i++ {
for j := i + 1; j < len(out); j++ {
if pos[out[j].ProjectID] < pos[out[i].ProjectID] {
out[i], out[j] = out[j], out[i]
}
}
}
data.PinnedProjects = out
return nil
}
// idsToArray flips a []uuid.UUID into a Postgres uuid[] payload via
// pq.Array — mirrors the pattern in rule_editor_orphans.go.
func idsToArray(ids []uuid.UUID) interface{} {
out := make([]string, len(ids))
for i, id := range ids {
out[i] = id.String()
}
return pq.Array(out)
}
// loadSummary fills DeadlineSummary + AppointmentSummary + MatterSummary.
//
// Bucket math comes from computeDeadlineBucketBounds (deadline_service.go) so

View File

@@ -0,0 +1,106 @@
package services
// FirmDashboardDefaultService manages paliad.firm_dashboard_default — the
// optional firm-wide dashboard layout that DashboardLayoutService prefers
// over the code-resident FactoryDefaultLayout when seeding a new user's
// row or resetting an existing layout.
//
// Design: docs/design-dashboard-configurable-2026-05-20.md §8.2
// (firm-wide default, deferred to v1.1 — activated in Slice C).
//
// Single optional row identified by id=1. Get returns (spec, true, nil)
// when set, (zero, false, nil) when never set. Set overwrites; Clear
// deletes (so GetOrSeed reverts to FactoryDefaultLayout).
//
// The HTTP layer (handlers/firm_dashboard_default.go) enforces admin-only
// via auth.RequireAdmin. The service itself takes no admin parameter —
// it trusts its callers because the only writer is the admin handler;
// the read path is used by DashboardLayoutService on every seed/reset.
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
)
// FirmDashboardDefaultService manages paliad.firm_dashboard_default.
type FirmDashboardDefaultService struct {
db *sqlx.DB
}
// NewFirmDashboardDefaultService wires the service.
func NewFirmDashboardDefaultService(db *sqlx.DB) *FirmDashboardDefaultService {
return &FirmDashboardDefaultService{db: db}
}
// Get returns (spec, true, nil) if a firm default is set, (zero, false,
// nil) otherwise. SanitizeForRead is applied so callers always receive a
// version-coherent spec; if anything had to be dropped (e.g. an admin
// stashed a layout that references widgets we later removed), the
// cleanup is in-memory only and the next admin write will persist it.
func (s *FirmDashboardDefaultService) Get(ctx context.Context) (DashboardLayoutSpec, bool, error) {
var raw json.RawMessage
err := s.db.GetContext(ctx, &raw, `
SELECT layout_json
FROM paliad.firm_dashboard_default
WHERE id = 1
`)
if errors.Is(err, sql.ErrNoRows) {
return DashboardLayoutSpec{}, false, nil
}
if err != nil {
return DashboardLayoutSpec{}, false, fmt.Errorf("get firm dashboard default: %w", err)
}
var spec DashboardLayoutSpec
if err := json.Unmarshal(raw, &spec); err != nil {
// Stored row is unparseable — treat as missing so the seed path
// reverts to FactoryDefaultLayout rather than crash.
return DashboardLayoutSpec{}, false, nil
}
spec.SanitizeForRead()
return spec, true, nil
}
// Set persists the layout as the firm-wide default. Validates against the
// catalog so an admin can't seed a layout that violates the contract.
// updatedBy is recorded for audit; passing uuid.Nil clears the column.
func (s *FirmDashboardDefaultService) Set(ctx context.Context, spec DashboardLayoutSpec, updatedBy uuid.UUID) (DashboardLayoutSpec, error) {
if err := spec.Validate(); err != nil {
return DashboardLayoutSpec{}, err
}
bytes, err := json.Marshal(spec)
if err != nil {
return DashboardLayoutSpec{}, fmt.Errorf("firm dashboard default marshal: %w", err)
}
var updaterArg interface{}
if updatedBy != uuid.Nil {
updaterArg = updatedBy
}
_, err = s.db.ExecContext(ctx, `
INSERT INTO paliad.firm_dashboard_default (id, layout_json, updated_by, updated_at)
VALUES (1, $1, $2, now())
ON CONFLICT (id) DO UPDATE
SET layout_json = EXCLUDED.layout_json,
updated_by = EXCLUDED.updated_by,
updated_at = now()
`, json.RawMessage(bytes), updaterArg)
if err != nil {
return DashboardLayoutSpec{}, fmt.Errorf("firm dashboard default upsert: %w", err)
}
return spec, nil
}
// Clear deletes the firm default so seeds/resets revert to FactoryDefault-
// Layout. Idempotent — clearing an already-absent row is a no-op.
func (s *FirmDashboardDefaultService) Clear(ctx context.Context) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM paliad.firm_dashboard_default WHERE id = 1`)
if err != nil {
return fmt.Errorf("firm dashboard default clear: %w", err)
}
return nil
}

View File

@@ -0,0 +1,93 @@
package services
// Live-DB tests for FirmDashboardDefaultService — gated on
// TEST_DATABASE_URL like the rest of the integration suite.
//
// These cover the round-trip (Set → Get → Clear → Get) and the
// SanitizeForRead behavior on read. Pure-function validation lives in
// dashboard_layout_spec_test.go.
import (
"context"
"encoding/json"
"os"
"testing"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
)
func openTestDBForFirmDefault(t *testing.T) *sqlx.DB {
t.Helper()
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
t.Skip("TEST_DATABASE_URL not set — skipping firm-dashboard-default live test")
}
db, err := sqlx.Connect("postgres", url)
if err != nil {
t.Fatalf("connect: %v", err)
}
return db
}
func TestFirmDashboardDefault_RoundTrip(t *testing.T) {
db := openTestDBForFirmDefault(t)
defer db.Close()
svc := NewFirmDashboardDefaultService(db)
ctx := context.Background()
// Start clean — prior tests may have left a row.
if err := svc.Clear(ctx); err != nil {
t.Fatalf("pre-clear: %v", err)
}
if _, ok, err := svc.Get(ctx); err != nil || ok {
t.Fatalf("Get after Clear: ok=%v err=%v; want ok=false err=nil", ok, err)
}
spec := FactoryDefaultLayout()
if _, err := svc.Set(ctx, spec, uuid.Nil); err != nil {
t.Fatalf("Set factory: %v", err)
}
got, ok, err := svc.Get(ctx)
if err != nil {
t.Fatalf("Get: %v", err)
}
if !ok {
t.Fatal("Get: ok=false after Set; want true")
}
if len(got.Widgets) != len(spec.Widgets) {
t.Errorf("widget count mismatch: %d vs %d", len(got.Widgets), len(spec.Widgets))
}
if got.Version != spec.Version {
t.Errorf("version mismatch: %d vs %d", got.Version, spec.Version)
}
// Clear is idempotent.
if err := svc.Clear(ctx); err != nil {
t.Fatalf("clear after set: %v", err)
}
if err := svc.Clear(ctx); err != nil {
t.Fatalf("second clear: %v", err)
}
if _, ok, err := svc.Get(ctx); err != nil || ok {
t.Fatalf("Get after Clear: ok=%v err=%v; want false/nil", ok, err)
}
}
func TestFirmDashboardDefault_RejectsInvalid(t *testing.T) {
db := openTestDBForFirmDefault(t)
defer db.Close()
svc := NewFirmDashboardDefaultService(db)
ctx := context.Background()
bad := DashboardLayoutSpec{Version: 1, Widgets: []DashboardWidgetRef{
{Key: WidgetUpcomingDeadlines, Visible: true, Settings: json.RawMessage(`{"count": 7}`)},
}}
_, err := svc.Set(ctx, bad, uuid.Nil)
if err == nil {
t.Fatal("Set with invalid count: err=nil; want ErrInvalidInput")
}
}

View File

@@ -0,0 +1,127 @@
package services
// Streaming support for the Paliadin chat surface (t-paliad-235).
//
// The legacy LocalPaliadinService.RunTurn returns the full response in
// one shot — the chat UI gets one `content` blob and the typewriter
// simulates streaming. That falls apart on long turns: the HTTP client
// hits its 130 s ceiling, paliad's SSE stream closes, the bubble shows
// "Verbindung verloren" and the response is lost.
//
// The aichat backend exposes a real streaming variant at
// /chat/turn/stream that emits incremental chunks + named heartbeat
// events while claude is thinking. AichatPaliadinService implements
// the StreamingPaliadin interface defined here; the handler probes
// for it via a type assertion and falls back to the one-shot RunTurn
// when the backend doesn't support streaming (legacy path).
//
// Recovery (a separate axis): when the transport drops mid-turn,
// the AichatRecoverer interface lets the handler ask the backend to
// look up the late response via aichat's conversation API rather than
// rely on the legacy filesystem janitor — which only knows about
// LocalPaliadinService's per-turn response files.
import (
"context"
"github.com/google/uuid"
)
// StreamEvent is one increment of a streaming turn. The handler
// receives these via the channel passed to RunTurnStream and forwards
// them as SSE frames to the browser.
//
// Exactly one of Kind's payloads is meaningful per event:
//
// StreamChunk → Content holds the next slice of assistant text
// StreamHeartbeat → ElapsedSeconds holds upstream "still thinking" tick
// StreamMeta → UsedTools / RowsSeen / ClassifierTag populated
// StreamError → Code / Message / Retryable populated
//
// StreamDone is implicit: when the channel closes without an error
// event, the turn completed. The accompanying *TurnResult returned by
// RunTurnStream carries the final accumulated body + meta + conversation
// id for persistence and recovery.
type StreamEvent struct {
Kind StreamEventKind
// StreamChunk
Content string
// StreamHeartbeat
ElapsedSeconds int
// StreamMeta (terminal-side; may also be merged into final TurnResult)
UsedTools []string
RowsSeen []int
ClassifierTag string
// StreamError
Code string
Message string
Retryable bool
// StreamConversation — aichat sometimes resolves the conversation id
// before the first chunk arrives. We surface it as soon as we have
// it so the handler can persist it for recovery, even if the stream
// is later interrupted.
ConversationID string
}
// StreamEventKind enumerates the meaningful flavours.
type StreamEventKind string
const (
StreamChunk StreamEventKind = "chunk"
StreamHeartbeat StreamEventKind = "heartbeat"
StreamMeta StreamEventKind = "meta"
StreamError StreamEventKind = "error"
StreamConversation StreamEventKind = "conversation"
)
// StreamingPaliadin is the optional extension the AichatPaliadinService
// implements. Handlers detect it via type assertion; backends that don't
// implement it (the legacy local + remote paths) fall back to the
// one-shot Paliadin.RunTurn.
//
// Contract:
// - RunTurnStream MUST close `events` before returning, so the handler
// loop terminates cleanly.
// - Returning a non-nil error implies the audit row was already
// stamped with an error_code; the handler does not double-stamp.
// - The *TurnResult is populated even on partial failure when the
// upstream produced any meaningful body — handlers may render it as
// a salvaged best-effort result instead of an error.
type StreamingPaliadin interface {
Paliadin
// RunTurnStream drives one turn against the streaming upstream and
// pushes StreamEvents onto `events` as they arrive. Blocks until the
// upstream finishes or the context cancels. `events` is closed by
// the implementation before this method returns.
RunTurnStream(ctx context.Context, req TurnRequest, events chan<- StreamEvent) (*TurnResult, error)
}
// AichatRecoverer is the optional extension that knows how to ask the
// aichat backend "did this turn actually complete?" when paliad's local
// audit row never got a response (because the transport dropped mid
// turn). Implementations look up the persisted aichat_conversation_id,
// query aichat's GET /chat/conversations/{id}/turns, find the matching
// assistant turn, and write the response back to paliad's row.
//
// Returns (nil, nil) when aichat doesn't have the response either —
// i.e. the turn is truly lost and the UI must degrade to "verloren"
// copy rather than "wird nachgereicht".
type AichatRecoverer interface {
RecoverTurn(ctx context.Context, callerID, turnID uuid.UUID) (*PaliadinTurn, error)
}
// safeSendStream pushes an event onto the channel, dropping on context
// cancel. Mirrors the handler-side `send` helper but works against a
// generic chan StreamEvent.
func safeSendStream(ctx context.Context, ch chan<- StreamEvent, ev StreamEvent) {
select {
case ch <- ev:
case <-ctx.Done():
}
}

View File

@@ -59,10 +59,16 @@ type projectChainRow struct {
ProceedingCode *string `db:"proceeding_code"`
}
// BuildProjectCode walks the ancestor chain via the existing
// paliad.projects.path ltree and returns the assembled code. One DB
// round-trip per call; suitable for per-row use in single-project
// projection paths.
// BuildProjectCode walks the ancestor chain via paliad.projects.path
// and returns the assembled code. One DB round-trip per call; suitable
// for per-row use in single-project projection paths.
//
// paliad.projects.path is stored as TEXT (dot-separated UUIDs), not as
// the ltree extension type — see export_service.go comment "ltree as
// text" and can_see_project's string_to_array decomposition. Ancestor
// walks use the same string_to_array(path, '.')::uuid[] pattern as the
// canonical visibility predicate; ltree operators (@>, nlevel) would
// raise "operator does not exist: text @> text" at runtime.
//
// For list endpoints with many rows, the call still scales fine for
// firm-scale datasets (order-of-100s); if profiling later flags it as
@@ -72,10 +78,12 @@ func BuildProjectCode(ctx context.Context, db sqlx.QueryerContext, projectID uui
SELECT p.id, p.type, p.title, p.reference, p.opponent_code,
p.patent_number, p.proceeding_type_id,
pt.code AS proceeding_code
FROM paliad.projects p
FROM paliad.projects target
JOIN paliad.projects p
ON p.id = ANY(string_to_array(target.path, '.')::uuid[])
LEFT JOIN paliad.proceeding_types pt ON pt.id = p.proceeding_type_id
WHERE p.path @> (SELECT path FROM paliad.projects WHERE id = $1)
ORDER BY nlevel(p.path)
WHERE target.id = $1
ORDER BY array_position(string_to_array(target.path, '.')::uuid[], p.id)
`
rows := []projectChainRow{}
if err := sqlx.SelectContext(ctx, db, &rows, query, projectID); err != nil {
@@ -102,8 +110,13 @@ func PopulateProjectCodes(ctx context.Context, db sqlx.QueryerContext, targets [
ids[i] = t.ID.String()
}
// One CTE-based query: for each target id, fetch the full ancestor
// chain joined to proceeding_types, ordered so we can group in Go.
// One query: for each target id, fetch the full ancestor chain
// joined to proceeding_types, ordered so we can group in Go.
//
// Ancestor walk uses string_to_array(path, '.')::uuid[] — same shape
// as can_see_project. paliad.projects.path is TEXT, so ltree
// operators (@>, nlevel) would fail with "operator does not exist:
// text @> text". See BuildProjectCode doc comment for context.
const query = `
WITH targets AS (
SELECT id, path
@@ -114,9 +127,10 @@ func PopulateProjectCodes(ctx context.Context, db sqlx.QueryerContext, targets [
p.id, p.type, p.title, p.reference, p.opponent_code,
p.patent_number, p.proceeding_type_id,
pt.code AS proceeding_code,
nlevel(p.path) AS chain_level
array_position(string_to_array(t.path, '.')::uuid[], p.id) AS chain_level
FROM targets t
JOIN paliad.projects p ON p.path @> t.path
JOIN paliad.projects p
ON p.id = ANY(string_to_array(t.path, '.')::uuid[])
LEFT JOIN paliad.proceeding_types pt ON pt.id = p.proceeding_type_id
ORDER BY t.id, chain_level
`

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
}

View File

@@ -31,10 +31,15 @@ const (
WidgetRecentActivity WidgetKey = "recent-activity"
WidgetInboxApprovals WidgetKey = "inbox-approvals"
WidgetPinnedProjects WidgetKey = "pinned-projects"
WidgetQuickActions WidgetKey = "quick-actions"
)
// KnownWidgetKeys is the canonical order used when seeding the factory
// default layout. New entries land at the bottom by default.
//
// Slice C activated WidgetPinnedProjects (reusing the pin-machinery
// PinService that pre-dates t-paliad-219) and added WidgetQuickActions
// (pure UI; no backend data path) per m's brief on catalog expansion.
var KnownWidgetKeys = []WidgetKey{
WidgetDeadlineSummary,
WidgetMatterSummary,
@@ -43,9 +48,18 @@ var KnownWidgetKeys = []WidgetKey{
WidgetInlineAgenda,
WidgetRecentActivity,
WidgetInboxApprovals,
// WidgetPinnedProjects ships in Slice C (catalog expansion) — not in
// the Slice A1 baseline. Keep the const above for forward-compat;
// omit from KnownWidgetKeys until the widget module lands.
WidgetPinnedProjects,
WidgetQuickActions,
}
// ViewOption is one entry in a widget's "view" knob — a presentation
// variant the widget supports (e.g. list vs calendar for upcoming-
// deadlines). The ID is what's persisted in user settings; the frontend
// picks the renderer based on it.
type ViewOption struct {
ID string `json:"id"`
LabelDE string `json:"label_de"`
LabelEN string `json:"label_en"`
}
// WidgetSettingsSchema declares which knobs a widget exposes. nil = no
@@ -59,6 +73,24 @@ type WidgetSettingsSchema struct {
// CountAllowsAll is true when "all" is a legal value for count
// (rendered as the literal -1 in the JSON). pinned-projects uses this.
CountAllowsAll bool
// CountMax is an upper bound for the "count" knob when the gear pane
// exposes a free-form numeric input alongside the dropdown. Zero =
// dropdown-only (legacy). When non-zero, the validator accepts any
// integer in [1, CountMax] in addition to entries in CountOptions.
CountMax int
// HorizonMax is the analogue for "horizon_days". Zero = dropdown-only.
HorizonMax int
// Views lists the supported presentation variants for the widget. Empty
// = the widget has a single hardcoded renderer (no view picker).
Views []ViewOption
}
// rawWidgetSettings is the typed projection of the JSON we accept. New
// knobs land here; the validator + frontend gear pane stay in lock-step.
type rawWidgetSettings struct {
Count *int `json:"count,omitempty"`
HorizonDays *int `json:"horizon_days,omitempty"`
View *string `json:"view,omitempty"`
}
// Validate enforces the schema against a raw settings blob. nil schema
@@ -71,28 +103,57 @@ func (sch *WidgetSettingsSchema) Validate(raw json.RawMessage) error {
return fmt.Errorf("%w: widget has no settings; got %s", ErrInvalidInput, string(raw))
}
var parsed struct {
Count *int `json:"count,omitempty"`
HorizonDays *int `json:"horizon_days,omitempty"`
}
var parsed rawWidgetSettings
if err := json.Unmarshal(raw, &parsed); err != nil {
return fmt.Errorf("%w: widget settings decode: %v", ErrInvalidInput, err)
}
if parsed.Count != nil {
if len(sch.CountOptions) == 0 {
if len(sch.CountOptions) == 0 && sch.CountMax == 0 {
return fmt.Errorf("%w: widget has no count knob", ErrInvalidInput)
}
if !(sch.CountAllowsAll && *parsed.Count == -1) && !slices.Contains(sch.CountOptions, *parsed.Count) {
return fmt.Errorf("%w: count %d not in %v", ErrInvalidInput, *parsed.Count, sch.CountOptions)
ok := false
if sch.CountAllowsAll && *parsed.Count == -1 {
ok = true
}
if !ok && slices.Contains(sch.CountOptions, *parsed.Count) {
ok = true
}
if !ok && sch.CountMax > 0 && *parsed.Count >= 1 && *parsed.Count <= sch.CountMax {
ok = true
}
if !ok {
return fmt.Errorf("%w: count %d not in %v (max %d)", ErrInvalidInput, *parsed.Count, sch.CountOptions, sch.CountMax)
}
}
if parsed.HorizonDays != nil {
if len(sch.HorizonOptions) == 0 {
if len(sch.HorizonOptions) == 0 && sch.HorizonMax == 0 {
return fmt.Errorf("%w: widget has no horizon knob", ErrInvalidInput)
}
if !slices.Contains(sch.HorizonOptions, *parsed.HorizonDays) {
return fmt.Errorf("%w: horizon_days %d not in %v", ErrInvalidInput, *parsed.HorizonDays, sch.HorizonOptions)
ok := false
if slices.Contains(sch.HorizonOptions, *parsed.HorizonDays) {
ok = true
}
if !ok && sch.HorizonMax > 0 && *parsed.HorizonDays >= 1 && *parsed.HorizonDays <= sch.HorizonMax {
ok = true
}
if !ok {
return fmt.Errorf("%w: horizon_days %d not in %v (max %d)", ErrInvalidInput, *parsed.HorizonDays, sch.HorizonOptions, sch.HorizonMax)
}
}
if parsed.View != nil {
if len(sch.Views) == 0 {
return fmt.Errorf("%w: widget has no view knob", ErrInvalidInput)
}
ok := false
for _, v := range sch.Views {
if v.ID == *parsed.View {
ok = true
break
}
}
if !ok {
return fmt.Errorf("%w: view %q not in catalog", ErrInvalidInput, *parsed.View)
}
}
return nil
@@ -100,20 +161,36 @@ func (sch *WidgetSettingsSchema) Validate(raw json.RawMessage) error {
// WidgetDef is one entry in the catalog. Title/description fields are the
// translation-key seeds; frontend resolves them via the i18n registry.
//
// Default size (W/H) drives both the factory layout and the resize
// clamp on the gear pane. W is grid columns 1..DashboardGridColumns; H is row
// span 1..N. Zero defaults are treated as W=DashboardGridColumns, H=1.
type WidgetDef struct {
Key WidgetKey `json:"key"`
TitleDE string `json:"title_de"`
TitleEN string `json:"title_en"`
DescriptionDE string `json:"description_de"`
DescriptionEN string `json:"description_en"`
DefaultVisible bool `json:"default_visible"`
DefaultCount *int `json:"default_count,omitempty"`
DefaultHorizon *int `json:"default_horizon_days,omitempty"`
Key WidgetKey `json:"key"`
TitleDE string `json:"title_de"`
TitleEN string `json:"title_en"`
DescriptionDE string `json:"description_de"`
DescriptionEN string `json:"description_en"`
DefaultVisible bool `json:"default_visible"`
DefaultCount *int `json:"default_count,omitempty"`
DefaultHorizon *int `json:"default_horizon_days,omitempty"`
DefaultView string `json:"default_view,omitempty"`
DefaultW int `json:"default_w,omitempty"`
DefaultH int `json:"default_h,omitempty"`
MinW int `json:"min_w,omitempty"`
MaxW int `json:"max_w,omitempty"`
MinH int `json:"min_h,omitempty"`
MaxH int `json:"max_h,omitempty"`
Settings *WidgetSettingsSchema `json:"settings,omitempty"`
}
// WidgetCatalog returns the v1 catalog. Returned by value (small struct
// slice) so callers can freely append i18n overrides for the wire format.
//
// Sizes use a 12-column grid (see DashboardGridColumns). Each widget declares its
// preferred default size (W/H) plus min/max clamps that the resize handle
// honours. Catalog defaults are tuned to fill the 12-col grid sensibly
// on first load — see FactoryDefaultLayout for the assembled flow.
func WidgetCatalog() []WidgetDef {
listCounts := []int{1, 3, 5, 10, 20}
listHorizon := []int{7, 14, 30, 60}
@@ -124,6 +201,19 @@ func WidgetCatalog() []WidgetDef {
threeDefault := 3
thirtyDefault := 30
listOrCalendar := []ViewOption{
{ID: "list", LabelDE: "Liste", LabelEN: "List"},
{ID: "calendar", LabelDE: "Kalender", LabelEN: "Calendar"},
}
activityViews := []ViewOption{
{ID: "full", LabelDE: "Ausführlich", LabelEN: "Full"},
{ID: "compact", LabelDE: "Kompakt", LabelEN: "Compact"},
}
agendaViews := []ViewOption{
{ID: "timeline", LabelDE: "Zeitachse", LabelEN: "Timeline"},
{ID: "list", LabelDE: "Liste", LabelEN: "List"},
}
return []WidgetDef{
{
Key: WidgetDeadlineSummary,
@@ -132,6 +222,12 @@ func WidgetCatalog() []WidgetDef {
DescriptionDE: "Ampel-Karten für überfällige, heutige und kommende Fristen.",
DescriptionEN: "Traffic-light cards for overdue, today, and upcoming deadlines.",
DefaultVisible: true,
DefaultW: 12,
DefaultH: 1,
MinW: 6,
MaxW: 12,
MinH: 1,
MaxH: 2,
},
{
Key: WidgetMatterSummary,
@@ -140,57 +236,101 @@ func WidgetCatalog() []WidgetDef {
DescriptionDE: "Aktiv-, archiviert- und Gesamtzahl deiner sichtbaren Akten.",
DescriptionEN: "Active, archived and total counts of your visible matters.",
DefaultVisible: true,
DefaultW: 6,
DefaultH: 1,
MinW: 4,
MaxW: 12,
MinH: 1,
MaxH: 1,
},
{
Key: WidgetUpcomingDeadlines,
TitleDE: "Kommende Fristen",
TitleEN: "Upcoming deadlines",
DescriptionDE: "Liste der nächsten Fristen — Anzahl und Zeitraum konfigurierbar.",
DescriptionEN: "List of upcoming deadlines — count and horizon configurable.",
DescriptionDE: "Liste der nächsten Fristen — Anzahl, Zeitraum und Darstellung konfigurierbar.",
DescriptionEN: "List of upcoming deadlines — count, horizon and view configurable.",
DefaultVisible: true,
DefaultCount: &tenDefault,
DefaultHorizon: &thirtyDefault,
DefaultView: "list",
DefaultW: 6,
DefaultH: 2,
MinW: 4,
MaxW: 12,
MinH: 1,
MaxH: 4,
Settings: &WidgetSettingsSchema{
CountOptions: listCounts,
CountMax: 50,
HorizonOptions: listHorizon,
HorizonMax: 365,
Views: listOrCalendar,
},
},
{
Key: WidgetUpcomingAppointments,
TitleDE: "Kommende Termine",
TitleEN: "Upcoming appointments",
DescriptionDE: "Liste der nächsten Termine — Anzahl und Zeitraum konfigurierbar.",
DescriptionEN: "List of upcoming appointments — count and horizon configurable.",
DescriptionDE: "Liste der nächsten Termine — Anzahl, Zeitraum und Darstellung konfigurierbar.",
DescriptionEN: "List of upcoming appointments — count, horizon and view configurable.",
DefaultVisible: true,
DefaultCount: &tenDefault,
DefaultHorizon: &thirtyDefault,
DefaultView: "list",
DefaultW: 6,
DefaultH: 2,
MinW: 4,
MaxW: 12,
MinH: 1,
MaxH: 4,
Settings: &WidgetSettingsSchema{
CountOptions: listCounts,
CountMax: 50,
HorizonOptions: listHorizon,
HorizonMax: 365,
Views: listOrCalendar,
},
},
{
Key: WidgetInlineAgenda,
TitleDE: "Agenda",
TitleEN: "Agenda",
DescriptionDE: "30-Tage-Agenda mit Fristen und Terminen kombiniert.",
DescriptionEN: "30-day agenda combining deadlines and appointments.",
DescriptionDE: "Agenda mit Fristen und Terminen kombiniert — Zeitraum und Darstellung konfigurierbar.",
DescriptionEN: "Agenda combining deadlines and appointments — horizon and view configurable.",
DefaultVisible: true,
DefaultHorizon: &thirtyDefault,
DefaultView: "timeline",
DefaultW: 12,
DefaultH: 2,
MinW: 6,
MaxW: 12,
MinH: 1,
MaxH: 4,
Settings: &WidgetSettingsSchema{
HorizonOptions: agendaHorizon,
HorizonMax: 365,
Views: agendaViews,
},
},
{
Key: WidgetRecentActivity,
TitleDE: "Letzte Aktivität",
TitleEN: "Recent activity",
DescriptionDE: "Verlauf der letzten Ereignisse in deinen sichtbaren Akten.",
DescriptionEN: "Recent events across your visible matters.",
DescriptionDE: "Verlauf der letzten Ereignisse — Anzahl und Darstellung konfigurierbar.",
DescriptionEN: "Recent events across your visible matters — count and view configurable.",
DefaultVisible: true,
DefaultCount: &tenDefault,
DefaultView: "full",
DefaultW: 12,
DefaultH: 2,
MinW: 4,
MaxW: 12,
MinH: 1,
MaxH: 4,
Settings: &WidgetSettingsSchema{
CountOptions: listCounts,
CountMax: 50,
Views: activityViews,
},
},
{
@@ -201,10 +341,58 @@ func WidgetCatalog() []WidgetDef {
DescriptionEN: "Your open approval requests with count and a short list.",
DefaultVisible: true,
DefaultCount: &threeDefault,
DefaultW: 6,
DefaultH: 1,
MinW: 4,
MaxW: 12,
MinH: 1,
MaxH: 2,
Settings: &WidgetSettingsSchema{
CountOptions: inboxCounts,
CountMax: 50,
},
},
// Slice C: pinned-projects rides on the pre-existing PinService
// (paliad.user_pinned_projects, mig 062/063 — pre-dates this
// task). DefaultVisible=false so existing users don't get a new
// widget injected unannounced; they opt in via the picker.
{
Key: WidgetPinnedProjects,
TitleDE: "Angepinnte Akten",
TitleEN: "Pinned matters",
DescriptionDE: "Schneller Zugriff auf deine angepinnten Akten.",
DescriptionEN: "Quick access to your pinned matters.",
DefaultVisible: false,
DefaultCount: &tenDefault,
DefaultW: 6,
DefaultH: 2,
MinW: 4,
MaxW: 12,
MinH: 1,
MaxH: 4,
Settings: &WidgetSettingsSchema{
CountOptions: listCounts,
CountAllowsAll: true,
CountMax: 50,
},
},
// Slice C: quick-actions is pure UI — no backend payload, no
// settings. Renders 3 affordances ("+ Akte", "+ Frist",
// "+ Termin") that link to the existing create surfaces.
{
Key: WidgetQuickActions,
TitleDE: "Schnellzugriff",
TitleEN: "Quick actions",
DescriptionDE: "Direkte Buttons für neue Akten, Fristen und Termine.",
DescriptionEN: "Direct buttons for new matters, deadlines, and appointments.",
DefaultVisible: false,
DefaultW: 12,
DefaultH: 1,
MinW: 6,
MaxW: 12,
MinH: 1,
MaxH: 1,
},
}
}