Compare commits

..

46 Commits

Author SHA1 Message Date
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
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
mAi
e56cb3b210 feat(checklists): t-paliad-225 Slice C frontend — Geteilte Vorlagen tab + outdated-template badge
m/paliad#61 Slice C frontend pass.

Discovery (Geteilte Vorlagen):
- New 4th tab on /checklists between "Meine Vorlagen" and "Vorhandene
  Instanzen". Filters the merged catalog response to authored entries
  not owned by the caller (firm-visible OR globally-promoted OR
  share-recipient). Tab state round-trips via ?tab=gallery.
- Regime filter pills (UPC / DE / EPA / OTHER) operate independently
  from the main Vorlagen tab.
- Cards show regime badge, item count, author line, visibility chip.
- Self-filter relies on /api/me email match — loadMe() fires once on
  page boot and is idempotent.

Versioning UI on /checklists/instances/{id}:
- "Vorlage aktualisiert" badge appears when the instance's
  template_version is known AND lags the live template version (only
  for authored templates; static templates never bump). Shows "v{from}
  → v{to}" delta.
- "Änderungen anzeigen" button opens a diff modal that compares the
  instance's template_snapshot against the live template body.
  Item-level grouping by (section title, item label). Surfaces added /
  removed / changed items with localised section labels. Empty state
  when only metadata changed.

i18n: 13 new keys per language (DE + EN) under
checklisten.tab.gallery, checklisten.gallery.*, checklisten.filter.other,
and checklisten.instance.{outdated,diff}.*. Total 2666 keys.

Build hygiene: bun run build clean; i18n scan clean. Go build/vet/test
+ TestBootSmoke ./cmd/server/ all green.
2026-05-20 15:50:38 +02:00
mAi
fffddcc71a feat(checklists): t-paliad-225 Slice C backend — template versioning + catalog Version
m/paliad#61 Slice C backend.

Schema (mig 116, idempotent):
- ALTER paliad.checklists ADD COLUMN version int NOT NULL DEFAULT 1.
  Pre-Slice-C rows default to 1 (the column was added with DEFAULT
  so the UPDATE clause is a no-op safety net).
- ALTER paliad.checklist_instances ADD COLUMN template_version int.
  NULL on existing rows — instance detail page leaves the "outdated"
  badge off when the snapshot version is unknown.

Services:
- ChecklistTemplateService.Update — version bumps on title/body
  changes (the meaningful edits that warrant notifying instance
  owners). Pure metadata tweaks (description/court/reference/deadline)
  update updated_at without bumping. Emits the new 'checklist.versioned'
  audit event with prior_version + new_version metadata.
- ChecklistInstanceService.Create — captures snapshot_version
  alongside the body snapshot.
- ChecklistCatalogService — CatalogEntry grew a Version field
  (1 for static; live column for authored). ListVisible / Find
  populate it.
- Models — Checklist.Version int; ChecklistInstance.TemplateVersion *int.
- /api/checklists/{slug} response now includes version so the
  instance detail page can compare against the snapshot.

Migration verified live via BEGIN..ROLLBACK against paliad.checklists
and paliad.checklist_instances.

Build hygiene: go build/vet/test ./internal/... + TestBootSmoke
./cmd/server/ all green.
2026-05-20 15:50:21 +02:00
mAi
b850eb755c Merge: t-paliad-225 Slice B — checklist sharing + admin promotion (m/paliad#61)
Second slice. Explicit sharing of personal checklists to user / office /
partner_unit / project + global_admin promote-to-firm / demote.

- mig 115 paliad.checklist_shares (FK to user_id / office_key / partner_unit_id
  / project_id; granted_by; granted_at). Partial indexes per share kind.
- Backend: ListShares / GrantShare / RevokeShare on ChecklistService.
  Promote/Demote on AdminChecklistService — flips visibility to/from 'global'
  and emits checklist_promoted_global / checklist_demoted audit events.
- HTTP routes (under /api/checklists/templates/ + /api/checklists/shares/ +
  /api/admin/checklists/ — all literal-prefixed to avoid the route-collision
  class the hotfix 6b63420 just shipped to address).
- Frontend: 'Teilen' modal on a checklist detail page (recipient picker:
  user / office / partner-unit / project); 'Als global markieren' / 'Aus
  global entfernen' admin buttons (global_admin only).
- RLS extended: select policy allows owner + visibility='firm' + visibility='global'
  + rows present in checklist_shares matching caller's ancestry.

Slice C (discoverability gallery + versioning) follows.
2026-05-20 15:39:56 +02:00
mAi
a93277a072 feat(checklists): t-paliad-225 Slice B frontend — share modal + admin promote/demote on detail page
m/paliad#61 Slice B frontend pass.

Detail page (/checklists/{slug}) gains:
- Provenance line ("Erstellt von <author>") for authored templates,
  populated from the catalog response's owner_display_name.
- Owner action buttons: Bearbeiten (links to
  /checklists/templates/{slug}/edit per the Slice A hotfix), Teilen,
  Löschen. Reveal driven by /api/me email match against the catalog
  response's owner_email.
- global_admin action buttons: "Als Firmen-Vorlage hinterlegen"
  (promote) when visibility != 'global'; "Aus Katalog entfernen"
  (demote) when visibility == 'global'. Reveal driven by /api/me
  global_role.

Share modal:
- Single modal with a kind-picker (Kollege / Office / Dezernat /
  Projekt) and a matching select per kind — sections toggle on the
  active kind.
- Recipient pickers populated from /api/users, /api/partner-units,
  /api/projects (loaded in parallel on open). Office options use the
  canonical 8-key set from internal/offices.
- Existing grants surface in a list under the form with per-row
  Entfernen buttons; Revoke confirms before DELETE.
- Errors surface inline (recipient-required, generic share failure).

i18n: 32 new keys per language (DE+EN) under checklisten.share.*
and checklisten.detail.promote/demote/delete.*. Total 2653 keys.

Build hygiene: go build/vet/test ./internal/... + ./cmd/server/ all
green; bun run build clean.
2026-05-20 15:38:43 +02:00
mAi
c3cd51eb85 feat(checklists): t-paliad-225 Slice B backend — explicit sharing + admin promotion
m/paliad#61 Slice B backend. Implements the explicit-share path
(checklist_shares + visibility predicate extension) and the
global_admin-only promotion / demotion of authored templates to and
from the firm catalog.

Schema (mig 115, idempotent):
- paliad.checklist_shares (uuid id, checklist_id FK, polymorphic
  recipient via xor-check: recipient_kind in {user, office,
  partner_unit, project} with exactly one matching recipient_* column
  populated; granted_by FK; granted_at)
- Hot-path lookup index + per-kind partial UNIQUE indexes prevent
  duplicate grants
- RLS: SELECT owner OR self-recipient (user-kind) OR global_admin;
  INSERT owner-only with granted_by=self; DELETE owner OR global_admin;
  no UPDATE (revoke = DELETE)
- can_see_checklist CREATE OR REPLACE — adds 4 share branches; project-
  share branch uses inline ltree walk over projects.path because
  can_see_project reads auth.uid() (NULL on service-role connection,
  same pattern as visibility.go)
- xor-check verified live: rejects kind='user' with recipient_office
  set; accepts the matching kind/recipient pair

Services:
- ChecklistShareService — Grant (owner-only, validates recipient kind +
  required FK target, friendly 409 on partial-unique-index conflict),
  Revoke (owner or global_admin), ListGrants (owner or global_admin;
  enriches recipient_label via LEFT JOINs)
- ChecklistPromotionService — Promote (global_admin → visibility=global
  + promoted_at/by + audit), Demote (global_admin → target visibility,
  default 'firm', clears promoted_at/by; rejects demote of non-global
  rows)
- ChecklistCatalogService.checklistVisibilityPredicate extended to
  include all 5 share branches; service-role-friendly (no auth.uid())
- ChecklistTemplateService.normaliseSliceAVisibility now accepts
  'shared' as an author-set value; 'global' stays admin-only

Endpoints:
- GET    /api/checklists/templates/{slug}/shares  — list grants (owner/admin)
- POST   /api/checklists/templates/{slug}/shares  — grant
- DELETE /api/checklists/shares/{id}              — revoke
- POST   /api/admin/checklists/{slug}/promote     — promote to global
- POST   /api/admin/checklists/{slug}/demote      — demote (body.target default 'firm')

Audit (paliad.system_audit_log):
- checklist.shared      — recipient_kind + recipient_id in metadata
- checklist.unshared    — same shape, captured pre-DELETE
- checklist.promoted_global — prior_visibility + owner_id
- checklist.demoted     — target_visibility

Tests: validateShareInput covers all 4 kinds (happy + missing-id);
predicate-shape test asserts all 6 visibility branches present;
pqUniqueViolation regex sniff; nullableString helper; SliceB visibility
opens 'shared' but keeps 'global' admin-only.

Hotfix-merge note: head shipped 794617c after Slice A — the
template-edit page route moved from /checklists/{slug}/edit to
/checklists/templates/{slug}/edit to disambiguate from
/checklists/instances/{id}. Slice B routes follow the safe
/<resource>/<noun>/{id} pattern (no new {slug}-then-verb endpoints).
2026-05-20 15:38:30 +02:00
mAi
6b634207c2 Merge: hotfix — disambiguate checklists route conflict (production-down) 2026-05-20 15:34:00 +02:00
mAi
794617cbfd hotfix(checklists): disambiguate /checklists/{slug}/edit → /checklists/templates/{slug}/edit (production-down route conflict)
Go ServeMux refused to register patterns 'GET /checklists/{slug}/edit' (from
dirac's Slice A merge b418705) and 'GET /checklists/instances/{id}' (existing)
because both match '/checklists/instances/edit'. Container crash-looped on
boot since 13:32 UTC; paliad.de returned 404 from Traefik because no app was
listening.

Renaming the new template-edit route to /checklists/templates/{slug}/edit
disambiguates — '/templates/...' is a literal segment so the {slug} is now
strictly under a fixed prefix that can't collide with 'instances'.

Touches:
- internal/handlers/handlers.go:257 — route pattern
- frontend/src/client/checklists.ts:290 — Bearbeiten link
- frontend/src/client/checklists-author.ts:52 — URL parser regex
- frontend/src/checklists-author.tsx — doc comment

go build + bun run build clean.
2026-05-20 15:34:00 +02:00
mAi
b418705775 Merge: t-paliad-225 Slice A — user-authored checklists (m/paliad#61)
First slice of the user-checklist feature. Personal templates + 'Meine Vorlagen'
authoring; private + firm visibility only (explicit sharing to specific
users/offices/units/projects + admin-promotion ship in Slices B + C).

- mig 114 paliad.user_checklists table (owner_id, visibility text, name, sections
  jsonb, created_at). RLS scoped to owner + 'firm' visibility = visible to
  all authenticated users. Verified-via-gap-tolerant-runner.
- ChecklistService — Create/List/Get/Update/Delete + RLS-aware queries.
- HTTP layer — GET/POST /api/checklists, PATCH/DELETE /api/checklists/{id}.
- 'Meine Vorlagen' surface on /tools/checklists with authoring wizard
  (sections + items + visibility radio).

Slice B (share-to-individual + promotion to global) and Slice C (gallery +
versioning) come in follow-up shifts.
2026-05-20 15:24:28 +02:00
mAi
7a1fd81d23 feat(checklists): t-paliad-225 Slice A frontend — Meine Vorlagen + authoring wizard
m/paliad#61 Slice A frontend pass.

Pages:
- /checklists gets a third tab "Meine Vorlagen" between Vorlagen and
  Vorhandene Instanzen — lists owned authored templates with regime
  badge, visibility chip, Bearbeiten / Löschen actions, "Neue Vorlage"
  CTA. Tab state round-trips via ?tab=mine.
- /checklists/new and /checklists/{slug}/edit serve a shared bundle
  (checklists-author.html). Client reads location.pathname to decide
  create vs edit mode; edit mode prefills from /api/checklists/templates/mine.

Wizard:
- Metadata form (title, description, regime UPC/DE/EPA/OTHER, court,
  reference, deadline, language de/en, visibility private/firm).
- Repeating section + item editor — add/remove sections, add/remove
  items per section, label + optional note + optional rule per item.
- Single-language authoring (lang column on paliad.checklists). The
  catalog read layer mirrors the title/description onto both DE and EN
  sides so the existing bilingual frontend renders without a special
  case for authored entries.
- Save POSTs (create) or PATCHes (edit) the template; visibility flip
  on edit goes through its own endpoint so the audit row captures the
  transition.

Merged catalog:
- /api/checklists now returns the merged list (static + DB visible);
  the Summary shape gained origin / visibility / owner_email /
  owner_display_name fields.

i18n: 55 new keys per language (110 total) under
checklisten.tab.mine.*, checklisten.mine.*, checklisten.author.*,
checklisten.detail.* (Bearbeiten/Löschen labels for Slice B). i18n
codegen total: 2621 keys.

Build hygiene: bun run build clean, go build clean, go vet clean,
go test ./internal/... + ./cmd/server/ all green.
2026-05-20 15:24:07 +02:00
mAi
a4e2f3526d feat(checklists): t-paliad-225 Slice A backend — user-authored templates
m/paliad#61 Slice A. Introduces paliad.checklists (mig 114) as the
DB-backed companion to the static Go catalog. ChecklistCatalogService
unifies both sources at read time; ChecklistTemplateService handles
authoring CRUD + visibility toggle (private↔firm; Slice B opens
'shared' and 'global').

Schema (mig 114, idempotent):
- paliad.checklists (uuid, slug UNIQUE, owner_id FK, title/description
  /regime/court/reference/deadline/lang, body jsonb, visibility CHECK
  ('private','shared','firm','global'), promoted_at/_by, timestamps)
- paliad.can_see_checklist(uuid, uuid) STABLE SECURITY DEFINER —
  owner OR firm/global. Slice B extends with the explicit-share branch.
- RLS: select via can_see_checklist; insert owner=self; update/delete
  owner OR global_admin
- ALTER paliad.checklist_instances ADD COLUMN template_snapshot jsonb
  (snapshot semantics so per-Akte instances stay decoupled from
  subsequent template edits)

Services:
- ChecklistCatalogService — ListVisible, Find, SnapshotBody, IsStaticSlug.
  Reapplies visibility application-side (service-role bypasses RLS, per
  visibility.go pattern). Static-slug map computed once at boot for
  collision detection.
- ChecklistTemplateService — Create (auto-generates u-<slug>-<hex> with
  retry), Update (changed_fields[] in audit), SetVisibility, Delete,
  ListOwnedBy, GetBySlug. Owner-or-global_admin gate.
- SystemAuditLogService.WriteChecklistEvent — thin helper writing into
  paliad.system_audit_log with scope='org'.
- ChecklistInstanceService.Create now captures template_snapshot via
  the catalog; GetByID returns it inline so the frontend can render
  the captured body even after the upstream template is mutated.

Endpoints (all owner-gated where mutating):
- GET    /api/checklists                 — merged catalog (static + DB visible)
- GET    /api/checklists/{slug}          — single template; static-first lookup
- GET    /api/checklists/templates/mine  — caller's authored templates
- POST   /api/checklists/templates       — create
- PATCH  /api/checklists/templates/{slug}            — edit
- PATCH  /api/checklists/templates/{slug}/visibility — private↔firm
- DELETE /api/checklists/templates/{slug}            — delete
- GET    /checklists/new, /checklists/{slug}/edit    — author wizard pages

Tests: pure-helper unit tests cover slugifyTitle (umlaut → ae/oe/ue/ss
normalisation + clamp), regime/lang/visibility validation, body-shape
enforcement, static-slug detection, predicate shape, clamp.
2026-05-20 15:24:06 +02:00
mAi
1c8cdd3079 docs(checklists): t-paliad-225 inventor design — user-authored checklists (#61)
918-line design doc covering all three capabilities from m/paliad#61:
authoring, multi-axis sharing, admin-promotion to global.

Load-bearing premise correction: the issue body claims `paliad.checklists`
is an existing table that gets new columns. It is NOT — checklists today
are static Go data in `internal/checklists/templates.go`. Design
introduces `paliad.checklists` from scratch and keeps the static catalog
as a parallel source via a hybrid catalog read layer.

Schema (mig 112): `paliad.checklists` (owner + visibility enum), `paliad.checklist_shares`
(polymorphic recipient: user/office/partner_unit/project),
`paliad.can_see_checklist` predicate, `paliad.checklist_instances.template_snapshot`
column for instance integrity under template edits.

12 decisions ledgered, all defaulted to (R) per task brief (no AskUserQuestion).
Three slices (A foundation, B sharing+promotion, C gallery+backfill).
2026-05-20 15:24:06 +02:00
mAi
82ecbe3b8e Merge: t-paliad-224 — calendar-view alignment (m/paliad#55)
Three calendar implementations consolidated into one. Custom Views' shape-calendar.ts
becomes the canonical renderer; /events Kalender tab and the orphaned
/deadlines/calendar + /appointments/calendar pages now use the same module.

- frontend/src/client/calendar/mount-calendar.ts — new canon module extracted
  from shape-calendar.ts. Month/week/day, URL state via ?cal_view/?cal_date,
  drill-down day view, kind-coded pills.
- /events Kalender tab folded onto mountCalendar(); the old modal popup
  replaced with day-view drill-down (Q2/(R)).
- /deadlines/calendar + /appointments/calendar become 301 redirects to
  /events?type=…&view=calendar (handlers test added to pin the targets).
- .frist-cal-* CSS block dropped (~180 lines). Dead i18n keys removed.

Net: ~700 LOC removed, ~100 added. Zero schema/endpoint changes. Same data-loader
shared across all surfaces. Single PR per Q7(R).
2026-05-20 15:23:50 +02:00
mAi
badbffa6e0 test(handlers): t-paliad-224 — pin /deadlines/calendar + /appointments/calendar redirect targets
Adds TestStandaloneCalendarHandlers_RedirectToEventsKalender to
internal/handlers/redirects_test.go covering both standalone-
calendar handlers. Each must 301 to the canonical Kalender-tab URL
on /events, preserving the bookmark contract called out in the
handler doc comments. Sister of the existing sub-projects redirect
test.
2026-05-20 15:23:28 +02:00
mAi
0f98d2cd39 refactor(calendar): t-paliad-224 — retire standalone calendar pages + prune dead code
Delete the four orphan files behind /deadlines/calendar +
/appointments/calendar:
- frontend/src/{deadlines,appointments}-calendar.tsx
- frontend/src/client/{deadlines,appointments}-calendar.ts
The standalone pages were unreachable from the UI since t-paliad-110
(Sidebar/BottomNav point at /events?type=…); their only role was as
bookmark targets.

Handlers in internal/handlers/{deadlines,appointments}_pages.go now
301-redirect to /events?type=…&view=calendar so bookmarks still
work. Route registrations in handlers.go remain unchanged — the
gate + redirect pair gives us the same URL surface with one canonical
renderer.

build.ts: drop the renderDeadlinesCalendar / renderAppointmentsCalendar
imports + entry-point bundle paths + dist HTML writes.

frontend/src/client/paliadin-context.ts: drop the two route-key
matches for the standalone URLs (the client never sees those
pathnames any more — 301 fires server-side).

Dead CSS pruned in frontend/src/styles/global.css (~180 lines):
- .frist-calendar, .frist-cal-{controls,month-label,grid,cell,…}
  block (lines 7464-7613 pre-refactor)
- @media (max-width: 700px) { .frist-cal-cell { min-height: 64px; } }
- .termin-cal-legend{,-item}
- .frist-cal-popup-time
- .frist-cal-dot.events-cal-dot-appointment

All verified by grep across frontend/ + internal/ to have no
non-calendar consumers before deletion.

Dead i18n keys removed (DE + EN + i18n-keys.ts union type):
- deadlines.kalender.{title,heading,subtitle,list,today,empty}
- appointments.kalender.{title,heading,subtitle,list,empty}
- deadlines.list.calendar, appointments.list.calendar (button labels
  on the deleted standalone routes)
- events.calendar.empty (replaced by cal.day.no_entries inside
  mountCalendar's day view)

Per head decisions §11 Q1 + Q8 (drop standalone pages as 301s; drop
dead i18n now).

Tests: go build ./... clean; go test ./internal/... 9 packages pass;
cd frontend && bun run build clean (2535 i18n keys); bun test
frontend/src/client/{calendar,views}/ all 73/73 pass.
2026-05-20 15:23:28 +02:00
mAi
d0f732d0ec refactor(events): t-paliad-224 — fold Kalender tab into mountCalendar()
The /events Kalender view now mounts the canonical mountCalendar()
module from frontend/src/client/calendar/ — same renderer Custom
Views uses for shape=calendar. Drops the events-page-specific
month-grid + popup code path entirely.

What replaces what
- renderCalendar() / openCalPopup() / calDotClass / fmtMonthYear /
  isoDate / itemDateISO and the calYear/calMonth module state →
  one mountCalendar() handle (lazy, urlState=true).
- events-cal-prev / events-cal-next / events-cal-today buttons →
  toolbar in mountCalendar (includes its own 'Heute' button).
- modal popup on cell click → drill-down to day view (matches
  /views; head decision §11 Q2).
- @media min-height shrink on .frist-cal-cell → views-calendar-*
  responsive surface (CSS unchanged from /views).

Behavioural deltas vs pre-refactor
- /events Kalender now persists view+anchor in ?cal_view + ?cal_date
  (head decision §11 Q3) — refresh / share-link safe.
- Pills are kind-coded (deadline / appointment) rather than urgency-
  coded; matches /views (head decision §11 Q4 — drop subtype dot
  colouring, file as follow-up).
- Empty-month message gone; the per-day no-entries state from the
  day-view replaces it (head decision §11 Q8 — drop dead i18n).

Adapter: toCalendarItem() preserves the pre-refactor bucketing rule
— deadlines bucket on due_date, appointments on start_at, both fall
back to event_date.

events.tsx: 31-line calendar subtree (toolbar + grid + modal +
empty hint) reduces to a single host div. mountCalendar fills it
when the user picks Kalender.
2026-05-20 15:23:28 +02:00
mAi
e83b150eda refactor(calendar): t-paliad-224 — extract mountCalendar() canon module
Lift the month/week/day renderer out of shape-calendar.ts into a new
frontend/src/client/calendar/mount-calendar.ts module so /events
Kalender (next commit) and Custom Views shape=calendar both go
through the same code path.

shape-calendar.ts becomes a thin adapter (ViewRow → CalendarItem +
defaultView=render.calendar.default_view, urlState=true). The
extracted module adds:

- update(items) on the returned handle so /events can re-mount on
  filter changes without rebuilding state.
- destroy() for clean teardown when /events switches shapes.
- A 'Heute' button in the toolbar (cal.today, DE+EN added to i18n.ts
  + i18n-keys.ts).
- Optional opts.urlPrefix for surfaces that may share a URL with
  another calendar.

mountCalendar reads ?cal_view / ?cal_date when opts.urlState=true.
/events will mount with urlState=true after the next commit so the
Kalender tab + day-view drill remain refresh-stable (per §11 Q3 in
the design doc).

Pure-helper test suite (mount-calendar.test.ts) covers isoDate,
startOfDay, startOfWeek, shift, bucketByDate, filterByDay, isToday —
12 assertions, all green. DOM rendering covered by manual smoke (no
jsdom in this repo's bun test setup; see verfahrensablauf-core.test.
ts comment for the convention).
2026-05-20 15:23:28 +02:00
mAi
2320cb765d docs(design): t-paliad-224 — head accepted all 8 (R) defaults
Decisions section §12 filled in per head msg #2087. Status → ACCEPTED.
Coder shift proceeds on same branch per Q7(R): single PR.
2026-05-20 15:23:28 +02:00
mAi
668558380d docs(design): t-paliad-224 — align calendar views (m/paliad#55)
Audit + refactor plan: three calendar implementations live today —
/events tab, standalone /deadlines|appointments/calendar pages, and
Custom Views shape-calendar.ts. Canonicalise on shape-calendar.ts by
extracting a shared mount-calendar.ts module, fold /events into it,
retire the standalone pages as 301 redirects, delete ~180 lines of
duplicated CSS.

Net: ~700 LOC removed, ~100 added, zero schema/endpoint changes.

8 open questions for head in §11; AskUserQuestion is disabled for this
task per role brief, so head answers via mai instruct and decisions
land in §12.
2026-05-20 15:23:28 +02:00
mAi
9dd47a0591 Merge: t-paliad-223 Slice B — Add User on /admin/team (m/paliad#49)
Completes t-paliad-223 (team & admin surface). Slice A (Project Admin role
+ inheritable role-edit) and Slice C (click-to-select) already merged at
111c7c3.

- SupabaseAdminService + AdminCreateUserFull — auth.users create via the
  Supabase Admin API (requires SUPABASE_SERVICE_ROLE_KEY env, provisioned
  on paliad's Dokploy compose by head 2026-05-20). Best-effort rollback
  on paliad.users insert failure: deletes the auth row to keep state
  clean.
- Welcome email with magic link sent on create when 'Send welcome email'
  checkbox is on (default per Q2).
- POST /api/admin/users/full endpoint, gated on global_admin.
- Frontend modal on /admin/team — 'Add user' button alongside the
  existing 'Invite colleague' / 'Onboard existing' actions.
- i18n keys for the new modal and toast feedback.
- Tests: happy path, duplicate-email refusal, paliad.users insert failure
  with best-effort auth rollback.

t-paliad-223 fully shipped.
2026-05-20 15:20:13 +02:00
mAi
3d3a4fa36d feat(team-admin): t-paliad-223 Slice B — Add User via Supabase Admin API
#49 — adds a third "Konto direkt anlegen" path on /admin/team alongside
"Onboard existing" and "Invite colleague". Creates both auth.users (via
Supabase Admin API) and paliad.users in one click; new user is visible in
dropdowns immediately and receives a paliad-branded magic-link email.

- internal/services/supabase_admin.go: new SupabaseAdminClient — thin net/http shim. 3 methods (CreateAuthUser, GenerateRecoveryLink, DeleteAuthUser). 10s timeout. ErrSupabaseAdminUnavailable when key unset, ErrSupabaseEmailExists when 422-with-"already" returned. apikey + Bearer headers on every call. Sentinel errors for handler mapping.
- internal/services/supabase_admin_test.go: 5 tests pin wire-shape (disabled mode, happy-path POST + headers + body, email-exists mapping, both action-link response shapes, DELETE-by-id route).
- internal/services/user_service.go: UserService grows optional supabase + mail + baseURL dependencies via SetAddUserDeps. AdminCreateFullInput (email/display_name/office/job_title/profession/lang/send_welcome_mail + inviter fields). AdminCreateUserFull validates input → calls supabase.CreateAuthUser → inserts paliad.users (best-effort DeleteAuthUser rollback on insert fail) → writes paliad.system_audit_log row (event_type='user.added_by_admin') → sends welcome mail with magic-link (best-effort).
- internal/templates/email/add_user_welcome.{de,en}.html: new template with magic-link CTA + base-URL fallback + firm-name placeholder. Editable through the existing /admin/email-templates editor (admin-overridable via DB).
- internal/services/email_template_*.go: register 'add_user_welcome' as a fourth canonical key, defaultSubjects entry, sample data, variable contract (6 vars).
- internal/services/mail_service_test.go: TestRenderTemplateAddUserWelcome pins both langs render with magic-link + firm + matching subject.
- internal/handlers/admin_users.go: handleAdminCreateFullUser POST /api/admin/users/full. Fills inviter fields from auth.uid() server-side (never trusts the request body). Error map: 503 (unavailable), 409 (email exists / already onboarded), 400 (invalid input), 403 (domain not on whitelist), 500 (other).
- internal/handlers/handlers.go: route registered behind adminGate.
- cmd/server/main.go: LoadSupabaseAdminClient + users.SetAddUserDeps + boot-log line so the deployer knows whether the path is active.
- frontend/src/admin-team.tsx: "Konto direkt anlegen" button + admin-add-full-modal with email/name/office/profession/job_title/lang fields + send-welcome checkbox (default on).
- frontend/src/client/admin-team.ts: initAddFullModal — POST to /api/admin/users/full, inline error handling for 503 / 409 / generic, optimistic insert into users[] on success, name auto-fills from email local-part on blur.
- i18n: +20 keys (admin.team.add.full + admin.team.add_full.*) × DE + EN.

Design picks honoured: Supabase Admin API path (Q1), welcome email default on (Q2), two-step with best-effort rollback (Q3), job_title default 'Associate' (Q4), profession default 'associate' (Q5). Trade-off #3 from §6 (privileged credential broadens trust surface) accepted by m via head.

go build && go test -short ./internal/... + bun run build all green.
2026-05-20 15:19:48 +02:00
mAi
1c021ed515 Merge: t-paliad-222 — project metadata rework (m/paliad#47 Client Role + m/paliad#50 auto-derived project codes)
Two related issues bundled in one PR.

## #47 Client Role
- mig 112 widens projects.our_side CHECK with new sub-roles: Active
  (claimant/applicant/appellant), Reactive (defendant/respondent),
  third_party/other. Drops 'court' + 'both' (semantically odd; backfilled
  to NULL).
- ProjectFormFields.tsx hides the field on type='client', 'litigation',
  'patent'; shows 'Client Role' on type='case' with 7 grouped options.
- Submission template variable bag — ourSideDE / ourSideEN updated for
  the new values. Determinator perspective inference: Active →
  claimant-perspective, Reactive → defendant-perspective.

## #50 Auto-derived project codes
- mig 113 adds paliad.projects.opponent_code text on litigations (vs
  brittle regex on title).
- New Go helper services/project_code.go: BuildProjectCode(ctx, projectID)
  walks the ltree ancestor chain, derives <CLIENT>.<OPPONENT>.<PATENT>.<TYPE>.<COURT>
  (each segment optional). Custom override via projects.reference still wins.
- Project JSON gets an eager 'code' field populated by the service (no
  per-render lookups; one DB round-trip per list page).
- Rendered as a second header badge on /projects/{id} + in the parent-picker
  typeahead so users see the auto code while organising the tree.

Both migrations land cleanly via the new gap-tolerant runner (boltzmann
c85c382). 376-line project_code_test.go covers the segment-derivation
matrix.
2026-05-20 14:56:25 +02:00
mAi
35217fab4f feat(project-picker): show auto-derived project code in parent typeahead
t-paliad-222 follow-up — wire .code into the parent-project picker so
two same-titled projects in different trees can be disambiguated by
their auto-derived dotted code. Search includes the code; the badge
renders only when distinct from the manual reference.

Excel __meta sheet still pending — the JSON code field is populated
by PopulateProjectCodes for every list payload, so the export
generator only needs to add one row in a follow-up shift.
2026-05-20 14:55:55 +02:00
mAi
225204cf1c feat(projects-detail): render auto-derived project code as a second header badge
t-paliad-222 follow-up — wire the .code field populated by
PopulateProjectCodes into the project-detail header. Shows next to
the manual reference when distinct, hidden when they match (avoid
duplication) or when no segments resolved. CSS `.entity-ref-code`
adds bracket-styling so the user knows the value is derived rather
than typed.

Also extends the frontend Project interface with code + opponent_code
to make TypeScript surface the new fields cleanly across consumers.
2026-05-20 14:55:55 +02:00
mAi
ea0715a8c7 feat(projects): t-paliad-222 — Client Role + auto-derived project codes
Implements m/paliad#47 (Client Role rework) + m/paliad#50 (auto-derived
project codes from the ancestor tree) in one shift.

Migrations:
- mig 112_client_role_rework: widen paliad.projects.our_side CHECK to
  seven sub-roles (claimant / defendant / applicant / appellant /
  respondent / third_party / other); drop legacy 'court' / 'both'
  and backfill rows to NULL (no-op on prod, defensive on staging).
- mig 113_projects_opponent_code: add paliad.projects.opponent_code
  text on litigation rows (slug pattern [A-Z0-9-]{1,16}); used as
  the middle segment when assembling auto-derived project codes.

Backend:
- internal/services/project_code.go — new package-level helpers
  BuildProjectCode (single row) + PopulateProjectCodes (bulk, one
  CTE-based round-trip). Walks the existing paliad.projects.path
  ltree; custom paliad.projects.reference on the target wins.
- Wired into ProjectService.List, GetByID, ListAncestors, GetTree,
  LoadCounterclaimChildrenVisible, BuildTreeWithOptions — every
  service entry-point that returns []models.Project / *models.Project
  populates .Code before returning.
- Models: Project.OurSide doc widened; new Project.OpponentCode
  (db:"opponent_code") and Project.Code (db:"-", projection-only).
- CreateProjectInput / UpdateProjectInput accept OpponentCode;
  validateOpponentCode + nullableOpponentCode mirror our_side helpers.
- validateOurSide widens to the seven sub-roles; legacy 'court' /
  'both' rejected at the service layer with a clear error before
  the DB CHECK fires.
- derivedCounterclaimOurSide CCR flip widened: applicant ↔ respondent,
  appellant → respondent; third_party / other / NULL pass through.
- submission_vars: project.code added to the placeholder bag.
  ourSideDE / ourSideEN now use the gender-neutral "-Seite" /
  "-Partei" suffix shape (Klägerseite / Antragstellerseite / ...);
  better legal-prose default for a B2B patent practice, matches the
  form labels which already used this shape (cf. head's soft-note on
  Q4).

Frontend:
- ProjectFormFields: opponent_code on a new projekt-fields-litigation
  block (hidden by default, shown when type=litigation); our_side
  moved into projekt-fields-case and re-labelled "Client Role" /
  "Mandantenrolle" with three <optgroup>s + seven options.
- project-form.ts: showFieldsForType toggles the new litigation
  block; readPayload / prefillForm wire opponent_code; our_side
  is now only emitted for type=case.
- fristenrechner: ourSideToPerspective widened to the seven sub-roles
  (Active→claimant, Reactive→defendant, Other→null). ProjectOption
  type literal updated.
- i18n.ts: new projects.field.client_role.* and
  projects.field.opponent_code.* keys (DE+EN). Legacy
  projects.field.our_side.* keys stay one release for cached
  bundles + Verlauf event-history rendering of the new sub-roles.

Tests:
- TestProjectCodeSegment, TestAssembleProjectCode, TestPatentLast3,
  TestSanitizeClientShort, TestProceedingTail, TestValidateOpponentCode,
  TestValidateOurSideSubRoles pin the new pure helpers.
- TestOurSideTranslations widened to the seven sub-roles + new
  prose shape; 'court'/'both' arms now return "" (legacy rejected).
- TestDerivedCounterclaimOurSide widened to the new flip map.

Migration slot history (this branch was rebumped twice on 2026-05-20):
mig 110 was claimed by m/paliad#51 (project_type_other, euler);
mig 111 was claimed by m/paliad#48 (project_admin_and_select, gauss).
Final slots 112 / 113.

go build && go test ./internal/... && cd frontend && bun run build
all clean.
2026-05-20 14:55:55 +02:00
mAi
3fdc969902 wip(projects): bump migrations 110→111, 111→112 (euler claimed 110) 2026-05-20 14:55:55 +02:00
mAi
5dea0a703b wip(projects): t-paliad-222 — backend + frontend changes (pre-merge checkpoint)
Backend: mig 110/111 (will be renumbered after merging main),
validators + helpers widened, BuildProjectCode helper + projection
populator wired into List/GetByID/ListAncestors/GetTree/CCR. All
internal Go tests pass.

Frontend: ProjectFormFields conditional render — opponent_code on
litigation, our_side renamed to Client Role on case with grouped
optgroups. i18n keys for both DE and EN. fristenrechner perspective
mapping widened. project-form.ts payload reader/writer + showFieldsForType
toggle for new litigation block.

Migration slots about to be bumped (mig 110 was claimed by euler's
project_type_other on main).
2026-05-20 14:55:55 +02:00
mAi
cc23e9e537 design(projects): t-paliad-222 — Client Role + auto-derived project codes
Design doc for paired m/paliad#47 (Client Role rework) + m/paliad#50
(auto-derived project codes from the ancestor tree). Two migrations
(110 widen our_side CHECK + backfill court/both → NULL; 111 add
opponent_code on litigations), one new BuildProjectCode helper that
walks the existing ltree path, plus form / submission-template /
Determinator wiring.

9 open design questions surfaced for the head; recommendations
default to the issue-body (R) picks unless a material concern is
flagged in §2.2 / §3.2.

Verified against live data (2026-05-20): all 12 projects have
our_side=NULL, so the backfill is a no-op on production today.
No 'opponent' field exists yet.
2026-05-20 14:55:55 +02:00
mAi
ca770636f7 Merge: m/paliad#58 — UPC CCR Procedure Roadmap (EN label + spawn-as-standalone renderer)
m's 2-part feedback on the UPC Counterclaim-for-Revocation roadmap surface.

1. Backfill missing English label on the trigger event 'Widerklage auf Nichtigkeit'
   → 'Counterclaim for Revocation'. Handled in services/proceeding_mapping.go
   (application layer; no corpus migration needed).
2. Generic 'spawn-as-standalone' renderer for sub-track proceeding types that
   have no native rules but spawn under a parent flag (CCR under
   upc.inf.cfi+with_ccr is the canonical case; the same pattern applies to
   R.46 Amendment etc.). When picked standalone, the timeline now renders the
   spawned rules with a contextual note explaining the normal parent context.

40 new unit tests in proceeding_mapping_test.go pin the renderer's standalone
detection + the EN label coverage.
2026-05-20 14:53:48 +02:00
mAi
ea9823db80 fix(verfahrensablauf): m/paliad#58 — UPC CCR roadmap (EN label + spawn-as-standalone)
m's 2026-05-20 14:08 reports on /tools/verfahrensablauf:

  1. "There seems to be a lacking english term here" — picking
     UPC CCR shows "Trigger event: Widerklage auf Nichtigkeit" on EN.
  2. "Nothing shows in the roadmap" — the timeline is empty because
     upc.ccr.cfi has no native rules (it's an illustrative peer that
     normally runs as a sub-track of upc.inf.cfi with with_ccr).

Root cause for (1): UIResponse.proceedingName was DE-only. When a
proceeding had no root rule the frontend fell back to that field, so
EN users saw the DE label. The DB already has bilingual names; this
was pure plumbing.

Root cause for (2): the upc.ccr.cfi proceeding-type row exists for
the picker (mig 096) but ResolveCounterclaimRouting — the helper
that maps it to upc.inf.cfi with the with_ccr flag — was defined
but never called. Calculate queried rules directly off upc.ccr.cfi
and got an empty list.

Fix:

  * Add ProceedingNameEN, ContextualNote, ContextualNoteEN to
    UIResponse. Frontend triggerEventLabelFor now consults the EN
    name on EN, falling back to DE only if the EN field is empty.
  * New SubTrackRouting registry in proceeding_mapping.go and a
    LookupSubTrackRouting lookup — single source of truth for the
    "this proceeding has no native rules, route to a parent with
    flags + show a contextual note" pattern. Today's only entry is
    upc.ccr.cfi → upc.inf.cfi + with_ccr; the pattern generalises
    to other sub-tracks via data-only additions.
  * Calculate consults the registry at the top: when a hit, the
    proceeding type is re-resolved to the parent for rule lookup, the
    default flags are merged into the user's flag set (user flags win
    on conflict), and the response identity (Code/Name/NameEN) stays
    on the user-picked proceeding so the page header still reads
    "Counterclaim for Revocation". The bilingual note surfaces in
    ContextualNote{,EN}.
  * Frontend renderResults paints a lime-accent banner above the
    timeline body when the response carries a note
    (.timeline-context-note). escHtml already exported from
    views/verfahrensablauf-core — imported here for the banner.

No DB migration: SELECTs against paliad.proceeding_types,
paliad.deadline_rules, and paliad.trigger_events confirm every
active row already has a non-empty name_en / name. The bug was
the API + frontend never reading the EN columns through the
proceedingName fallback path.

Tests: TestSubTrackRoutings pins the registry shape (every entry
has matching key/value, non-empty parent+flags, bilingual notes;
CCR's exact shape is asserted; non-sub-tracks miss). The existing
TestResolveCounterclaimRouting continues to pass because the
helper now consults the registry but the CCR semantics are
unchanged.
2026-05-20 14:53:22 +02:00
88 changed files with 12154 additions and 3580 deletions

View File

@@ -128,6 +128,20 @@ func main() {
inviteSvc := services.NewInviteService(pool, mailSvc, handlers.AllowedEmailDomains, baseURL)
reminderSvc := services.NewReminderService(pool, mailSvc, users, baseURL)
// t-paliad-223 Slice B (#49) — Supabase Admin API client for the
// new "Konto direkt anlegen" path on /admin/team. The key is
// optional: when unset the client still wires (so dependents
// don't panic) but every call short-circuits with
// ErrSupabaseAdminUnavailable so the rest of the server stays
// runnable.
supabaseAdminClient := services.LoadSupabaseAdminClient()
if supabaseAdminClient.Enabled() {
log.Println("supabase admin API configured — /admin/team Add-User path active")
} else {
log.Println("SUPABASE_SERVICE_ROLE_KEY not set — /admin/team Add-User path will return 503")
}
users.SetAddUserDeps(supabaseAdminClient, mailSvc, baseURL)
// Wire EmailTemplateService onto the MailService so DB-backed admin
// edits propagate without a process restart. The constructor is split
// from MailService creation because the DB pool isn't available yet
@@ -137,6 +151,11 @@ func main() {
eventTypeSvc := services.NewEventTypeService(pool, users)
deadlineSvc := services.NewDeadlineService(pool, projectSvc, eventTypeSvc)
// t-paliad-225 Slice A — user-authored checklist templates.
// Slice B adds checklist_shares grants + admin promotion.
checklistCatalogSvc := services.NewChecklistCatalogService(pool)
sysAuditSvc := services.NewSystemAuditLogService(pool)
checklistTemplateSvc := services.NewChecklistTemplateService(pool, checklistCatalogSvc, sysAuditSvc, users)
svcBundle = &handlers.Services{
Project: projectSvc,
Team: teamSvc,
@@ -165,7 +184,11 @@ func main() {
EventType: eventTypeSvc,
Dashboard: services.NewDashboardService(pool, users),
Note: services.NewNoteService(pool, projectSvc, appointmentSvc),
ChecklistInst: services.NewChecklistInstanceService(pool, projectSvc),
ChecklistInst: services.NewChecklistInstanceService(pool, projectSvc, checklistCatalogSvc),
ChecklistCatalog: checklistCatalogSvc,
ChecklistTemplate: checklistTemplateSvc,
ChecklistShare: services.NewChecklistShareService(pool, checklistTemplateSvc, sysAuditSvc, users),
ChecklistPromotion: services.NewChecklistPromotionService(pool, checklistTemplateSvc, sysAuditSvc, users),
Mail: mailSvc,
Invite: inviteSvc,
Agenda: services.NewAgendaService(pool, users, eventTypeSvc),
@@ -178,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
@@ -194,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

@@ -0,0 +1,448 @@
# Design: Align calendar-view rendering between Events/Termine and Custom Views
**Task:** t-paliad-224 — m/paliad#55
**Author:** bohr (inventor)
**Date:** 2026-05-20
**Status:** ACCEPTED — all 8 (R) defaults confirmed by head 2026-05-20 (msg #2087); coder shift authorised on same branch.
**Branch:** `mai/bohr/calendar-view-align`
---
## 0. Premise check (verified against live source 2026-05-20)
m's brief mentions two surfaces ("Events/Termine" and "Custom Views' calendar view type"). The live codebase has **three** distinct calendar implementations, not two:
| | A — Events tab | B — Standalone | C — Custom Views |
|---|---|---|---|
| URL | `/events?type=…&` calendar tab | `/deadlines/calendar`, `/appointments/calendar` | `/views/{slug}` with `render_spec.shape="calendar"` |
| Shell TSX | `frontend/src/events.tsx:239-269` (inline `events-calendar-wrap` block) | `frontend/src/deadlines-calendar.tsx`, `frontend/src/appointments-calendar.tsx` | `frontend/src/views.tsx:104` (`views-shape-calendar` host) |
| Renderer | `frontend/src/client/events.ts:589-656` (`renderCalendar()`) | `frontend/src/client/deadlines-calendar.ts`, `frontend/src/client/appointments-calendar.ts` | `frontend/src/client/views/shape-calendar.ts` (525 lines, mounted from `client/views.ts:227`) |
| Build entry | `events.html` (one bundle) | `deadlines-calendar.html` + `appointments-calendar.html` (two extra bundles) — `frontend/build.ts:258,261,387,390` | none (mounted into the views host at runtime) |
| Handler | `handleEventsPage` | `handleDeadlinesCalendarPage`, `handleAppointmentsCalendarPage``internal/handlers/handlers.go:470,476`; impls in `internal/handlers/deadlines_pages.go:26`, `internal/handlers/appointments_pages.go:27` | `handleViewsBySlug` |
**Reachability of B (standalone calendars).** `grep` for the URL strings inside `frontend/` finds only `paliadin-context.ts:96,100` (which decode the URL when the user is **already** on the page). The current Sidebar (`frontend/src/components/Sidebar.tsx:162-163`) routes to `/events?type=deadline` and `/events?type=appointment` — the calendar tab inside `/events` is the only UI-reachable calendar today. Routes B exist but are orphaned in navigation; they live for bookmarks / external links / paliadin context.
The brief's choice of canonical renderer ("likely the Custom Views renderer if it's the more recent / general one") is the right one — verified below in §3.
---
## 1. m's intent (as I read it)
> "the calendar views in Events / Termine are different than in the custom views calendar view type. That should be aligned!"
The literal statement is about visual + behavioural parity. Read alongside the brief's "drop the duplicate code path" and the explicit naming of `shape-calendar.ts` / `appointments-calendar.tsx` / `client/appointments-calendar.ts`, the intent is:
1. **One calendar component**, mounted from both the events-page surface and the custom-views surface.
2. **Identical visual output** when the same items land in either surface.
3. **No duplicate code path** — orphaned standalone calendar TSX + client + dist pages go.
4. **Alignment first, not new features** — drag-to-create / week-resize / etc. are explicitly out of scope per the issue body.
The smallest-diff path that delivers that intent is "canonicalise on shape-calendar.ts and fold A in" — see §3.
---
## 2. What actually diverges today
Side-by-side after reading all three implementations (cited line numbers above):
| Dimension | A (`/events` tab) | B (`/deadlines/calendar`, `/appointments/calendar`) | C (Custom Views) |
|---|---|---|---|
| Views offered | month only | month only | month + week + day |
| URL deep-link state | none (calendar month is in-memory, lost on refresh) | none | yes — `?cal_view=…&cal_date=YYYY-MM-DD` |
| Cell content | day-num + max 4 dots + "+N" | day-num + max 4 dots + "+N" | day-num + max 3 text **pills** + "+N" |
| Dot/pill colour key | urgency for deadlines (`frist-urgency-overdue/soon/later/done`) + single appointment colour (`events-cal-dot-appointment`) — mixed semantics | (deadlines page) urgency only; (appointments page) appointment-type colours via `termin-type-hearing/meeting/consultation/deadline_hearing` + legend strip | **kind-coded**`views-calendar-pill--{deadline|appointment|project_event|approval_request}` |
| Today indicator | accent circle on day-number (`frist-cal-today .frist-cal-day` → coloured pill) | identical to A | border + inset box-shadow ring on entire cell (`views-calendar-cell--today`) |
| Click cell | opens modal popup (`#events-cal-popup`) listing the day's items | opens modal popup (`#cal-popup`) | drills into **day view** (changes URL via `?cal_view=day&cal_date=…`), no modal |
| "+N" overflow | rendered as static `.frist-cal-more` span (not clickable) | identical | rendered as a button — opens the day view (same drill as the day-num button) |
| Empty state | per-month "Keine Einträge im ausgewählten Zeitraum." | per-month "Keine Fristen…" / "Keine Termine…" | per-day in week/day views ("Keine Einträge."), no per-month empty in month view |
| Toolbar | inline month-label + Heute button | identical | view-switcher chips (M/W/D) + range-label + (in day/week) "Zurück zum Monat" link |
| Weekday header | 7 static `.frist-cal-weekday` divs hard-coded in TSX | identical | rendered inline in the JS grid (single grid spans weekday row + day cells) |
| Mobile fallback | `@media (max-width: 700px)` shrinks cell min-height to 64px (CSS-only) | identical | `<600px` → adds a notice + uses cards-style stack; CSS-only no special media query (notice is data-driven) |
| Data source | `/api/events` (one fetch, all items unfiltered by date) | `/api/deadlines` or `/api/appointments` separately | `/api/views/{slug}/run` (filter-spec backed, ViewRow[] discriminated by `kind`) |
| Item shape | `EventListItem` (discriminator field `type`) | `Deadline` or `Appointment` (typed) | `ViewRow` (discriminator field `kind`) |
| Detail link | `/deadlines/{id}` or `/appointments/{id}` from popup row | identical | direct anchor on the pill/row, no popup |
| Lang / i18n | `cal.day.*`, `events.calendar.empty` | `cal.day.*`, `appointments.kalender.empty`, `deadlines.kalender.empty`, `appointments.type.*` (legend) | `cal.day.*`, `cal.view.*`, `cal.month.{prev,next}`, `cal.week.*`, `cal.day.no_entries`, `views.calendar.mobile_fallback` |
The two A/B implementations are near-clones of each other — Slice C alignment alone wouldn't fix the bigger "two of these are the same code with a coat of paint" problem.
CSS surface: `.frist-cal-*` is consumed **only** by A + B (verified by grep across `frontend/` + `internal/` — no third party). After the refactor, the entire `.frist-cal-calendar`, `.frist-cal-grid`, `.frist-cal-cell{,-empty,-has}`, `.frist-cal-day`, `.frist-cal-today`, `.frist-cal-dot{*}`, `.frist-cal-more`, `.frist-cal-popup-*`, `.frist-cal-weekday`, `.termin-cal-legend{,-item}`, `.termin-cal-dot`, `.events-cal-dot-appointment` block in `frontend/src/styles/global.css:7464-7620` and `:8019-8023` and `:8680-8700` and `:11519-11533` is deletable. About **180 lines of CSS** go away.
---
## 3. Recommended design (TL;DR)
| Area | Recommendation | Smallest-diff alternative considered & rejected |
|---|---|---|
| **Canonical renderer** | `shape-calendar.ts` is the canonical renderer. Extract its mount API behind a small `mountCalendar(host, items, opts)` boundary so both /events and /views call it. | Two-way merge (cherry-pick best of both into a third component) — strictly more code, no clean canon to point coders at later. |
| **/events calendar tab** | Replaces inline month grid + popup with a `mountCalendar(host, items, { urlState: true, defaultView: "month" })` call. Drops `renderCalendar()`, `openCalPopup()`, `wireCalNav()`, and the entire `events-cal-*` TSX subtree. Gains month/week/day views, drill-down, URL state — for free. | Keep A as-is, only converge B with C: leaves the headline divergence (the one m sees in the UI today) unresolved. |
| **/deadlines/calendar + /appointments/calendar** | Routes redirect 301 to `/events?type=deadline&view=calendar` and `/events?type=appointment&view=calendar`. TSX + client + dist artefacts deleted. `paliadin-context.ts` entries for the old paths kept (the redirect target carries through to the same context label). | Delete routes outright: breaks bookmarks. A 301 is one line per route. |
| **Data adapter** | `client/events.ts` already loads `EventListItem[]` from `/api/events`. Adapter is a one-liner field rename (`type``kind`) — the rest of the shape is identical to `ViewRow`. Existing API endpoints unchanged. | Migrate /events tab to `/api/views/{slug}/run` with an ad-hoc filter spec: pulls a lot of substrate (filter spec assembly, view caching) into the events flow for zero gain when the existing API already returns the right shape. |
| **Per-shape config** | Reuse `CalendarConfig` (`default_view`, `show_weekends`). `/events` calendar tab passes `default_view: "month"` so it stays month-first; future surfaces can pass `"week"` if needed. | Hard-code "month" inside mountCalendar — closes the door on /events week/day tabs we may want later. |
| **Subtype dot colouring** | Drop the per-appointment-type colour legend (deadline-only colouring was urgency-based and mixed semantics with subtype anyway). Pills are kind-coded only — same as `/views/{slug}` with `shape=calendar` does today. Subtype colouring can be added later as a `CalendarConfig.subtype_colors: bool` flag if a user asks. | Preserve the type-colour legend on the events page: only the orphaned /appointments/calendar page exposes it today, and bringing it into /events means designing the legend at the events-page level (events can be deadlines OR appointments OR both per current chip filter). Easier to defer until requested. |
| **CSS** | Delete the `.frist-cal-*` block entirely (~180 lines). The single source of truth becomes `.views-calendar-*`. Same lime-green accent (`var(--color-accent)`), same surface tokens — colour parity is automatic. | Keep both blocks: leaves a CSS minefield where future devs are unsure which class to use. |
| **i18n** | New keys land under the existing `cal.*` namespace (`cal.view.month/week/day`, `cal.day.back_to_month`, `cal.day.open_day`, `cal.day.no_entries`, `views.calendar.mobile_fallback`). These already exist for Custom Views — no new strings needed. Delete the `appointments.kalender.*`, `deadlines.kalender.*`, `appointments.type.*` (legend-only) keys, plus `events.calendar.empty` (replaced by `cal.day.no_entries` at the day-view level). | Keep DE/EN strings as-is for compatibility: just delete-and-go. The keys aren't part of any user-saved data. |
**Net code change (estimated by file):**
- **Delete:** `frontend/src/appointments-calendar.tsx`, `frontend/src/deadlines-calendar.tsx`, `frontend/src/client/appointments-calendar.ts`, `frontend/src/client/deadlines-calendar.ts` — together ~560 lines.
- **Trim:** ~80 lines from `events.tsx` (calendar subtree), ~140 lines from `client/events.ts` (`renderCalendar`/`openCalPopup`/nav handlers/calendar state).
- **Trim:** ~180 lines from `global.css` (`.frist-cal-*` block).
- **Add:** `frontend/src/client/calendar/mount-calendar.ts` — the extracted public API (~60 lines incl. types).
- **Refactor:** `frontend/src/client/views/shape-calendar.ts` becomes a 30-line wrapper that calls `mountCalendar` with `urlState: true` and the spec's calendar config. Most of the existing 525 lines move into `mount-calendar.ts` verbatim.
- **Backend:** 4 lines total — turn the two standalone-calendar handlers into 301 redirects (one line each, plus matching delete of the standalone HTML file write in `frontend/build.ts:387,390`).
Net: **~700 LOC removed, ~100 LOC added, zero new endpoints, zero schema changes, zero new dependencies.**
---
## 4. Architecture sketch
```
┌─────────────────────────────┐
│ frontend/src/client/ │
│ calendar/ │
│ mount-calendar.ts ★ │ ← new shared module
│ types.ts (CalendarItem)│
└──────────────┬──────────────┘
┌────────────────────────┼─────────────────────────┐
│ │ │
client/events.ts (Kalender tab) client/views/ │
│ shape-calendar.ts │
│ (thin wrapper) │
│ │ │
│ ▼ │
│ client/views.ts │
│ paintRows(…, "calendar") │
│ │
└──────────────────────────────────────────────────┘
Data flows:
A: /events → fetch /api/events?type=…&status=… → EventListItem[]
→ toCalendarItem(items) → CalendarItem[]
→ mountCalendar(host, items, opts)
C: /views/{slug} → fetch /api/views/{slug}/run → ViewRow[]
→ toCalendarItem(rows) (noop-ish: rename typekind already done)
→ renderCalendarShape() → mountCalendar(host, items, opts)
```
### 4.1 The shared module (`mount-calendar.ts`)
```ts
// frontend/src/client/calendar/mount-calendar.ts
import { t, tDyn, getLang, type I18nKey } from "../i18n";
export type CalendarKind =
| "deadline" | "appointment" | "project_event" | "approval_request";
export interface CalendarItem {
kind: CalendarKind;
id: string;
title: string;
event_date: string; // ISO-8601; first 10 chars are yyyy-mm-dd
project_id?: string;
project_title?: string;
project_reference?: string;
}
export interface CalendarOpts {
defaultView?: "month" | "week" | "day";
/** If true, calendar reads/writes ?cal_view + ?cal_date (or the prefixed
* equivalents); if false, state is in-memory only (use for embedded
* calendars where URL state belongs to the host page). */
urlState?: boolean;
/** Optional prefix for URL params (default: empty). Set if more than
* one calendar might live on the same URL. */
urlPrefix?: string;
/** Optional override: how to render a row's href. Default uses the
* kind→/deadlines|/appointments|/inbox|/projects routing the existing
* shape-calendar.ts ships with. */
hrefFor?: (item: CalendarItem) => string;
}
export interface CalendarHandle {
/** Re-render with a new item set (e.g. after a filter change in /events). */
update(items: CalendarItem[]): void;
/** Tear down listeners + clear host. */
destroy(): void;
}
export function mountCalendar(
host: HTMLElement,
items: CalendarItem[],
opts?: CalendarOpts,
): CalendarHandle;
```
Internals lifted verbatim from `shape-calendar.ts` (toolbar, renderMonth/Week/Day, renderPill, renderRowAnchor, bucketByDate, filterByDay, startOfWeek, shift, isToday, isoDate, formatRangeLabel, formatWeekHeader, readView/Anchor, writeURL). Two tweaks:
- `readView`/`readAnchor`/`writeURL` accept the `urlPrefix` so embedded calendars on `/events?…&` don't clobber other pages' `?cal_view`.
- `urlState: false` skips the URL read/write entirely — initial state comes from `opts.defaultView` and "today".
### 4.2 `shape-calendar.ts` (after refactor)
```ts
import type { RenderSpec, ViewRow } from "./types";
import { mountCalendar, type CalendarItem } from "../calendar/mount-calendar";
export function renderCalendarShape(
host: HTMLElement, rows: ViewRow[], render: RenderSpec,
): void {
const items: CalendarItem[] = rows.map(r => ({
kind: r.kind,
id: r.id, title: r.title,
event_date: r.event_date,
project_id: r.project_id,
project_title: r.project_title,
project_reference: r.project_reference,
}));
mountCalendar(host, items, {
defaultView: render.calendar?.default_view ?? "month",
urlState: true,
});
}
```
### 4.3 `client/events.ts` (calendar arm only)
```ts
// near the top
import { mountCalendar, type CalendarItem, type CalendarHandle } from "./calendar/mount-calendar";
// state
let calendar: CalendarHandle | null = null;
// inside applyView() when switching to calendar view:
function ensureCalendarMounted(host: HTMLElement, items: CalendarItem[]) {
if (calendar) { calendar.update(items); return; }
calendar = mountCalendar(host, items, { urlState: false, defaultView: "month" });
}
// inside applyView() when switching AWAY from calendar:
function teardownCalendar() {
if (calendar) { calendar.destroy(); calendar = null; }
}
function toCalendarItem(it: EventListItem): CalendarItem {
return {
kind: it.type as CalendarKind, // type "deadline" | "appointment"
id: it.id, title: it.title,
event_date: itemDateISO(it) + "T00:00:00",
project_id: it.project_id,
project_title: it.project_title,
project_reference: it.project_reference,
};
}
```
`urlState: false` for /events because the page already owns its own URL contract (`?type=`, `?status=`, etc.) and a second calendar deep-link param set would compete with future events-page state. (See §11 Q3 — this is a defaultable preference, not a hard constraint.)
### 4.4 Standalone calendar redirects
```go
// internal/handlers/deadlines_pages.go
func handleDeadlinesCalendarPage(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/events?type=deadline&view=calendar", http.StatusMovedPermanently)
}
// internal/handlers/appointments_pages.go
func handleAppointmentsCalendarPage(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/events?type=appointment&view=calendar", http.StatusMovedPermanently)
}
```
The `view=calendar` query string is a **new** events-page URL contract — needs a one-line addition to `client/events.ts:readURLState()` (which already reads `type`, `status`) to honour `view`. Today the view is in-memory only; pinning it to URL is a free side-benefit of this refactor (and lets the redirects land users on the calendar, not on the cards view).
Build pipeline: delete entries `frontend/build.ts:258`, `261`, `387`, `390` (the two standalone calendar bundles + HTML writes). `paliadin-context.ts:96,100` keep their URL matches — the 301 fires server-side, so the client only ever sees `/events?type=…&view=calendar` (which already maps to a paliadin context).
---
## 5. Visual + interaction parity audit
Walking m's brief checklist against the proposed end-state (assuming the user is on /events Kalender tab after this refactor):
| Brief item | Today (A) | After refactor | Matches /views? |
|---|---|---|---|
| Event tile shape | dot | **pill with text** | ✓ |
| Color | mixed (urgency + single appointment colour) | **kind-coded** (deadline / appointment / project_event / approval_request) | ✓ |
| Click behaviour (navigate to detail) | modal popup → anchor | **direct anchor on pill** (no modal) | ✓ |
| Today highlight | accent circle on day-num | **border ring on entire cell + box-shadow** | ✓ |
| Weekday header | static TSX divs | **rendered inline in the JS grid** | ✓ |
| Date-range / project / type filter shape | same `EventListItem[]` post-adapter | identical adapter feeds same `CalendarItem[]` shape | ✓ shared loader contract |
Two surfaces still differ after the refactor — and that's by design:
1. **/events** still has its three view chips above the calendar (Karten / Liste / Kalender) because the events page is multi-shape at the outer level. /views also has its outer shape chips (Liste / Karten / Kalender / Timeline). Both surfaces' shape chips look identical (`agenda-chip-row`).
2. **/events** keeps the events-page-level filters (type chip, status select, project select, event-type/appointment-type filters) above the calendar; /views shows its filter-bar (filter-spec-driven axes) instead. Both surfaces' filter chrome is governed by the page, not the calendar — the calendar component itself is the same DOM either way.
---
## 6. Mobile parity
`shape-calendar.ts` today does a mobile fallback at <600px (`mountCalendar` would carry this behaviour over). The fallback appends a single `<p>` notice "Auf schmalen Bildschirmen empfehlen wir die Listenansicht" or equivalent (i18n key `views.calendar.mobile_fallback`). Cells still render and are responsive (the existing CSS uses CSS-grid + 1fr columns).
After this refactor:
- /events Kalender tab: gets the **same** notice + a contextual hint suggesting "Wechsle zu Karten oder Liste" (the events-page shape chips). One new i18n key, OR reuse the existing `views.calendar.mobile_fallback` and accept that it mentions "Listenansicht" generically.
- /views Kalender shape: behaviour unchanged from today.
Mobile audit boxes ticked:
| | Today A | Today B | Today C | After |
|---|---|---|---|---|
| Cell shrinks on narrow viewport | (min-height 64px) | | partial (cells stay 80px) | (carry the C behaviour, plus the @media min-height shrink ported) |
| Touch target size on pills | n/a (dots, not tappable) | n/a | OK (8px+ at 1x) but verify on a real phone during coder smoke | OK |
| Modal vs drill-down | modal (small viewports lose layout) | modal | drill-down (changes URL natural back button) | drill-down across both surfaces |
| Sidebar collision | sidebar collapses to bottom nav under 768px (existing behaviour) | identical | identical | identical |
One coder-time TODO: verify the drill-down day-view is comfortable on mobile (it's a vertical list, should be fine, but worth one Playwright screenshot during smoke).
---
## 7. Tests + smoke
Existing test coverage relevant to this refactor:
- `frontend/src/client/views/shape-timeline-cv.test.ts` sibling of shape-calendar, no calendar-specific tests today. Add `frontend/src/client/calendar/mount-calendar.test.ts` for the extracted module.
- No Go tests touch handler dispatch for `/deadlines/calendar` or `/appointments/calendar` specifically (verified by grep).
- `internal/services/render_spec_test.go` covers `CalendarConfig.validate()` unchanged.
New test plan:
1. **`mount-calendar.test.ts` (new)** table-driven:
- Empty `items[]` month view renders 7-column grid + no pills + (for /views) per-day "no entries" only in day/week views.
- `items[]` with mixed kinds pills get the correct `views-calendar-pill--{kind}` class.
- `?cal_view=week` week column grid renders.
- Today bucket flagged with `--today` class on the correct cell.
- `+N` overflow renders when items per day > MAX_PILLS_PER_MONTH_CELL (3).
- `update(items)` after first mount swaps content without leaking listeners (assert no double-fire on month-nav click).
2. **`client/events.ts`** — light test (existing pattern): after refactor, switching to Kalender chip mounts the calendar, switching away calls `destroy()`. No test exists for events.ts today (it's mostly DOM glue), so this is a new test or skip with a comment.
3. **Smoke (manual, with `bun run build` + dev server)**:
- /events Kalender tab loads, shows pills, click pill navigates to detail.
- Day-num click → day view (URL changes if urlState is on for /events per Q3).
- /views/{slug} with `render_spec.shape=calendar` (need a saved view or temporary system view to exercise) still loads identical pills + drill-down.
- /deadlines/calendar → 301 → /events?type=deadline&view=calendar lands on Kalender tab.
- /appointments/calendar → 301 → /events?type=appointment&view=calendar lands on Kalender tab.
- DE + EN language toggle on both surfaces.
- Light + dark theme on both.
4. **Build gate**: `go build ./... && go test ./internal/... && cd frontend && bun run build` must all be clean (per task brief).
---
## 8. Risks + mitigations
| Risk | Likelihood | Mitigation |
|---|---|---|
| Custom Views users have saved views with `shape=calendar` and rely on the current week/day behaviour | low (shape-calendar is the canonical, only behaviour I'm changing about it is making `urlState` opt-in) | The refactor is structural — same toolbar, same drill-down, same URL params for /views. `urlState=true` stays the default for that surface. |
| `paliadin-context.ts` keys (`deadlines.calendar`, `appointments.calendar`) become unreachable after redirects | low | The 301 fires before the client sees the URL; new URL maps to existing `events` context. If we want to preserve the labels, add `events?type=…&view=calendar` matchers in paliadin-context (one if-branch each) — recommend doing this in the same coder PR for tidiness. |
| Subtype colouring loss is a feature regression for someone who used /appointments/calendar's legend | low | The page is unreachable from the UI; nobody reaches it without a bookmark. Q4 below confirms with m. |
| Events-page calendar `urlState: false` means refresh loses the Kalender chip selection | medium (today: same — calendar is in-memory either way) | Either accept (status quo) or extend events.ts URL state to include `view` (~3 lines). Q3 below. |
| /events fetch is unfiltered by date (loads everything); on a busy team Kalender may load slow | medium (existing behaviour) | Not addressed by this refactor. Filed as follow-up in §10. Filter spec / /api/views path solves it but is out of scope here. |
| The 301 redirect to `/events?type=…&view=calendar` requires events.ts to honour `view=calendar` from the URL | hard requirement | Must include this in the coder PR. ~3 lines in `readURLState()`. |
---
## 9. What stays "out of scope" (consistent with the issue body)
- New calendar UX: drag-to-create, week-resize, hover-preview, multi-day event spans.
- Performance: switching `/events` to a date-window-bounded fetch (today it loads everything and filters client-side).
- A unified events↔views landing (e.g. /events as a Saved View). Discussed in `design-events-unification-2026-05-04.md` and `design-data-display-model-2026-05-06.md`; deliberately not folded in here.
- /agenda surface. It's a timeline-grouped feed, not a calendar grid — separate conversation if m wants to converge it.
- Subtype dot colouring (deferred per §3 trade-off row).
---
## 10. Follow-ups (file as separate issues after this lands)
1. **Date-windowed loading for /events Kalender.** Pass `?from=…&to=…` to `/api/events` matched to the visible month so a 5-year-old project history doesn't ship to the client on every Kalender open. Backend already accepts `from`/`to` per `internal/handlers/events.go`. Small.
2. **Per-shape config: subtype colouring.** Add `CalendarConfig.subtype_colors` (bool, default false). Surface a `--subtype-{value}` modifier on the pill so the appointment-type colour key can come back per-view, if a user asks.
3. **Multi-day event spans.** Most events are single-day; deadlines are point-in-time. But appointments have `end_at`. Today neither A nor C surfaces span-rendering. Defer until requested.
4. **/agenda convergence.** /agenda is a different visual (day-grouped feed), but the data shape is the same `EventListItem`. If m wants /agenda to disappear (it's a sibling overview entry today per `design-events-unification-2026-05-04.md`), consider folding it into /events as a fourth shape ("feed" / "agenda"). Out of this design's scope.
---
## 11. Open questions for head (NO AskUserQuestion — answered via mai instruct)
> The role brief disables `AskUserQuestion` for this task. Each question below has a defaulted answer marked **(R)**; head/m can confirm or override via `mai instruct head`. After head replies, decisions land in §12.
**Q1 — Canonical renderer.** Adopt `shape-calendar.ts` as the canon, fold A into it (§3 sketch), and retire the two standalone routes B as 301-redirects to `/events?type=…&view=calendar`?
- **(R) Yes** — covers m's intent ("pick the canonical one — likely the Custom Views renderer"). Net code goes down, no schema changes.
- Alternative: keep the standalone routes as standalone pages but make them call `mountCalendar` internally — adds nothing for users (page is unreachable), wastes a build target each.
- *(answer: yes / keep-standalone / something-else)*
**Q2 — Events-page Kalender tab: drill-down vs modal-popup.** Today /events Kalender opens a modal listing the day's items. After the refactor, clicking a day-num drills into the day view (changes view chip, same URL component, but the page swaps to a day-list). Drop the modal entirely?
- **(R) Drop the modal** — matches /views behaviour, gives a real day-view (not just a list of links), and removes one popup-management code path.
- Alternative: keep the modal on /events only (parity break — defeats the point of the issue).
- *(answer: drop / keep)*
**Q3 — URL state for the /events calendar.** Should the /events Kalender persist its view (month/week/day) and date in the URL via `?cal_view=…&cal_date=…` (matching /views)?
- **(R) Yes, persist** — refresh-stable, shareable, ~3 lines in `readURLState()`. /views does it. Cost is owning the param contract on /events.
- Alternative: in-memory only — today's behaviour. Keeps /events URL surface minimal.
- *(answer: persist / in-memory)*
**Q4 — Subtype dot colouring on appointments.** The orphaned /appointments/calendar today colours dots by appointment_type (Verhandlung / Besprechung / Beratung / Fristverhandlung) with a legend strip. After the refactor pills are kind-coded only (deadline vs appointment vs …). Drop subtype colouring?
- **(R) Drop now, file as follow-up** (§10.2) — page is UI-unreachable today; nobody will notice; can come back as a `CalendarConfig.subtype_colors` flag if/when requested.
- Alternative: preserve subtype colouring on /events Kalender tab as well, with a fresh legend matching the new pill colours.
- *(answer: drop / preserve)*
**Q5 — Mobile fallback text.** /views Kalender shows a notice "Auf schmalen Bildschirmen empfehlen wir die Listenansicht" (key `views.calendar.mobile_fallback`). Reuse the same key on /events, or add an /events-specific key recommending the events-page "Karten" or "Liste" shape?
- **(R) Reuse the existing key** — generic phrasing covers both surfaces; both have Karten/Liste alternatives.
- Alternative: dedicated key per surface — clearer copy but more strings to maintain.
- *(answer: reuse / dedicated)*
**Q6 — Test approach for the extracted module.** Add `mount-calendar.test.ts` with the seven listed cases (§7.1), OR also add a Playwright smoke that drives the new flow end-to-end through both surfaces?
- **(R) Unit tests + manual smoke gauntlet** — matches the codebase's existing test layout (most client/* tests are unit-level; Playwright is reserved for fewer flows). Manual smoke per §7.3 is the brief's bar.
- Alternative: unit + Playwright.
- *(answer: unit-only / unit-plus-playwright)*
**Q7 — Sequencing across PRs.** One PR (extract + adopt + retire + CSS prune) or three (extract, then adopt+retire, then CSS prune)?
- **(R) One PR** — refactors that don't bisect well are worse split (each intermediate state has unused exports / dead code paths / orphaned CSS classes for a few hours). The diff is reviewable in one read because it's mostly moves + deletes.
- Alternative: three PRs — easier rollback at each step, but you'd have to land #2 before m sees any UI alignment, which loses the point.
- *(answer: one-pr / three-pr)*
**Q8 — When (if at all) to delete /events `events.calendar.empty` i18n key.** Replaced by `cal.day.no_entries` in the new flow. Drop now or leave as a dead key in `i18n-keys.ts` for one release?
- **(R) Drop now** — i18n-keys.ts is the source of truth; dead keys aren't enforced at compile time but they're a slow-rotting maintenance tax. /events' new calendar surface doesn't render an "empty month" message any more (per-day "no entries" is the only empty state, matching /views).
- Alternative: leave for one release as a soft-deprecate.
- *(answer: drop / leave)*
---
## 12. m's decisions (2026-05-20, via head msg #2087)
Head accepted all 8 (R) defaults in one round-trip ("Design accepted in
full — all 8 (R) defaults stand"). Recorded verbatim below; each entry
is the (R) pick from §11.
- **Q1 — Canonical renderer:** Yes. Canonicalise on `shape-calendar.ts`; fold A into it via extracted `mountCalendar()`; retire B as 301 redirects to `/events?type=…&view=calendar`.
- **Q2 — Drill-down vs modal:** Drop the modal on /events. Day-num/+N click drills into the day view, matching /views.
- **Q3 — URL state on /events:** Persist. /events Kalender reads/writes `?cal_view=…&cal_date=…` like /views does. Adds `view=calendar` to `client/events.ts:readURLState()` so refreshes/redirects land on the Kalender tab.
- **Q4 — Subtype dot colouring:** Drop now. Filed as follow-up §10.2. Pills are kind-coded only after the refactor (deadline / appointment / project_event / approval_request).
- **Q5 — Mobile fallback text:** Reuse the existing `views.calendar.mobile_fallback` key on /events as well — generic phrasing covers both surfaces.
- **Q6 — Test approach:** Unit tests (`mount-calendar.test.ts`) + manual smoke gauntlet (§7.3). No Playwright on this refactor.
- **Q7 — Sequencing:** One PR. Extract + adopt + retire + CSS prune land together on `mai/bohr/calendar-view-align`.
- **Q8 — Empty-state i18n key:** Drop dead keys now (`events.calendar.empty`, `appointments.kalender.*`, `deadlines.kalender.*`, appointment-type legend keys not used elsewhere).
---
## 13. Coder hand-off (after m's go on §11)
Once §12 is filled in, the coder shift can proceed in this order:
1. Create `frontend/src/client/calendar/mount-calendar.ts` + `frontend/src/client/calendar/mount-calendar.test.ts`. Lift the shape-calendar internals; add `update`/`destroy` to the returned handle; pipe `urlState` + `urlPrefix` through.
2. Update `frontend/src/client/views/shape-calendar.ts` to delegate to `mountCalendar` (≈30 lines after the lift).
3. Update `frontend/src/client/events.ts`: import `mountCalendar`, replace `renderCalendar`/`openCalPopup` and nav handlers with a `mountCalendar(host, items, { urlState: <per Q3>, defaultView: "month" })` call inside the existing `applyView()` branch. Add the `view=calendar` URL state handling per Q3.
4. Update `frontend/src/events.tsx`: strip the `events-calendar-wrap` inline DOM (toolbar + grid + modal). The empty container `<div id="events-shape-calendar" />` plus a wrapper class is enough — `mountCalendar` builds the DOM.
5. Delete `frontend/src/appointments-calendar.tsx`, `frontend/src/deadlines-calendar.tsx`, `frontend/src/client/appointments-calendar.ts`, `frontend/src/client/deadlines-calendar.ts`.
6. Update `frontend/build.ts`: remove the `*-calendar.ts` entry-point lines (≈250s) and the `*-calendar.html` writes (≈387s).
7. Update `internal/handlers/deadlines_pages.go` + `internal/handlers/appointments_pages.go`: turn the two calendar handlers into 301 redirects to `/events?type=…&view=calendar`.
8. Update `frontend/src/styles/global.css`: delete `.frist-cal-*`, `.termin-cal-*`, `.events-cal-dot-appointment`, the 700px-media tweak (lines ~7464-7620, ~8019-8023, ~8680-8700, ~11519-11533). Sanity-check no other consumer (already verified via grep — none).
9. Update i18n: drop `appointments.kalender.*`, `deadlines.kalender.*`, `appointments.type.*` (legend keys only — keep type values used elsewhere), `events.calendar.empty` per Q8. Make sure `cal.view.*`, `cal.day.no_entries`, `cal.day.back_to_month`, `cal.day.open_day`, `views.calendar.mobile_fallback` (or a new events-specific key per Q5) all exist DE + EN — most already do.
10. `paliadin-context.ts`: optional one-line addition to map `events?view=calendar` to the new context label.
11. Run `go build ./... && go test ./internal/... && cd frontend && bun run build`.
12. Manual smoke per §7.3.
13. Commit. `mai report completed` with SHA per task brief.
Estimated coder shift: one PR per Q7 (R).
---

View File

@@ -0,0 +1,918 @@
# User-authored checklists: authoring, sharing, admin-promotion
**Task:** t-paliad-225 — Gitea m/paliad#61
**Inventor:** dirac, 2026-05-20
**Branch:** `mai/dirac/user-checklists`
**Status:** DESIGN READY FOR REVIEW
## 1. Problem statement
Paliad ships a curated catalog of UPC / DE / EPA checklists today
(`internal/checklists/templates.go`, 6 templates). Users instantiate them
on Akten and check items off; per-instance state lives in
`paliad.checklist_instances` and is gated by the parent project's
team-based visibility.
m wants three new capabilities (m 2026-05-20 14:14):
1. **User-authored templates** — any non-`global_admin` can create a
checklist template they own (title, sections, items, references).
2. **Sharing** — author shares with specific colleagues, an Office, a
Dezernat (partner-unit), a project team, or the whole firm.
3. **Admin promotion to global**`global_admin` promotes an authored
template into the firm-wide catalog so it appears alongside the
curated UPC/DE/EPA templates for every user.
This design covers all three across three sequential slices.
## 2. Premises verified live (load-bearing findings)
The Gitea issue body says "Add `owner_id uuid NULL` to
`paliad.checklists`". That table **does not exist**. Verifying against
the live DB and the code corrected several premises:
- **`paliad.checklists` does NOT exist as a DB table.** Templates today
are pure Go data in `internal/checklists/templates.go` (6 entries,
~310 lines), served by `internal/handlers/checklists.go` via
`checklists.Summaries()` and `checklists.Find(slug)`. The DB has
`paliad.checklist_instances` (per-user state) and
`paliad.checklist_feedback` (a thumbs-up/down sink). That's it. The
design has to introduce `paliad.checklists` from scratch.
- **`paliad.checklist_instances.template_slug` is `text` with no FK** —
validity is enforced in `ChecklistInstanceService.Create` against the
static Go registry. This is what lets the design keep the static
catalog as one source of truth and add the DB catalog as a parallel
source: instance creation just resolves the slug against the merged
view and snapshots the template body.
- **Migration tracker live = 106; on-disk head = 111.** Five unapplied
on-disk migrations (107 caldav-binding-id, 108 mkcalendar-capability,
109 user_dashboard_layouts, 110 project_type_other, 111
project_admin_and_select — gauss's t-paliad-223 Slice A, m-locked
today). At inventor time the next free slot is **112**. The coder
MUST re-verify with `ls internal/db/migrations/ | tail` at shift
start — the slot can drift if other branches merge first.
- **`paliad.effective_project_admin(_user_id, _project_id)` lands with
migration 111** (gauss, today). Mirrors `can_see_project`'s shape:
STABLE SECURITY DEFINER, ltree ancestor walk against `projects.path`,
branches on global_admin shortcut + project_teams responsibility =
'admin'. **Used by this design** to gate the "Make global" button (we
reuse the global_admin shortcut, not the project-admin branch — see
§4.4) and as the precedent for any new STABLE SECURITY DEFINER
predicates we add.
- **`paliad.system_audit_log` (mig 102) is the org-scope audit sink.**
Columns: `event_type` (free-text), `actor_id`, `actor_email`,
`scope` ∈ {org, project, personal}, `scope_root uuid`,
`metadata jsonb`. RLS: self-read for the actor +
global_admin read-all. **Pattern to follow:** insert event row at
state transition (see `ExportService.WriteAuditRow` in
`internal/services/export_service.go:1120` for the canonical shape).
- **`paliad.project_events`** is the project-timeline audit sink and is
already wired for checklist instance lifecycle events
(`checklist_created`, `_renamed`, `_unlinked`, `_linked`, `_reset`,
`_deleted`). We do NOT need to invent a new event_type for instance
events; we'll add a few `_snapshot_taken` / template-level events to
`system_audit_log` and keep instance events on `project_events`.
- **`paliad.users.office`** is `text` (CHECK against the office key
list in `internal/offices/offices.go` — 8 keys: munich, duesseldorf,
hamburg, amsterdam, london, paris, milan, madrid). Multi-office users
have `additional_offices text[]`. Both are first-class columns; no
separate `offices` table.
- **`paliad.partner_units`** (cols: id, name, lead_user_id, office,
timestamps) is the Dezernat / practice-group table. Membership lives
in `paliad.partner_unit_members`. Projects attach via
`paliad.project_partner_units` (with derivation flags). All three
are referenceable from a share recipient.
- **`paliad.users.global_role`** is `text`; values include
`'global_admin'`. Used for the firm-wide promote/demote authority.
- **`paliad.project_teams`** (mig 111 just added) carries
`responsibility` ∈ {admin, lead, member, observer, external}. We
reuse `can_see_project` (visibility) for share-to-project recipients,
NOT `effective_project_admin`. The semantic of "share with a project
team" is "anyone on the matter sees it", not "anyone who can edit
membership sees it".
- **No precedent for entity-level sharing in paliad.** The personal-
sidecar tables (`user_views`, `user_dashboard_layouts`,
`user_pinned_projects`, `user_card_layouts`) are owner-only with no
share columns. Existing visibility predicates
(`paliad.can_see_project`) walk the project tree, not arbitrary
entities. This design introduces the first multi-axis share pattern
in the codebase (§3.2).
## 3. Architecture: hybrid templates + share table
### 3.1 Two template sources, one read layer
**KEEP** the static Go template registry as the firm's curated catalog.
It's version-controlled, code-reviewed, immutable at runtime, and the
right substrate for legally-curated content (RoP citations, EPC rule
references). Migrating those into DB rows would lose the git review
trail for content that requires lawyer eyes.
**ADD** `paliad.checklists` as the DB catalog for user-authored content.
Same Template shape (slug, titles, regime, court, groups[], items[])
but stored as JSONB so the schema doesn't have to chase content
evolution.
A `ChecklistCatalogService` unifies the two at read time:
- `ListVisible(user)` → static templates DB rows the user can see
- `Find(slug, user)` → static lookup first, then DB lookup with visibility check
- Slug-uniqueness enforced **across both spaces** at write time (DB slugs
rejected if they collide with a static slug).
Existing `/api/checklists` and `/api/checklists/{slug}` endpoints keep
their JSON shape — they just delegate to the catalog service instead of
the bare static registry.
### 3.2 Multi-axis sharing — checklist-specific table, polymorphism deferred
The task brief asks for a "modular / abstract" solution. I considered a
polymorphic `paliad.entity_shares(target_kind, target_id, recipient_kind,
recipient_*)` table that could later carry shares for views, dashboards,
saved searches, project templates, etc.
**Decision: keep it checklist-specific (`paliad.checklist_shares`) for
v1.** Reasons:
1. There is NO second entity in paliad that requests sharing today —
`user_views`, `user_dashboard_layouts`, `user_card_layouts`,
`user_pinned_projects` are all explicitly owner-only by design (see
migration comments). The "future reuse" is hypothetical.
2. Polymorphic FKs forfeit ON DELETE CASCADE — every recipient kind
needs its own deletion trigger. That complexity is real, the
reusability gain is not.
3. The CORRECT abstraction emerges by extracting *after* the second use
case shows up. Right now we don't know whether dashboards want the
same recipient axes (user / office / partner-unit / project) or a
different set (e.g. dashboards probably want "everyone on a project"
not "the whole firm").
The design IS modular in the sense that the recipient resolution logic
(below) is centralized in one SQL predicate (§4.3) which a future
polymorphic refactor can lift verbatim.
If the second entity asks for sharing within ~3 months, refactor to
`paliad.entity_shares` as a single-mig follow-up. Until then,
`paliad.checklist_shares` keeps the schema honest.
### 3.3 Visibility states
`paliad.checklists.visibility text` (CHECK enum):
| state | who sees | who edits |
|-----------|----------------------------------------------------|---------------------|
| `private` | owner only | owner |
| `shared` | owner + explicit recipients in checklist_shares | owner |
| `firm` | owner + every authenticated paliad user | owner |
| `global` | owner + every authenticated paliad user + catalog | owner + global_admin|
`firm` vs `global` distinction:
- `firm` = author self-published. Author can flip back to private/shared
any time. Does NOT appear in the main `/checklists` Vorlagen tab; only
in the new "Geteilte Vorlagen" / "Shared by colleagues" surface.
- `global` = admin-promoted into the firm catalog. Appears in the main
Vorlagen tab alongside the static templates. Author retains edit
authority by default; only `global_admin` can demote.
Demotion target: `global → firm` (preserves visibility for users who
already started instances). Author can subsequently narrow further.
### 3.4 Template snapshot on instance create
m's brief calls this out as a design decision: when an author edits a
template, do existing instances pick up the changes (propagate) or stay
on the version they were created from (snapshot)?
**Pick: snapshot.** Inventor pick (R). Rationale:
1. **Data integrity.** Instances are working artefacts. A user halfway
through a Klageerwiderung instance shouldn't have items disappear or
reorder under them because the author edited the template.
2. **Audit story.** The completed instance shows exactly what the
author saw when they started. Reconstruction without git-blame on
the template.
3. **Visibility narrowing safe by construction.** If author unshares
from a colleague who already has an instance, the instance survives
because the snapshot is local.
4. Cost is trivial: a typical template is <2 KB JSONB; instances rarely
exceed a few per user per template. Even 10× the row size of today
is fine.
Schema cost: one nullable `template_snapshot jsonb` column on
`paliad.checklist_instances`. Backfilled lazily existing instances
keep `NULL`, service falls back to looking the slug up in the catalog;
new instances always get a snapshot. Slice C can backfill the column
for already-existing rows via a one-off `UPDATE` if we want strict
consistency.
## 4. Schema (migration 112 — verify slot at coder shift)
Single migration file `internal/db/migrations/112_user_checklists.up.sql`
+ matching `.down.sql`. Idempotent throughout
(`CREATE TABLE IF NOT EXISTS`, `DO $$ … EXCEPTION` guards).
> Slot caveat: at design time, latest disk = 111, live tracker = 106
> (mig 107-111 pending deploy). Coder MUST re-verify
> `ls internal/db/migrations/ | tail` at shift start. If a higher
> number lands first (e.g. boltzmann's gap-tolerant runner ships as
> 112), bump to the next free slot.
### 4.1 `paliad.checklists` — authored template catalog
```sql
CREATE TABLE paliad.checklists (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
slug text NOT NULL UNIQUE,
-- Authoring metadata
owner_id uuid NOT NULL REFERENCES paliad.users(id) ON DELETE CASCADE,
title text NOT NULL,
description text NOT NULL DEFAULT '',
regime text NOT NULL DEFAULT 'OTHER', -- UPC | DE | EPA | OTHER
court text NOT NULL DEFAULT '',
reference text NOT NULL DEFAULT '',
deadline text NOT NULL DEFAULT '',
lang text NOT NULL DEFAULT 'de', -- 'de' | 'en' — author's primary language
-- Body
body jsonb NOT NULL, -- { groups: [{ title, items: [{ label, note, rule }] }] }
-- Lifecycle
visibility text NOT NULL DEFAULT 'private'
CHECK (visibility IN ('private', 'shared', 'firm', 'global')),
promoted_at timestamptz, -- set on transition to 'global'
promoted_by uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
-- Timestamps
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX checklists_owner_idx ON paliad.checklists (owner_id);
CREATE INDEX checklists_visibility_idx ON paliad.checklists (visibility) WHERE visibility IN ('firm', 'global');
CREATE INDEX checklists_regime_idx ON paliad.checklists (regime);
```
**Slug-collision safety net:** application layer validates that the
chosen slug doesn't collide with a static template slug. The static
list is loaded into a `map[string]bool` at boot. New authored slugs
auto-prefixed with `u-` so collisions with static slugs are structurally
unlikely (`u-my-strategy-2026` vs `upc-statement-of-claim`).
### 4.2 `paliad.checklist_shares` — explicit grants
```sql
CREATE TABLE paliad.checklist_shares (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
checklist_id uuid NOT NULL REFERENCES paliad.checklists(id) ON DELETE CASCADE,
recipient_kind text NOT NULL CHECK (recipient_kind IN ('user', 'office', 'partner_unit', 'project')),
recipient_user_id uuid REFERENCES paliad.users(id) ON DELETE CASCADE,
recipient_office text,
recipient_partner_unit_id uuid REFERENCES paliad.partner_units(id) ON DELETE CASCADE,
recipient_project_id uuid REFERENCES paliad.projects(id) ON DELETE CASCADE,
granted_by uuid NOT NULL REFERENCES paliad.users(id) ON DELETE SET NULL,
granted_at timestamptz NOT NULL DEFAULT now(),
-- XOR check: exactly one recipient_* column populated per kind
CONSTRAINT checklist_shares_recipient_xor CHECK (
(recipient_kind = 'user' AND recipient_user_id IS NOT NULL AND recipient_office IS NULL AND recipient_partner_unit_id IS NULL AND recipient_project_id IS NULL)
OR (recipient_kind = 'office' AND recipient_office IS NOT NULL AND recipient_user_id IS NULL AND recipient_partner_unit_id IS NULL AND recipient_project_id IS NULL)
OR (recipient_kind = 'partner_unit' AND recipient_partner_unit_id IS NOT NULL AND recipient_user_id IS NULL AND recipient_office IS NULL AND recipient_project_id IS NULL)
OR (recipient_kind = 'project' AND recipient_project_id IS NOT NULL AND recipient_user_id IS NULL AND recipient_office IS NULL AND recipient_partner_unit_id IS NULL)
)
);
-- Avoid duplicates per recipient
CREATE UNIQUE INDEX checklist_shares_user_uniq ON paliad.checklist_shares (checklist_id, recipient_user_id) WHERE recipient_kind = 'user';
CREATE UNIQUE INDEX checklist_shares_office_uniq ON paliad.checklist_shares (checklist_id, recipient_office) WHERE recipient_kind = 'office';
CREATE UNIQUE INDEX checklist_shares_partner_unit_uniq ON paliad.checklist_shares (checklist_id, recipient_partner_unit_id) WHERE recipient_kind = 'partner_unit';
CREATE UNIQUE INDEX checklist_shares_project_uniq ON paliad.checklist_shares (checklist_id, recipient_project_id) WHERE recipient_kind = 'project';
-- Hot-path index for the visibility predicate
CREATE INDEX checklist_shares_lookup_idx ON paliad.checklist_shares (checklist_id);
```
### 4.3 `paliad.can_see_checklist(_user_id, _checklist_id)` predicate
```sql
CREATE OR REPLACE FUNCTION paliad.can_see_checklist(_user_id uuid, _checklist_id uuid)
RETURNS boolean
LANGUAGE sql STABLE SECURITY DEFINER
SET search_path TO 'paliad', 'public'
AS $$
-- Owner can always see
SELECT EXISTS (
SELECT 1 FROM paliad.checklists c
WHERE c.id = _checklist_id
AND c.owner_id = _user_id
)
-- 'firm' / 'global' visible to all authenticated users
OR EXISTS (
SELECT 1 FROM paliad.checklists c
WHERE c.id = _checklist_id
AND c.visibility IN ('firm', 'global')
)
-- Explicit share: user
OR EXISTS (
SELECT 1 FROM paliad.checklist_shares s
WHERE s.checklist_id = _checklist_id
AND s.recipient_kind = 'user'
AND s.recipient_user_id = _user_id
)
-- Explicit share: office (matches user.office OR additional_offices)
OR EXISTS (
SELECT 1
FROM paliad.checklist_shares s
JOIN paliad.users u ON u.id = _user_id
WHERE s.checklist_id = _checklist_id
AND s.recipient_kind = 'office'
AND (s.recipient_office = u.office
OR s.recipient_office = ANY(u.additional_offices))
)
-- Explicit share: partner_unit (caller is a member)
OR EXISTS (
SELECT 1
FROM paliad.checklist_shares s
JOIN paliad.partner_unit_members pum
ON pum.partner_unit_id = s.recipient_partner_unit_id
AND pum.user_id = _user_id
WHERE s.checklist_id = _checklist_id
AND s.recipient_kind = 'partner_unit'
)
-- Explicit share: project (caller can see the project via existing predicate)
OR EXISTS (
SELECT 1 FROM paliad.checklist_shares s
WHERE s.checklist_id = _checklist_id
AND s.recipient_kind = 'project'
AND paliad.can_see_project(s.recipient_project_id) -- reuses ltree walk
);
$$;
```
> Note on `can_see_project` self-reference: that function reads
> `auth.uid()` internally — when called from inside another SECURITY
> DEFINER body it picks up the caller's uid via search_path inheritance
> (same pattern as `effective_project_admin` reuse in mig 111).
### 4.4 RLS on `paliad.checklists`
```sql
ALTER TABLE paliad.checklists ENABLE ROW LEVEL SECURITY;
-- SELECT: owner OR visible via can_see_checklist
CREATE POLICY checklists_select
ON paliad.checklists FOR SELECT TO authenticated
USING (paliad.can_see_checklist(auth.uid(), id));
-- INSERT: caller can only create templates owned by themselves
CREATE POLICY checklists_insert
ON paliad.checklists FOR INSERT TO authenticated
WITH CHECK (owner_id = auth.uid());
-- UPDATE: owner always; global_admin if visibility='global' (for demotion)
CREATE POLICY checklists_update
ON paliad.checklists FOR UPDATE TO authenticated
USING (
owner_id = auth.uid()
OR EXISTS (
SELECT 1 FROM paliad.users u
WHERE u.id = auth.uid() AND u.global_role = 'global_admin'
)
)
WITH CHECK (
owner_id = auth.uid()
OR EXISTS (
SELECT 1 FROM paliad.users u
WHERE u.id = auth.uid() AND u.global_role = 'global_admin'
)
);
-- DELETE: owner OR global_admin
CREATE POLICY checklists_delete
ON paliad.checklists FOR DELETE TO authenticated
USING (
owner_id = auth.uid()
OR EXISTS (
SELECT 1 FROM paliad.users u
WHERE u.id = auth.uid() AND u.global_role = 'global_admin'
)
);
```
### 4.5 RLS on `paliad.checklist_shares`
```sql
ALTER TABLE paliad.checklist_shares ENABLE ROW LEVEL SECURITY;
-- SELECT: caller can see if they own the checklist OR they are the recipient OR global_admin
CREATE POLICY checklist_shares_select
ON paliad.checklist_shares FOR SELECT TO authenticated
USING (
EXISTS (SELECT 1 FROM paliad.checklists c WHERE c.id = checklist_id AND c.owner_id = auth.uid())
OR (recipient_kind = 'user' AND recipient_user_id = auth.uid())
OR EXISTS (SELECT 1 FROM paliad.users u WHERE u.id = auth.uid() AND u.global_role = 'global_admin')
);
-- INSERT: only the checklist owner can grant
CREATE POLICY checklist_shares_insert
ON paliad.checklist_shares FOR INSERT TO authenticated
WITH CHECK (
EXISTS (SELECT 1 FROM paliad.checklists c WHERE c.id = checklist_id AND c.owner_id = auth.uid())
AND granted_by = auth.uid()
);
-- DELETE: owner OR global_admin (no UPDATE policy — shares are immutable; revoke = delete + reinsert)
CREATE POLICY checklist_shares_delete
ON paliad.checklist_shares FOR DELETE TO authenticated
USING (
EXISTS (SELECT 1 FROM paliad.checklists c WHERE c.id = checklist_id AND c.owner_id = auth.uid())
OR EXISTS (SELECT 1 FROM paliad.users u WHERE u.id = auth.uid() AND u.global_role = 'global_admin')
);
```
### 4.6 `paliad.checklist_instances.template_snapshot jsonb`
```sql
-- Idempotent — column NULL on existing rows; service handles fallback to catalog lookup.
ALTER TABLE paliad.checklist_instances
ADD COLUMN IF NOT EXISTS template_snapshot jsonb;
```
Existing RLS on `checklist_instances` untouched.
## 5. Service layer
### 5.1 `internal/services/checklist_catalog_service.go` (new)
Unified read facade over static + DB templates.
```go
type ChecklistCatalogService struct {
db *sqlx.DB
}
type CatalogEntry struct {
Slug string // matches checklists.Template.Slug or paliad.checklists.slug
Origin string // "static" | "authored"
OwnerID *uuid.UUID // nil for static
OwnerName string // empty for static
Visibility string // "static" | "private" | "shared" | "firm" | "global"
Template checklists.Template
}
// ListVisible returns every catalog entry the caller can see.
// Static entries are always returned. DB entries pass through RLS.
func (s *ChecklistCatalogService) ListVisible(ctx context.Context, userID uuid.UUID) ([]CatalogEntry, error)
// Find returns one entry by slug (static lookup first, then DB).
func (s *ChecklistCatalogService) Find(ctx context.Context, userID uuid.UUID, slug string) (*CatalogEntry, error)
// SnapshotBody returns the JSONB body for a slug — used at instance creation to capture the template state.
func (s *ChecklistCatalogService) SnapshotBody(ctx context.Context, userID uuid.UUID, slug string) (json.RawMessage, error)
```
### 5.2 `internal/services/checklist_template_service.go` (new — Slice A)
CRUD on `paliad.checklists`.
```go
type ChecklistTemplateService struct {
db *sqlx.DB
users *UserService
}
type CreateTemplateInput struct {
Title string
Description string
Regime string
Court string
Reference string
Deadline string
Lang string
Body checklists.Template // unmarshalled to body jsonb minus slug/titles/etc
}
func (s *ChecklistTemplateService) Create(ctx, userID, input) (*Template, error)
func (s *ChecklistTemplateService) Update(ctx, userID, slug, input) (*Template, error)
func (s *ChecklistTemplateService) Delete(ctx, userID, slug) error
func (s *ChecklistTemplateService) SetVisibility(ctx, userID, slug, visibility) error // private/firm only
func (s *ChecklistTemplateService) ListOwnedBy(ctx, userID) ([]Template, error)
```
Slug generation: lowercase, alphanumeric+hyphen, `u-` prefix, unique
suffix (collision retry up to 3x). Validator enforces
`^u-[a-z0-9][a-z0-9-]{2,62}$`. Reserved slugs from
`internal/checklists/checklists.go` Templates rejected at write time.
### 5.3 `internal/services/checklist_share_service.go` (new — Slice B)
```go
type ChecklistShareService struct { db *sqlx.DB }
type ShareGrantInput struct {
RecipientKind string
UserID *uuid.UUID
Office string
PartnerUnitID *uuid.UUID
ProjectID *uuid.UUID
}
func (s *ChecklistShareService) Grant(ctx, callerID, checklistID, input) (*Share, error)
func (s *ChecklistShareService) Revoke(ctx, callerID, shareID) error
func (s *ChecklistShareService) ListGrants(ctx, callerID, checklistID) ([]Share, error)
```
### 5.4 `internal/services/checklist_promotion_service.go` (new — Slice B)
`global_admin`-only operations.
```go
type ChecklistPromotionService struct { db *sqlx.DB, audit *SystemAuditLogService }
func (s *ChecklistPromotionService) Promote(ctx, callerID, checklistID) error
func (s *ChecklistPromotionService) Demote(ctx, callerID, checklistID, target /* 'firm' | 'private' */) error
```
Promote: assert caller.global_role = 'global_admin' UPDATE visibility =
'global', promoted_at = now(), promoted_by = caller audit row
`event_type='checklist.promoted_global'`.
Demote: assert caller is global_admin UPDATE visibility = target
(default 'firm') audit row `event_type='checklist.demoted'`.
### 5.5 Wire instance create to take snapshot
`ChecklistInstanceService.Create` extends to capture
`template_snapshot` at insert time via
`catalog.SnapshotBody(ctx, userID, slug)`. Existing instances unchanged
(NULL snapshot, fallback path in read layer).
### 5.6 Endpoints
| Method | Path | Slice | Purpose |
|--------|------|-------|---------|
| `GET` | `/api/checklists` | (existing)| Merged catalog list (static + visible DB) |
| `GET` | `/api/checklists/{slug}` | (existing)| Single template (static or DB) |
| `POST` | `/api/checklists/templates` | A | Create authored template |
| `GET` | `/api/checklists/templates/mine` | A | List own authored templates |
| `PATCH` | `/api/checklists/templates/{slug}` | A | Edit authored template |
| `DELETE` | `/api/checklists/templates/{slug}` | A | Delete authored template |
| `PATCH` | `/api/checklists/templates/{slug}/visibility` | A | Toggle private/firm |
| `GET` | `/api/checklists/templates/{slug}/shares` | B | List grants |
| `POST` | `/api/checklists/templates/{slug}/shares` | B | Grant share |
| `DELETE` | `/api/checklists/shares/{id}` | B | Revoke share |
| `POST` | `/api/admin/checklists/{slug}/promote` | B | Admin promote to global |
| `POST` | `/api/admin/checklists/{slug}/demote` | B | Admin demote |
| `GET` | `/api/checklists/gallery` | C | Browse all firm + global templates |
## 6. Instance snapshot lifecycle
**On Create (`ChecklistInstanceService.Create`):**
1. Resolve slug via `catalog.Find(userID, slug)` enforces visibility.
2. `snapshot = catalog.SnapshotBody(userID, slug)` captures the
template body (groups + items) at this moment, as JSONB.
3. Insert into `checklist_instances` with
`template_snapshot = snapshot`, `template_slug = slug`,
`state = '{}'::jsonb`.
**On Read (`ChecklistInstanceService.GetByID`):**
- Return the instance with `template_snapshot` if non-null.
- If NULL (legacy row created before mig 112), fall back to
`catalog.Find(slug)`. Logged at INFO; not a fatal path.
**On Template Edit (Slice A):**
- Owner edits template via PATCH DB row mutated `checklists.updated_at`
bumped no propagation. Existing instances continue rendering their
snapshot. New instances pick up the edit.
- Audit row `event_type='checklist.edited'`,
`metadata={ checklist_id, slug, changes:[...] }`.
**On Template Delete:**
- DB row deleted. Instances that snapshotted survive (snapshot is
local). Instances that DIDN'T snapshot (NULL) gracefully degrade
service detects "template not found in catalog" and returns the
instance with a sentinel "template withdrawn" body (renders a small
banner client-side; checkboxes still work because `state` is the
source of truth, not the template).
**On Visibility Narrow (firm → shared → private):**
- Existing instances unaffected (snapshot is local; visibility check is
on the template, not instance).
- New instance attempts fail with `ErrNotVisible` (the user can no
longer see the template to instantiate it).
## 7. Frontend (concise sketch — coder owns the detail)
### 7.1 `/checklists` (existing page) — Slice A adds "Meine Vorlagen"
Add a third tab between "Vorlagen" and "Vorhandene Instanzen":
```
[Vorlagen] [Meine Vorlagen] [Vorhandene Instanzen]
```
- **Vorlagen** (existing): static catalog + global-promoted DB
templates, grouped by Regime, filter pills (UPC/DE/EPA).
- **Meine Vorlagen** (NEW): caller's own authored templates + a "Neue
Vorlage" CTA. Each card shows title, description, visibility chip,
Aktions-Buttons (Bearbeiten / Teilen / Löschen).
- **Vorhandene Instanzen** (existing): unchanged behaviour; rows now
optionally render an "📌 Snapshot" badge when `template_snapshot` is
non-null (Slice A backfill marker).
Slice C adds a fourth tab: **Geteilte Vorlagen** (firm-level shared
templates not yet promoted discovery surface).
### 7.2 `/checklists/new` (NEW — Slice A)
Authoring wizard. Three steps:
1. Metadata title, description, regime (UPC/DE/EPA/OTHER), court,
reference, deadline.
2. Sections + items repeating editor (group title items[] of
{label, note, rule}).
3. Visibility radio: privat / firm-weit. (Sharing flow comes in
Slice B.)
Save POST `/api/checklists/templates` redirect to
`/checklists/{slug}` detail.
### 7.3 `/checklists/{slug}/edit` (NEW — Slice A)
Same wizard, prefilled. Owner-only (404 otherwise).
### 7.4 `/checklists/{slug}` detail page
Existing detail page renders the template (static OR authored).
Additions:
- Owner-only "Bearbeiten" / "Löschen" / "Teilen" buttons in the header.
- `global_admin`-only "Als Firmen-Vorlage hinterlegen" / "Aus Katalog
entfernen" button (Slice B).
- Provenance line under the title: "Erstellt von <author> · <date>"
(only for DB templates).
### 7.5 Share modal (Slice B)
Triggered by "Teilen" on owner's detail page. Four pickers stacked:
- Kollegen (user-picker, multi-select)
- Office (chip-select from `offices.All`)
- Dezernat (chip-select from `partner_units`)
- Projekt (autocomplete from owner-visible projects)
Footer: "Visibility" radio (privat / geteilt / firm-weit). Picking
"firm-weit" greys out the picker (firm-weit doesn't need grants).
Apply → POST grants individually → audit emits one
`event_type='checklist.shared'` per grant with
`metadata={ recipient_kind, recipient_id, checklist_id }`.
### 7.6 i18n keys
~28 new keys (DE+EN) under `checklisten.authoring.*`,
`checklisten.share.*`, `checklisten.promote.*`. Naming convention
matches existing `checklisten.tab.*` / `checklisten.instances.*`.
## 8. Audit events
Org-scope (`paliad.system_audit_log` via a small new helper
`SystemAuditLogService.WriteChecklistEvent`):
| event_type | actor | metadata keys |
|----------------------------------|-------------|----------------------------------------------------|
| `checklist.authored` | owner | checklist_id, slug, visibility |
| `checklist.edited` | owner | checklist_id, slug, changed_fields[] |
| `checklist.visibility_changed` | owner | checklist_id, slug, from, to |
| `checklist.shared` | owner | checklist_id, slug, recipient_kind, recipient_id |
| `checklist.unshared` | owner | checklist_id, slug, recipient_kind, recipient_id |
| `checklist.promoted_global` | global_admin| checklist_id, slug, owner_id |
| `checklist.demoted` | global_admin| checklist_id, slug, target_visibility |
| `checklist.deleted` | owner OR ga | checklist_id, slug, was_visibility |
Project-scope (`paliad.project_events` — existing helper
`insertProjectEventWithMeta`): existing checklist-instance events
unchanged. NO new project_events types for templates — templates are
not project-scoped.
`AuditService.ListEntries` already reads from `system_audit_log` via
the UNION ALL branch added in t-paliad-214 — no changes needed there;
new event_types surface automatically in the audit log UI.
## 9. Slice plan
### Slice A — Foundation (~700 LoC)
**Schema:** mig 112 §4.1 (`paliad.checklists`) + §4.3 predicate + §4.4
RLS + §4.6 instance snapshot column. **Skip** §4.2 / §4.5 in Slice A —
no share table yet; visibility limited to private/firm.
**Service:** `ChecklistCatalogService` (unified read), `ChecklistTemplateService`
(CRUD), `ChecklistInstanceService.Create` snapshot wiring,
`SystemAuditLogService.WriteChecklistEvent` helper.
**Endpoints:** `/api/checklists` (delegate to catalog), `POST/PATCH/DELETE
/api/checklists/templates`, `PATCH /api/checklists/templates/{slug}/visibility`.
**Frontend:** "Meine Vorlagen" tab on `/checklists`, `/checklists/new`,
`/checklists/{slug}/edit`, owner controls on detail page.
**Test pass:** unit tests for slug validation, snapshot capture,
visibility predicate (without share rows), audit emit, fallback to
catalog when snapshot NULL.
**No share, no admin promote, no gallery.** Ships immediately useful
for solo authoring + firm-wide publishing.
### Slice B — Sharing + Promotion (~600 LoC)
**Schema:** mig 113 — `paliad.checklist_shares` (§4.2) + revised RLS
(§4.5) + extend visibility CHECK to include 'shared' if Slice A used a
sub-enum (Slice A schema already includes 'shared' as valid value —
just no grants point at it yet).
**Service:** `ChecklistShareService`, `ChecklistPromotionService`.
**Endpoints:** shares endpoints + admin promote/demote.
**Frontend:** Share modal, "Make global" admin button on detail page,
share-grant chip list on detail page (owner-only).
**Audit:** new event_types (shared, unshared, promoted_global, demoted).
### Slice C — Discoverability + UX polish (~400 LoC)
**Gallery page** `/checklists/gallery`: browses every template the user
can see that's NOT their own, grouped by Regime / Author / Recency.
Filter pills. "Diese Vorlage verwenden" → instantiates with snapshot.
**Backfill** existing `checklist_instances` with `template_snapshot`
via a one-off migration (mig 114) — pure data move, no schema change.
After backfill, the catalog-fallback path can be removed (deferred to
Slice D / cleanup).
**Optional**:
- "Vorlage kopieren" action — clone an existing template (static OR
authored) into the caller's "Meine Vorlagen" for personal adaptation.
- Per-template instance counter ("12 Kollegen haben diese Vorlage
benutzt") — surfaced from `checklist_instances` group-by.
## 10. Trade-offs flagged
1. **Hybrid catalog (static + DB).** Two sources of truth means two
slug spaces to merge. Mitigated by `u-` prefix on authored slugs +
reserved-list rejection. Refactoring all static templates into DB
loses the git review trail; the hybrid is the right cost.
2. **Polymorphism deferred.** A future second sharable entity will need
to either copy the `checklist_shares` pattern (cheap but duplicative)
or refactor to `entity_shares` (one mig). The refactor is small;
premature abstraction now would pay complexity for no current
benefit.
3. **Snapshot semantics may surprise.** A user who edits their template
expecting downstream instances to update will be confused.
Mitigations: (a) UI banner on edit ("Bearbeitungen wirken nur auf
neue Instanzen"); (b) "Neu instantiieren" affordance on the instance
detail page that re-snapshots from the current template (preserves
the user's checkbox state to the extent items still match).
4. **Office membership is set-membership, not hierarchy.** Sharing to
"munich" reaches every user with `office='munich'` OR
`'munich' = ANY(additional_offices)`. There's no concept of "Munich
plus its sub-teams" because offices don't nest in paliad. Fine.
5. **Partner-unit membership join is N+1 on the predicate.** Each
visibility check touches `partner_unit_members` if any partner-unit
share exists. Indexes on `partner_unit_members(user_id, partner_unit_id)`
already exist (per mig 027 lineage); the join is single-row.
6. **Share-to-project recipient resolution uses
`can_see_project(s.recipient_project_id)`.** That predicate reads
`auth.uid()` from the session, so it works correctly inside our
SECURITY DEFINER body. Confirmed by reading `can_see_project`'s body
in `paliad.can_see_project` source — same pattern that
`effective_project_admin` uses in mig 111.
7. **`global_admin` UPDATE RLS on `paliad.checklists` is full-row.**
Means a global_admin can edit content of any user's template, not
just visibility. This is intentional for catalog hygiene
(correcting typos, removing inflammatory content) but should be used
sparingly and audited. The audit log captures every
global_admin-attributed edit via `checklist.edited` with actor_id.
8. **Instance snapshot fallback path lives indefinitely.** Existing
pre-mig-112 instances stay NULL until Slice C backfills. The
fallback code in `ChecklistInstanceService.GetByID` is ~10 LoC and
no hot-path concern — but it's "dead code" once the backfill runs.
Acceptable until Slice C.
9. **Cascade on owner deletion.** If an authored template's owner is
removed (`paliad.users.id` cascades), the template is wiped along
with all its shares. Existing instances survive via snapshot. The
alternative (transfer ownership to global_admin on user-delete) is
more polite but introduces governance questions ("which admin?")
that aren't worth Slice A complexity. Flag for Slice C if it bites.
10. **Slug uniqueness across origins enforced application-side.**
The static catalog is in-memory at boot. If a deploy adds a static
slug that collides with an existing DB slug, the deploy boots
cleanly but the DB row becomes unreachable via the catalog read
layer (static wins on slug lookup). Mitigation: a boot-time
integrity check in `cmd/server/main.go` logs WARN if collision
detected. Owner can rename their template manually via the edit UI.
## 11. m's decisions ledger (all defaulted to (R) per task brief)
Per task brief "NO AskUserQuestion. Defaults to (R). Escalate to head if
material." I have not escalated; all picks below default to (R).
| # | Question | (R) pick |
|---|---------------------------------------------------------|-------------------------------------------|
| 1 | Storage model for authored templates | Hybrid: keep static catalog + new `paliad.checklists` DB table |
| 2 | Instance lifecycle on template edit | **Snapshot** at instance create (NOT propagate) |
| 3 | Visibility enum values | `private`, `shared`, `firm`, `global` |
| 4 | Share recipients | user, office, partner_unit, project (4 axes) |
| 5 | Share-to-project resolution | Reuse `can_see_project` (visibility, not just team rows) |
| 6 | Promotion authority | `global_admin` only (no per-project admin promote in v1) |
| 7 | Demotion target | `global → firm` (preserves visibility for in-flight instances) |
| 8 | Slug strategy | `u-` prefix on authored, application-side collision check vs static |
| 9 | Polymorphic share table (`entity_shares`) vs scoped | **Scoped (`checklist_shares`).** Refactor to polymorphic *after* second sharable entity appears |
| 10| Authoring i18n | Author picks single language (DE or EN) per template via `lang` column; verbatim render |
| 11| Audit sink for template lifecycle | `paliad.system_audit_log` (org-scope); instance events stay on `paliad.project_events` |
| 12| Slice ordering | A (foundation) → B (share + promote) → C (gallery + backfill) |
Material escalation list: empty. If m disagrees with any of the above,
amend §11 in the next inventor shift; the schema is designed to be
forward-compatible with most reversals (e.g. flipping snapshot →
propagate is a service-layer change, not a schema change).
## 12. Acceptance criteria — Slice A
1. **Migration 112 applies cleanly on a fresh DB** and is idempotent
on re-apply (verified via `BEGIN…ROLLBACK` dry-run against the live
`paliad` schema).
2. **`/api/checklists` returns merged catalog** — static templates
plus DB templates the caller can see (visibility ∈ {firm, global}
OR owner = caller).
3. **POST `/api/checklists/templates`** creates a row, returns the
created template with auto-generated `u-…` slug, emits
`checklist.authored` audit row.
4. **PATCH `/api/checklists/templates/{slug}`** updates owner-only
fields, rejects 403 from non-owner non-admin, emits
`checklist.edited`.
5. **PATCH `/api/checklists/templates/{slug}/visibility`** toggles
private↔firm; rejects `shared` and `global` in Slice A (those land
in Slice B); emits `checklist.visibility_changed`.
6. **DELETE `/api/checklists/templates/{slug}`** removes the row;
existing instances survive via snapshot.
7. **Instance create snapshots the template body**
`template_snapshot` non-null on every new instance row.
8. **Legacy instances (NULL snapshot) still render** via catalog
fallback (covered by a regression test).
9. **"Meine Vorlagen" tab** lists owner's templates; "Neue Vorlage"
CTA navigates to `/checklists/new`; wizard saves successfully.
10. **`go build ./... && go vet ./... && go test ./internal/...`
clean.** `bun run build` clean (i18n key count incremented by ~20).
11. **Live smoke**: tester@hlc.de can create + edit + delete a private
template; setting visibility to `firm` makes it visible to a second
tester account; deleting the template doesn't break existing
instances.
## 13. Recommended implementer
Pattern-fluent **Sonnet coder**, NOT cronus (per project memory
directive 2026-05-06). Substrate is well-trodden:
- Migration shape mirrors mig 111 (gauss) for the predicate function +
policy replacement pattern.
- Service shape mirrors `ChecklistInstanceService` for CRUD + audit
emit + visibility check.
- Endpoint shape mirrors `internal/handlers/checklist_instances.go`.
- Frontend tab pattern mirrors the existing
`entity-tabs` / `entity-tab-panel` substrate in `checklists.tsx`.
Novel pieces:
- Catalog merge layer (~80 LoC) — the only logic the coder needs to
prototype before committing to the full slice. Pure function; easy
to unit-test.
- Share predicate (Slice B) — straightforward translation of §4.3 SQL
into a STABLE SECURITY DEFINER function; pattern matches mig 111
exactly.
Branch: keep on `mai/dirac/user-checklists`. Three slices = three PRs,
or one branch with three commits — coder's call. Each slice ends with
acceptance criteria; head merges between slices for fast feedback.
## 14. Out of scope (explicitly)
- Importing checklists from external sources (Notion, Trello, .docx).
- Approval-policy gating on checklist edits (admin pre-publish review).
- Cross-firm template marketplace.
- Translation workflow (de↔en) for authored templates — Slice A
ships single-language; if firm appetite shows up post-launch, file
a follow-up.
- Static-catalog editor UI (the static templates remain code-only).
- Versioning UI ("show me the version this instance was created from")
— snapshot is captured; surfacing it is Slice C polish.
---
**Inventor parked per gate protocol.** No auto-shift to coder. Head
decides: same worker as `/mai-coder` with this brief, fresh coder, or
rescope. Slice ordering A → B → C is independent enough that the head
can also greenlight Slice A alone and re-design B/C after Slice A
ships.

View File

@@ -10,6 +10,7 @@ import { renderLinks } from "./src/links";
import { renderGlossary } from "./src/glossary";
import { renderGebuehrentabellen } from "./src/gebuehrentabellen";
import { renderChecklists } from "./src/checklists";
import { renderChecklistsAuthor } from "./src/checklists-author";
import { renderChecklistsDetail } from "./src/checklists-detail";
import { renderChecklistsInstance } from "./src/checklists-instance";
import { renderCourts } from "./src/courts";
@@ -20,10 +21,8 @@ import { renderProjectsChart } from "./src/projects-chart";
import { renderEvents } from "./src/events";
import { renderDeadlinesNew } from "./src/deadlines-new";
import { renderDeadlinesDetail } from "./src/deadlines-detail";
import { renderDeadlinesCalendar } from "./src/deadlines-calendar";
import { renderAppointmentsNew } from "./src/appointments-new";
import { renderAppointmentsDetail } from "./src/appointments-detail";
import { renderAppointmentsCalendar } from "./src/appointments-calendar";
import { renderSettings } from "./src/settings";
import { renderDashboard } from "./src/dashboard";
import { renderAgenda } from "./src/agenda";
@@ -245,6 +244,7 @@ async function build() {
join(import.meta.dir, "src/client/glossary.ts"),
join(import.meta.dir, "src/client/gebuehrentabellen.ts"),
join(import.meta.dir, "src/client/checklists.ts"),
join(import.meta.dir, "src/client/checklists-author.ts"),
join(import.meta.dir, "src/client/checklists-detail.ts"),
join(import.meta.dir, "src/client/checklists-instance.ts"),
join(import.meta.dir, "src/client/courts.ts"),
@@ -255,10 +255,8 @@ async function build() {
join(import.meta.dir, "src/client/events.ts"),
join(import.meta.dir, "src/client/deadlines-new.ts"),
join(import.meta.dir, "src/client/deadlines-detail.ts"),
join(import.meta.dir, "src/client/deadlines-calendar.ts"),
join(import.meta.dir, "src/client/appointments-new.ts"),
join(import.meta.dir, "src/client/appointments-detail.ts"),
join(import.meta.dir, "src/client/appointments-calendar.ts"),
join(import.meta.dir, "src/client/settings.ts"),
join(import.meta.dir, "src/client/dashboard.ts"),
join(import.meta.dir, "src/client/agenda.ts"),
@@ -370,6 +368,7 @@ async function build() {
await Bun.write(join(DIST, "glossary.html"), renderGlossary());
await Bun.write(join(DIST, "gebuehrentabellen.html"), renderGebuehrentabellen());
await Bun.write(join(DIST, "checklists.html"), renderChecklists());
await Bun.write(join(DIST, "checklists-author.html"), renderChecklistsAuthor());
await Bun.write(join(DIST, "checklists-detail.html"), renderChecklistsDetail());
await Bun.write(join(DIST, "checklists-instance.html"), renderChecklistsInstance());
await Bun.write(join(DIST, "courts.html"), renderCourts());
@@ -384,10 +383,8 @@ async function build() {
await Bun.write(join(DIST, "events.html"), renderEvents());
await Bun.write(join(DIST, "deadlines-new.html"), renderDeadlinesNew());
await Bun.write(join(DIST, "deadlines-detail.html"), renderDeadlinesDetail());
await Bun.write(join(DIST, "deadlines-calendar.html"), renderDeadlinesCalendar());
await Bun.write(join(DIST, "appointments-new.html"), renderAppointmentsNew());
await Bun.write(join(DIST, "appointments-detail.html"), renderAppointmentsDetail());
await Bun.write(join(DIST, "appointments-calendar.html"), renderAppointmentsCalendar());
await Bun.write(join(DIST, "settings.html"), renderSettings());
await Bun.write(join(DIST, "dashboard.html"), renderDashboard());
await Bun.write(join(DIST, "agenda.html"), renderAgenda());

View File

@@ -33,6 +33,9 @@ export function renderAdminTeam(): string {
</p>
</div>
<div className="admin-team-actions">
<button className="btn-primary" id="admin-team-add-full" type="button" data-i18n="admin.team.add.full">
Konto direkt anlegen
</button>
<button className="btn-primary" id="admin-team-direct-add" type="button" data-i18n="admin.team.add.direct">
Bestehendes Konto onboarden
</button>
@@ -132,6 +135,67 @@ export function renderAdminTeam(): string {
</div>
</div>
{/* t-paliad-223 Slice B (#49) — "Konto direkt anlegen" modal.
Creates BOTH the auth.users row (via Supabase Admin API) and
the paliad.users row in one click. New user is visible in
dropdowns immediately. */}
<div className="modal-overlay" id="admin-add-full-modal" style="display:none">
<div className="modal-card">
<div className="modal-header">
<h2 data-i18n="admin.team.add_full.title">Konto direkt anlegen</h2>
<button className="modal-close" id="admin-af-close" type="button" aria-label="Close">&times;</button>
</div>
<p data-i18n="admin.team.add_full.body" className="invite-modal-body">
Legt sowohl das Login-Konto als auch das Paliad-Profil an. Die neue Person erh&auml;lt eine E-Mail mit einem Link, &uuml;ber den sie ein Passwort setzt.
</p>
<form id="admin-add-full-form" className="entity-form" autocomplete="off">
<div className="form-field">
<label htmlFor="admin-af-email" data-i18n="admin.team.add_full.email">E-Mail</label>
<input type="email" id="admin-af-email" name="email" required autocomplete="off" />
</div>
<div className="form-field">
<label htmlFor="admin-af-name" data-i18n="admin.team.add_full.name">Anzeigename</label>
<input type="text" id="admin-af-name" name="display_name" required />
</div>
<div className="form-field">
<label htmlFor="admin-af-office" data-i18n="admin.team.add_full.office">Standort</label>
<select id="admin-af-office" name="office" required />
</div>
<div className="form-field">
<label htmlFor="admin-af-profession" data-i18n="admin.team.add_full.profession">Profession</label>
<select id="admin-af-profession" name="profession">
<option value="partner" data-i18n="projects.team.profession.partner">Partner</option>
<option value="of_counsel" data-i18n="projects.team.profession.of_counsel">Of Counsel</option>
<option value="associate" selected data-i18n="projects.team.profession.associate">Associate</option>
<option value="senior_pa" data-i18n="projects.team.profession.senior_pa">Senior PA</option>
<option value="pa" data-i18n="projects.team.profession.pa">PA</option>
<option value="paralegal" data-i18n="projects.team.profession.paralegal">Paralegal</option>
</select>
</div>
<div className="form-field">
<label htmlFor="admin-af-job-title" data-i18n="admin.team.add_full.job_title">Berufsbezeichnung</label>
<input type="text" id="admin-af-job-title" name="job_title" placeholder="Associate" />
</div>
<div className="form-field">
<label htmlFor="admin-af-lang" data-i18n="admin.team.add_full.lang">Sprache</label>
<select id="admin-af-lang" name="lang">
<option value="de" selected>Deutsch</option>
<option value="en">English</option>
</select>
</div>
<label className="form-checkbox">
<input type="checkbox" id="admin-af-send-welcome" checked />
<span data-i18n="admin.team.add_full.send_welcome">Willkommens-E-Mail mit Login-Link senden</span>
</label>
<div id="admin-af-feedback" className="form-msg" style="display:none" />
<div className="form-actions">
<button type="button" className="btn-cancel" id="admin-af-cancel" data-i18n="admin.team.add_full.cancel">Abbrechen</button>
<button type="submit" className="btn-primary" id="admin-af-submit" data-i18n="admin.team.add_full.submit">Anlegen</button>
</div>
</form>
</div>
</div>
<Footer />
<PaliadinWidget />
<script src="/assets/admin-team.js"></script>

View File

@@ -1,103 +0,0 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { PaliadinWidget } from "./components/PaliadinWidget";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
export function renderAppointmentsCalendar(): string {
return "<!DOCTYPE html>" + (
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#BFF355" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<PWAHead />
<title data-i18n="appointments.kalender.title">Terminkalender &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/events?type=appointment" />
<BottomNav currentPath="/events?type=appointment" />
<main>
<section className="tool-page">
<div className="container">
<div className="tool-header">
<div className="entity-header-row">
<div>
<h1 data-i18n="appointments.kalender.heading">Terminkalender</h1>
<p className="tool-subtitle" data-i18n="appointments.kalender.subtitle">
Monats&uuml;bersicht aller Termine.
</p>
</div>
<div className="fristen-header-actions">
<a href="/events?type=appointment" className="btn-secondary" data-i18n="appointments.kalender.list">Listenansicht</a>
<a href="/appointments/new" className="btn-primary btn-cta-lime" data-i18n="appointments.list.new">Neuer Termin</a>
</div>
</div>
</div>
<div className="frist-calendar-controls">
<button type="button" id="cal-prev" className="btn-secondary btn-small" aria-label="Vorheriger Monat">&larr;</button>
<h2 id="cal-month-label" className="frist-cal-month-label" />
<button type="button" id="cal-next" className="btn-secondary btn-small" aria-label="N&auml;chster Monat">&rarr;</button>
<button type="button" id="cal-today" className="btn-secondary btn-small" data-i18n="deadlines.kalender.today">Heute</button>
</div>
<div className="termin-cal-legend">
<span className="termin-cal-legend-item">
<span className="termin-dot termin-type-hearing" />
<span data-i18n="appointments.type.hearing">Verhandlung</span>
</span>
<span className="termin-cal-legend-item">
<span className="termin-dot termin-type-meeting" />
<span data-i18n="appointments.type.meeting">Besprechung</span>
</span>
<span className="termin-cal-legend-item">
<span className="termin-dot termin-type-consultation" />
<span data-i18n="appointments.type.consultation">Beratung</span>
</span>
<span className="termin-cal-legend-item">
<span className="termin-dot termin-type-deadline_hearing" />
<span data-i18n="appointments.type.deadline_hearing">Fristverhandlung</span>
</span>
</div>
<div className="frist-calendar" id="appointment-calendar">
<div className="frist-cal-weekday" data-i18n="cal.day.mon">Mo</div>
<div className="frist-cal-weekday" data-i18n="cal.day.tue">Di</div>
<div className="frist-cal-weekday" data-i18n="cal.day.wed">Mi</div>
<div className="frist-cal-weekday" data-i18n="cal.day.thu">Do</div>
<div className="frist-cal-weekday" data-i18n="cal.day.fri">Fr</div>
<div className="frist-cal-weekday" data-i18n="cal.day.sat">Sa</div>
<div className="frist-cal-weekday" data-i18n="cal.day.sun">So</div>
<div id="appointment-cal-grid" className="frist-cal-grid" />
</div>
<p className="entity-events-empty" id="appointment-cal-empty" style="display:none" data-i18n="appointments.kalender.empty">
Keine Termine im ausgew&auml;hlten Zeitraum.
</p>
<div className="modal-overlay" id="cal-popup" style="display:none">
<div className="modal-card">
<div className="modal-header">
<h2 id="cal-popup-date" />
<button className="modal-close" id="cal-popup-close" type="button">&times;</button>
</div>
<ul className="frist-cal-popup-list" id="cal-popup-list" />
</div>
</div>
</div>
</section>
</main>
<Footer />
<PaliadinWidget />
<script src="/assets/appointments-calendar.js"></script>
</body>
</html>
);
}

View File

@@ -0,0 +1,120 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { PaliadinWidget } from "./components/PaliadinWidget";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
// Authoring wizard for paliad.checklists. Both /checklists/new and
// /checklists/templates/{slug}/edit serve this same bundle; the client reads
// window.location.pathname to decide create vs edit mode.
export function renderChecklistsAuthor(): string {
return "<!DOCTYPE html>" + (
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#BFF355" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<PWAHead />
<title data-i18n="checklisten.author.title">Vorlage erstellen &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/checklists" />
<BottomNav currentPath="/checklists" />
<main>
<section className="tool-page">
<div className="container">
<div className="tool-header">
<h1 id="author-heading" data-i18n="checklisten.author.heading.new">Neue Checklisten-Vorlage</h1>
<p className="tool-subtitle" data-i18n="checklisten.author.subtitle">
Erstellen Sie eine eigene Checkliste mit Sektionen und Punkten.
</p>
</div>
<form id="author-form" className="form-stack" autoComplete="off">
<div className="form-row">
<label className="form-label" htmlFor="title" data-i18n="checklisten.author.field.title">Titel</label>
<input className="form-input" id="title" name="title" type="text" required maxLength="200" />
<p className="form-hint" data-i18n="checklisten.author.field.title.hint">z.B. &bdquo;UPC SoC &mdash; interne Checkliste&ldquo;.</p>
</div>
<div className="form-row">
<label className="form-label" htmlFor="description" data-i18n="checklisten.author.field.description">Kurzbeschreibung</label>
<textarea className="form-input" id="description" name="description" rows="3" maxLength="2000" />
</div>
<div className="form-grid form-grid-2">
<div className="form-row">
<label className="form-label" htmlFor="regime" data-i18n="checklisten.author.field.regime">Regime</label>
<select className="form-input" id="regime" name="regime">
<option value="UPC">UPC</option>
<option value="DE">DE</option>
<option value="EPA">EPA</option>
<option value="OTHER" selected>OTHER</option>
</select>
</div>
<div className="form-row">
<label className="form-label" htmlFor="lang" data-i18n="checklisten.author.field.lang">Sprache</label>
<select className="form-input" id="lang" name="lang">
<option value="de" selected>Deutsch</option>
<option value="en">English</option>
</select>
</div>
</div>
<div className="form-grid form-grid-2">
<div className="form-row">
<label className="form-label" htmlFor="court" data-i18n="checklisten.author.field.court">Gericht / Beh&ouml;rde</label>
<input className="form-input" id="court" name="court" type="text" maxLength="200" />
</div>
<div className="form-row">
<label className="form-label" htmlFor="reference" data-i18n="checklisten.author.field.reference">Rechtsgrundlage</label>
<input className="form-input" id="reference" name="reference" type="text" maxLength="200" />
</div>
</div>
<div className="form-row">
<label className="form-label" htmlFor="deadline" data-i18n="checklisten.author.field.deadline">Deadline (optional)</label>
<input className="form-input" id="deadline" name="deadline" type="text" maxLength="200" />
</div>
<fieldset className="form-fieldset">
<legend data-i18n="checklisten.author.field.visibility">Sichtbarkeit</legend>
<label className="form-radio">
<input type="radio" name="visibility" value="private" checked />
<span><strong data-i18n="checklisten.mine.visibility.private">Privat</strong> &mdash; <span data-i18n="checklisten.author.visibility.private.hint">Nur f&uuml;r Sie sichtbar.</span></span>
</label>
<label className="form-radio">
<input type="radio" name="visibility" value="firm" />
<span><strong data-i18n="checklisten.mine.visibility.firm">Firmenweit</strong> &mdash; <span data-i18n="checklisten.author.visibility.firm.hint">F&uuml;r alle angemeldeten Kolleginnen und Kollegen sichtbar.</span></span>
</label>
</fieldset>
<fieldset className="form-fieldset">
<legend data-i18n="checklisten.author.groups.heading">Sektionen und Punkte</legend>
<div id="groups-container" />
<button type="button" className="btn btn-secondary" id="add-group" data-i18n="checklisten.author.groups.add">+ Sektion hinzuf&uuml;gen</button>
</fieldset>
<p id="author-error" className="form-error" style="display:none" role="alert" />
<div className="form-actions">
<button type="submit" className="btn btn-primary" id="author-save" data-i18n="checklisten.author.save">Speichern</button>
<a className="btn btn-secondary" href="/checklists?tab=mine" data-i18n="checklisten.author.cancel">Abbrechen</a>
</div>
</form>
</div>
</section>
</main>
<Footer />
<PaliadinWidget />
<script src="/assets/checklists-author.js"></script>
</body>
</html>
);
}

View File

@@ -39,12 +39,28 @@ export function renderChecklistsDetail(): string {
<div>
<h1 id="checklist-title">&nbsp;</h1>
<p className="tool-subtitle" id="checklist-subtitle">&nbsp;</p>
{/* Provenance line — visible only for authored
templates; populated by the client from the
catalog response's owner_display_name. */}
<p className="checklist-provenance" id="checklist-provenance" style="display:none" />
<dl className="checklist-meta" id="checklist-meta" />
</div>
<div className="checklist-actions">
<button type="button" id="btn-new-instance" className="btn-primary btn-cta-lime" data-i18n="checklisten.newInstance">
Neue Instanz
</button>
{/* Owner controls (Slice B) — toggled on by the
client once /api/checklists/{slug} returns
origin='authored' AND owner_email matches the
logged-in user. Kept hidden by default so
guests / non-owners never see them. */}
<a id="btn-edit-template" className="btn-cta-lime btn-outline" style="display:none" data-i18n="checklisten.detail.edit">Bearbeiten</a>
<button type="button" id="btn-share-template" className="btn-cta-lime btn-outline" style="display:none" data-i18n="checklisten.detail.share">Teilen</button>
<button type="button" id="btn-delete-template" className="btn-cta-lime btn-outline" style="display:none" data-i18n="checklisten.mine.delete">L&ouml;schen</button>
{/* global_admin controls — revealed by the client
when /api/me reports global_role='global_admin'. */}
<button type="button" id="btn-promote-template" className="btn-cta-lime btn-outline" style="display:none" data-i18n="checklisten.detail.promote">Als Firmen-Vorlage hinterlegen</button>
<button type="button" id="btn-demote-template" className="btn-cta-lime btn-outline" style="display:none" data-i18n="checklisten.detail.demote">Aus Katalog entfernen</button>
<button type="button" id="btn-feedback" className="btn-cta-lime btn-outline">
<span data-i18n="checklisten.feedback.btn">Feedback</span>
</button>
@@ -122,6 +138,65 @@ export function renderChecklistsDetail(): string {
</div>
</div>
{/* Share modal (Slice B) — owner-only, hidden until btn-share-template
opens it. Four recipient kinds in a single modal: pick the kind,
then the matching entity (user / office / partner_unit / project). */}
<div className="modal-overlay" id="share-modal" style="display:none">
<div className="modal-card modal-card-wide">
<div className="modal-header">
<h2 data-i18n="checklisten.share.title">Vorlage teilen</h2>
<button className="modal-close" id="share-close" type="button">&times;</button>
</div>
<div className="form-field">
<label data-i18n="checklisten.share.kind">Empf&auml;ngertyp</label>
<div className="filter-pills" id="share-kind-pills">
<button type="button" className="filter-pill active" data-kind="user" data-i18n="checklisten.share.kind.user">Kollege</button>
<button type="button" className="filter-pill" data-kind="office" data-i18n="checklisten.share.kind.office">Office</button>
<button type="button" className="filter-pill" data-kind="partner_unit" data-i18n="checklisten.share.kind.partner_unit">Dezernat</button>
<button type="button" className="filter-pill" data-kind="project" data-i18n="checklisten.share.kind.project">Projekt</button>
</div>
</div>
<div className="form-field share-kind-section" data-kind="user">
<label htmlFor="share-user" data-i18n="checklisten.share.kind.user">Kollege</label>
<select id="share-user">
<option value="" data-i18n="checklisten.share.pick">&mdash; ausw&auml;hlen &mdash;</option>
</select>
</div>
<div className="form-field share-kind-section" data-kind="office" style="display:none">
<label htmlFor="share-office" data-i18n="checklisten.share.kind.office">Office</label>
<select id="share-office">
<option value="" data-i18n="checklisten.share.pick">&mdash; ausw&auml;hlen &mdash;</option>
</select>
</div>
<div className="form-field share-kind-section" data-kind="partner_unit" style="display:none">
<label htmlFor="share-partner-unit" data-i18n="checklisten.share.kind.partner_unit">Dezernat</label>
<select id="share-partner-unit">
<option value="" data-i18n="checklisten.share.pick">&mdash; ausw&auml;hlen &mdash;</option>
</select>
</div>
<div className="form-field share-kind-section" data-kind="project" style="display:none">
<label htmlFor="share-project" data-i18n="checklisten.share.kind.project">Projekt</label>
<select id="share-project">
<option value="" data-i18n="checklisten.share.pick">&mdash; ausw&auml;hlen &mdash;</option>
</select>
</div>
<div className="form-actions">
<button type="button" className="btn-cancel" id="share-cancel" data-i18n="checklisten.share.cancel">Abbrechen</button>
<button type="button" className="btn-primary btn-cta-lime" id="share-submit" data-i18n="checklisten.share.submit">Freigeben</button>
</div>
<p className="form-msg" id="share-msg" />
{/* Existing grants — populated on open from
/api/checklists/templates/{slug}/shares. */}
<h3 className="share-grants-heading" data-i18n="checklisten.share.grants.heading">Bestehende Freigaben</h3>
<ul className="share-grants-list" id="share-grants-list">
<li className="entity-events-empty" id="share-grants-empty" data-i18n="checklisten.share.grants.empty">Keine Freigaben.</li>
</ul>
</div>
</div>
{/* Feedback modal */}
<div className="modal-overlay" id="feedback-modal" style="display:none">
<div className="modal-card">

View File

@@ -58,6 +58,10 @@ export function renderChecklistsInstance(): string {
</div>
<p className="tool-subtitle" id="instance-template-title">&nbsp;</p>
<dl className="checklist-meta" id="instance-meta" />
{/* Slice C: 'template updated since this instance
was created' banner. Populated by the client
when instance.template_version &lt; template.version. */}
<div id="instance-outdated-slot" />
</div>
<div className="checklist-actions">
<button type="button" id="btn-print" className="btn-ghost" data-i18n="checklisten.print">Drucken</button>
@@ -118,6 +122,21 @@ export function renderChecklistsInstance(): string {
</div>
</div>
{/* Slice C: template-diff modal — opened from the
"Änderungen anzeigen" button on the outdated banner. */}
<div className="modal-overlay" id="instance-diff-modal" style="display:none">
<div className="modal-card modal-card-wide">
<div className="modal-header">
<h2 data-i18n="checklisten.instance.diff.title">Ge&auml;nderte Punkte</h2>
<button className="modal-close" id="instance-diff-close" type="button">&times;</button>
</div>
<div id="instance-diff-body" />
<div className="form-actions">
<button type="button" className="btn-cancel" id="instance-diff-close-bottom" data-i18n="checklisten.instance.diff.close">Schlie&szlig;en</button>
</div>
</div>
</div>
<Footer />
<PaliadinWidget />
<script src="/assets/checklists-instance.js"></script>

View File

@@ -34,6 +34,8 @@ export function renderChecklists(): string {
<nav className="entity-tabs" id="checklists-tabs" aria-label="Checklisten-Ansichten">
<a className="entity-tab active" data-tab="templates" href="/checklists" data-i18n="checklisten.tab.templates">Vorlagen</a>
<a className="entity-tab" data-tab="mine" href="/checklists?tab=mine" data-i18n="checklisten.tab.mine">Meine Vorlagen</a>
<a className="entity-tab" data-tab="gallery" href="/checklists?tab=gallery" data-i18n="checklisten.tab.gallery">Geteilte Vorlagen</a>
<a className="entity-tab" data-tab="instances" href="/checklists?tab=instances" data-i18n="checklisten.tab.instances">Vorhandene Instanzen</a>
</nav>
@@ -49,6 +51,36 @@ export function renderChecklists(): string {
<div className="checklist-grid" id="checklist-grid" />
</section>
{/* Meine Vorlagen tab — caller's own authored templates */}
<section className="entity-tab-panel" id="tab-mine" style="display:none">
<div className="tool-actions" style="margin-bottom:1rem">
<a href="/checklists/new" className="btn btn-primary" data-i18n="checklisten.mine.new">Neue Vorlage</a>
</div>
<p className="entity-events-empty" id="checklists-mine-loading" data-i18n="checklisten.mine.loading">L&auml;dt&hellip;</p>
<p className="entity-events-empty" id="checklists-mine-empty" style="display:none" data-i18n="checklisten.mine.empty">
Sie haben noch keine eigene Vorlage angelegt.
</p>
<div className="checklist-grid" id="checklists-mine-grid" style="display:none" />
</section>
{/* Geteilte Vorlagen tab — discovery surface for templates
that aren't owned by the caller (firm-published,
globally-promoted, or explicitly shared). Slice C. */}
<section className="entity-tab-panel" id="tab-gallery" style="display:none">
<div className="checklist-filters" id="checklist-gallery-filters">
<button className="filter-pill active" data-regime="all" type="button" data-i18n="checklisten.filter.all">Alle</button>
<button className="filter-pill" data-regime="UPC" type="button">UPC</button>
<button className="filter-pill" data-regime="DE" type="button" data-i18n="checklisten.filter.de">DE</button>
<button className="filter-pill" data-regime="EPA" type="button">EPA</button>
<button className="filter-pill" data-regime="OTHER" type="button" data-i18n="checklisten.filter.other">Sonstige</button>
</div>
<p className="entity-events-empty" id="checklists-gallery-loading" data-i18n="checklisten.mine.loading">L&auml;dt&hellip;</p>
<p className="entity-events-empty" id="checklists-gallery-empty" style="display:none" data-i18n="checklisten.gallery.empty">
Noch keine geteilten Vorlagen sichtbar.
</p>
<div className="checklist-grid" id="checklists-gallery-grid" style="display:none" />
</section>
{/* Instances tab — every visible instance across templates */}
<section className="entity-tab-panel" id="tab-instances" style="display:none">
<p className="entity-events-empty" id="checklists-instances-loading" data-i18n="checklisten.instances.all.loading">L&auml;dt&hellip;</p>

View File

@@ -468,11 +468,125 @@ function initInviteButton() {
});
}
// t-paliad-223 Slice B (#49) — "Konto direkt anlegen" modal. Creates both
// the auth.users row (via Supabase Admin API) and the paliad.users row in
// one POST. New user appears in dropdowns immediately. Welcome email with
// magic-link is sent by default; admin can opt out via the checkbox.
function openAddFullModal() {
const modal = document.getElementById("admin-add-full-modal")!;
const fb = document.getElementById("admin-af-feedback")!;
const officeSel = document.getElementById("admin-af-office") as HTMLSelectElement;
const emailField = document.getElementById("admin-af-email") as HTMLInputElement;
const nameField = document.getElementById("admin-af-name") as HTMLInputElement;
const jobTitleField = document.getElementById("admin-af-job-title") as HTMLInputElement;
const profSel = document.getElementById("admin-af-profession") as HTMLSelectElement;
const langSel = document.getElementById("admin-af-lang") as HTMLSelectElement;
const sendWelcome = document.getElementById("admin-af-send-welcome") as HTMLInputElement;
fb.style.display = "none";
emailField.value = "";
nameField.value = "";
jobTitleField.value = "";
profSel.value = "associate";
langSel.value = "de";
sendWelcome.checked = true;
officeSel.innerHTML = officeOptions("munich");
modal.style.display = "flex";
emailField.focus();
}
function closeAddFullModal() {
document.getElementById("admin-add-full-modal")!.style.display = "none";
}
function initAddFullModal() {
document.getElementById("admin-team-add-full")!.addEventListener("click", openAddFullModal);
document.getElementById("admin-af-close")!.addEventListener("click", closeAddFullModal);
document.getElementById("admin-af-cancel")!.addEventListener("click", closeAddFullModal);
document.getElementById("admin-add-full-modal")!.addEventListener("click", (e) => {
if (e.target === e.currentTarget) closeAddFullModal();
});
const emailField = document.getElementById("admin-af-email") as HTMLInputElement;
const nameField = document.getElementById("admin-af-name") as HTMLInputElement;
// Pre-fill the display name from the email local-part the first time the
// admin tabs out of the email field — mirrors the existing onboard flow.
emailField.addEventListener("blur", () => {
if (nameField.value || !emailField.value) return;
const local = emailField.value.split("@")[0] ?? "";
nameField.value = local
.split(/[._-]/)
.map((s) => (s ? s[0].toUpperCase() + s.slice(1) : s))
.join(" ")
.trim();
});
const form = document.getElementById("admin-add-full-form") as HTMLFormElement;
form.addEventListener("submit", async (e) => {
e.preventDefault();
const fb = document.getElementById("admin-af-feedback")!;
fb.style.display = "none";
const officeSel = document.getElementById("admin-af-office") as HTMLSelectElement;
const jobTitleField = document.getElementById("admin-af-job-title") as HTMLInputElement;
const profSel = document.getElementById("admin-af-profession") as HTMLSelectElement;
const langSel = document.getElementById("admin-af-lang") as HTMLSelectElement;
const sendWelcome = document.getElementById("admin-af-send-welcome") as HTMLInputElement;
const submitBtn = document.getElementById("admin-af-submit") as HTMLButtonElement;
const payload: Record<string, unknown> = {
email: emailField.value.trim().toLowerCase(),
display_name: nameField.value.trim(),
office: officeSel.value,
job_title: jobTitleField.value.trim() || "Associate",
profession: profSel.value,
lang: langSel.value,
send_welcome_mail: sendWelcome.checked,
};
submitBtn.disabled = true;
try {
const resp = await fetch("/api/admin/users/full", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!resp.ok) {
const body = await resp.json().catch(() => ({ error: resp.statusText }));
// Map two friendly cases inline; everything else surfaces the
// server message so the admin can act on it.
if (resp.status === 503) {
fb.textContent = t("admin.team.add_full.error.unavailable")
|| "Add-User-Pfad ist nicht konfiguriert (SUPABASE_SERVICE_ROLE_KEY fehlt am Server).";
} else if (resp.status === 409) {
fb.textContent = body.error
|| (t("admin.team.add_full.error.email_exists")
|| "Es existiert bereits ein Konto für diese E-Mail — bitte 'Bestehendes Konto onboarden' verwenden.");
} else {
fb.textContent = body.error || (t("admin.team.add_full.error.generic") || "Fehler.");
}
fb.className = "form-msg form-msg-error";
fb.style.display = "block";
return;
}
const created = (await resp.json()) as User;
users = users.concat(created);
closeAddFullModal();
showFeedback(t("admin.team.add_full.feedback.added") || "Konto angelegt.", false);
render();
} finally {
submitBtn.disabled = false;
}
});
}
document.addEventListener("DOMContentLoaded", () => {
initI18n();
initSidebar();
initSearch();
initDirectAddModal();
initAddFullModal();
initInviteButton();
onLangChange(() => {
buildOfficeFilters();

View File

@@ -1,193 +0,0 @@
import { initI18n, onLangChange, t, tDyn, getLang } from "./i18n";
import { initSidebar } from "./sidebar";
interface Appointment {
id: string;
project_id?: string;
title: string;
start_at: string;
end_at?: string;
appointment_type?: string;
project_reference?: string;
project_title?: string;
}
let allAppointments: Appointment[] = [];
let viewYear = 0;
let viewMonth = 0;
function esc(s: string): string {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
function fmtMonth(year: number, month: number): string {
return `${tDyn(`cal.month.${month}`)} ${year}`;
}
function isoDate(year: number, month: number, day: number): string {
const m = String(month + 1).padStart(2, "0");
const d = String(day).padStart(2, "0");
return `${year}-${m}-${d}`;
}
async function loadAppointments() {
// Pull a wide window (current month plus a little buffer either side).
// We could narrow this, but the user typically navigates ±1-2 months
// and the dataset is small.
try {
const resp = await fetch("/api/appointments");
if (resp.ok) allAppointments = await resp.json();
} catch {
/* non-fatal */
}
}
function appointmentsForDate(iso: string): Appointment[] {
return allAppointments.filter((t) => t.start_at.slice(0, 10) === iso);
}
function typeClass(t?: string): string {
return t ? `termin-type-${t}` : "termin-type-default";
}
function fmtTime(iso: string): string {
try {
const d = new Date(iso);
return d.toLocaleTimeString(getLang() === "de" ? "de-DE" : "en-GB", {
hour: "2-digit",
minute: "2-digit",
});
} catch {
return iso;
}
}
function render() {
document.getElementById("cal-month-label")!.textContent = fmtMonth(viewYear, viewMonth);
const firstDay = new Date(viewYear, viewMonth, 1);
const jsWeekday = firstDay.getDay();
const offset = (jsWeekday + 6) % 7;
const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
const today = new Date();
const todayISO = isoDate(today.getFullYear(), today.getMonth(), today.getDate());
const cells: string[] = [];
for (let i = 0; i < offset; i++) {
cells.push(`<div class="frist-cal-cell frist-cal-cell-empty"></div>`);
}
for (let day = 1; day <= daysInMonth; day++) {
const iso = isoDate(viewYear, viewMonth, day);
const items = appointmentsForDate(iso);
const isToday = iso === todayISO;
const dots = items
.slice(0, 4)
.map((tt) => `<span class="termin-dot ${typeClass(tt.appointment_type)}" title="${esc(tt.title)}"></span>`)
.join("");
const more = items.length > 4 ? `<span class="frist-cal-more">+${items.length - 4}</span>` : "";
cells.push(
`<div class="frist-cal-cell${isToday ? " frist-cal-today" : ""}${items.length > 0 ? " frist-cal-cell-has" : ""}" data-iso="${iso}">
<span class="frist-cal-day">${day}</span>
<div class="frist-cal-dots">${dots}${more}</div>
</div>`,
);
}
const grid = document.getElementById("appointment-cal-grid")!;
grid.innerHTML = cells.join("");
grid.querySelectorAll<HTMLElement>(".frist-cal-cell-has").forEach((cell) => {
cell.addEventListener("click", () => openPopup(cell.dataset.iso!));
});
const monthStart = isoDate(viewYear, viewMonth, 1);
const monthEnd = isoDate(viewYear, viewMonth, daysInMonth);
const hasInMonth = allAppointments.some((tt) => {
const iso = tt.start_at.slice(0, 10);
return iso >= monthStart && iso <= monthEnd;
});
const empty = document.getElementById("appointment-cal-empty")!;
empty.style.display = hasInMonth ? "none" : "";
}
function openPopup(iso: string) {
const items = appointmentsForDate(iso);
if (items.length === 0) return;
const popup = document.getElementById("cal-popup")!;
const dateEl = document.getElementById("cal-popup-date")!;
const list = document.getElementById("cal-popup-list")!;
const d = new Date(iso + "T00:00:00");
dateEl.textContent = d.toLocaleDateString(getLang() === "de" ? "de-DE" : "en-GB", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
});
list.innerHTML = items
.map((tt) => {
const akteRef = tt.project_id
? `<a href="/projects/${esc(tt.project_id)}" class="frist-cal-popup-project">${esc(tt.project_reference ?? "")}</a>`
: `<span class="termin-personal-tag">${esc(t("appointments.personal"))}</span>`;
return `<li class="frist-cal-popup-item">
<span class="termin-dot ${typeClass(tt.appointment_type)}"></span>
<span class="frist-cal-popup-time">${esc(fmtTime(tt.start_at))}</span>
<a href="/appointments/${esc(tt.id)}" class="frist-cal-popup-title">${esc(tt.title)}</a>
${akteRef}
</li>`;
})
.join("");
popup.style.display = "flex";
}
function initPopup() {
const popup = document.getElementById("cal-popup")!;
const close = document.getElementById("cal-popup-close")!;
close.addEventListener("click", () => (popup.style.display = "none"));
popup.addEventListener("click", (e) => {
if (e.target === e.currentTarget) popup.style.display = "none";
});
}
function initNav() {
document.getElementById("cal-prev")!.addEventListener("click", () => {
viewMonth -= 1;
if (viewMonth < 0) {
viewMonth = 11;
viewYear -= 1;
}
render();
});
document.getElementById("cal-next")!.addEventListener("click", () => {
viewMonth += 1;
if (viewMonth > 11) {
viewMonth = 0;
viewYear += 1;
}
render();
});
document.getElementById("cal-today")!.addEventListener("click", () => {
const now = new Date();
viewYear = now.getFullYear();
viewMonth = now.getMonth();
render();
});
}
document.addEventListener("DOMContentLoaded", async () => {
initI18n();
initSidebar();
const now = new Date();
viewYear = now.getFullYear();
viewMonth = now.getMonth();
initNav();
initPopup();
onLangChange(render);
await loadAppointments();
render();
});

View File

@@ -0,0 +1,135 @@
import { describe, expect, test } from "bun:test";
import {
bucketByDate,
filterByDay,
isToday,
isoDate,
shift,
startOfDay,
startOfWeek,
type CalendarItem,
} from "./mount-calendar";
// Regression tests for t-paliad-224: the calendar bucket / week / shift
// helpers underpin both /events Kalender and the Custom Views shape=
// calendar. DOM-rendering is covered by manual smoke (frontend tests in
// this repo run in plain Node, no jsdom — see verfahrensablauf-core.test
// ts comment), so the pure date-math goes here.
const item = (overrides: Partial<CalendarItem> = {}): CalendarItem => ({
kind: "deadline",
id: "00000000-0000-0000-0000-000000000000",
title: "Klageerwiderung",
event_date: "2026-05-08T00:00:00Z",
...overrides,
});
describe("isoDate / startOfDay / startOfWeek", () => {
test("isoDate pads month + day", () => {
expect(isoDate(new Date(2026, 0, 3))).toBe("2026-01-03");
expect(isoDate(new Date(2026, 11, 31))).toBe("2026-12-31");
});
test("startOfDay strips time", () => {
const d = new Date(2026, 4, 8, 13, 47, 22);
const out = startOfDay(d);
expect(out.getHours()).toBe(0);
expect(out.getMinutes()).toBe(0);
expect(out.getSeconds()).toBe(0);
expect(isoDate(out)).toBe("2026-05-08");
});
test("startOfWeek snaps to Monday (Mon=0)", () => {
// 2026-05-08 was a Friday.
const fri = new Date(2026, 4, 8);
expect(isoDate(startOfWeek(fri))).toBe("2026-05-04");
// Sunday wraps backward to the same Monday, not forward to the next.
const sun = new Date(2026, 4, 10);
expect(isoDate(startOfWeek(sun))).toBe("2026-05-04");
// Monday is its own startOfWeek.
const mon = new Date(2026, 4, 4);
expect(isoDate(startOfWeek(mon))).toBe("2026-05-04");
});
});
describe("shift", () => {
test("month shift lands on day=1 of the target month", () => {
const out = shift(new Date(2026, 4, 15), "month", 1);
expect(out.getFullYear()).toBe(2026);
expect(out.getMonth()).toBe(5);
expect(out.getDate()).toBe(1);
});
test("month shift wraps year boundary", () => {
const out = shift(new Date(2026, 11, 15), "month", 1);
expect(out.getFullYear()).toBe(2027);
expect(out.getMonth()).toBe(0);
expect(out.getDate()).toBe(1);
});
test("week shift moves seven days", () => {
const out = shift(new Date(2026, 4, 8), "week", 1);
expect(isoDate(out)).toBe("2026-05-15");
});
test("day shift moves one day", () => {
const out = shift(new Date(2026, 4, 8), "day", -1);
expect(isoDate(out)).toBe("2026-05-07");
});
});
describe("bucketByDate", () => {
test("groups items by ISO date and skips items outside the filter", () => {
const rows = [
item({ id: "a", event_date: "2026-05-08T00:00:00Z" }),
item({ id: "b", event_date: "2026-05-08T15:30:00Z" }),
item({ id: "c", event_date: "2026-05-09T00:00:00Z" }),
// outside the May 2026 filter:
item({ id: "x", event_date: "2026-06-01T00:00:00Z" }),
// malformed:
item({ id: "bad", event_date: "not-a-date" }),
];
const out = bucketByDate(rows, (d) => d.getMonth() === 4 && d.getFullYear() === 2026);
expect(out.size).toBe(2);
expect(out.get("2026-05-08")?.map((r) => r.id)).toEqual(["a", "b"]);
expect(out.get("2026-05-09")?.map((r) => r.id)).toEqual(["c"]);
expect(out.has("2026-06-01")).toBe(false);
});
});
describe("filterByDay", () => {
test("returns only items whose calendar day equals the target", () => {
const rows = [
item({ id: "a", event_date: "2026-05-08T00:00:00Z" }),
item({ id: "b", event_date: "2026-05-08T23:59:00Z" }),
item({ id: "c", event_date: "2026-05-09T00:00:00Z" }),
];
expect(filterByDay(rows, new Date(2026, 4, 8)).map((r) => r.id)).toEqual(["a", "b"]);
expect(filterByDay(rows, new Date(2026, 4, 9)).map((r) => r.id)).toEqual(["c"]);
expect(filterByDay(rows, new Date(2026, 4, 10))).toEqual([]);
});
test("ignores malformed dates", () => {
const rows = [
item({ id: "ok", event_date: "2026-05-08T00:00:00Z" }),
item({ id: "bad", event_date: "not-a-date" }),
];
expect(filterByDay(rows, new Date(2026, 4, 8)).map((r) => r.id)).toEqual(["ok"]);
});
});
describe("isToday", () => {
test("matches today's calendar day", () => {
expect(isToday(new Date())).toBe(true);
});
test("rejects yesterday + tomorrow", () => {
const now = new Date();
const yesterday = new Date(now);
yesterday.setDate(now.getDate() - 1);
const tomorrow = new Date(now);
tomorrow.setDate(now.getDate() + 1);
expect(isToday(yesterday)).toBe(false);
expect(isToday(tomorrow)).toBe(false);
});
});

View File

@@ -0,0 +1,579 @@
import { t, tDyn, getLang, type I18nKey } from "../i18n";
// mount-calendar.ts — the canonical month/week/day calendar (t-paliad-224).
// Lifted from the original shape-calendar.ts so both Custom Views
// (shape=calendar) and /events Kalender tab render through the same DOM.
// See docs/design-calendar-view-align-2026-05-20.md for the audit + plan.
//
// Surfaces wire in via mountCalendar(host, items, opts). The returned
// handle exposes update(items) for re-render after a filter change and
// destroy() for teardown when the host swaps to a different view.
export type CalendarKind =
| "deadline" | "appointment" | "project_event" | "approval_request";
export interface CalendarItem {
kind: CalendarKind;
id: string;
title: string;
/** ISO-8601 timestamp or date string. First 10 chars are read as the
* calendar bucket (yyyy-mm-dd). */
event_date: string;
project_id?: string;
project_title?: string;
project_reference?: string;
}
export type CalendarView = "month" | "week" | "day";
export interface CalendarOpts {
/** Initial view if URL has no override (or urlState is disabled). */
defaultView?: CalendarView;
/** Read/write ?cal_view + ?cal_date so a refresh restores the calendar.
* Surfaces that own their own URL contract pass urlState=false. */
urlState?: boolean;
/** Optional URL param prefix (e.g. "events" → ?eventsCalView=…). Only
* meaningful when urlState=true. Leave empty for the default
* ?cal_view / ?cal_date contract. */
urlPrefix?: string;
/** Override how a row's href is built. Default routes by kind. */
hrefFor?: (item: CalendarItem) => string;
}
export interface CalendarHandle {
/** Replace the item set and re-paint at the current view+anchor. */
update(items: CalendarItem[]): void;
/** Clear host + drop the keep-alive state. After destroy(), the handle
* is dead; create a fresh one with mountCalendar(). */
destroy(): void;
}
const MAX_PILLS_PER_MONTH_CELL = 3;
export function mountCalendar(
host: HTMLElement,
initialItems: CalendarItem[],
opts: CalendarOpts = {},
): CalendarHandle {
let items = initialItems;
let view: CalendarView;
let anchor: Date;
let destroyed = false;
const urlEnabled = opts.urlState ?? false;
const viewParam = urlEnabled ? paramName(opts.urlPrefix, "cal_view") : "";
const dateParam = urlEnabled ? paramName(opts.urlPrefix, "cal_date") : "";
view = urlEnabled
? readView(viewParam, opts.defaultView ?? "month")
: (opts.defaultView ?? "month");
anchor = urlEnabled ? readAnchor(dateParam, items) : firstAnchor(items);
paint();
return {
update(nextItems) {
if (destroyed) return;
items = nextItems;
paint();
},
destroy() {
destroyed = true;
host.innerHTML = "";
},
};
// --- paint -----------------------------------------------------------
function paint(): void {
if (destroyed) return;
host.innerHTML = "";
// Mobile fallback notice (<600px). Documented in design-calendar-
// view-align-2026-05-20.md §6. CSS still lays out the grid; the
// notice just nudges users toward a friendlier view.
if (typeof window !== "undefined" && window.innerWidth < 600) {
const notice = document.createElement("p");
notice.className = "views-calendar-mobile-notice";
notice.textContent = t("views.calendar.mobile_fallback");
host.appendChild(notice);
}
const wrap = document.createElement("div");
wrap.className = `views-calendar views-calendar--${view}`;
wrap.appendChild(renderToolbar());
if (view === "month") {
wrap.appendChild(renderMonth());
} else if (view === "week") {
wrap.appendChild(renderWeek());
} else {
wrap.appendChild(renderDay());
}
host.appendChild(wrap);
}
function setView(nextView: CalendarView, nextAnchor: Date): void {
view = nextView;
anchor = nextAnchor;
if (urlEnabled) writeURL(viewParam, dateParam, nextView, nextAnchor);
paint();
}
// --- Toolbar ---------------------------------------------------------
function renderToolbar(): HTMLElement {
const bar = document.createElement("div");
bar.className = "views-calendar-toolbar";
const switcher = document.createElement("div");
switcher.className = "views-calendar-view-switcher agenda-chip-row";
switcher.setAttribute("role", "tablist");
for (const v of ["month", "week", "day"] as CalendarView[]) {
const chip = document.createElement("button");
chip.type = "button";
chip.className = "agenda-chip views-calendar-view-chip" + (v === view ? " agenda-chip-active" : "");
chip.dataset.calView = v;
chip.setAttribute("role", "tab");
chip.setAttribute("aria-selected", v === view ? "true" : "false");
chip.textContent = t(`cal.view.${v}` as I18nKey);
chip.addEventListener("click", () => {
if (v === view) return;
setView(v, anchor);
});
switcher.appendChild(chip);
}
bar.appendChild(switcher);
const nav = document.createElement("div");
nav.className = "views-calendar-nav";
const prev = document.createElement("button");
prev.type = "button";
prev.className = "btn-secondary btn-small views-calendar-nav-btn";
prev.setAttribute("aria-label", t(navLabelKey(view, "prev")));
prev.textContent = "";
prev.addEventListener("click", () => setView(view, shift(anchor, view, -1)));
nav.appendChild(prev);
const label = document.createElement("span");
label.className = "views-calendar-nav-label";
label.textContent = formatRangeLabel(view, anchor);
nav.appendChild(label);
const next = document.createElement("button");
next.type = "button";
next.className = "btn-secondary btn-small views-calendar-nav-btn";
next.setAttribute("aria-label", t(navLabelKey(view, "next")));
next.textContent = "";
next.addEventListener("click", () => setView(view, shift(anchor, view, 1)));
nav.appendChild(next);
// "Heute" button — jump back to today in the current view. Adds a
// recognisable affordance for the /events Kalender users who relied
// on the old toolbar's "Heute" button.
const today = document.createElement("button");
today.type = "button";
today.className = "btn-secondary btn-small views-calendar-nav-btn";
today.textContent = t("cal.today");
today.addEventListener("click", () => setView(view, startOfDay(new Date())));
nav.appendChild(today);
if (view !== "month") {
const backToMonth = document.createElement("button");
backToMonth.type = "button";
backToMonth.className = "btn-link views-calendar-back-to-month";
backToMonth.textContent = t("cal.day.back_to_month");
backToMonth.addEventListener("click", () => setView("month", anchor));
nav.appendChild(backToMonth);
}
bar.appendChild(nav);
return bar;
}
// --- Month -----------------------------------------------------------
function renderMonth(): HTMLElement {
const lang = getLang() === "de" ? "de-DE" : "en-GB";
const wrap = document.createElement("div");
wrap.className = "views-calendar-month";
const header = document.createElement("h2");
header.className = "views-calendar-month-label";
header.textContent = anchor.toLocaleDateString(lang, { month: "long", year: "numeric" });
wrap.appendChild(header);
const grid = document.createElement("div");
grid.className = "views-calendar-grid";
const weekdayKeys: I18nKey[] = [
"cal.day.mon", "cal.day.tue", "cal.day.wed", "cal.day.thu",
"cal.day.fri", "cal.day.sat", "cal.day.sun",
];
for (const k of weekdayKeys) {
const cell = document.createElement("div");
cell.className = "views-calendar-weekday";
cell.textContent = t(k);
grid.appendChild(cell);
}
const monthStart = new Date(anchor.getFullYear(), anchor.getMonth(), 1);
const startWeekday = (monthStart.getDay() + 6) % 7; // Mon=0
const daysInMonth = new Date(anchor.getFullYear(), anchor.getMonth() + 1, 0).getDate();
for (let i = 0; i < startWeekday; i++) {
const cell = document.createElement("div");
cell.className = "views-calendar-cell views-calendar-cell--out";
grid.appendChild(cell);
}
const byDate = bucketByDate(items, (d) =>
d.getMonth() === anchor.getMonth() && d.getFullYear() === anchor.getFullYear(),
);
for (let day = 1; day <= daysInMonth; day++) {
const dayDate = new Date(anchor.getFullYear(), anchor.getMonth(), day);
const dateKey = isoDate(dayDate);
const dayRows = byDate.get(dateKey) ?? [];
grid.appendChild(renderMonthCell(dayDate, day, dayRows));
}
wrap.appendChild(grid);
return wrap;
}
function renderMonthCell(dayDate: Date, dayNum: number, dayRows: CalendarItem[]): HTMLElement {
const cell = document.createElement("div");
cell.className = "views-calendar-cell";
if (isToday(dayDate)) cell.classList.add("views-calendar-cell--today");
if (dayRows.length > 0) cell.classList.add("views-calendar-cell--has");
const dayLabel = document.createElement("button");
dayLabel.type = "button";
dayLabel.className = "views-calendar-cell-day";
dayLabel.textContent = String(dayNum);
dayLabel.setAttribute("aria-label", t("cal.day.open_day"));
dayLabel.addEventListener("click", (e) => {
e.stopPropagation();
setView("day", dayDate);
});
cell.appendChild(dayLabel);
if (dayRows.length > 0) {
const ul = document.createElement("ul");
ul.className = "views-calendar-pills";
const visible = dayRows.slice(0, MAX_PILLS_PER_MONTH_CELL);
for (const row of visible) ul.appendChild(renderPill(row));
if (dayRows.length > visible.length) {
const more = document.createElement("li");
const moreBtn = document.createElement("button");
moreBtn.type = "button";
moreBtn.className = "views-calendar-pill views-calendar-pill--more";
moreBtn.textContent = `+${dayRows.length - visible.length}`;
moreBtn.setAttribute("aria-label", t("cal.day.open_day"));
moreBtn.addEventListener("click", (e) => {
e.stopPropagation();
setView("day", dayDate);
});
more.appendChild(moreBtn);
ul.appendChild(more);
}
cell.appendChild(ul);
}
return cell;
}
// --- Week ------------------------------------------------------------
function renderWeek(): HTMLElement {
const lang = getLang() === "de" ? "de-DE" : "en-GB";
const wrap = document.createElement("div");
wrap.className = "views-calendar-week";
const weekStart = startOfWeek(anchor);
const weekEnd = new Date(weekStart);
weekEnd.setDate(weekStart.getDate() + 6);
const header = document.createElement("h2");
header.className = "views-calendar-month-label";
header.textContent = formatWeekHeader(weekStart, weekEnd, lang);
wrap.appendChild(header);
const grid = document.createElement("div");
grid.className = "views-calendar-week-grid";
for (let i = 0; i < 7; i++) {
const day = new Date(weekStart);
day.setDate(weekStart.getDate() + i);
grid.appendChild(renderWeekColumn(day));
}
wrap.appendChild(grid);
return wrap;
}
function renderWeekColumn(day: Date): HTMLElement {
const lang = getLang() === "de" ? "de-DE" : "en-GB";
const col = document.createElement("div");
col.className = "views-calendar-week-column";
if (isToday(day)) col.classList.add("views-calendar-week-column--today");
const head = document.createElement("div");
head.className = "views-calendar-week-head";
const weekdayKey = WEEKDAY_KEYS[(day.getDay() + 6) % 7];
const dow = document.createElement("span");
dow.className = "views-calendar-week-dow";
dow.textContent = t(weekdayKey);
const dnum = document.createElement("span");
dnum.className = "views-calendar-week-dnum";
dnum.textContent = day.toLocaleDateString(lang, { day: "numeric", month: "short" });
head.appendChild(dow);
head.appendChild(dnum);
col.appendChild(head);
const dayRows = filterByDay(items, day);
if (dayRows.length === 0) {
const empty = document.createElement("p");
empty.className = "views-calendar-week-empty";
empty.textContent = t("cal.day.no_entries");
col.appendChild(empty);
return col;
}
const ul = document.createElement("ul");
ul.className = "views-calendar-week-list";
for (const row of dayRows) {
const li = document.createElement("li");
li.appendChild(renderRowAnchor(row, "week"));
ul.appendChild(li);
}
col.appendChild(ul);
return col;
}
// --- Day -------------------------------------------------------------
function renderDay(): HTMLElement {
const lang = getLang() === "de" ? "de-DE" : "en-GB";
const wrap = document.createElement("div");
wrap.className = "views-calendar-day-wrap";
const header = document.createElement("h2");
header.className = "views-calendar-month-label";
header.textContent = anchor.toLocaleDateString(lang, {
weekday: "long", year: "numeric", month: "long", day: "numeric",
});
wrap.appendChild(header);
const dayRows = filterByDay(items, anchor);
if (dayRows.length === 0) {
const empty = document.createElement("p");
empty.className = "views-calendar-day-empty";
empty.textContent = t("cal.day.no_entries");
wrap.appendChild(empty);
return wrap;
}
const ul = document.createElement("ul");
ul.className = "views-calendar-day-list";
for (const row of dayRows) {
const li = document.createElement("li");
li.appendChild(renderRowAnchor(row, "day"));
ul.appendChild(li);
}
wrap.appendChild(ul);
return wrap;
}
// --- Row rendering ---------------------------------------------------
function renderPill(row: CalendarItem): HTMLElement {
const li = document.createElement("li");
const a = document.createElement("a");
a.className = `views-calendar-pill views-calendar-pill--${row.kind}`;
a.href = hrefFor(row);
a.textContent = row.title;
a.title = row.title + (row.project_title ? `${row.project_title}` : "");
a.addEventListener("click", (e) => e.stopPropagation());
li.appendChild(a);
return li;
}
function renderRowAnchor(row: CalendarItem, density: "week" | "day"): HTMLElement {
const a = document.createElement("a");
a.className = `views-calendar-row views-calendar-row--${density} views-calendar-row--${row.kind}`;
a.href = hrefFor(row);
const dot = document.createElement("span");
dot.className = `views-calendar-row-dot views-calendar-row-dot--${row.kind}`;
a.appendChild(dot);
const body = document.createElement("span");
body.className = "views-calendar-row-body";
const title = document.createElement("span");
title.className = "views-calendar-row-title";
title.textContent = row.title;
body.appendChild(title);
const metaParts: string[] = [];
metaParts.push(tDyn("views.kind." + row.kind));
if (row.project_reference) metaParts.push(row.project_reference);
else if (row.project_title) metaParts.push(row.project_title);
if (metaParts.length > 0) {
const meta = document.createElement("span");
meta.className = "views-calendar-row-meta";
meta.textContent = metaParts.join(" · ");
body.appendChild(meta);
}
a.appendChild(body);
return a;
}
function hrefFor(row: CalendarItem): string {
if (opts.hrefFor) return opts.hrefFor(row);
return defaultHrefFor(row);
}
}
// --- Pure helpers (shared, not closure-bound) ----------------------------
const WEEKDAY_KEYS: I18nKey[] = [
"cal.day.mon", "cal.day.tue", "cal.day.wed", "cal.day.thu",
"cal.day.fri", "cal.day.sat", "cal.day.sun",
];
function navLabelKey(view: CalendarView, dir: "prev" | "next"): I18nKey {
if (view === "month") return dir === "prev" ? "cal.month.prev" : "cal.month.next";
if (view === "week") return dir === "prev" ? "cal.week.prev" : "cal.week.next";
return dir === "prev" ? "cal.day.prev" : "cal.day.next";
}
function defaultHrefFor(row: CalendarItem): string {
switch (row.kind) {
case "deadline": return `/deadlines/${encodeURIComponent(row.id)}`;
case "appointment": return `/appointments/${encodeURIComponent(row.id)}`;
case "approval_request": return `/inbox`;
case "project_event": return row.project_id ? `/projects/${encodeURIComponent(row.project_id)}` : "#";
}
}
export function bucketByDate(
rows: CalendarItem[], filter: (d: Date) => boolean,
): Map<string, CalendarItem[]> {
const out = new Map<string, CalendarItem[]>();
for (const row of rows) {
const d = new Date(row.event_date);
if (isNaN(d.getTime())) continue;
if (!filter(d)) continue;
const key = isoDate(d);
const arr = out.get(key);
if (arr) arr.push(row);
else out.set(key, [row]);
}
return out;
}
export function filterByDay(rows: CalendarItem[], day: Date): CalendarItem[] {
const key = isoDate(day);
return rows.filter((r) => {
const d = new Date(r.event_date);
if (isNaN(d.getTime())) return false;
return isoDate(d) === key;
});
}
export function startOfWeek(d: Date): Date {
const out = new Date(d.getFullYear(), d.getMonth(), d.getDate());
const offset = (out.getDay() + 6) % 7;
out.setDate(out.getDate() - offset);
return out;
}
export function startOfDay(d: Date): Date {
return new Date(d.getFullYear(), d.getMonth(), d.getDate());
}
export function shift(d: Date, view: CalendarView, dir: number): Date {
if (view === "month") return new Date(d.getFullYear(), d.getMonth() + dir, 1);
if (view === "week") return new Date(d.getFullYear(), d.getMonth(), d.getDate() + dir * 7);
return new Date(d.getFullYear(), d.getMonth(), d.getDate() + dir);
}
export function isToday(d: Date): boolean {
const now = new Date();
return d.getFullYear() === now.getFullYear()
&& d.getMonth() === now.getMonth()
&& d.getDate() === now.getDate();
}
export function isoDate(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
function formatRangeLabel(view: CalendarView, anchor: Date): string {
const lang = getLang() === "de" ? "de-DE" : "en-GB";
if (view === "month") {
return anchor.toLocaleDateString(lang, { month: "long", year: "numeric" });
}
if (view === "week") {
const start = startOfWeek(anchor);
const end = new Date(start);
end.setDate(start.getDate() + 6);
return formatWeekHeader(start, end, lang);
}
return anchor.toLocaleDateString(lang, {
weekday: "short", year: "numeric", month: "long", day: "numeric",
});
}
function formatWeekHeader(start: Date, end: Date, lang: string): string {
const startStr = start.toLocaleDateString(lang, { day: "numeric", month: "short" });
const endStr = end.toLocaleDateString(lang, { day: "numeric", month: "short", year: "numeric" });
return `${startStr} ${endStr}`;
}
function firstAnchor(rows: CalendarItem[]): Date {
for (const row of rows) {
const d = new Date(row.event_date);
if (!isNaN(d.getTime())) return startOfDay(d);
}
return startOfDay(new Date());
}
function paramName(prefix: string | undefined, base: string): string {
if (!prefix) return base;
return `${prefix}_${base}`;
}
function readView(viewParam: string, fallback: CalendarView): CalendarView {
if (typeof window === "undefined") return fallback;
const params = new URLSearchParams(window.location.search);
const raw = params.get(viewParam);
if (raw === "month" || raw === "week" || raw === "day") return raw;
return fallback;
}
function readAnchor(dateParam: string, rows: CalendarItem[]): Date {
if (typeof window === "undefined") return firstAnchor(rows);
const params = new URLSearchParams(window.location.search);
const raw = params.get(dateParam);
if (raw) {
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(raw);
if (m) {
const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
if (!isNaN(d.getTime())) return d;
}
}
return firstAnchor(rows);
}
function writeURL(viewParam: string, dateParam: string, view: CalendarView, anchor: Date): void {
if (typeof window === "undefined") return;
const url = new URL(window.location.href);
url.searchParams.set(viewParam, view);
url.searchParams.set(dateParam, isoDate(anchor));
history.replaceState(null, "", url.toString());
}

View File

@@ -0,0 +1,365 @@
// Authoring wizard for paliad.checklists. Serves both /checklists/new
// (create) and /checklists/templates/{slug}/edit (edit). The HTML bundle is the
// same; this client reads location.pathname to decide which mode to
// boot into.
import { initI18n, t } from "./i18n";
import { initSidebar } from "./sidebar";
interface Item {
labelDE: string;
labelEN: string;
noteDE?: string;
noteEN?: string;
rule?: string;
}
interface Group {
titleDE: string;
titleEN: string;
items: Item[];
}
interface Checklist {
id: string;
slug: string;
title: string;
description: string;
regime: string;
court: string;
reference: string;
deadline: string;
lang: string;
visibility: string;
body: { groups: Group[] };
}
function esc(s: string): string {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
function escAttr(s: string): string {
return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
}
function detectMode(): { mode: "create" | "edit"; slug?: string } {
const path = window.location.pathname;
if (path === "/checklists/new") {
return { mode: "create" };
}
const m = path.match(/^\/checklists\/templates\/([^/]+)\/edit$/);
if (m) {
return { mode: "edit", slug: m[1] };
}
return { mode: "create" };
}
let groups: Group[] = [];
function renderGroups() {
const container = document.getElementById("groups-container")!;
if (groups.length === 0) {
// Seed with a single empty group + item so the user has something
// to fill out rather than a blank canvas.
groups = [{ titleDE: "", titleEN: "", items: [{ labelDE: "", labelEN: "" }] }];
}
container.innerHTML = groups.map((g, gi) => {
const itemsHTML = g.items.map((it, ii) => {
return `<div class="author-item" data-gi="${gi}" data-ii="${ii}">
<div class="form-row">
<label class="form-label">${esc(t("checklisten.author.item.label"))}</label>
<input class="form-input" data-field="label" value="${escAttr(it.labelDE || "")}" />
</div>
<div class="form-grid form-grid-2">
<div class="form-row">
<label class="form-label">${esc(t("checklisten.author.item.note"))}</label>
<input class="form-input" data-field="note" value="${escAttr(it.noteDE || "")}" />
</div>
<div class="form-row">
<label class="form-label">${esc(t("checklisten.author.item.rule"))}</label>
<input class="form-input" data-field="rule" value="${escAttr(it.rule || "")}" />
</div>
</div>
<button type="button" class="btn btn-small btn-danger" data-action="remove-item">${esc(t("checklisten.author.item.remove"))}</button>
</div>`;
}).join("");
return `<div class="author-group" data-gi="${gi}">
<div class="form-row">
<label class="form-label">${esc(t("checklisten.author.group.title"))}</label>
<input class="form-input" data-field="group-title" value="${escAttr(g.titleDE || "")}" />
</div>
<div class="author-items">${itemsHTML}</div>
<div class="author-group-actions">
<button type="button" class="btn btn-small" data-action="add-item">${esc(t("checklisten.author.item.add"))}</button>
<button type="button" class="btn btn-small btn-danger" data-action="remove-group">${esc(t("checklisten.author.group.remove"))}</button>
</div>
</div>`;
}).join("");
// Wire input changes back into the data array.
container.querySelectorAll<HTMLInputElement>(".author-group > .form-row input[data-field=group-title]").forEach((input) => {
const groupDiv = input.closest<HTMLElement>(".author-group")!;
const gi = parseInt(groupDiv.dataset.gi!, 10);
input.addEventListener("input", () => {
groups[gi].titleDE = input.value;
groups[gi].titleEN = input.value; // single-language for Slice A
});
});
container.querySelectorAll<HTMLDivElement>(".author-item").forEach((itemDiv) => {
const gi = parseInt(itemDiv.dataset.gi!, 10);
const ii = parseInt(itemDiv.dataset.ii!, 10);
itemDiv.querySelectorAll<HTMLInputElement>("input[data-field]").forEach((input) => {
input.addEventListener("input", () => {
const field = input.dataset.field!;
if (field === "label") {
groups[gi].items[ii].labelDE = input.value;
groups[gi].items[ii].labelEN = input.value;
} else if (field === "note") {
groups[gi].items[ii].noteDE = input.value || undefined;
groups[gi].items[ii].noteEN = input.value || undefined;
} else if (field === "rule") {
groups[gi].items[ii].rule = input.value || undefined;
}
});
});
itemDiv.querySelector<HTMLButtonElement>("button[data-action=remove-item]")!.addEventListener("click", () => {
groups[gi].items.splice(ii, 1);
if (groups[gi].items.length === 0) {
groups[gi].items.push({ labelDE: "", labelEN: "" });
}
renderGroups();
});
});
container.querySelectorAll<HTMLButtonElement>("button[data-action=add-item]").forEach((btn) => {
const groupDiv = btn.closest<HTMLElement>(".author-group")!;
const gi = parseInt(groupDiv.dataset.gi!, 10);
btn.addEventListener("click", () => {
groups[gi].items.push({ labelDE: "", labelEN: "" });
renderGroups();
});
});
container.querySelectorAll<HTMLButtonElement>("button[data-action=remove-group]").forEach((btn) => {
const groupDiv = btn.closest<HTMLElement>(".author-group")!;
const gi = parseInt(groupDiv.dataset.gi!, 10);
btn.addEventListener("click", () => {
groups.splice(gi, 1);
renderGroups();
});
});
}
function showError(msg: string) {
const err = document.getElementById("author-error")!;
err.textContent = msg;
err.style.display = "";
err.scrollIntoView({ behavior: "smooth", block: "center" });
}
function clearError() {
const err = document.getElementById("author-error")!;
err.textContent = "";
err.style.display = "none";
}
function collectInput() {
const title = (document.getElementById("title") as HTMLInputElement).value.trim();
const description = (document.getElementById("description") as HTMLTextAreaElement).value.trim();
const regime = (document.getElementById("regime") as HTMLSelectElement).value;
const court = (document.getElementById("court") as HTMLInputElement).value.trim();
const reference = (document.getElementById("reference") as HTMLInputElement).value.trim();
const deadline = (document.getElementById("deadline") as HTMLInputElement).value.trim();
const lang = (document.getElementById("lang") as HTMLSelectElement).value;
const visibilityInput = document.querySelector<HTMLInputElement>("input[name=visibility]:checked");
const visibility = visibilityInput?.value || "private";
return { title, description, regime, court, reference, deadline, lang, visibility };
}
function validateGroups(): boolean {
if (groups.length === 0) return false;
let totalItems = 0;
for (const g of groups) {
if (!g.titleDE.trim()) return false;
for (const it of g.items) {
if (it.labelDE.trim()) totalItems += 1;
}
}
return totalItems > 0;
}
function trimmedGroups(): Group[] {
return groups
.filter((g) => g.titleDE.trim() && g.items.some((it) => it.labelDE.trim()))
.map((g) => ({
titleDE: g.titleDE.trim(),
titleEN: g.titleEN.trim(),
items: g.items
.filter((it) => it.labelDE.trim())
.map((it) => ({
labelDE: it.labelDE.trim(),
labelEN: it.labelEN.trim(),
noteDE: it.noteDE?.trim() || undefined,
noteEN: it.noteEN?.trim() || undefined,
rule: it.rule?.trim() || undefined,
})),
}));
}
async function loadEditTemplate(slug: string) {
// Use /api/checklists/{slug} (catalog Find with visibility check) +
// the mine list to ensure we have the editable fields. Templates the
// caller doesn't own/admin will trip the PATCH gate later.
const resp = await fetch(`/api/checklists/templates/mine`);
if (!resp.ok) {
showError(t("checklisten.author.error.notfound"));
return;
}
const rows: Checklist[] = (await resp.json()) ?? [];
const tpl = rows.find((r) => r.slug === slug);
if (!tpl) {
showError(t("checklisten.author.error.notfound"));
return;
}
(document.getElementById("author-heading")!).textContent = t("checklisten.author.heading.edit");
document.title = t("checklisten.author.title.edit");
(document.getElementById("title") as HTMLInputElement).value = tpl.title;
(document.getElementById("description") as HTMLTextAreaElement).value = tpl.description;
(document.getElementById("regime") as HTMLSelectElement).value = tpl.regime;
(document.getElementById("court") as HTMLInputElement).value = tpl.court;
(document.getElementById("reference") as HTMLInputElement).value = tpl.reference;
(document.getElementById("deadline") as HTMLInputElement).value = tpl.deadline;
(document.getElementById("lang") as HTMLSelectElement).value = tpl.lang || "de";
const visIn = document.querySelector<HTMLInputElement>(`input[name=visibility][value=${tpl.visibility}]`);
if (visIn) visIn.checked = true;
groups = (tpl.body?.groups || []).map((g) => ({
titleDE: g.titleDE || "",
titleEN: g.titleEN || g.titleDE || "",
items: g.items.map((it) => ({
labelDE: it.labelDE || "",
labelEN: it.labelEN || it.labelDE || "",
noteDE: it.noteDE,
noteEN: it.noteEN,
rule: it.rule,
})),
}));
if (groups.length === 0) {
groups = [{ titleDE: "", titleEN: "", items: [{ labelDE: "", labelEN: "" }] }];
}
renderGroups();
}
async function submitCreate() {
clearError();
const input = collectInput();
if (!input.title) {
showError(t("checklisten.author.error.title"));
return;
}
if (!validateGroups()) {
showError(t("checklisten.author.error.no_groups"));
return;
}
const saveBtn = document.getElementById("author-save") as HTMLButtonElement;
saveBtn.disabled = true;
saveBtn.textContent = t("checklisten.author.saving");
const body = JSON.stringify({ ...input, body: { groups: trimmedGroups() } });
const resp = await fetch("/api/checklists/templates", {
method: "POST",
headers: { "Content-Type": "application/json" },
body,
});
saveBtn.disabled = false;
saveBtn.textContent = t("checklisten.author.save");
if (!resp.ok) {
let msg = t("checklisten.author.error.generic");
try {
const j = await resp.json();
if (j?.error) msg = j.error;
} catch { /* keep generic */ }
showError(msg);
return;
}
const created: Checklist = await resp.json();
window.location.href = `/checklists/${encodeURIComponent(created.slug)}`;
}
async function submitEdit(slug: string) {
clearError();
const input = collectInput();
if (!input.title) {
showError(t("checklisten.author.error.title"));
return;
}
if (!validateGroups()) {
showError(t("checklisten.author.error.no_groups"));
return;
}
const saveBtn = document.getElementById("author-save") as HTMLButtonElement;
saveBtn.disabled = true;
saveBtn.textContent = t("checklisten.author.saving");
const patch = {
title: input.title,
description: input.description,
regime: input.regime,
court: input.court,
reference: input.reference,
deadline: input.deadline,
body: { groups: trimmedGroups() },
};
const resp = await fetch(`/api/checklists/templates/${encodeURIComponent(slug)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(patch),
});
// Visibility lives on its own endpoint so the audit row reflects the
// distinct transition. Only call if it actually changed.
if (resp.ok && input.visibility) {
await fetch(`/api/checklists/templates/${encodeURIComponent(slug)}/visibility`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ visibility: input.visibility }),
});
}
saveBtn.disabled = false;
saveBtn.textContent = t("checklisten.author.save");
if (!resp.ok) {
let msg = t("checklisten.author.error.generic");
try {
const j = await resp.json();
if (j?.error) msg = j.error;
} catch { /* keep generic */ }
showError(msg);
return;
}
window.location.href = `/checklists/${encodeURIComponent(slug)}`;
}
document.addEventListener("DOMContentLoaded", () => {
initI18n();
initSidebar();
renderGroups();
document.getElementById("add-group")!.addEventListener("click", () => {
groups.push({ titleDE: "", titleEN: "", items: [{ labelDE: "", labelEN: "" }] });
renderGroups();
});
const { mode, slug } = detectMode();
if (mode === "edit" && slug) {
void loadEditTemplate(slug);
}
document.getElementById("author-form")!.addEventListener("submit", (e) => {
e.preventDefault();
if (mode === "edit" && slug) {
void submitEdit(slug);
} else {
void submitCreate();
}
});
});

View File

@@ -30,6 +30,37 @@ interface Checklist {
referenceDE?: string;
referenceEN?: string;
groups: ChecklistGroup[];
// Slice B fields — present on authored entries via the merged
// catalog response. 'static' templates don't carry these.
origin?: "static" | "authored";
visibility?: string;
owner_email?: string;
owner_display_name?: string;
}
interface Me {
id: string;
email: string;
display_name: string;
global_role?: string;
}
interface UserSummary {
id: string;
email: string;
display_name: string;
}
interface PartnerUnit {
id: string;
name: string;
}
interface Share {
id: string;
checklist_id: string;
recipient_kind: "user" | "office" | "partner_unit" | "project";
recipient_label: string;
}
interface ChecklistInstance {
@@ -371,13 +402,320 @@ function rerenderAll() {
renderInstances();
}
// --- Slice B: owner actions + admin promote + share modal ----------------
let me: Me | null = null;
let isOwner = false;
let isAdmin = false;
let shareUsers: UserSummary[] = [];
let sharePartnerUnits: PartnerUnit[] = [];
let shareProjects: AkteSummary[] = [];
let activeShareKind: "user" | "office" | "partner_unit" | "project" = "user";
async function loadMe(): Promise<Me | null> {
try {
const resp = await fetch("/api/me");
if (!resp.ok) return null;
return await resp.json();
} catch {
return null;
}
}
function templateOriginInfo() {
return template as unknown as {
origin?: string;
visibility?: string;
owner_email?: string;
owner_display_name?: string;
} | null;
}
function applyOwnerControls() {
const info = templateOriginInfo();
const isAuthored = info?.origin === "authored";
const provenance = document.getElementById("checklist-provenance")!;
if (isAuthored && info?.owner_display_name) {
provenance.style.display = "";
provenance.textContent = t("checklisten.detail.authored.by").replace("{author}", info.owner_display_name);
} else {
provenance.style.display = "none";
}
isOwner = !!(isAuthored && me && info?.owner_email && me.email.toLowerCase() === info.owner_email.toLowerCase());
isAdmin = !!(me && me.global_role === "global_admin");
const ownerOnly = (id: string, show: boolean) => {
const el = document.getElementById(id);
if (el) (el as HTMLElement).style.display = show ? "" : "none";
};
if (template) {
(document.getElementById("btn-edit-template") as HTMLAnchorElement | null)?.setAttribute(
"href",
`/checklists/templates/${encodeURIComponent(template.slug)}/edit`,
);
}
ownerOnly("btn-edit-template", isOwner);
ownerOnly("btn-share-template", isOwner);
ownerOnly("btn-delete-template", isOwner);
// Admin promote/demote — only when an authored template is visible to
// an admin, and only the appropriate one for the current visibility.
if (isAuthored && isAdmin) {
const isGlobal = info?.visibility === "global";
ownerOnly("btn-promote-template", !isGlobal);
ownerOnly("btn-demote-template", isGlobal);
} else {
ownerOnly("btn-promote-template", false);
ownerOnly("btn-demote-template", false);
}
}
function initOwnerActions() {
document.getElementById("btn-delete-template")?.addEventListener("click", async () => {
if (!template) return;
const isEN = getLang() === "en";
const title = isEN ? template.titleEN : template.titleDE;
const msg = t("checklisten.detail.delete.confirm").replace("{title}", title);
if (!window.confirm(msg)) return;
const resp = await fetch(`/api/checklists/templates/${encodeURIComponent(template.slug)}`, { method: "DELETE" });
if (!resp.ok) {
window.alert(t("checklisten.detail.delete.error"));
return;
}
window.location.href = "/checklists?tab=mine";
});
document.getElementById("btn-promote-template")?.addEventListener("click", async () => {
if (!template) return;
if (!window.confirm(t("checklisten.detail.promote.confirm"))) return;
const resp = await fetch(`/api/admin/checklists/${encodeURIComponent(template.slug)}/promote`, { method: "POST" });
if (!resp.ok) {
window.alert(t("checklisten.detail.promote.error"));
return;
}
window.location.reload();
});
document.getElementById("btn-demote-template")?.addEventListener("click", async () => {
if (!template) return;
if (!window.confirm(t("checklisten.detail.demote.confirm"))) return;
const resp = await fetch(`/api/admin/checklists/${encodeURIComponent(template.slug)}/demote`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ target: "firm" }),
});
if (!resp.ok) {
window.alert(t("checklisten.detail.promote.error"));
return;
}
window.location.reload();
});
}
async function loadSharePickerData() {
// Fire all three lookups in parallel — the share modal needs all of
// them but doesn't depend on their order.
try {
const [usersResp, unitsResp, projectsResp] = await Promise.all([
fetch("/api/users"),
fetch("/api/partner-units"),
fetch("/api/projects"),
]);
shareUsers = usersResp.ok ? await usersResp.json() : [];
sharePartnerUnits = unitsResp.ok ? await unitsResp.json() : [];
shareProjects = projectsResp.ok ? await projectsResp.json() : [];
} catch {
/* leave whatever loaded */
}
populateSharePickerOptions();
}
function populateSharePickerOptions() {
const userSel = document.getElementById("share-user") as HTMLSelectElement;
if (userSel) {
userSel.innerHTML = `<option value="">${esc(t("checklisten.share.pick"))}</option>`;
shareUsers
.slice()
.sort((a, b) => a.display_name.localeCompare(b.display_name))
.forEach((u) => {
if (me && u.id === me.id) return; // can't share with self
const opt = document.createElement("option");
opt.value = u.id;
opt.textContent = `${u.display_name} (${u.email})`;
userSel.appendChild(opt);
});
}
const officeSel = document.getElementById("share-office") as HTMLSelectElement;
if (officeSel) {
const officeKeys = ["munich", "duesseldorf", "hamburg", "amsterdam", "london", "paris", "milan", "madrid"];
officeSel.innerHTML = `<option value="">${esc(t("checklisten.share.pick"))}</option>`;
officeKeys.forEach((k) => {
const opt = document.createElement("option");
opt.value = k;
opt.textContent = k.charAt(0).toUpperCase() + k.slice(1);
officeSel.appendChild(opt);
});
}
const puSel = document.getElementById("share-partner-unit") as HTMLSelectElement;
if (puSel) {
puSel.innerHTML = `<option value="">${esc(t("checklisten.share.pick"))}</option>`;
sharePartnerUnits
.slice()
.sort((a, b) => a.name.localeCompare(b.name))
.forEach((u) => {
const opt = document.createElement("option");
opt.value = u.id;
opt.textContent = u.name;
puSel.appendChild(opt);
});
}
const prSel = document.getElementById("share-project") as HTMLSelectElement;
if (prSel) {
prSel.innerHTML = `<option value="">${esc(t("checklisten.share.pick"))}</option>`;
shareProjects
.slice()
.sort((a, b) => (a.reference || a.title).localeCompare(b.reference || b.title))
.forEach((p) => {
const opt = document.createElement("option");
opt.value = p.id;
opt.textContent = `${p.reference || ""}${p.title}`;
prSel.appendChild(opt);
});
}
}
function switchShareKind(kind: "user" | "office" | "partner_unit" | "project") {
activeShareKind = kind;
document.querySelectorAll<HTMLButtonElement>("#share-kind-pills .filter-pill").forEach((p) => {
p.classList.toggle("active", p.dataset.kind === kind);
});
document.querySelectorAll<HTMLElement>(".share-kind-section").forEach((s) => {
s.style.display = s.dataset.kind === kind ? "" : "none";
});
}
function initShareModal() {
const modal = document.getElementById("share-modal")!;
const msg = document.getElementById("share-msg")!;
const close = () => { modal.style.display = "none"; };
document.getElementById("btn-share-template")?.addEventListener("click", async () => {
if (!template) return;
msg.textContent = "";
msg.className = "form-msg";
switchShareKind("user");
modal.style.display = "flex";
await loadSharePickerData();
await renderGrants();
});
document.getElementById("share-close")?.addEventListener("click", close);
document.getElementById("share-cancel")?.addEventListener("click", close);
modal.addEventListener("click", (e) => { if (e.target === e.currentTarget) close(); });
document.getElementById("share-kind-pills")?.addEventListener("click", (e) => {
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>(".filter-pill[data-kind]");
if (!btn) return;
switchShareKind(btn.dataset.kind as typeof activeShareKind);
});
document.getElementById("share-submit")?.addEventListener("click", async () => {
if (!template) return;
const input: Record<string, unknown> = { recipient_kind: activeShareKind };
switch (activeShareKind) {
case "user": {
const v = (document.getElementById("share-user") as HTMLSelectElement).value;
if (!v) { msg.textContent = t("checklisten.share.error.pick"); msg.className = "form-msg form-msg-error"; return; }
input["recipient_user_id"] = v;
break;
}
case "office": {
const v = (document.getElementById("share-office") as HTMLSelectElement).value;
if (!v) { msg.textContent = t("checklisten.share.error.pick"); msg.className = "form-msg form-msg-error"; return; }
input["recipient_office"] = v;
break;
}
case "partner_unit": {
const v = (document.getElementById("share-partner-unit") as HTMLSelectElement).value;
if (!v) { msg.textContent = t("checklisten.share.error.pick"); msg.className = "form-msg form-msg-error"; return; }
input["recipient_partner_unit_id"] = v;
break;
}
case "project": {
const v = (document.getElementById("share-project") as HTMLSelectElement).value;
if (!v) { msg.textContent = t("checklisten.share.error.pick"); msg.className = "form-msg form-msg-error"; return; }
input["recipient_project_id"] = v;
break;
}
}
const resp = await fetch(`/api/checklists/templates/${encodeURIComponent(template.slug)}/shares`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
if (!resp.ok) {
let errMsg = t("checklisten.share.error.generic");
try {
const j = await resp.json();
if (j?.error) errMsg = j.error;
} catch { /* keep generic */ }
msg.textContent = errMsg;
msg.className = "form-msg form-msg-error";
return;
}
msg.textContent = t("checklisten.share.success");
msg.className = "form-msg form-msg-success";
await renderGrants();
});
}
async function renderGrants() {
if (!template) return;
const list = document.getElementById("share-grants-list")!;
const empty = document.getElementById("share-grants-empty")!;
const resp = await fetch(`/api/checklists/templates/${encodeURIComponent(template.slug)}/shares`);
const rows: Share[] = resp.ok ? await resp.json() : [];
if (rows.length === 0) {
list.innerHTML = "";
list.appendChild(empty);
empty.style.display = "";
return;
}
empty.style.display = "none";
list.innerHTML = rows.map((s) => {
const kindLabel = esc(t(("checklisten.share.grants.recipient." + s.recipient_kind) as never) || s.recipient_kind);
return `<li class="share-grant-row" data-id="${esc(s.id)}">
<span class="share-grant-kind">${kindLabel}</span>
<span class="share-grant-label">${esc(s.recipient_label || "")}</span>
<button type="button" class="btn-small btn-ghost" data-action="revoke" data-id="${esc(s.id)}">${esc(t("checklisten.share.grants.revoke"))}</button>
</li>`;
}).join("");
list.querySelectorAll<HTMLButtonElement>("button[data-action=revoke]").forEach((btn) => {
btn.addEventListener("click", async () => {
if (!window.confirm(t("checklisten.share.grants.revoke.confirm"))) return;
const resp = await fetch(`/api/checklists/shares/${encodeURIComponent(btn.dataset.id!)}`, { method: "DELETE" });
if (!resp.ok && resp.status !== 204) {
window.alert(t("checklisten.share.grants.revoke.error"));
return;
}
await renderGrants();
});
});
}
document.addEventListener("DOMContentLoaded", () => {
initI18n();
initSidebar();
initNewInstance();
initFeedback();
initOwnerActions();
initShareModal();
onLangChange(rerenderAll);
void loadTemplate();
void (async () => {
me = await loadMe();
await loadTemplate();
applyOwnerControls();
})();
void loadInstances();
void loadAkten();
});

View File

@@ -40,6 +40,16 @@ interface Instance {
created_by: string;
created_at: string;
updated_at: string;
// Slice C — snapshot of the template body + its version at create time.
template_snapshot?: { groups: ChecklistGroup[] } | null;
template_version?: number | null;
}
// Slice C — augmented Checklist with origin + version, returned by
// /api/checklists/{slug}.
interface ChecklistWithMeta extends Checklist {
origin?: "static" | "authored";
version?: number;
}
let template: Checklist | null = null;
@@ -155,6 +165,119 @@ function renderHeader() {
parts.push(`<div class="checklist-meta-item"><dt>${akteLabel}</dt><dd><a href="/projects/${esc(instance.project_id)}">${t("checklisten.instance.akte.open") || "Öffnen"}</a></dd></div>`);
}
document.getElementById("instance-meta")!.innerHTML = parts.join("");
renderOutdatedBadge();
}
// Slice C — show an "outdated" badge when the live template has a
// version > the instance's snapshot version. Both values must be
// non-null for the comparison to be meaningful (pre-Slice-C instances
// have NULL template_version; static templates always have version=1
// and never bump).
function renderOutdatedBadge() {
const slot = document.getElementById("instance-outdated-slot");
if (!slot || !instance || !template) return;
const tplMeta = template as ChecklistWithMeta;
const instVersion = instance.template_version;
const tplVersion = tplMeta.version;
if (
instVersion == null ||
tplVersion == null ||
tplMeta.origin !== "authored" ||
tplVersion <= instVersion
) {
slot.innerHTML = "";
return;
}
const badge = esc(t("checklisten.instance.outdated.badge"));
const note = esc(
t("checklisten.instance.outdated.note")
.replace("{from}", String(instVersion))
.replace("{to}", String(tplVersion)),
);
const action = esc(t("checklisten.instance.outdated.diff"));
slot.innerHTML = `<div class="instance-outdated-banner">
<span class="instance-outdated-badge">${badge}</span>
<span class="instance-outdated-note">${note}</span>
<button type="button" class="btn-small" id="btn-show-diff">${action}</button>
</div>`;
document.getElementById("btn-show-diff")!.addEventListener("click", openDiffModal);
}
// Shallow diff between two checklist bodies. Compares item label/note/
// rule pairs grouped by section title. Items with the same group title
// + same label are matched; differences in note/rule are flagged
// 'changed'. Items present only in snapshot are 'removed'; items only
// in current are 'added'.
function diffBodies(snapshot: { groups: ChecklistGroup[] } | null | undefined, current: ChecklistGroup[]):
{ added: string[]; removed: string[]; changed: string[] } {
const added: string[] = [];
const removed: string[] = [];
const changed: string[] = [];
const oldGroups = snapshot?.groups ?? [];
const oldMap: Record<string, ChecklistItem> = {};
for (const g of oldGroups) {
for (const it of g.items) {
const key = `${g.titleDE || g.titleEN}::${it.labelDE || it.labelEN}`;
oldMap[key] = it;
}
}
const newMap: Record<string, ChecklistItem> = {};
for (const g of current) {
for (const it of g.items) {
const key = `${g.titleDE || g.titleEN}::${it.labelDE || it.labelEN}`;
newMap[key] = it;
if (!(key in oldMap)) {
added.push(it.labelDE || it.labelEN);
} else {
const o = oldMap[key];
if ((o.noteDE || o.noteEN || "") !== (it.noteDE || it.noteEN || "") ||
(o.rule || "") !== (it.rule || "")) {
changed.push(it.labelDE || it.labelEN);
}
}
}
}
for (const key in oldMap) {
if (!(key in newMap)) {
const labelParts = key.split("::");
removed.push(labelParts[1] || key);
}
}
return { added, removed, changed };
}
function openDiffModal() {
if (!template || !instance) return;
const modal = document.getElementById("instance-diff-modal")!;
const body = document.getElementById("instance-diff-body")!;
const diff = diffBodies(instance.template_snapshot, template.groups);
const empty = diff.added.length === 0 && diff.removed.length === 0 && diff.changed.length === 0;
if (empty) {
body.innerHTML = `<p class="entity-events-empty">${esc(t("checklisten.instance.diff.empty"))}</p>`;
} else {
const section = (label: string, klass: string, items: string[]) => {
if (items.length === 0) return "";
return `<section class="instance-diff-section ${klass}">
<h3>${esc(label)}</h3>
<ul>${items.map((s) => `<li>${esc(s)}</li>`).join("")}</ul>
</section>`;
};
body.innerHTML = [
section(t("checklisten.instance.diff.added"), "instance-diff-added", diff.added),
section(t("checklisten.instance.diff.removed"), "instance-diff-removed", diff.removed),
section(t("checklisten.instance.diff.changed"), "instance-diff-changed", diff.changed),
].join("");
}
modal.style.display = "flex";
}
function initDiffModal() {
const modal = document.getElementById("instance-diff-modal");
if (!modal) return;
const close = () => { modal.style.display = "none"; };
document.getElementById("instance-diff-close")?.addEventListener("click", close);
document.getElementById("instance-diff-close-bottom")?.addEventListener("click", close);
modal.addEventListener("click", (e) => { if (e.target === e.currentTarget) close(); });
}
function renderGroups() {
@@ -389,6 +512,7 @@ document.addEventListener("DOMContentLoaded", () => {
initPrint();
initRename();
initFeedback();
initDiffModal();
onLangChange(renderAll);
void bootstrap();
});

View File

@@ -11,6 +11,26 @@ interface ChecklistSummary {
courtDE: string;
courtEN: string;
itemCount: number;
origin?: "static" | "authored";
visibility?: string;
owner_email?: string;
owner_display_name?: string;
}
interface MyChecklist {
id: string;
slug: string;
owner_id: string;
title: string;
description: string;
regime: string;
court: string;
reference: string;
deadline: string;
lang: string;
visibility: string;
created_at: string;
updated_at: string;
}
interface ChecklistInstance {
@@ -26,15 +46,20 @@ interface ChecklistInstance {
project_title?: string | null;
}
type TabId = "templates" | "instances";
type TabId = "templates" | "mine" | "gallery" | "instances";
const VALID_TABS: TabId[] = ["templates", "instances"];
const VALID_TABS: TabId[] = ["templates", "mine", "gallery", "instances"];
let allChecklists: ChecklistSummary[] = [];
let activeRegime = "all";
let galleryRegime = "all";
let allInstances: ChecklistInstance[] = [];
let templatesBySlug: Record<string, ChecklistSummary> = {};
let instancesLoaded = false;
let myTemplates: MyChecklist[] = [];
let myTemplatesLoaded = false;
let galleryLoaded = false;
let me: { id: string; email: string } | null = null;
let activeTab: TabId = "templates";
function esc(s: string): string {
@@ -208,7 +233,10 @@ function showTab(tab: TabId, opts: { pushHistory?: boolean } = {}) {
el.style.display = el.id === `tab-${tab}` ? "" : "none";
});
if (opts.pushHistory ?? true) {
const newURL = tab === "instances" ? "/checklists?tab=instances" : "/checklists";
let newURL = "/checklists";
if (tab === "instances") newURL = "/checklists?tab=instances";
if (tab === "mine") newURL = "/checklists?tab=mine";
if (tab === "gallery") newURL = "/checklists?tab=gallery";
if (window.location.pathname + window.location.search !== newURL) {
window.history.replaceState({}, "", newURL);
}
@@ -216,6 +244,155 @@ function showTab(tab: TabId, opts: { pushHistory?: boolean } = {}) {
if (tab === "instances") {
void loadInstances();
}
if (tab === "mine") {
void loadMyTemplates();
}
if (tab === "gallery") {
void loadGallery();
}
}
async function loadGallery(force = false) {
if (galleryLoaded && !force) return;
galleryLoaded = true;
// /api/checklists already returns the merged catalog; the gallery
// filter just narrows to non-static + non-owned + non-private.
if (allChecklists.length === 0) {
await loadTemplates();
}
renderGallery();
}
function renderGallery() {
const loading = document.getElementById("checklists-gallery-loading")!;
const empty = document.getElementById("checklists-gallery-empty")!;
const grid = document.getElementById("checklists-gallery-grid") as HTMLElement;
loading.style.display = "none";
const visible = allChecklists.filter((c) => {
if (c.origin !== "authored") return false;
if (me && c.owner_email && me.email.toLowerCase() === c.owner_email.toLowerCase()) return false;
if (galleryRegime !== "all" && c.regime !== galleryRegime) return false;
return true;
});
if (visible.length === 0) {
empty.style.display = "";
grid.style.display = "none";
return;
}
empty.style.display = "none";
grid.style.display = "";
const isEN = getLang() === "en";
grid.innerHTML = visible.map((c) => {
const title = isEN ? c.titleEN : c.titleDE;
const desc = isEN ? c.descriptionEN : c.descriptionDE;
const court = isEN ? c.courtEN : c.courtDE;
const itemsLabel = isEN ? "items" : "Punkte";
const visKey = `checklisten.mine.visibility.${c.visibility || ""}`;
const visLabel = c.visibility ? esc(t(visKey as never) || c.visibility) : "";
const authorLine = c.owner_display_name
? `<p class="checklist-card-author">${esc(t("checklisten.detail.authored.by").replace("{author}", c.owner_display_name))}</p>`
: "";
return `<a href="/checklists/${esc(c.slug)}" class="checklist-card">
<div class="checklist-card-top">
<span class="checklist-regime checklist-regime-${esc(c.regime)}">${esc(c.regime)}</span>
<span class="checklist-card-count">${c.itemCount} ${itemsLabel}</span>
</div>
<h2 class="checklist-card-title">${esc(title)}</h2>
<p class="checklist-card-desc">${esc(desc)}</p>
<p class="checklist-card-court">${esc(court)}</p>
${authorLine}
${visLabel ? `<span class="visibility-chip visibility-chip-${esc(c.visibility || "")}">${visLabel}</span>` : ""}
</a>`;
}).join("");
}
function initGalleryFilters() {
const container = document.getElementById("checklist-gallery-filters");
if (!container) return;
container.addEventListener("click", (e) => {
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>(".filter-pill");
if (!btn) return;
container.querySelectorAll(".filter-pill").forEach((p) => p.classList.remove("active"));
btn.classList.add("active");
galleryRegime = btn.dataset.regime ?? "all";
renderGallery();
});
}
async function loadMe() {
try {
const resp = await fetch("/api/me");
if (resp.ok) me = await resp.json();
} catch { /* leave me=null */ }
}
async function loadMyTemplates(force = false) {
if (myTemplatesLoaded && !force) return;
myTemplatesLoaded = true;
const resp = await fetch("/api/checklists/templates/mine");
if (!resp.ok) {
myTemplates = [];
} else {
myTemplates = (await resp.json()) ?? [];
}
renderMyTemplates();
}
function renderMyTemplates() {
const loading = document.getElementById("checklists-mine-loading")!;
const empty = document.getElementById("checklists-mine-empty")!;
const grid = document.getElementById("checklists-mine-grid") as HTMLElement;
loading.style.display = "none";
if (myTemplates.length === 0) {
empty.style.display = "";
grid.style.display = "none";
return;
}
empty.style.display = "none";
grid.style.display = "";
grid.innerHTML = myTemplates.map((tpl) => {
const visKey = `checklisten.mine.visibility.${tpl.visibility}`;
const visLabel = esc(t(visKey as never) || tpl.visibility);
const titleSafe = esc(tpl.title);
return `<article class="checklist-card checklist-card-mine" data-slug="${esc(tpl.slug)}" data-title="${escAttr(tpl.title)}">
<div class="checklist-card-top">
<span class="checklist-regime checklist-regime-${esc(tpl.regime)}">${esc(tpl.regime)}</span>
<span class="checklist-card-count visibility-chip visibility-chip-${esc(tpl.visibility)}">${visLabel}</span>
</div>
<h2 class="checklist-card-title">
<a href="/checklists/${esc(tpl.slug)}">${titleSafe}</a>
</h2>
<p class="checklist-card-desc">${esc(tpl.description || "")}</p>
<p class="checklist-card-court">${esc(tpl.court || "")}</p>
<div class="checklist-card-actions">
<a class="btn btn-small" href="/checklists/templates/${esc(tpl.slug)}/edit" data-i18n="checklisten.mine.edit">Bearbeiten</a>
<button class="btn btn-small btn-danger" data-action="delete" data-slug="${esc(tpl.slug)}" data-title="${escAttr(tpl.title)}" data-i18n="checklisten.mine.delete">L&ouml;schen</button>
</div>
</article>`;
}).join("");
grid.querySelectorAll<HTMLButtonElement>("button[data-action=delete]").forEach((btn) => {
btn.addEventListener("click", async (e) => {
e.preventDefault();
const slug = btn.dataset.slug!;
const title = btn.dataset.title || slug;
const msg = t("checklisten.mine.delete.confirm").replace("{title}", title);
if (!window.confirm(msg)) return;
const resp = await fetch(`/api/checklists/templates/${encodeURIComponent(slug)}`, { method: "DELETE" });
if (!resp.ok) {
window.alert(t("checklisten.mine.delete.error"));
return;
}
await loadMyTemplates(true);
});
});
}
function initTabs() {
@@ -234,11 +411,15 @@ document.addEventListener("DOMContentLoaded", () => {
initI18n();
initSidebar();
initFilters();
initGalleryFilters();
initTabs();
onLangChange(() => {
renderTemplates();
if (instancesLoaded) renderInstances();
if (myTemplatesLoaded) renderMyTemplates();
if (galleryLoaded) renderGallery();
});
void loadMe();
void loadTemplates();
showTab(parseTab(), { pushHistory: false });
});

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

@@ -1,181 +0,0 @@
import { initI18n, onLangChange, t, tDyn, getLang } from "./i18n";
import { initSidebar } from "./sidebar";
interface Deadline {
id: string;
project_id: string;
title: string;
due_date: string;
status: string;
project_reference: string;
project_title: string;
}
let allDeadlines: Deadline[] = [];
let viewYear = 0;
let viewMonth = 0; // 0-11
function esc(s: string): string {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
function fmtMonth(year: number, month: number): string {
return `${tDyn(`cal.month.${month}`)} ${year}`;
}
function urgencyClass(due: string, status: string): string {
if (status === "completed") return "frist-urgency-done";
const today = new Date();
today.setHours(0, 0, 0, 0);
const d = new Date(due.slice(0, 10) + "T00:00:00");
const diffDays = Math.floor((d.getTime() - today.getTime()) / 86400000);
if (diffDays < 0) return "frist-urgency-overdue";
if (diffDays <= 7) return "frist-urgency-soon";
return "frist-urgency-later";
}
async function loadDeadlines() {
try {
const resp = await fetch("/api/deadlines?status=all");
if (resp.ok) allDeadlines = await resp.json();
} catch {
/* non-fatal */
}
}
function deadlinesForDate(iso: string): Deadline[] {
return allDeadlines.filter((f) => f.due_date.slice(0, 10) === iso);
}
function isoDate(year: number, month: number, day: number): string {
const m = String(month + 1).padStart(2, "0");
const d = String(day).padStart(2, "0");
return `${year}-${m}-${d}`;
}
function render() {
document.getElementById("cal-month-label")!.textContent = fmtMonth(viewYear, viewMonth);
const firstDay = new Date(viewYear, viewMonth, 1);
const jsWeekday = firstDay.getDay();
const offset = (jsWeekday + 6) % 7;
const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
const today = new Date();
const todayISO = isoDate(today.getFullYear(), today.getMonth(), today.getDate());
const cells: string[] = [];
for (let i = 0; i < offset; i++) {
cells.push(`<div class="frist-cal-cell frist-cal-cell-empty"></div>`);
}
for (let day = 1; day <= daysInMonth; day++) {
const iso = isoDate(viewYear, viewMonth, day);
const items = deadlinesForDate(iso);
const isToday = iso === todayISO;
const dots = items
.slice(0, 4)
.map((f) => `<span class="frist-cal-dot ${urgencyClass(f.due_date, f.status)}"></span>`)
.join("");
const more = items.length > 4 ? `<span class="frist-cal-more">+${items.length - 4}</span>` : "";
cells.push(
`<div class="frist-cal-cell${isToday ? " frist-cal-today" : ""}${items.length > 0 ? " frist-cal-cell-has" : ""}" data-iso="${iso}">
<span class="frist-cal-day">${day}</span>
<div class="frist-cal-dots">${dots}${more}</div>
</div>`,
);
}
const grid = document.getElementById("deadline-cal-grid")!;
grid.innerHTML = cells.join("");
grid.querySelectorAll<HTMLElement>(".frist-cal-cell-has").forEach((cell) => {
cell.addEventListener("click", () => openPopup(cell.dataset.iso!));
});
const monthStart = isoDate(viewYear, viewMonth, 1);
const monthEnd = isoDate(viewYear, viewMonth, daysInMonth);
const hasInMonth = allDeadlines.some((f) => {
const iso = f.due_date.slice(0, 10);
return iso >= monthStart && iso <= monthEnd;
});
const empty = document.getElementById("deadline-cal-empty")!;
empty.style.display = hasInMonth ? "none" : "";
}
function openPopup(iso: string) {
const items = deadlinesForDate(iso);
if (items.length === 0) return;
const popup = document.getElementById("cal-popup")!;
const dateEl = document.getElementById("cal-popup-date")!;
const list = document.getElementById("cal-popup-list")!;
const d = new Date(iso + "T00:00:00");
dateEl.textContent = d.toLocaleDateString(getLang() === "de" ? "de-DE" : "en-GB", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
});
list.innerHTML = items
.map((f) => {
const cls = urgencyClass(f.due_date, f.status);
return `<li class="frist-cal-popup-item">
<span class="frist-cal-dot ${cls}"></span>
<a href="/deadlines/${esc(f.id)}" class="frist-cal-popup-title">${esc(f.title)}</a>
<a href="/projects/${esc(f.project_id)}" class="frist-cal-popup-project">${esc(f.project_reference)}</a>
</li>`;
})
.join("");
popup.style.display = "flex";
}
function initPopup() {
const popup = document.getElementById("cal-popup")!;
const close = document.getElementById("cal-popup-close")!;
close.addEventListener("click", () => (popup.style.display = "none"));
popup.addEventListener("click", (e) => {
if (e.target === e.currentTarget) popup.style.display = "none";
});
}
function initNav() {
document.getElementById("cal-prev")!.addEventListener("click", () => {
viewMonth -= 1;
if (viewMonth < 0) {
viewMonth = 11;
viewYear -= 1;
}
render();
});
document.getElementById("cal-next")!.addEventListener("click", () => {
viewMonth += 1;
if (viewMonth > 11) {
viewMonth = 0;
viewYear += 1;
}
render();
});
document.getElementById("cal-today")!.addEventListener("click", () => {
const now = new Date();
viewYear = now.getFullYear();
viewMonth = now.getMonth();
render();
});
}
document.addEventListener("DOMContentLoaded", async () => {
initI18n();
initSidebar();
const now = new Date();
viewYear = now.getFullYear();
viewMonth = now.getMonth();
initNav();
initPopup();
onLangChange(render);
await loadDeadlines();
render();
});

View File

@@ -8,6 +8,7 @@ import {
type FilterHandle,
} from "./event-types";
import { projectIndent } from "./project-indent";
import { mountCalendar, type CalendarHandle, type CalendarItem } from "./calendar/mount-calendar";
// Two-eyes glyph 👀 inside .approval-pill--icon. m's 2026-05-08 follow-
// up: "two eyes instead of the one." Emoji rather than SVG keeps the
@@ -157,8 +158,10 @@ let me: Me | null = null;
let eventTypeFilter: FilterHandle | null = null;
let eventTypeByID: Map<string, EventType> = new Map();
let loadedOK = false;
let calYear = 0;
let calMonth = 0;
// Calendar handle is created lazily when /events first switches into the
// Kalender view (t-paliad-224). The handle owns its own month/week/day
// state + ?cal_view / ?cal_date URL contract via mountCalendar.
let calendar: CalendarHandle | null = null;
function urlParams(): URLSearchParams {
return new URLSearchParams(window.location.search);
@@ -429,12 +432,13 @@ function hideTableAndCalendar() {
const calWrap = document.getElementById("events-calendar-wrap");
if (tableWrap) tableWrap.style.display = "none";
if (calWrap) calWrap.hidden = true;
teardownCalendar();
}
function render() {
if (!loadedOK) return;
if (currentView === "calendar") {
renderCalendar();
renderCalendarView();
} else {
renderTable();
}
@@ -557,135 +561,57 @@ function renderRow(item: EventListItem, showReopen: boolean): string {
</tr>`;
}
// itemDateISO returns the per-item bucketing date (YYYY-MM-DD) used when
// plotting an event onto the calendar. Deadlines bucket on due_date;
// appointments on start_at's local-date component.
function itemDateISO(item: EventListItem): string {
// toCalendarItem adapts an EventListItem to the canonical CalendarItem
// shape consumed by mountCalendar (t-paliad-224). Bucketing date matches
// the pre-refactor behaviour: deadlines bucket on due_date (fallback to
// event_date); appointments bucket on start_at (fallback to event_date).
function toCalendarItem(item: EventListItem): CalendarItem {
let bucketDate: string;
if (item.type === "deadline") {
const src = item.due_date ?? item.event_date;
return src.slice(0, 10);
bucketDate = item.due_date ?? item.event_date;
} else if (item.start_at) {
bucketDate = item.start_at;
} else {
bucketDate = item.event_date;
}
if (!item.start_at) return item.event_date.slice(0, 10);
const d = new Date(item.start_at);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
return {
kind: item.type,
id: item.id,
title: item.title,
event_date: bucketDate,
project_id: item.project_id,
project_title: item.project_title,
project_reference: item.project_reference,
};
}
function isoDate(year: number, month: number, day: number): string {
return `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
}
function fmtMonthYear(year: number, month: number): string {
return `${tDyn(`cal.month.${month}`)} ${year}`;
}
function calDotClass(item: EventListItem): string {
// Per-item dot colour. Deadlines reuse the existing urgency palette;
// appointments get their own colour so they're visually distinct from
// deadlines on a mixed (Beides) calendar.
if (item.type === "appointment") return "events-cal-dot-appointment";
return urgencyClass(item).replace("frist-urgency-", "frist-urgency-");
}
function renderCalendar() {
const wrap = document.getElementById("events-calendar-wrap")!;
const grid = document.getElementById("events-cal-grid")!;
const empty = document.getElementById("events-cal-empty") as HTMLElement;
const monthLabel = document.getElementById("events-cal-month-label")!;
function renderCalendarView() {
const host = document.getElementById("events-calendar-wrap");
if (!host) return;
const tableEmpty = document.getElementById("events-empty")!;
const tableEmptyFiltered = document.getElementById("events-empty-filtered")!;
// Calendar always renders the visible month from allItems, regardless of
// pristine vs filtered state — empty calendar is allowed (the per-month
// empty hint communicates "no items in this month" without confusing it
// with the table-mode "no items at all" empty state).
tableEmpty.style.display = "none";
tableEmptyFiltered.style.display = "none";
wrap.hidden = false;
(host as HTMLElement).hidden = false;
monthLabel.textContent = fmtMonthYear(calYear, calMonth);
const firstDay = new Date(calYear, calMonth, 1);
const jsWeekday = firstDay.getDay();
const offset = (jsWeekday + 6) % 7;
const daysInMonth = new Date(calYear, calMonth + 1, 0).getDate();
const today = new Date();
const todayISO = isoDate(today.getFullYear(), today.getMonth(), today.getDate());
// Bucket items by ISO date once, so day-cell rendering is O(d) not O(d*n).
const byDate = new Map<string, EventListItem[]>();
for (const item of allItems) {
const iso = itemDateISO(item);
const list = byDate.get(iso);
if (list) list.push(item);
else byDate.set(iso, [item]);
const items = allItems.map(toCalendarItem);
if (calendar) {
calendar.update(items);
return;
}
const cells: string[] = [];
for (let i = 0; i < offset; i++) {
cells.push(`<div class="frist-cal-cell frist-cal-cell-empty"></div>`);
}
for (let day = 1; day <= daysInMonth; day++) {
const iso = isoDate(calYear, calMonth, day);
const items = byDate.get(iso) ?? [];
const isToday = iso === todayISO;
const dots = items
.slice(0, 4)
.map((it) => `<span class="frist-cal-dot ${calDotClass(it)}"></span>`)
.join("");
const more = items.length > 4 ? `<span class="frist-cal-more">+${items.length - 4}</span>` : "";
cells.push(
`<div class="frist-cal-cell${isToday ? " frist-cal-today" : ""}${items.length > 0 ? " frist-cal-cell-has" : ""}" data-iso="${iso}">
<span class="frist-cal-day">${day}</span>
<div class="frist-cal-dots">${dots}${more}</div>
</div>`,
);
}
grid.innerHTML = cells.join("");
grid.querySelectorAll<HTMLElement>(".frist-cal-cell-has").forEach((cell) => {
cell.addEventListener("click", () => openCalPopup(cell.dataset.iso!, byDate.get(cell.dataset.iso!) ?? []));
// urlState=true: the Kalender tab persists its month/week/day + anchor
// in ?cal_view + ?cal_date so a refresh / shared link lands on the same
// calendar state (per t-paliad-224 §11 Q3 head decision).
calendar = mountCalendar(host as HTMLElement, items, {
urlState: true,
defaultView: "month",
});
const monthStart = isoDate(calYear, calMonth, 1);
const monthEnd = isoDate(calYear, calMonth, daysInMonth);
const hasInMonth = allItems.some((it) => {
const iso = itemDateISO(it);
return iso >= monthStart && iso <= monthEnd;
});
empty.hidden = hasInMonth;
}
function openCalPopup(iso: string, items: EventListItem[]) {
if (items.length === 0) return;
const popup = document.getElementById("events-cal-popup") as HTMLElement;
const dateEl = document.getElementById("events-cal-popup-date")!;
const list = document.getElementById("events-cal-popup-list")!;
const d = new Date(iso + "T00:00:00");
dateEl.textContent = d.toLocaleDateString(getLang() === "de" ? "de-DE" : "en-GB", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
});
list.innerHTML = items
.map((it) => {
const cls = calDotClass(it);
const href = it.type === "deadline" ? `/deadlines/${esc(it.id)}` : `/appointments/${esc(it.id)}`;
const projectHref = it.project_id ? `/projects/${esc(it.project_id)}` : "";
const projectLabel = it.project_reference ?? "";
const projectCell = projectHref
? `<a href="${projectHref}" class="frist-cal-popup-akte">${esc(projectLabel)}</a>`
: "";
return `<li class="frist-cal-popup-item">
<span class="frist-cal-dot ${cls}"></span>
<a href="${href}" class="frist-cal-popup-title">${rowTypeChip(it)} ${esc(it.title)}</a>
${projectCell}
</li>`;
})
.join("");
popup.style.display = "flex";
function teardownCalendar() {
if (!calendar) return;
calendar.destroy();
calendar = null;
}
function applyView() {
@@ -706,12 +632,18 @@ function applyView() {
// Cards view = the original layout (5-card summary + table).
// List view = no summary cards, table only — gives more vertical space
// and matches users' mental model of a flat list.
// Calendar view = month grid; cards + table both hidden.
// Calendar view = mountCalendar() canon (month/week/day); cards + table
// both hidden. The handle is torn down when the user leaves Kalender
// so its URL state isn't reapplied to other shapes.
summary.style.display = currentView === "cards" ? "" : "none";
tableWrap.style.display = currentView === "calendar" ? "none" : "";
calWrap.hidden = currentView !== "calendar";
if (currentView === "calendar" && loadedOK) renderCalendar();
if (currentView === "calendar") {
if (loadedOK) renderCalendarView();
} else {
teardownCalendar();
}
}
function wireRowHandlers(tbody: HTMLElement) {
@@ -1013,12 +945,10 @@ function initFilters() {
}
function initView() {
// Calendar always opens on the current month — month navigation is
// local to the view (cheap pagination, doesn't refetch).
const now = new Date();
calYear = now.getFullYear();
calMonth = now.getMonth();
// Kalender state (view + anchor) lives inside mountCalendar; no
// events-page-level wiring needed. The view chips below switch
// between Karten / Liste / Kalender; applyView() handles the
// mount + teardown.
document.querySelectorAll<HTMLButtonElement>("[data-event-view]").forEach((btn) => {
btn.addEventListener("click", () => {
const next = btn.dataset.eventView as EventView;
@@ -1028,31 +958,6 @@ function initView() {
syncURLParams();
});
});
document.getElementById("events-cal-prev")?.addEventListener("click", () => {
calMonth -= 1;
if (calMonth < 0) { calMonth = 11; calYear -= 1; }
renderCalendar();
});
document.getElementById("events-cal-next")?.addEventListener("click", () => {
calMonth += 1;
if (calMonth > 11) { calMonth = 0; calYear += 1; }
renderCalendar();
});
document.getElementById("events-cal-today")?.addEventListener("click", () => {
const t = new Date();
calYear = t.getFullYear();
calMonth = t.getMonth();
renderCalendar();
});
const popup = document.getElementById("events-cal-popup") as HTMLElement;
document.getElementById("events-cal-popup-close")?.addEventListener("click", () => {
popup.style.display = "none";
});
popup?.addEventListener("click", (e) => {
if (e.target === popup) popup.style.display = "none";
});
}
function initSummaryCards() {

View File

@@ -555,7 +555,101 @@ const translations: Record<Lang, Record<string, string>> = {
"checklisten.heading": "Checklisten",
"checklisten.subtitle": "Interaktive Checklisten f\u00fcr typische Verfahrensschritte vor UPC, BPatG und EPA. Abhaken, ausdrucken, kein Punkt vergessen.",
"checklisten.tab.templates": "Vorlagen",
"checklisten.tab.mine": "Meine Vorlagen",
"checklisten.tab.instances": "Vorhandene Instanzen",
"checklisten.mine.empty": "Sie haben noch keine eigene Vorlage angelegt.",
"checklisten.tab.gallery": "Geteilte Vorlagen",
"checklisten.gallery.empty": "Noch keine geteilten Vorlagen sichtbar.",
"checklisten.filter.other": "Sonstige",
"checklisten.instance.outdated.badge": "Vorlage aktualisiert",
"checklisten.instance.outdated.note": "Die zugrundeliegende Vorlage wurde seit dem Anlegen dieser Instanz aktualisiert (v{from} → v{to}).",
"checklisten.instance.outdated.diff": "Änderungen anzeigen",
"checklisten.instance.diff.title": "Geänderte Punkte",
"checklisten.instance.diff.close": "Schließen",
"checklisten.instance.diff.added": "Neu",
"checklisten.instance.diff.removed": "Entfernt",
"checklisten.instance.diff.changed": "Geändert",
"checklisten.instance.diff.empty": "Keine inhaltlichen Unterschiede in den Punkten.",
"checklisten.instance.diff.error": "Vergleich fehlgeschlagen.",
"checklisten.mine.new": "Neue Vorlage",
"checklisten.mine.loading": "Lädt…",
"checklisten.mine.visibility.private": "Privat",
"checklisten.mine.visibility.firm": "Firmenweit",
"checklisten.mine.visibility.shared": "Geteilt",
"checklisten.mine.visibility.global": "Im Katalog",
"checklisten.mine.edit": "Bearbeiten",
"checklisten.mine.delete": "Löschen",
"checklisten.mine.delete.confirm": "Vorlage „{title}“ wirklich löschen? Bestehende Instanzen bleiben erhalten.",
"checklisten.mine.delete.error": "Löschen fehlgeschlagen.",
"checklisten.mine.origin.authored": "Eigene Vorlage",
"checklisten.author.title": "Vorlage erstellen — Paliad",
"checklisten.author.title.edit": "Vorlage bearbeiten — Paliad",
"checklisten.author.heading.new": "Neue Checklisten-Vorlage",
"checklisten.author.heading.edit": "Vorlage bearbeiten",
"checklisten.author.subtitle": "Erstellen Sie eine eigene Checkliste mit Sektionen und Punkten. Sie können sie privat halten oder firmenweit verfügbar machen.",
"checklisten.author.field.title": "Titel",
"checklisten.author.field.title.hint": "z.B. „UPC SoC — interne Checkliste“.",
"checklisten.author.field.description": "Kurzbeschreibung",
"checklisten.author.field.regime": "Regime",
"checklisten.author.field.court": "Gericht / Behörde",
"checklisten.author.field.reference": "Rechtsgrundlage",
"checklisten.author.field.deadline": "Deadline (optional)",
"checklisten.author.field.lang": "Sprache",
"checklisten.author.field.visibility": "Sichtbarkeit",
"checklisten.author.visibility.private.hint": "Nur für Sie sichtbar.",
"checklisten.author.visibility.firm.hint": "Für alle angemeldeten Kolleginnen und Kollegen sichtbar.",
"checklisten.author.groups.heading": "Sektionen und Punkte",
"checklisten.author.groups.add": "+ Sektion hinzufügen",
"checklisten.author.group.title": "Sektionsname",
"checklisten.author.group.remove": "Sektion löschen",
"checklisten.author.item.add": "+ Punkt hinzufügen",
"checklisten.author.item.label": "Punkt",
"checklisten.author.item.note": "Notiz (optional)",
"checklisten.author.item.rule": "Vorschrift (optional)",
"checklisten.author.item.remove": "Punkt löschen",
"checklisten.author.save": "Speichern",
"checklisten.author.cancel": "Abbrechen",
"checklisten.author.saving": "Speichert…",
"checklisten.author.error.title": "Bitte geben Sie einen Titel ein.",
"checklisten.author.error.no_groups": "Bitte mindestens eine Sektion mit einem Punkt anlegen.",
"checklisten.author.error.generic": "Speichern fehlgeschlagen. Bitte erneut versuchen.",
"checklisten.author.error.notfound": "Diese Vorlage existiert nicht oder Sie haben keine Berechtigung sie zu bearbeiten.",
"checklisten.detail.edit": "Bearbeiten",
"checklisten.detail.delete": "Löschen",
"checklisten.detail.share": "Teilen",
"checklisten.detail.promote": "Als Firmen-Vorlage hinterlegen",
"checklisten.detail.demote": "Aus Katalog entfernen",
"checklisten.detail.promote.confirm": "Diese Vorlage in den Firmen-Katalog übernehmen? Alle Kolleg:innen sehen sie dann unter Vorlagen.",
"checklisten.detail.demote.confirm": "Vorlage aus dem Firmen-Katalog entfernen? Sie bleibt firmenweit sichtbar.",
"checklisten.detail.promote.error": "Übernahme fehlgeschlagen.",
"checklisten.detail.delete.confirm": "Vorlage „{title}\" wirklich löschen? Bestehende Instanzen bleiben erhalten.",
"checklisten.detail.delete.error": "Löschen fehlgeschlagen.",
"checklisten.detail.authored.by": "Erstellt von {author}",
"checklisten.detail.visibility": "Sichtbarkeit: {state}",
"checklisten.detail.visibility.set.firm": "Für Firma freigeben",
"checklisten.detail.visibility.set.private": "Privat schalten",
"checklisten.detail.visibility.error": "Sichtbarkeit konnte nicht geändert werden.",
"checklisten.share.title": "Vorlage teilen",
"checklisten.share.kind": "Empfängertyp",
"checklisten.share.kind.user": "Kollege",
"checklisten.share.kind.office": "Office",
"checklisten.share.kind.partner_unit": "Dezernat",
"checklisten.share.kind.project": "Projekt",
"checklisten.share.pick": "— auswählen —",
"checklisten.share.submit": "Freigeben",
"checklisten.share.cancel": "Abbrechen",
"checklisten.share.error.pick": "Bitte einen Empfänger auswählen.",
"checklisten.share.error.generic": "Freigeben fehlgeschlagen.",
"checklisten.share.success": "Freigegeben.",
"checklisten.share.grants.heading": "Bestehende Freigaben",
"checklisten.share.grants.empty": "Keine Freigaben.",
"checklisten.share.grants.revoke": "Entfernen",
"checklisten.share.grants.revoke.confirm": "Freigabe entfernen?",
"checklisten.share.grants.revoke.error": "Entfernen fehlgeschlagen.",
"checklisten.share.grants.recipient.user": "Kollege",
"checklisten.share.grants.recipient.office": "Office",
"checklisten.share.grants.recipient.partner_unit": "Dezernat",
"checklisten.share.grants.recipient.project": "Projekt",
"checklisten.instances.all.loading": "L\u00e4dt\u2026",
"checklisten.instances.all.empty": "Noch keine Checklisten-Instanzen erfasst. Legen Sie eine \u00fcber den Vorlagen-Tab an.",
"checklisten.instances.all.col.template": "Vorlage",
@@ -694,7 +788,6 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.list.heading": "Fristen",
"deadlines.list.subtitle": "Persistente Fristen f\u00fcr Ihre Akten. \u00dcberf\u00e4llig, heute, diese Woche, n\u00e4chste Woche \u2014 auf einen Blick.",
"deadlines.list.new": "Neue Frist",
"deadlines.list.calendar": "Kalenderansicht",
"deadlines.summary.overdue": "\u00dcberf\u00e4llig",
"deadlines.summary.today": "Heute",
"deadlines.summary.thisweek": "Diese Woche",
@@ -817,12 +910,6 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.source.caldav": "CalDAV",
"deadlines.source.imported": "Import",
"deadlines.kalender.title": "Fristenkalender \u2014 Paliad",
"deadlines.kalender.heading": "Fristenkalender",
"deadlines.kalender.subtitle": "Monats\u00fcbersicht aller Fristen Ihrer Akten.",
"deadlines.kalender.list": "Listenansicht",
"deadlines.kalender.today": "Heute",
"deadlines.kalender.empty": "Keine Fristen im ausgew\u00e4hlten Zeitraum.",
"cal.day.mon": "Mo",
"cal.day.tue": "Di",
"cal.day.wed": "Mi",
@@ -919,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",
@@ -1600,7 +1731,6 @@ const translations: Record<Lang, Record<string, string>> = {
"appointments.list.title": "Termine \u2014 Paliad",
"appointments.list.heading": "Termine",
"appointments.list.subtitle": "Verhandlungen, Besprechungen, Beratungen \u2014 pers\u00f6nlich oder aktenbezogen.",
"appointments.list.calendar": "Kalenderansicht",
"appointments.list.new": "Neuer Termin",
"appointments.summary.today": "Heute",
"appointments.summary.thisweek": "Diese Woche",
@@ -1656,11 +1786,6 @@ const translations: Record<Lang, Record<string, string>> = {
"appointments.detail.saved": "Gespeichert.",
"appointments.detail.delete": "Termin l\u00f6schen",
"appointments.detail.delete.confirm": "Diesen Termin wirklich l\u00f6schen?",
"appointments.kalender.title": "Terminkalender \u2014 Paliad",
"appointments.kalender.heading": "Terminkalender",
"appointments.kalender.subtitle": "Monats\u00fcbersicht aller Termine.",
"appointments.kalender.list": "Listenansicht",
"appointments.kalender.empty": "Keine Termine im ausgew\u00e4hlten Zeitraum.",
// t-paliad-110 \u2014 unified Events page (rendered on both /deadlines and
// /appointments). The user-facing "Fristen" / "Termine" branding stays;
@@ -1684,7 +1809,6 @@ const translations: Record<Lang, Record<string, string>> = {
"events.view.cards": "Karten",
"events.view.list": "Liste",
"events.view.calendar": "Kalender",
"events.calendar.empty": "Keine Eintr\u00e4ge im ausgew\u00e4hlten Zeitraum.",
"caldav.title": "CalDAV-Synchronisation \u2014 Paliad",
"caldav.heading": "CalDAV-Synchronisation",
"caldav.subtitle": "Synchronisieren Sie Ihre Paliad-Termine mit Ihrem externen Kalender (Nextcloud, iCloud, Outlook, mailcow\u2026). Das Passwort wird verschl\u00fcsselt gespeichert und nie zur\u00fcckgegeben.",
@@ -1802,6 +1926,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",
@@ -2077,8 +2208,24 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.team.heading": "Team-Verwaltung",
"admin.team.subtitle": "Alle Paliad-Konten anzeigen, bearbeiten oder hinzufügen.",
"admin.team.search.placeholder": "Nach Name oder E-Mail suchen…",
"admin.team.add.full": "Konto direkt anlegen",
"admin.team.add.direct": "Bestehendes Konto onboarden",
"admin.team.add.invite": "Neue:n Kolleg:in einladen",
"admin.team.add_full.title": "Konto direkt anlegen",
"admin.team.add_full.body": "Legt sowohl das Login-Konto als auch das Paliad-Profil an. Die neue Person erhält eine E-Mail mit einem Link, über den sie ein Passwort setzt.",
"admin.team.add_full.email": "E-Mail",
"admin.team.add_full.name": "Anzeigename",
"admin.team.add_full.office": "Standort",
"admin.team.add_full.profession": "Profession",
"admin.team.add_full.job_title": "Berufsbezeichnung",
"admin.team.add_full.lang": "Sprache",
"admin.team.add_full.send_welcome": "Willkommens-E-Mail mit Login-Link senden",
"admin.team.add_full.cancel": "Abbrechen",
"admin.team.add_full.submit": "Anlegen",
"admin.team.add_full.feedback.added": "Konto angelegt.",
"admin.team.add_full.error.unavailable": "Add-User-Pfad ist nicht konfiguriert (SUPABASE_SERVICE_ROLE_KEY fehlt am Server).",
"admin.team.add_full.error.email_exists": "Es existiert bereits ein Konto für diese E-Mail — bitte 'Bestehendes Konto onboarden' verwenden.",
"admin.team.add_full.error.generic": "Konto konnte nicht angelegt werden.",
"admin.team.loading": "Lade…",
"admin.team.empty": "Keine Treffer.",
"admin.team.error.forbidden": "Zugriff nur für Admins.",
@@ -3282,7 +3429,101 @@ const translations: Record<Lang, Record<string, string>> = {
"checklisten.heading": "Checklists",
"checklisten.subtitle": "Interactive checklists for typical procedural steps before the UPC, German Patent Court, and EPO. Tick off, print, miss nothing.",
"checklisten.tab.templates": "Templates",
"checklisten.tab.mine": "My templates",
"checklisten.tab.instances": "Existing instances",
"checklisten.mine.empty": "You haven't authored a template yet.",
"checklisten.tab.gallery": "Shared templates",
"checklisten.gallery.empty": "No shared templates visible yet.",
"checklisten.filter.other": "Other",
"checklisten.instance.outdated.badge": "Template updated",
"checklisten.instance.outdated.note": "The underlying template has been updated since this instance was created (v{from} → v{to}).",
"checklisten.instance.outdated.diff": "Show changes",
"checklisten.instance.diff.title": "Changed items",
"checklisten.instance.diff.close": "Close",
"checklisten.instance.diff.added": "Added",
"checklisten.instance.diff.removed": "Removed",
"checklisten.instance.diff.changed": "Changed",
"checklisten.instance.diff.empty": "No content differences in items.",
"checklisten.instance.diff.error": "Diff failed.",
"checklisten.mine.new": "New template",
"checklisten.mine.loading": "Loading…",
"checklisten.mine.visibility.private": "Private",
"checklisten.mine.visibility.firm": "Firm-wide",
"checklisten.mine.visibility.shared": "Shared",
"checklisten.mine.visibility.global": "In catalog",
"checklisten.mine.edit": "Edit",
"checklisten.mine.delete": "Delete",
"checklisten.mine.delete.confirm": "Delete template \"{title}\"? Existing instances remain.",
"checklisten.mine.delete.error": "Delete failed.",
"checklisten.mine.origin.authored": "Your template",
"checklisten.author.title": "Author template — Paliad",
"checklisten.author.title.edit": "Edit template — Paliad",
"checklisten.author.heading.new": "New checklist template",
"checklisten.author.heading.edit": "Edit template",
"checklisten.author.subtitle": "Author your own checklist with sections and items. Keep it private or open it firm-wide.",
"checklisten.author.field.title": "Title",
"checklisten.author.field.title.hint": "e.g. \"UPC SoC — internal checklist\".",
"checklisten.author.field.description": "Short description",
"checklisten.author.field.regime": "Regime",
"checklisten.author.field.court": "Court / authority",
"checklisten.author.field.reference": "Legal source",
"checklisten.author.field.deadline": "Deadline (optional)",
"checklisten.author.field.lang": "Language",
"checklisten.author.field.visibility": "Visibility",
"checklisten.author.visibility.private.hint": "Visible only to you.",
"checklisten.author.visibility.firm.hint": "Visible to every authenticated colleague.",
"checklisten.author.groups.heading": "Sections and items",
"checklisten.author.groups.add": "+ Add section",
"checklisten.author.group.title": "Section title",
"checklisten.author.group.remove": "Remove section",
"checklisten.author.item.add": "+ Add item",
"checklisten.author.item.label": "Item",
"checklisten.author.item.note": "Note (optional)",
"checklisten.author.item.rule": "Rule (optional)",
"checklisten.author.item.remove": "Remove item",
"checklisten.author.save": "Save",
"checklisten.author.cancel": "Cancel",
"checklisten.author.saving": "Saving…",
"checklisten.author.error.title": "Please enter a title.",
"checklisten.author.error.no_groups": "Please add at least one section with one item.",
"checklisten.author.error.generic": "Save failed. Please try again.",
"checklisten.author.error.notfound": "Template not found or you don't have permission to edit it.",
"checklisten.detail.edit": "Edit",
"checklisten.detail.delete": "Delete",
"checklisten.detail.share": "Share",
"checklisten.detail.promote": "Add to firm catalog",
"checklisten.detail.demote": "Remove from catalog",
"checklisten.detail.promote.confirm": "Add this template to the firm catalog? Every colleague will see it under Templates.",
"checklisten.detail.demote.confirm": "Remove this template from the firm catalog? It stays firm-visible.",
"checklisten.detail.promote.error": "Promotion failed.",
"checklisten.detail.delete.confirm": "Delete template \"{title}\"? Existing instances remain.",
"checklisten.detail.delete.error": "Delete failed.",
"checklisten.detail.authored.by": "Authored by {author}",
"checklisten.detail.visibility": "Visibility: {state}",
"checklisten.detail.visibility.set.firm": "Share with firm",
"checklisten.detail.visibility.set.private": "Make private",
"checklisten.detail.visibility.error": "Couldn't change visibility.",
"checklisten.share.title": "Share template",
"checklisten.share.kind": "Recipient type",
"checklisten.share.kind.user": "Colleague",
"checklisten.share.kind.office": "Office",
"checklisten.share.kind.partner_unit": "Practice unit",
"checklisten.share.kind.project": "Project",
"checklisten.share.pick": "— pick —",
"checklisten.share.submit": "Share",
"checklisten.share.cancel": "Cancel",
"checklisten.share.error.pick": "Please pick a recipient.",
"checklisten.share.error.generic": "Share failed.",
"checklisten.share.success": "Shared.",
"checklisten.share.grants.heading": "Existing grants",
"checklisten.share.grants.empty": "No grants.",
"checklisten.share.grants.revoke": "Remove",
"checklisten.share.grants.revoke.confirm": "Remove this grant?",
"checklisten.share.grants.revoke.error": "Revoke failed.",
"checklisten.share.grants.recipient.user": "Colleague",
"checklisten.share.grants.recipient.office": "Office",
"checklisten.share.grants.recipient.partner_unit": "Practice unit",
"checklisten.share.grants.recipient.project": "Project",
"checklisten.instances.all.loading": "Loading…",
"checklisten.instances.all.empty": "No checklist instances yet. Create one from the Templates tab.",
"checklisten.instances.all.col.template": "Template",
@@ -3421,7 +3662,6 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.list.heading": "Deadlines",
"deadlines.list.subtitle": "Persistent deadlines for your matters. Overdue, today, this week, next week \u2014 at a glance.",
"deadlines.list.new": "New deadline",
"deadlines.list.calendar": "Calendar view",
"deadlines.summary.overdue": "Overdue",
"deadlines.summary.today": "Today",
"deadlines.summary.thisweek": "This week",
@@ -3544,12 +3784,6 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.source.caldav": "CalDAV",
"deadlines.source.imported": "Import",
"deadlines.kalender.title": "Deadline calendar \u2014 Paliad",
"deadlines.kalender.heading": "Deadline calendar",
"deadlines.kalender.subtitle": "Monthly view of all deadlines on your matters.",
"deadlines.kalender.list": "List view",
"deadlines.kalender.today": "Today",
"deadlines.kalender.empty": "No deadlines in the selected period.",
"cal.day.mon": "Mon",
"cal.day.tue": "Tue",
"cal.day.wed": "Wed",
@@ -3579,6 +3813,7 @@ const translations: Record<Lang, Record<string, string>> = {
"cal.day.prev": "Previous day",
"cal.day.next": "Next day",
"cal.day.back_to_month": "Back to month",
"cal.today": "Today",
"cal.day.open_day": "Open day view",
"cal.day.no_entries": "Nothing scheduled this day.",
@@ -3641,6 +3876,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",
@@ -4313,7 +4585,6 @@ const translations: Record<Lang, Record<string, string>> = {
"appointments.list.title": "Appointments \u2014 Paliad",
"appointments.list.heading": "Appointments",
"appointments.list.subtitle": "Hearings, meetings, consultations \u2014 personal or matter-linked.",
"appointments.list.calendar": "Calendar view",
"appointments.list.new": "New appointment",
"appointments.summary.today": "Today",
"appointments.summary.thisweek": "This week",
@@ -4369,11 +4640,6 @@ const translations: Record<Lang, Record<string, string>> = {
"appointments.detail.saved": "Saved.",
"appointments.detail.delete": "Delete appointment",
"appointments.detail.delete.confirm": "Really delete this appointment?",
"appointments.kalender.title": "Appointment calendar \u2014 Paliad",
"appointments.kalender.heading": "Appointment calendar",
"appointments.kalender.subtitle": "Monthly overview of all appointments.",
"appointments.kalender.list": "List view",
"appointments.kalender.empty": "No appointments in the selected period.",
// t-paliad-110 \u2014 unified Events page (rendered on /deadlines + /appointments).
"events.toggle.deadline": "Deadlines",
@@ -4394,7 +4660,6 @@ const translations: Record<Lang, Record<string, string>> = {
"events.view.cards": "Cards",
"events.view.list": "List",
"events.view.calendar": "Calendar",
"events.calendar.empty": "No entries in the selected period.",
"caldav.title": "CalDAV sync \u2014 Paliad",
"caldav.heading": "CalDAV sync",
"caldav.subtitle": "Sync your Paliad appointments with your external calendar (Nextcloud, iCloud, Outlook, mailcow\u2026). The password is stored encrypted and never returned.",
@@ -4512,6 +4777,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",
@@ -4785,8 +5057,24 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.team.heading": "Team Management",
"admin.team.subtitle": "View, edit and add Paliad accounts.",
"admin.team.search.placeholder": "Search by name or email…",
"admin.team.add.full": "Add account directly",
"admin.team.add.direct": "Onboard existing account",
"admin.team.add.invite": "Invite Colleague",
"admin.team.add_full.title": "Add account directly",
"admin.team.add_full.body": "Creates both the login account and the Paliad profile. The new colleague receives an email with a link to set a password.",
"admin.team.add_full.email": "Email",
"admin.team.add_full.name": "Display name",
"admin.team.add_full.office": "Office",
"admin.team.add_full.profession": "Profession",
"admin.team.add_full.job_title": "Job title",
"admin.team.add_full.lang": "Language",
"admin.team.add_full.send_welcome": "Send welcome email with login link",
"admin.team.add_full.cancel": "Cancel",
"admin.team.add_full.submit": "Create",
"admin.team.add_full.feedback.added": "Account created.",
"admin.team.add_full.error.unavailable": "Add-User path is not configured (SUPABASE_SERVICE_ROLE_KEY missing on the server).",
"admin.team.add_full.error.email_exists": "An account already exists for this email — please use 'Onboard existing account' instead.",
"admin.team.add_full.error.generic": "Could not create the account.",
"admin.team.loading": "Loading…",
"admin.team.empty": "No matches.",
"admin.team.error.forbidden": "Admins only.",

View File

@@ -93,12 +93,13 @@ export function routeNameFor(pathname: string): string {
if (/^\/projects\/[^/]+\/settings/.test(pathname)) return "projects.settings";
if (/^\/deadlines\/[^/]+$/.test(pathname)) return "deadlines.detail";
if (pathname === "/deadlines/new") return "deadlines.new";
if (pathname === "/deadlines/calendar") return "deadlines.calendar";
if (pathname === "/deadlines") return "deadlines.list";
if (/^\/appointments\/[^/]+$/.test(pathname)) return "appointments.detail";
if (pathname === "/appointments/new") return "appointments.new";
if (pathname === "/appointments/calendar") return "appointments.calendar";
if (pathname === "/appointments") return "appointments.list";
// /deadlines/calendar + /appointments/calendar are 301 redirects to
// /events?type=…&view=calendar since t-paliad-224 — the client never
// sees those pathnames any more.
if (pathname === "/agenda") return "agenda";
if (pathname === "/inbox") return "inbox";
if (pathname === "/dashboard" || pathname === "/") return "dashboard";

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

@@ -13,6 +13,7 @@ import { initSidebar } from "./sidebar";
import {
type DeadlineResponse,
calculateDeadlines,
escHtml,
formatDate,
populateCourtPicker,
renderColumnsBody,
@@ -157,13 +158,19 @@ async function doCalc() {
// the first event in the proceeding — e.g. Klageerhebung for
// upc.inf.cfi, Nichtigkeitsklage for upc.rev.cfi. Falls back to the
// active proceeding name if no root rule fires (shouldn't happen for
// healthy data, but safer than a blank).
// healthy data, but safer than a blank). Fallback respects language —
// proceedingNameEN is consulted on EN before the DE proceedingName
// (m/paliad#58: prior fallback rendered DE on EN for sub-track
// proceedings like upc.ccr.cfi which had no rules → no root).
function triggerEventLabelFor(data: DeadlineResponse): string {
const root = data.deadlines.find((d) => d.isRootEvent);
if (root) {
return getLang() === "en" ? (root.nameEN || root.name) : (root.name || root.nameEN);
}
return data.proceedingName || "";
if (getLang() === "en") {
return data.proceedingNameEN || data.proceedingName || "";
}
return data.proceedingName || data.proceedingNameEN || "";
}
function syncTriggerEventLabel() {
@@ -193,11 +200,23 @@ function renderResults(data: DeadlineResponse) {
<span class="timeline-trigger-date">${t("deadlines.trigger.label")}: ${formatDate(data.triggerDate)}</span>
</div>`;
// Sub-track contextual note (m/paliad#58). Surfaces above the
// timeline body when the server routed the user-picked proceeding
// through a parent (e.g. upc.ccr.cfi → upc.inf.cfi with with_ccr).
// Plain-text banner — server-side copy is plain text per the
// SubTrackRouting contract.
const noteText = getLang() === "en"
? (data.contextualNoteEN || data.contextualNote || "")
: (data.contextualNote || data.contextualNoteEN || "");
const noteHtml = noteText
? `<div class="timeline-context-note" role="note">${escHtml(noteText)}</div>`
: "";
const bodyHtml = procedureView === "columns"
? renderColumnsBody(data, { editable: true, showNotes })
: renderTimelineBody(data, { showParty: true, editable: true, showNotes });
container.innerHTML = headerHtml + bodyHtml;
container.innerHTML = headerHtml + noteHtml + bodyHtml;
if (printBtn) printBtn.style.display = "block";
if (toggle) toggle.style.display = "";

View File

@@ -1,525 +1,28 @@
import { t, tDyn, type I18nKey, getLang } from "../i18n";
import { mountCalendar, type CalendarItem } from "../calendar/mount-calendar";
import type { RenderSpec, ViewRow } from "./types";
// shape-calendar: month / week / day views. The view switcher is rendered
// inline above the grid; the active view persists in the URL via
// ?cal_view= so /views/<slug>?cal_view=day&cal_date=2026-05-18 is a
// shareable deep-link. Each view buckets the same flat ViewRow[] by
// ISO-date — only the rendering differs.
type CalView = "month" | "week" | "day";
const VIEW_PARAM = "cal_view";
const DATE_PARAM = "cal_date";
const MAX_PILLS_PER_MONTH_CELL = 3;
// shape-calendar — Custom Views calendar shape. Since t-paliad-224 this
// is a thin adapter on top of the canonical mountCalendar() in
// frontend/src/client/calendar/mount-calendar.ts. /events Kalender tab
// uses the same module so both surfaces render identical DOM.
// See docs/design-calendar-view-align-2026-05-20.md.
export function renderCalendarShape(host: HTMLElement, rows: ViewRow[], render: RenderSpec): void {
host.innerHTML = "";
const cfg = render.calendar ?? {};
// Mobile fallback: viewport <600px collapses to cards (cleaner on narrow
// screens). Documented in design §9 trade-off 8.
if (window.innerWidth < 600) {
const notice = document.createElement("p");
notice.className = "views-calendar-mobile-notice";
notice.textContent = t("views.calendar.mobile_fallback");
host.appendChild(notice);
}
const initialView = readView(cfg.default_view);
const anchor = readAnchor(rows);
paint(host, rows, anchor, initialView);
}
// paint redraws the calendar in the supplied view + anchor. Called from
// the view switcher and from the day/week navigation buttons. Each paint
// clears the host so we don't leak prior DOM.
function paint(host: HTMLElement, rows: ViewRow[], anchor: Date, view: CalView): void {
// Keep the mobile-notice (first child) if present; everything else is
// re-rendered each time.
const notice = host.querySelector<HTMLElement>(".views-calendar-mobile-notice");
host.innerHTML = "";
if (notice) host.appendChild(notice);
const wrap = document.createElement("div");
wrap.className = `views-calendar views-calendar--${view}`;
wrap.appendChild(renderToolbar(view, anchor, (nextView, nextAnchor) => {
writeURL(nextView, nextAnchor);
paint(host, rows, nextAnchor, nextView);
}));
if (view === "month") {
wrap.appendChild(renderMonth(anchor, rows, (clickedDate) => {
writeURL("day", clickedDate);
paint(host, rows, clickedDate, "day");
}));
} else if (view === "week") {
wrap.appendChild(renderWeek(anchor, rows));
} else {
wrap.appendChild(renderDay(anchor, rows));
}
host.appendChild(wrap);
}
// --- Toolbar -------------------------------------------------------------
function renderToolbar(
view: CalView,
anchor: Date,
onNav: (view: CalView, anchor: Date) => void,
): HTMLElement {
const bar = document.createElement("div");
bar.className = "views-calendar-toolbar";
// View switcher: month / week / day chips.
const switcher = document.createElement("div");
switcher.className = "views-calendar-view-switcher agenda-chip-row";
switcher.setAttribute("role", "tablist");
for (const v of ["month", "week", "day"] as CalView[]) {
const chip = document.createElement("button");
chip.type = "button";
chip.className = "agenda-chip views-calendar-view-chip" + (v === view ? " agenda-chip-active" : "");
chip.dataset.calView = v;
chip.setAttribute("role", "tab");
chip.setAttribute("aria-selected", v === view ? "true" : "false");
chip.textContent = t(`cal.view.${v}` as I18nKey);
chip.addEventListener("click", () => {
if (v === view) return;
onNav(v, anchor);
});
switcher.appendChild(chip);
}
bar.appendChild(switcher);
// Prev / current-label / next. Step size depends on the view.
const nav = document.createElement("div");
nav.className = "views-calendar-nav";
const prev = document.createElement("button");
prev.type = "button";
prev.className = "btn-secondary btn-small views-calendar-nav-btn";
prev.setAttribute("aria-label", t(navLabelKey(view, "prev")));
prev.textContent = "";
prev.addEventListener("click", () => onNav(view, shift(anchor, view, -1)));
nav.appendChild(prev);
const label = document.createElement("span");
label.className = "views-calendar-nav-label";
label.textContent = formatRangeLabel(view, anchor);
nav.appendChild(label);
const next = document.createElement("button");
next.type = "button";
next.className = "btn-secondary btn-small views-calendar-nav-btn";
next.setAttribute("aria-label", t(navLabelKey(view, "next")));
next.textContent = "";
next.addEventListener("click", () => onNav(view, shift(anchor, view, 1)));
nav.appendChild(next);
// Day/week view: provide a "Zurück zum Monat" link so users can climb
// back without hunting for the switcher chip.
if (view !== "month") {
const backToMonth = document.createElement("button");
backToMonth.type = "button";
backToMonth.className = "btn-link views-calendar-back-to-month";
backToMonth.textContent = t("cal.day.back_to_month");
backToMonth.addEventListener("click", () => onNav("month", anchor));
nav.appendChild(backToMonth);
}
bar.appendChild(nav);
return bar;
}
function navLabelKey(view: CalView, dir: "prev" | "next"): I18nKey {
if (view === "month") return dir === "prev" ? "cal.month.prev" : "cal.month.next";
if (view === "week") return dir === "prev" ? "cal.week.prev" : "cal.week.next";
return dir === "prev" ? "cal.day.prev" : "cal.day.next";
}
// --- Month view ----------------------------------------------------------
function renderMonth(anchor: Date, rows: ViewRow[], onDayDrill: (d: Date) => void): HTMLElement {
const lang = getLang() === "de" ? "de-DE" : "en-GB";
const wrap = document.createElement("div");
wrap.className = "views-calendar-month";
const header = document.createElement("h2");
header.className = "views-calendar-month-label";
header.textContent = anchor.toLocaleDateString(lang, { month: "long", year: "numeric" });
wrap.appendChild(header);
// Single grid with one column-template that the weekday row and the day
// cells share. The header row is added with `grid-column: span 7` so
// it spans the full width above the day grid (laid out below).
const grid = document.createElement("div");
grid.className = "views-calendar-grid";
const weekdayKeys: I18nKey[] = [
"cal.day.mon", "cal.day.tue", "cal.day.wed", "cal.day.thu",
"cal.day.fri", "cal.day.sat", "cal.day.sun",
];
for (const k of weekdayKeys) {
const cell = document.createElement("div");
cell.className = "views-calendar-weekday";
cell.textContent = t(k);
grid.appendChild(cell);
}
const monthStart = new Date(anchor.getFullYear(), anchor.getMonth(), 1);
const startWeekday = (monthStart.getDay() + 6) % 7; // Mon=0
const daysInMonth = new Date(anchor.getFullYear(), anchor.getMonth() + 1, 0).getDate();
// Pad start with prev-month spillover.
for (let i = 0; i < startWeekday; i++) {
const cell = document.createElement("div");
cell.className = "views-calendar-cell views-calendar-cell--out";
grid.appendChild(cell);
}
// Bucket rows by ISO date (yyyy-mm-dd) within the visible month.
const byDate = bucketByDate(rows, (d) =>
d.getMonth() === anchor.getMonth() && d.getFullYear() === anchor.getFullYear(),
);
for (let day = 1; day <= daysInMonth; day++) {
const dayDate = new Date(anchor.getFullYear(), anchor.getMonth(), day);
const dateKey = isoDate(dayDate);
const dayRows = byDate.get(dateKey) ?? [];
const cell = renderMonthCell(dayDate, day, dayRows, onDayDrill);
grid.appendChild(cell);
}
wrap.appendChild(grid);
return wrap;
}
function renderMonthCell(
dayDate: Date,
dayNum: number,
dayRows: ViewRow[],
onDayDrill: (d: Date) => void,
): HTMLElement {
const cell = document.createElement("div");
cell.className = "views-calendar-cell";
if (isToday(dayDate)) cell.classList.add("views-calendar-cell--today");
if (dayRows.length > 0) cell.classList.add("views-calendar-cell--has");
// Day-number is a click-target that switches to the day view. We render
// it as a button to keep keyboard semantics; the surrounding cell stays
// a div so it doesn't compete with the inner row anchors.
const dayLabel = document.createElement("button");
dayLabel.type = "button";
dayLabel.className = "views-calendar-cell-day";
dayLabel.textContent = String(dayNum);
dayLabel.setAttribute("aria-label", t("cal.day.open_day"));
dayLabel.addEventListener("click", (e) => {
e.stopPropagation();
onDayDrill(dayDate);
});
cell.appendChild(dayLabel);
if (dayRows.length > 0) {
const ul = document.createElement("ul");
ul.className = "views-calendar-pills";
const visible = dayRows.slice(0, MAX_PILLS_PER_MONTH_CELL);
for (const row of visible) {
ul.appendChild(renderPill(row));
}
if (dayRows.length > visible.length) {
const more = document.createElement("li");
const moreBtn = document.createElement("button");
moreBtn.type = "button";
moreBtn.className = "views-calendar-pill views-calendar-pill--more";
moreBtn.textContent = `+${dayRows.length - visible.length}`;
moreBtn.setAttribute("aria-label", t("cal.day.open_day"));
moreBtn.addEventListener("click", (e) => {
e.stopPropagation();
onDayDrill(dayDate);
});
more.appendChild(moreBtn);
ul.appendChild(more);
}
cell.appendChild(ul);
}
return cell;
}
// --- Week view -----------------------------------------------------------
function renderWeek(anchor: Date, rows: ViewRow[]): HTMLElement {
const lang = getLang() === "de" ? "de-DE" : "en-GB";
const wrap = document.createElement("div");
wrap.className = "views-calendar-week";
const weekStart = startOfWeek(anchor);
const header = document.createElement("h2");
header.className = "views-calendar-month-label";
const weekEnd = new Date(weekStart);
weekEnd.setDate(weekStart.getDate() + 6);
header.textContent = formatWeekHeader(weekStart, weekEnd, lang);
wrap.appendChild(header);
const grid = document.createElement("div");
grid.className = "views-calendar-week-grid";
for (let i = 0; i < 7; i++) {
const day = new Date(weekStart);
day.setDate(weekStart.getDate() + i);
const col = renderWeekColumn(day, rows);
grid.appendChild(col);
}
wrap.appendChild(grid);
return wrap;
}
function renderWeekColumn(day: Date, rows: ViewRow[]): HTMLElement {
const lang = getLang() === "de" ? "de-DE" : "en-GB";
const col = document.createElement("div");
col.className = "views-calendar-week-column";
if (isToday(day)) col.classList.add("views-calendar-week-column--today");
const head = document.createElement("div");
head.className = "views-calendar-week-head";
const weekdayKey = WEEKDAY_KEYS[(day.getDay() + 6) % 7];
const dow = document.createElement("span");
dow.className = "views-calendar-week-dow";
dow.textContent = t(weekdayKey);
const dnum = document.createElement("span");
dnum.className = "views-calendar-week-dnum";
dnum.textContent = day.toLocaleDateString(lang, { day: "numeric", month: "short" });
head.appendChild(dow);
head.appendChild(dnum);
col.appendChild(head);
// No 3-row cap on week / day views — show everything for that day.
const dayRows = filterByDay(rows, day);
if (dayRows.length === 0) {
const empty = document.createElement("p");
empty.className = "views-calendar-week-empty";
empty.textContent = t("cal.day.no_entries");
col.appendChild(empty);
return col;
}
const ul = document.createElement("ul");
ul.className = "views-calendar-week-list";
for (const row of dayRows) {
const li = document.createElement("li");
li.appendChild(renderRowAnchor(row, "week"));
ul.appendChild(li);
}
col.appendChild(ul);
return col;
}
// --- Day view ------------------------------------------------------------
function renderDay(anchor: Date, rows: ViewRow[]): HTMLElement {
const lang = getLang() === "de" ? "de-DE" : "en-GB";
const wrap = document.createElement("div");
wrap.className = "views-calendar-day-wrap";
const header = document.createElement("h2");
header.className = "views-calendar-month-label";
header.textContent = anchor.toLocaleDateString(lang, {
weekday: "long", year: "numeric", month: "long", day: "numeric",
});
wrap.appendChild(header);
const dayRows = filterByDay(rows, anchor);
if (dayRows.length === 0) {
const empty = document.createElement("p");
empty.className = "views-calendar-day-empty";
empty.textContent = t("cal.day.no_entries");
wrap.appendChild(empty);
return wrap;
}
const ul = document.createElement("ul");
ul.className = "views-calendar-day-list";
for (const row of dayRows) {
const li = document.createElement("li");
li.appendChild(renderRowAnchor(row, "day"));
ul.appendChild(li);
}
wrap.appendChild(ul);
return wrap;
}
// --- Row rendering -------------------------------------------------------
function renderPill(row: ViewRow): HTMLElement {
const li = document.createElement("li");
const a = document.createElement("a");
a.className = `views-calendar-pill views-calendar-pill--${row.kind}`;
a.href = rowHref(row);
a.textContent = row.title;
a.title = row.title + (row.project_title ? `${row.project_title}` : "");
// Pills are anchors — month-cell day-button click ignores them via
// stopPropagation on the button; cell-level handlers would intercept
// them otherwise.
a.addEventListener("click", (e) => e.stopPropagation());
li.appendChild(a);
return li;
}
function renderRowAnchor(row: ViewRow, density: "week" | "day"): HTMLElement {
const a = document.createElement("a");
a.className = `views-calendar-row views-calendar-row--${density} views-calendar-row--${row.kind}`;
a.href = rowHref(row);
const dot = document.createElement("span");
dot.className = `views-calendar-row-dot views-calendar-row-dot--${row.kind}`;
a.appendChild(dot);
const body = document.createElement("span");
body.className = "views-calendar-row-body";
const title = document.createElement("span");
title.className = "views-calendar-row-title";
title.textContent = row.title;
body.appendChild(title);
const metaParts: string[] = [];
metaParts.push(tDyn("views.kind." + row.kind));
if (row.project_reference) metaParts.push(row.project_reference);
else if (row.project_title) metaParts.push(row.project_title);
if (metaParts.length > 0) {
const meta = document.createElement("span");
meta.className = "views-calendar-row-meta";
meta.textContent = metaParts.join(" · ");
body.appendChild(meta);
}
a.appendChild(body);
return a;
}
function rowHref(row: ViewRow): string {
switch (row.kind) {
case "deadline": return `/deadlines/${encodeURIComponent(row.id)}`;
case "appointment": return `/appointments/${encodeURIComponent(row.id)}`;
case "approval_request": return `/inbox`;
case "project_event":
// project_events surface on the project's Verlauf — best we can do
// is link to the project. If no project, leave as a non-link target.
return row.project_id ? `/projects/${encodeURIComponent(row.project_id)}` : "#";
}
}
// --- Bucketing / date helpers --------------------------------------------
const WEEKDAY_KEYS: I18nKey[] = [
"cal.day.mon", "cal.day.tue", "cal.day.wed", "cal.day.thu",
"cal.day.fri", "cal.day.sat", "cal.day.sun",
];
function bucketByDate(rows: ViewRow[], filter: (d: Date) => boolean): Map<string, ViewRow[]> {
const out = new Map<string, ViewRow[]>();
for (const row of rows) {
const d = new Date(row.event_date);
if (isNaN(d.getTime())) continue;
if (!filter(d)) continue;
const key = isoDate(d);
const arr = out.get(key);
if (arr) arr.push(row);
else out.set(key, [row]);
}
return out;
}
function filterByDay(rows: ViewRow[], day: Date): ViewRow[] {
const key = isoDate(day);
return rows.filter((r) => {
const d = new Date(r.event_date);
if (isNaN(d.getTime())) return false;
return isoDate(d) === key;
const items: CalendarItem[] = rows.map(toCalendarItem);
mountCalendar(host, items, {
defaultView: render.calendar?.default_view ?? "month",
urlState: true,
});
}
function startOfWeek(d: Date): Date {
const out = new Date(d.getFullYear(), d.getMonth(), d.getDate());
const offset = (out.getDay() + 6) % 7; // Mon=0
out.setDate(out.getDate() - offset);
return out;
}
function shift(d: Date, view: CalView, dir: number): Date {
if (view === "month") return new Date(d.getFullYear(), d.getMonth() + dir, 1);
if (view === "week") return new Date(d.getFullYear(), d.getMonth(), d.getDate() + dir * 7);
return new Date(d.getFullYear(), d.getMonth(), d.getDate() + dir);
}
function isToday(d: Date): boolean {
const now = new Date();
return d.getFullYear() === now.getFullYear()
&& d.getMonth() === now.getMonth()
&& d.getDate() === now.getDate();
}
function isoDate(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
function formatRangeLabel(view: CalView, anchor: Date): string {
const lang = getLang() === "de" ? "de-DE" : "en-GB";
if (view === "month") {
return anchor.toLocaleDateString(lang, { month: "long", year: "numeric" });
}
if (view === "week") {
const start = startOfWeek(anchor);
const end = new Date(start);
end.setDate(start.getDate() + 6);
return formatWeekHeader(start, end, lang);
}
return anchor.toLocaleDateString(lang, {
weekday: "short", year: "numeric", month: "long", day: "numeric",
});
}
function formatWeekHeader(start: Date, end: Date, lang: string): string {
const startStr = start.toLocaleDateString(lang, { day: "numeric", month: "short" });
const endStr = end.toLocaleDateString(lang, { day: "numeric", month: "short", year: "numeric" });
return `${startStr} ${endStr}`;
}
// --- URL state -----------------------------------------------------------
function readView(defaultView: CalView | undefined): CalView {
const params = new URLSearchParams(window.location.search);
const raw = params.get(VIEW_PARAM);
if (raw === "month" || raw === "week" || raw === "day") return raw;
return defaultView ?? "month";
}
function readAnchor(rows: ViewRow[]): Date {
const params = new URLSearchParams(window.location.search);
const raw = params.get(DATE_PARAM);
if (raw) {
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(raw);
if (m) {
const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
if (!isNaN(d.getTime())) return d;
}
}
// No URL anchor — pick the first row's date, or today.
for (const row of rows) {
const d = new Date(row.event_date);
if (!isNaN(d.getTime())) return new Date(d.getFullYear(), d.getMonth(), d.getDate());
}
const now = new Date();
return new Date(now.getFullYear(), now.getMonth(), now.getDate());
}
function writeURL(view: CalView, anchor: Date): void {
const url = new URL(window.location.href);
url.searchParams.set(VIEW_PARAM, view);
url.searchParams.set(DATE_PARAM, isoDate(anchor));
history.replaceState(null, "", url.toString());
function toCalendarItem(row: ViewRow): CalendarItem {
return {
kind: row.kind,
id: row.id,
title: row.title,
event_date: row.event_date,
project_id: row.project_id,
project_title: row.project_title,
project_reference: row.project_reference,
};
}

View File

@@ -95,8 +95,21 @@ export function priorityRendering(
export interface DeadlineResponse {
proceedingType: string;
proceedingName: string;
// proceedingNameEN: English label of the picked proceeding. Empty
// when not populated server-side; frontend falls back to
// proceedingName. Used for the "Trigger event" fallback when the
// timeline has no root rule. (m/paliad#58)
proceedingNameEN?: string;
triggerDate: string;
deadlines: CalculatedDeadline[];
// contextualNote / contextualNoteEN render as a banner above the
// timeline. Populated when the picked proceeding is a sub-track of
// another proceeding (e.g. upc.ccr.cfi runs inside upc.inf.cfi with
// with_ccr) — the server routes to the parent's rules but keeps the
// picked proceeding's identity in the response, and the note
// explains the framing. (m/paliad#58)
contextualNote?: string;
contextualNoteEN?: string;
}
export interface CourtRow {

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

@@ -1,84 +0,0 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { PaliadinWidget } from "./components/PaliadinWidget";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
export function renderDeadlinesCalendar(): string {
return "<!DOCTYPE html>" + (
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#BFF355" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<PWAHead />
<title data-i18n="deadlines.kalender.title">Fristenkalender &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/events?type=deadline" />
<BottomNav currentPath="/events?type=deadline" />
<main>
<section className="tool-page">
<div className="container">
<div className="tool-header">
<div className="entity-header-row">
<div>
<h1 data-i18n="deadlines.kalender.heading">Fristenkalender</h1>
<p className="tool-subtitle" data-i18n="deadlines.kalender.subtitle">
Monats&uuml;bersicht aller Fristen Ihrer Akten.
</p>
</div>
<div className="fristen-header-actions">
<a href="/events?type=deadline" className="btn-secondary" data-i18n="deadlines.kalender.list">Listenansicht</a>
<a href="/deadlines/new" className="btn-primary btn-cta-lime" data-i18n="deadlines.list.new">Neue Frist</a>
</div>
</div>
</div>
<div className="frist-calendar-controls">
<button type="button" id="cal-prev" className="btn-secondary btn-small" aria-label="Vorheriger Monat">&larr;</button>
<h2 id="cal-month-label" className="frist-cal-month-label" />
<button type="button" id="cal-next" className="btn-secondary btn-small" aria-label="N&auml;chster Monat">&rarr;</button>
<button type="button" id="cal-today" className="btn-secondary btn-small" data-i18n="deadlines.kalender.today">Heute</button>
</div>
<div className="frist-calendar" id="deadline-calendar">
<div className="frist-cal-weekday" data-i18n="cal.day.mon">Mo</div>
<div className="frist-cal-weekday" data-i18n="cal.day.tue">Di</div>
<div className="frist-cal-weekday" data-i18n="cal.day.wed">Mi</div>
<div className="frist-cal-weekday" data-i18n="cal.day.thu">Do</div>
<div className="frist-cal-weekday" data-i18n="cal.day.fri">Fr</div>
<div className="frist-cal-weekday" data-i18n="cal.day.sat">Sa</div>
<div className="frist-cal-weekday" data-i18n="cal.day.sun">So</div>
<div id="deadline-cal-grid" className="frist-cal-grid" />
</div>
<p className="entity-events-empty" id="deadline-cal-empty" style="display:none" data-i18n="deadlines.kalender.empty">
Keine Fristen im ausgew&auml;hlten Zeitraum.
</p>
<div className="modal-overlay" id="cal-popup" style="display:none">
<div className="modal-card">
<div className="modal-header">
<h2 id="cal-popup-date" />
<button className="modal-close" id="cal-popup-close" type="button">&times;</button>
</div>
<ul className="frist-cal-popup-list" id="cal-popup-list" />
</div>
</div>
</div>
</section>
</main>
<Footer />
<PaliadinWidget />
<script src="/assets/deadlines-calendar.js"></script>
</body>
</html>
);
}

View File

@@ -236,37 +236,10 @@ export function renderEvents(): string {
</table>
</div>
<div id="events-calendar-wrap" className="events-calendar-wrap" hidden>
<div className="frist-calendar-controls">
<button type="button" id="events-cal-prev" className="btn-secondary btn-small" aria-label="Vorheriger Monat">&larr;</button>
<h2 id="events-cal-month-label" className="frist-cal-month-label" />
<button type="button" id="events-cal-next" className="btn-secondary btn-small" aria-label="N&auml;chster Monat">&rarr;</button>
<button type="button" id="events-cal-today" className="btn-secondary btn-small" data-i18n="deadlines.kalender.today">Heute</button>
</div>
<div className="frist-calendar">
<div className="frist-cal-weekday" data-i18n="cal.day.mon">Mo</div>
<div className="frist-cal-weekday" data-i18n="cal.day.tue">Di</div>
<div className="frist-cal-weekday" data-i18n="cal.day.wed">Mi</div>
<div className="frist-cal-weekday" data-i18n="cal.day.thu">Do</div>
<div className="frist-cal-weekday" data-i18n="cal.day.fri">Fr</div>
<div className="frist-cal-weekday" data-i18n="cal.day.sat">Sa</div>
<div className="frist-cal-weekday" data-i18n="cal.day.sun">So</div>
<div id="events-cal-grid" className="frist-cal-grid" />
</div>
<p className="entity-events-empty" id="events-cal-empty" hidden data-i18n="events.calendar.empty">
Keine Eintr&auml;ge im ausgew&auml;hlten Zeitraum.
</p>
</div>
<div className="modal-overlay" id="events-cal-popup" style="display:none">
<div className="modal-card">
<div className="modal-header">
<h2 id="events-cal-popup-date" />
<button className="modal-close" id="events-cal-popup-close" type="button" aria-label="Close">&times;</button>
</div>
<ul className="frist-cal-popup-list" id="events-cal-popup-list" />
</div>
</div>
{/* Calendar host — mountCalendar() (t-paliad-224) builds the
month/week/day grid + toolbar into this container when
the Kalender view chip is active. Empty until then. */}
<div id="events-calendar-wrap" className="events-calendar-wrap" hidden />
<div className="entity-empty" id="events-empty" style="display:none">
<h2 data-i18n="events.empty.title">Keine Eintr&auml;ge vorhanden</h2>

View File

@@ -440,7 +440,23 @@ export type I18nKey =
| "admin.section.planned"
| "admin.subtitle"
| "admin.team.add.direct"
| "admin.team.add.full"
| "admin.team.add.invite"
| "admin.team.add_full.body"
| "admin.team.add_full.cancel"
| "admin.team.add_full.email"
| "admin.team.add_full.error.email_exists"
| "admin.team.add_full.error.generic"
| "admin.team.add_full.error.unavailable"
| "admin.team.add_full.feedback.added"
| "admin.team.add_full.job_title"
| "admin.team.add_full.lang"
| "admin.team.add_full.name"
| "admin.team.add_full.office"
| "admin.team.add_full.profession"
| "admin.team.add_full.send_welcome"
| "admin.team.add_full.submit"
| "admin.team.add_full.title"
| "admin.team.col.actions"
| "admin.team.col.additional"
| "admin.team.col.created"
@@ -486,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"
@@ -555,12 +578,6 @@ export type I18nKey =
| "appointments.filter.type"
| "appointments.filter.type.all"
| "appointments.form.approval_hint"
| "appointments.kalender.empty"
| "appointments.kalender.heading"
| "appointments.kalender.list"
| "appointments.kalender.subtitle"
| "appointments.kalender.title"
| "appointments.list.calendar"
| "appointments.list.heading"
| "appointments.list.new"
| "appointments.list.subtitle"
@@ -705,6 +722,7 @@ export type I18nKey =
| "cal.month.9"
| "cal.month.next"
| "cal.month.prev"
| "cal.today"
| "cal.view.day"
| "cal.view.month"
| "cal.view.week"
@@ -789,7 +807,54 @@ export type I18nKey =
| "changelog.tag.feature"
| "changelog.tag.fix"
| "changelog.title"
| "checklisten.author.cancel"
| "checklisten.author.error.generic"
| "checklisten.author.error.no_groups"
| "checklisten.author.error.notfound"
| "checklisten.author.error.title"
| "checklisten.author.field.court"
| "checklisten.author.field.deadline"
| "checklisten.author.field.description"
| "checklisten.author.field.lang"
| "checklisten.author.field.reference"
| "checklisten.author.field.regime"
| "checklisten.author.field.title"
| "checklisten.author.field.title.hint"
| "checklisten.author.field.visibility"
| "checklisten.author.group.remove"
| "checklisten.author.group.title"
| "checklisten.author.groups.add"
| "checklisten.author.groups.heading"
| "checklisten.author.heading.edit"
| "checklisten.author.heading.new"
| "checklisten.author.item.add"
| "checklisten.author.item.label"
| "checklisten.author.item.note"
| "checklisten.author.item.remove"
| "checklisten.author.item.rule"
| "checklisten.author.save"
| "checklisten.author.saving"
| "checklisten.author.subtitle"
| "checklisten.author.title"
| "checklisten.author.title.edit"
| "checklisten.author.visibility.firm.hint"
| "checklisten.author.visibility.private.hint"
| "checklisten.back"
| "checklisten.detail.authored.by"
| "checklisten.detail.delete"
| "checklisten.detail.delete.confirm"
| "checklisten.detail.delete.error"
| "checklisten.detail.demote"
| "checklisten.detail.demote.confirm"
| "checklisten.detail.edit"
| "checklisten.detail.promote"
| "checklisten.detail.promote.confirm"
| "checklisten.detail.promote.error"
| "checklisten.detail.share"
| "checklisten.detail.visibility"
| "checklisten.detail.visibility.error"
| "checklisten.detail.visibility.set.firm"
| "checklisten.detail.visibility.set.private"
| "checklisten.disclaimer"
| "checklisten.empty"
| "checklisten.feedback.btn"
@@ -807,11 +872,23 @@ export type I18nKey =
| "checklisten.feedback.type"
| "checklisten.filter.all"
| "checklisten.filter.de"
| "checklisten.filter.other"
| "checklisten.gallery.empty"
| "checklisten.heading"
| "checklisten.instance.akte.open"
| "checklisten.instance.back"
| "checklisten.instance.diff.added"
| "checklisten.instance.diff.changed"
| "checklisten.instance.diff.close"
| "checklisten.instance.diff.empty"
| "checklisten.instance.diff.error"
| "checklisten.instance.diff.removed"
| "checklisten.instance.diff.title"
| "checklisten.instance.loading"
| "checklisten.instance.notfound"
| "checklisten.instance.outdated.badge"
| "checklisten.instance.outdated.diff"
| "checklisten.instance.outdated.note"
| "checklisten.instance.rename"
| "checklisten.instance.rename.error"
| "checklisten.instance.rename.save"
@@ -834,6 +911,18 @@ export type I18nKey =
| "checklisten.instances.heading"
| "checklisten.instances.loading"
| "checklisten.instances.sub"
| "checklisten.mine.delete"
| "checklisten.mine.delete.confirm"
| "checklisten.mine.delete.error"
| "checklisten.mine.edit"
| "checklisten.mine.empty"
| "checklisten.mine.loading"
| "checklisten.mine.new"
| "checklisten.mine.origin.authored"
| "checklisten.mine.visibility.firm"
| "checklisten.mine.visibility.global"
| "checklisten.mine.visibility.private"
| "checklisten.mine.visibility.shared"
| "checklisten.newInstance"
| "checklisten.newInstance.akte"
| "checklisten.newInstance.akte.hint"
@@ -850,8 +939,31 @@ export type I18nKey =
| "checklisten.reset"
| "checklisten.reset.confirm"
| "checklisten.reset.error"
| "checklisten.share.cancel"
| "checklisten.share.error.generic"
| "checklisten.share.error.pick"
| "checklisten.share.grants.empty"
| "checklisten.share.grants.heading"
| "checklisten.share.grants.recipient.office"
| "checklisten.share.grants.recipient.partner_unit"
| "checklisten.share.grants.recipient.project"
| "checklisten.share.grants.recipient.user"
| "checklisten.share.grants.revoke"
| "checklisten.share.grants.revoke.confirm"
| "checklisten.share.grants.revoke.error"
| "checklisten.share.kind"
| "checklisten.share.kind.office"
| "checklisten.share.kind.partner_unit"
| "checklisten.share.kind.project"
| "checklisten.share.kind.user"
| "checklisten.share.pick"
| "checklisten.share.submit"
| "checklisten.share.success"
| "checklisten.share.title"
| "checklisten.subtitle"
| "checklisten.tab.gallery"
| "checklisten.tab.instances"
| "checklisten.tab.mine"
| "checklisten.tab.templates"
| "checklisten.title"
| "common.cancel"
@@ -927,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"
@@ -938,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"
@@ -1120,13 +1269,6 @@ export type I18nKey =
| "deadlines.inbox.label"
| "deadlines.inbox.posteingang"
| "deadlines.inbox.posteingang.title"
| "deadlines.kalender.empty"
| "deadlines.kalender.heading"
| "deadlines.kalender.list"
| "deadlines.kalender.subtitle"
| "deadlines.kalender.title"
| "deadlines.kalender.today"
| "deadlines.list.calendar"
| "deadlines.list.heading"
| "deadlines.list.new"
| "deadlines.list.subtitle"
@@ -1454,7 +1596,6 @@ export type I18nKey =
| "event_types.picker.no_match"
| "event_types.picker.remove"
| "event_types.picker.search"
| "events.calendar.empty"
| "events.col.appointment_type"
| "events.col.date"
| "events.col.location"

View File

@@ -3304,6 +3304,23 @@ input[type="range"]::-moz-range-thumb {
font-size: 1rem;
}
/* Sub-track contextual note banner (m/paliad#58). Renders above the
timeline body when the picked proceeding is a sub-track of another
proceeding (e.g. UPC CCR rendered standalone). Plain-text content;
white-space: pre-line preserves paragraph breaks if server copy
ever uses them. */
.timeline-context-note {
margin: 0 0 1rem;
padding: 0.7rem 0.9rem;
background: rgba(198, 244, 28, 0.10);
border-left: 3px solid var(--brand-lime, #c6f41c);
border-radius: 4px;
color: var(--color-text, #222);
font-size: 0.9rem;
line-height: 1.4;
white-space: pre-line;
}
.timeline {
position: relative;
}
@@ -7453,158 +7470,10 @@ dialog.modal::backdrop {
max-width: 22rem;
}
/* Calendar view */
.frist-calendar-controls {
display: flex;
align-items: center;
gap: 0.75rem;
margin: 0 0 1rem;
}
.frist-cal-month-label {
font-size: 1.15rem;
margin: 0;
min-width: 11rem;
text-align: center;
}
.frist-calendar {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 1px;
background: var(--color-border);
border: 1px solid var(--color-border);
border-radius: var(--radius);
overflow: hidden;
}
.frist-cal-weekday {
background: var(--color-surface-2);
color: var(--color-text-muted);
font-size: 0.78rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 0.4rem 0.6rem;
text-align: center;
}
.frist-cal-grid {
grid-column: 1 / -1;
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 1px;
background: var(--color-border);
}
.frist-cal-cell {
background: var(--color-surface);
min-height: 88px;
padding: 0.4rem 0.5rem;
display: flex;
flex-direction: column;
justify-content: space-between;
cursor: default;
}
.frist-cal-cell-empty {
background: var(--color-bg-subtle);
}
.frist-cal-cell-has {
cursor: pointer;
}
.frist-cal-cell-has:hover {
background: var(--color-bg-lime-tint);
}
.frist-cal-day {
font-size: 0.8rem;
color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
}
.frist-cal-today .frist-cal-day {
background: var(--color-accent);
color: var(--color-accent-dark);
border-radius: 999px;
width: 1.5rem;
height: 1.5rem;
display: inline-flex;
align-items: center;
justify-content: center;
font-weight: 600;
}
.frist-cal-dots {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 3px;
}
.frist-cal-dot {
display: inline-block;
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--frist-grey);
}
.frist-cal-dot.frist-urgency-overdue { background: var(--frist-red); }
.frist-cal-dot.frist-urgency-soon { background: var(--frist-amber); }
.frist-cal-dot.frist-urgency-later { background: var(--frist-green); }
.frist-cal-dot.frist-urgency-done { background: var(--frist-grey); }
.frist-cal-more {
font-size: 0.7rem;
color: var(--color-text-muted);
margin-left: 0.2rem;
}
.frist-cal-popup-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.frist-cal-popup-item {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 0.6rem;
padding: 0.4rem 0;
border-bottom: 1px solid var(--color-border);
}
.frist-cal-popup-item:last-child {
border-bottom: none;
}
.frist-cal-popup-title {
color: var(--color-text);
text-decoration: none;
font-weight: 500;
}
.frist-cal-popup-title:hover {
text-decoration: underline;
}
.frist-cal-popup-akte {
color: var(--color-text-muted);
font-size: 0.8rem;
text-decoration: none;
}
.frist-cal-popup-akte:hover {
color: var(--color-text);
}
/* Calendar view styles live in .views-calendar-* (search for that
prefix). The /events Kalender tab and Custom Views shape=calendar
both mount the same component (frontend/src/client/calendar/
mount-calendar.ts, t-paliad-224). */
/* Fristenrechner save-to-Akte modal */
@@ -8010,9 +7879,6 @@ dialog.modal::backdrop {
.frist-summary-cards {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.frist-cal-cell {
min-height: 64px;
}
}
/* ========================================================================
@@ -8210,19 +8076,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 {
@@ -8265,7 +8125,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;
@@ -8487,6 +8374,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;
@@ -8559,6 +8487,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)
--------------------------------------------------------------------------- */
@@ -8671,27 +9150,6 @@ dialog.modal::backdrop {
.termin-card-week .frist-summary-dot { background: #2563eb; }
.termin-card-later .frist-summary-dot { background: #475569; }
.termin-cal-legend {
display: flex;
gap: 1.2rem;
flex-wrap: wrap;
margin: 0.5rem 0 1rem;
color: #475569;
font-size: 0.85rem;
}
.termin-cal-legend-item {
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
/* Calendar popup: extra time column for termine (vs. the deadline popup). */
.frist-cal-popup-time {
color: #475569;
font-variant-numeric: tabular-nums;
margin-right: 0.5rem;
}
/* CalDAV settings page */
.caldav-status-card {
background: var(--color-surface-muted);
@@ -11510,18 +11968,13 @@ dialog.quick-add-sheet::backdrop {
box-shadow: var(--shadow-sm, 0 1px 2px rgba(0, 0, 0, 0.08));
}
/* Calendar view (t-paliad-115). Reuses the existing .frist-calendar
styles — only the appointment dot colour is new. The frist-cal-dot
urgency variants already cover the deadline palette; we just need a
distinct hue for appointments so a mixed-type cell reads at a glance. */
/* Calendar host — mountCalendar() (t-paliad-224) builds the toolbar +
grid into this wrapper when the user picks the Kalender chip. All
internal styling lives in .views-calendar-* (search for that prefix). */
.events-calendar-wrap {
margin: 0.25rem 0 1rem;
}
.frist-cal-dot.events-cal-dot-appointment {
background: var(--bucket-next-week, #1d4ed8);
}
/* Add-modal styling — extends the existing .modal-overlay/.modal pattern. */
.event-type-add-modal {
width: 28rem;

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,13 @@
-- Reverse of mig 114 — t-paliad-225 / m/paliad#61 Slice A.
ALTER TABLE paliad.checklist_instances
DROP COLUMN IF EXISTS template_snapshot;
DROP POLICY IF EXISTS checklists_delete ON paliad.checklists;
DROP POLICY IF EXISTS checklists_update ON paliad.checklists;
DROP POLICY IF EXISTS checklists_insert ON paliad.checklists;
DROP POLICY IF EXISTS checklists_select ON paliad.checklists;
DROP FUNCTION IF EXISTS paliad.can_see_checklist(uuid, uuid);
DROP TABLE IF EXISTS paliad.checklists;

View File

@@ -0,0 +1,178 @@
-- mig 114 — t-paliad-225 / m/paliad#61 Slice A — user-authored checklists.
--
-- Design: docs/design-user-checklists-2026-05-20.md
--
-- Introduces paliad.checklists (the authored-template catalog), the
-- paliad.can_see_checklist(uuid, uuid) visibility predicate, and a
-- nullable template_snapshot column on paliad.checklist_instances so
-- per-Akte instances stay decoupled from subsequent template edits.
--
-- Slice A ships with private + firm visibility only; the 'shared' and
-- 'global' values are valid in the CHECK enum so Slice B can add the
-- explicit-share path and admin-promotion without a second migration
-- to the enum.
--
-- Sections:
-- 1. CREATE TABLE paliad.checklists.
-- 2. paliad.can_see_checklist(uuid, uuid) predicate.
-- 3. RLS policies on paliad.checklists.
-- 4. ALTER TABLE paliad.checklist_instances ADD COLUMN template_snapshot.
--
-- Idempotent throughout (CREATE … IF NOT EXISTS / CREATE OR REPLACE
-- FUNCTION / DROP POLICY IF EXISTS + CREATE POLICY).
-- ============================================================================
-- 1. paliad.checklists — authored-template catalog.
--
-- The static Go catalog (internal/checklists/templates.go) stays the
-- firm's curated source for legally-reviewed templates. This table holds
-- user-authored templates that augment that catalog at read time via
-- ChecklistCatalogService.
--
-- Slugs are author-facing and unique within this table. The application
-- layer rejects slugs that collide with the static catalog (see
-- ChecklistTemplateService.Create — applies a 'u-' prefix and falls back
-- through a collision-retry loop).
--
-- body jsonb carries { "groups": [{ "title", "items": [{ "label", "note",
-- "rule" }] }] } — the same shape as the static checklists.Template
-- minus the metadata (which lives in dedicated columns).
-- ============================================================================
CREATE TABLE IF NOT EXISTS paliad.checklists (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
slug text NOT NULL UNIQUE,
owner_id uuid NOT NULL REFERENCES paliad.users(id) ON DELETE CASCADE,
title text NOT NULL,
description text NOT NULL DEFAULT '',
regime text NOT NULL DEFAULT 'OTHER',
court text NOT NULL DEFAULT '',
reference text NOT NULL DEFAULT '',
deadline text NOT NULL DEFAULT '',
lang text NOT NULL DEFAULT 'de',
body jsonb NOT NULL,
visibility text NOT NULL DEFAULT 'private'
CHECK (visibility IN ('private', 'shared', 'firm', 'global')),
promoted_at timestamptz,
promoted_by uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS checklists_owner_idx
ON paliad.checklists (owner_id);
CREATE INDEX IF NOT EXISTS checklists_visibility_idx
ON paliad.checklists (visibility)
WHERE visibility IN ('firm', 'global');
CREATE INDEX IF NOT EXISTS checklists_regime_idx
ON paliad.checklists (regime);
COMMENT ON TABLE paliad.checklists IS
'User-authored checklist templates. Augments the static Go catalog '
'at read time via ChecklistCatalogService. Visibility levels: '
'private (owner only), shared (Slice B), firm (all authenticated), '
'global (admin-promoted into firm catalog — Slice B).';
-- ============================================================================
-- 2. paliad.can_see_checklist(_user_id, _checklist_id)
--
-- Pattern mirrors paliad.can_see_project / paliad.effective_project_admin
-- (mig 111): STABLE SECURITY DEFINER, single-statement, predicate-friendly.
--
-- Slice A only relies on the owner + firm/global branches. The shared
-- branch (matching against paliad.checklist_shares) is wired now so
-- Slice B doesn't need to replace the function — a NULL row count just
-- returns false. The table doesn't exist yet, so the EXISTS clause must
-- be guarded; we inline a NOT EXISTS check on pg_class so the function
-- body compiles cleanly on Slice A while staying ready for Slice B.
-- ============================================================================
CREATE OR REPLACE FUNCTION paliad.can_see_checklist(_user_id uuid, _checklist_id uuid)
RETURNS boolean
LANGUAGE sql STABLE SECURITY DEFINER
SET search_path TO 'paliad', 'public'
AS $$
-- Owner can always see.
SELECT EXISTS (
SELECT 1 FROM paliad.checklists c
WHERE c.id = _checklist_id
AND c.owner_id = _user_id
)
-- firm / global visibility: every authenticated user.
OR EXISTS (
SELECT 1 FROM paliad.checklists c
WHERE c.id = _checklist_id
AND c.visibility IN ('firm', 'global')
);
$$;
COMMENT ON FUNCTION paliad.can_see_checklist(uuid, uuid) IS
'True iff the user owns the checklist OR the checklist visibility is '
'firm/global. Slice B extends this predicate with the explicit-share '
'path over paliad.checklist_shares.';
-- ============================================================================
-- 3. RLS on paliad.checklists.
-- ============================================================================
ALTER TABLE paliad.checklists ENABLE ROW LEVEL SECURITY;
-- SELECT: owner OR visible via can_see_checklist.
DROP POLICY IF EXISTS checklists_select ON paliad.checklists;
CREATE POLICY checklists_select
ON paliad.checklists FOR SELECT TO authenticated
USING (paliad.can_see_checklist(auth.uid(), id));
-- INSERT: caller can only create templates owned by themselves.
DROP POLICY IF EXISTS checklists_insert ON paliad.checklists;
CREATE POLICY checklists_insert
ON paliad.checklists FOR INSERT TO authenticated
WITH CHECK (owner_id = auth.uid());
-- UPDATE: owner OR global_admin.
DROP POLICY IF EXISTS checklists_update ON paliad.checklists;
CREATE POLICY checklists_update
ON paliad.checklists FOR UPDATE TO authenticated
USING (
owner_id = auth.uid()
OR EXISTS (
SELECT 1 FROM paliad.users u
WHERE u.id = auth.uid() AND u.global_role = 'global_admin'
)
)
WITH CHECK (
owner_id = auth.uid()
OR EXISTS (
SELECT 1 FROM paliad.users u
WHERE u.id = auth.uid() AND u.global_role = 'global_admin'
)
);
-- DELETE: owner OR global_admin.
DROP POLICY IF EXISTS checklists_delete ON paliad.checklists;
CREATE POLICY checklists_delete
ON paliad.checklists FOR DELETE TO authenticated
USING (
owner_id = auth.uid()
OR EXISTS (
SELECT 1 FROM paliad.users u
WHERE u.id = auth.uid() AND u.global_role = 'global_admin'
)
);
-- ============================================================================
-- 4. paliad.checklist_instances.template_snapshot — instance integrity column.
--
-- Captures the template body (groups + items) at instance create time so
-- subsequent template edits / visibility narrowing don't affect existing
-- per-Akte instances. NULL on rows created before this migration; the
-- service layer falls back to live catalog lookup for those.
-- ============================================================================
ALTER TABLE paliad.checklist_instances
ADD COLUMN IF NOT EXISTS template_snapshot jsonb;
COMMENT ON COLUMN paliad.checklist_instances.template_snapshot IS
'Snapshot of the template body at instance create time. NULL for '
'pre-mig-114 rows; service layer falls back to live catalog lookup '
'in that case (legacy path; backfilled in Slice C).';

View File

@@ -0,0 +1,26 @@
-- Reverse of mig 115 — t-paliad-225 / m/paliad#61 Slice B.
--
-- Restore the owner+firm/global-only body of paliad.can_see_checklist
-- (matches the mig 114 definition) so a rollback of Slice B leaves the
-- function pointing at the Slice A behaviour.
CREATE OR REPLACE FUNCTION paliad.can_see_checklist(_user_id uuid, _checklist_id uuid)
RETURNS boolean
LANGUAGE sql STABLE SECURITY DEFINER
SET search_path TO 'paliad', 'public'
AS $$
SELECT EXISTS (
SELECT 1 FROM paliad.checklists c
WHERE c.id = _checklist_id AND c.owner_id = _user_id
)
OR EXISTS (
SELECT 1 FROM paliad.checklists c
WHERE c.id = _checklist_id AND c.visibility IN ('firm', 'global')
);
$$;
DROP POLICY IF EXISTS checklist_shares_delete ON paliad.checklist_shares;
DROP POLICY IF EXISTS checklist_shares_insert ON paliad.checklist_shares;
DROP POLICY IF EXISTS checklist_shares_select ON paliad.checklist_shares;
DROP TABLE IF EXISTS paliad.checklist_shares;

View File

@@ -0,0 +1,211 @@
-- mig 115 — t-paliad-225 / m/paliad#61 Slice B — explicit sharing +
-- admin-promotion plumbing for user-authored checklists.
--
-- Design: docs/design-user-checklists-2026-05-20.md §3.2 / §4.2 / §4.3
-- / §4.5.
--
-- Introduces paliad.checklist_shares with the polymorphic recipient
-- pattern (xor-check enforces exactly one recipient_* column populated
-- per recipient_kind). Extends paliad.can_see_checklist with the
-- explicit-share branches so the 'shared' visibility level actually
-- gates anything.
--
-- Sections:
-- 1. CREATE TABLE paliad.checklist_shares (+ indexes + RLS).
-- 2. CREATE OR REPLACE paliad.can_see_checklist — adds 4 share
-- branches (user / office / partner_unit / project).
--
-- Idempotent throughout.
-- ============================================================================
-- 1. paliad.checklist_shares — explicit grants for a single checklist.
--
-- recipient_kind disambiguates which recipient_* column is populated.
-- The XOR check makes the constraint structurally enforce "exactly one
-- recipient_<kind> non-null per row". Per-kind UNIQUE partial indexes
-- prevent duplicate grants per (checklist, recipient).
--
-- Slice A's checklists.visibility CHECK already includes 'shared' so no
-- ALTER is needed here.
-- ============================================================================
CREATE TABLE IF NOT EXISTS paliad.checklist_shares (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
checklist_id uuid NOT NULL REFERENCES paliad.checklists(id) ON DELETE CASCADE,
recipient_kind text NOT NULL
CHECK (recipient_kind IN ('user', 'office', 'partner_unit', 'project')),
recipient_user_id uuid REFERENCES paliad.users(id) ON DELETE CASCADE,
recipient_office text,
recipient_partner_unit_id uuid REFERENCES paliad.partner_units(id) ON DELETE CASCADE,
recipient_project_id uuid REFERENCES paliad.projects(id) ON DELETE CASCADE,
granted_by uuid NOT NULL REFERENCES paliad.users(id) ON DELETE SET NULL,
granted_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT checklist_shares_recipient_xor CHECK (
(recipient_kind = 'user'
AND recipient_user_id IS NOT NULL
AND recipient_office IS NULL
AND recipient_partner_unit_id IS NULL
AND recipient_project_id IS NULL)
OR (recipient_kind = 'office'
AND recipient_office IS NOT NULL
AND recipient_user_id IS NULL
AND recipient_partner_unit_id IS NULL
AND recipient_project_id IS NULL)
OR (recipient_kind = 'partner_unit'
AND recipient_partner_unit_id IS NOT NULL
AND recipient_user_id IS NULL
AND recipient_office IS NULL
AND recipient_project_id IS NULL)
OR (recipient_kind = 'project'
AND recipient_project_id IS NOT NULL
AND recipient_user_id IS NULL
AND recipient_office IS NULL
AND recipient_partner_unit_id IS NULL)
)
);
-- Hot-path lookup for the visibility predicate.
CREATE INDEX IF NOT EXISTS checklist_shares_lookup_idx
ON paliad.checklist_shares (checklist_id);
-- Uniqueness per recipient kind. Partial indexes so a NULL recipient_<other>
-- doesn't collide with another row's NULL recipient_<other>.
CREATE UNIQUE INDEX IF NOT EXISTS checklist_shares_user_uniq
ON paliad.checklist_shares (checklist_id, recipient_user_id)
WHERE recipient_kind = 'user';
CREATE UNIQUE INDEX IF NOT EXISTS checklist_shares_office_uniq
ON paliad.checklist_shares (checklist_id, recipient_office)
WHERE recipient_kind = 'office';
CREATE UNIQUE INDEX IF NOT EXISTS checklist_shares_partner_unit_uniq
ON paliad.checklist_shares (checklist_id, recipient_partner_unit_id)
WHERE recipient_kind = 'partner_unit';
CREATE UNIQUE INDEX IF NOT EXISTS checklist_shares_project_uniq
ON paliad.checklist_shares (checklist_id, recipient_project_id)
WHERE recipient_kind = 'project';
COMMENT ON TABLE paliad.checklist_shares IS
'Explicit grants for paliad.checklists. Polymorphic recipient '
'(user/office/partner_unit/project) enforced by recipient_xor CHECK. '
'Owner of the checklist grants and revokes; global_admin can revoke '
'as well. Slice B (t-paliad-225) — see can_see_checklist body for '
'the visibility branches that consume these rows.';
ALTER TABLE paliad.checklist_shares ENABLE ROW LEVEL SECURITY;
-- SELECT: caller can see the row if they own the parent checklist OR
-- they are the recipient (for user-kind grants — recipients shouldn't
-- be surprised by who else can also see the checklist) OR global_admin.
DROP POLICY IF EXISTS checklist_shares_select ON paliad.checklist_shares;
CREATE POLICY checklist_shares_select
ON paliad.checklist_shares FOR SELECT TO authenticated
USING (
EXISTS (
SELECT 1 FROM paliad.checklists c
WHERE c.id = checklist_id AND c.owner_id = auth.uid()
)
OR (recipient_kind = 'user' AND recipient_user_id = auth.uid())
OR EXISTS (
SELECT 1 FROM paliad.users u
WHERE u.id = auth.uid() AND u.global_role = 'global_admin'
)
);
-- INSERT: only the checklist owner can grant; granted_by must be self.
DROP POLICY IF EXISTS checklist_shares_insert ON paliad.checklist_shares;
CREATE POLICY checklist_shares_insert
ON paliad.checklist_shares FOR INSERT TO authenticated
WITH CHECK (
EXISTS (
SELECT 1 FROM paliad.checklists c
WHERE c.id = checklist_id AND c.owner_id = auth.uid()
)
AND granted_by = auth.uid()
);
-- DELETE: owner OR global_admin. No UPDATE policy — grants are
-- immutable, revoke = DELETE + re-insert with the corrected recipient.
DROP POLICY IF EXISTS checklist_shares_delete ON paliad.checklist_shares;
CREATE POLICY checklist_shares_delete
ON paliad.checklist_shares FOR DELETE TO authenticated
USING (
EXISTS (
SELECT 1 FROM paliad.checklists c
WHERE c.id = checklist_id AND c.owner_id = auth.uid()
)
OR EXISTS (
SELECT 1 FROM paliad.users u
WHERE u.id = auth.uid() AND u.global_role = 'global_admin'
)
);
-- ============================================================================
-- 2. paliad.can_see_checklist — extend with the 4 share branches.
--
-- Owner + firm/global branches stay as in mig 114. Share branches:
-- - user — the row's recipient_user_id matches the caller
-- - office — recipient_office matches caller's office OR is in
-- their additional_offices array
-- - partner_unit — caller is a member of the recipient partner_unit
-- - project — caller can see the recipient project (reuses
-- paliad.can_see_project, ltree-walked)
--
-- can_see_project reads auth.uid() through SECURITY DEFINER inheritance
-- (same pattern effective_project_admin uses in mig 111).
-- ============================================================================
CREATE OR REPLACE FUNCTION paliad.can_see_checklist(_user_id uuid, _checklist_id uuid)
RETURNS boolean
LANGUAGE sql STABLE SECURITY DEFINER
SET search_path TO 'paliad', 'public'
AS $$
-- Owner
SELECT EXISTS (
SELECT 1 FROM paliad.checklists c
WHERE c.id = _checklist_id AND c.owner_id = _user_id
)
-- firm / global
OR EXISTS (
SELECT 1 FROM paliad.checklists c
WHERE c.id = _checklist_id AND c.visibility IN ('firm', 'global')
)
-- Explicit share: user
OR EXISTS (
SELECT 1 FROM paliad.checklist_shares s
WHERE s.checklist_id = _checklist_id
AND s.recipient_kind = 'user'
AND s.recipient_user_id = _user_id
)
-- Explicit share: office (caller's primary OR additional offices)
OR EXISTS (
SELECT 1
FROM paliad.checklist_shares s
JOIN paliad.users u ON u.id = _user_id
WHERE s.checklist_id = _checklist_id
AND s.recipient_kind = 'office'
AND (s.recipient_office = u.office
OR s.recipient_office = ANY(u.additional_offices))
)
-- Explicit share: partner_unit (caller is a member)
OR EXISTS (
SELECT 1
FROM paliad.checklist_shares s
JOIN paliad.partner_unit_members pum
ON pum.partner_unit_id = s.recipient_partner_unit_id
AND pum.user_id = _user_id
WHERE s.checklist_id = _checklist_id
AND s.recipient_kind = 'partner_unit'
)
-- Explicit share: project (caller can see the project via existing predicate)
OR EXISTS (
SELECT 1 FROM paliad.checklist_shares s
WHERE s.checklist_id = _checklist_id
AND s.recipient_kind = 'project'
AND paliad.can_see_project(s.recipient_project_id)
);
$$;
COMMENT ON FUNCTION paliad.can_see_checklist(uuid, uuid) IS
'True iff the user owns the checklist OR firm/global visibility OR '
'an explicit share row matches the caller (by user / office / '
'partner_unit / project ancestry).';

View File

@@ -0,0 +1,7 @@
-- Reverse of mig 116 — t-paliad-225 / m/paliad#61 Slice C.
ALTER TABLE paliad.checklist_instances
DROP COLUMN IF EXISTS template_version;
ALTER TABLE paliad.checklists
DROP COLUMN IF EXISTS version;

View File

@@ -0,0 +1,39 @@
-- mig 116 — t-paliad-225 / m/paliad#61 Slice C — template versioning.
--
-- Design: docs/design-user-checklists-2026-05-20.md §3.4 / §6.
--
-- Adds an integer version counter to paliad.checklists that bumps on
-- every meaningful edit (body or title — see
-- ChecklistTemplateService.Update). Adds a matching template_version
-- column on paliad.checklist_instances so the instance detail page can
-- surface "the template you instantiated from has been updated" and
-- offer a diff view.
--
-- Existing rows backfill to version=1 / template_version=NULL. The
-- NULL on instances means "we don't know which version was snapshotted"
-- (pre-Slice-C row); the snapshot column is still authoritative for
-- rendering, but the "outdated" badge stays off because we can't
-- compare.
--
-- Idempotent throughout.
ALTER TABLE paliad.checklists
ADD COLUMN IF NOT EXISTS version int NOT NULL DEFAULT 1;
-- Backfill any rows that somehow ended up at 0 (shouldn't happen with
-- DEFAULT 1, but defensive — the column was added with default so this
-- is a no-op on the live DB).
UPDATE paliad.checklists SET version = 1 WHERE version < 1;
COMMENT ON COLUMN paliad.checklists.version IS
'Monotonic version counter, bumps in ChecklistTemplateService.Update '
'whenever body or title changes. Used by the instance detail page '
'to show an "outdated" badge when the user''s snapshot is older.';
ALTER TABLE paliad.checklist_instances
ADD COLUMN IF NOT EXISTS template_version int;
COMMENT ON COLUMN paliad.checklist_instances.template_version IS
'Snapshot of paliad.checklists.version at instance create time. '
'NULL for pre-Slice-C rows where the version wasn''t captured; the '
'"outdated" badge stays off in that case.';

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

@@ -44,6 +44,78 @@ func handleAdminListUnonboarded(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, rows)
}
// POST /api/admin/users/full — create BOTH an auth.users row (via Supabase
// Admin API) and a paliad.users row in one operation. t-paliad-223 Slice B
// (#49). Lets a global_admin onboard a colleague without forcing them
// through the email-invitation round-trip; the new user is visible in
// dropdowns immediately and can log in via the emailed magic-link.
//
// Requires SUPABASE_SERVICE_ROLE_KEY at the server. Returns 503 when
// unset so a deploy that hasn't provisioned the credential yet gets a
// clear diagnostic instead of a cryptic 500.
//
// Error mapping:
// - ErrSupabaseAdminUnavailable → 503
// - ErrSupabaseEmailExists → 409 (hint to use "Onboard existing")
// - ErrUserAlreadyOnboarded → 409 (paliad.users dup; should be unreachable)
// - ErrInvalidInput → 400 (bad shape)
// - email domain not on whitelist → 403 (mirrors handleAdminCreateUser)
// - other → 500
func handleAdminCreateFullUser(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
var input services.AdminCreateFullInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
if !isAllowedEmailDomain(input.Email) {
writeJSON(w, http.StatusForbidden, map[string]string{
"error": "email domain not on the " + branding.Name + " allow-list",
})
return
}
// Look up the inviter (the calling admin) so the welcome email and
// audit row carry their identity. Failures here shouldn't block the
// create; we just degrade to empty fields.
inviter, err := dbSvc.users.GetByID(r.Context(), uid)
if err == nil && inviter != nil {
input.InviterID = inviter.ID
input.InviterName = inviter.DisplayName
input.InviterEmail = inviter.Email
}
u, err := dbSvc.users.AdminCreateUserFull(r.Context(), input)
if err != nil {
switch {
case errors.Is(err, services.ErrSupabaseAdminUnavailable):
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
"error": "add-user flow requires SUPABASE_SERVICE_ROLE_KEY on the server",
})
case errors.Is(err, services.ErrSupabaseEmailExists):
writeJSON(w, http.StatusConflict, map[string]string{
"error": "auth account already exists — please use 'Onboard existing' instead",
})
case errors.Is(err, services.ErrUserAlreadyOnboarded):
writeJSON(w, http.StatusConflict, map[string]string{
"error": "user already onboarded",
})
case errors.Is(err, services.ErrInvalidInput):
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
default:
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
}
return
}
writeJSON(w, http.StatusCreated, u)
}
// POST /api/admin/users — direct-create a paliad.users row for an existing
// auth.users entry. The recipient email's domain must already match the
// allowed-email policy (Supabase wouldn't have let them sign up otherwise),

View File

@@ -24,8 +24,13 @@ func handleAppointmentsDetailPage(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "dist/appointments-detail.html")
}
// handleAppointmentsCalendarPage 301-redirects the legacy standalone
// calendar route to the canonical /events Kalender tab (t-paliad-224 /
// m/paliad#55). Counterpart of handleDeadlinesCalendarPage — same
// reasoning: the standalone page was orphaned in navigation since
// t-paliad-110, the canonical calendar lives inside /events.
func handleAppointmentsCalendarPage(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "dist/appointments-calendar.html")
http.Redirect(w, r, "/events?type=appointment&view=calendar", http.StatusMovedPermanently)
}
// handleSettingsPage serves the unified settings page with tabs for

View File

@@ -0,0 +1,131 @@
package handlers
import (
"encoding/json"
"errors"
"net/http"
"github.com/google/uuid"
"mgit.msbls.de/m/paliad/internal/services"
)
// GET /api/checklists/templates/{slug}/shares — list grants (owner/admin).
func handleListChecklistShares(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
slug := r.PathValue("slug")
rows, err := dbSvc.checklistShare.ListGrants(r.Context(), uid, slug)
if err != nil {
writeChecklistShareError(w, err)
return
}
writeJSON(w, http.StatusOK, rows)
}
// POST /api/checklists/templates/{slug}/shares — grant a share.
func handleGrantChecklistShare(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
slug := r.PathValue("slug")
var input services.ShareGrantInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
share, err := dbSvc.checklistShare.Grant(r.Context(), uid, slug, input)
if err != nil {
writeChecklistShareError(w, err)
return
}
writeJSON(w, http.StatusCreated, share)
}
// DELETE /api/checklists/shares/{id} — revoke a share by id.
func handleRevokeChecklistShare(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
return
}
if err := dbSvc.checklistShare.Revoke(r.Context(), uid, id); err != nil {
writeChecklistShareError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// POST /api/admin/checklists/{slug}/promote — global_admin only.
func handlePromoteChecklist(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
slug := r.PathValue("slug")
if err := dbSvc.checklistPromotion.Promote(r.Context(), uid, slug); err != nil {
writeChecklistShareError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// POST /api/admin/checklists/{slug}/demote — global_admin only.
func handleDemoteChecklist(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
slug := r.PathValue("slug")
var body struct {
Target string `json:"target"`
}
// Body is optional — Demote defaults to 'firm' when empty.
_ = json.NewDecoder(r.Body).Decode(&body)
if err := dbSvc.checklistPromotion.Demote(r.Context(), uid, slug, body.Target); err != nil {
writeChecklistShareError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// writeChecklistShareError maps the share/promotion service errors.
// Same as the templates handler: ErrInvalidInput → 400, ErrForbidden →
// 403, ErrNotVisible → 404, fall through to writeServiceError.
func writeChecklistShareError(w http.ResponseWriter, err error) {
if errors.Is(err, services.ErrInvalidInput) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
if errors.Is(err, services.ErrForbidden) {
writeJSON(w, http.StatusForbidden, map[string]string{"error": err.Error()})
return
}
if errors.Is(err, services.ErrNotVisible) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "checklist not found"})
return
}
writeServiceError(w, err)
}

View File

@@ -0,0 +1,133 @@
package handlers
import (
"encoding/json"
"errors"
"net/http"
"strings"
"mgit.msbls.de/m/paliad/internal/services"
)
// GET /api/checklists/templates/mine — list authored templates owned by caller.
func handleListMyChecklistTemplates(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
rows, err := dbSvc.checklistTemplate.ListOwnedBy(r.Context(), uid)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, rows)
}
// POST /api/checklists/templates — create a new authored template.
func handleCreateChecklistTemplate(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
var input services.CreateTemplateInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
t, err := dbSvc.checklistTemplate.Create(r.Context(), uid, input)
if err != nil {
writeChecklistTemplateError(w, err)
return
}
writeJSON(w, http.StatusCreated, t)
}
// PATCH /api/checklists/templates/{slug} — update authored template (owner only).
func handleUpdateChecklistTemplate(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
slug := r.PathValue("slug")
var input services.UpdateTemplateInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
t, err := dbSvc.checklistTemplate.Update(r.Context(), uid, slug, input)
if err != nil {
writeChecklistTemplateError(w, err)
return
}
writeJSON(w, http.StatusOK, t)
}
// PATCH /api/checklists/templates/{slug}/visibility — toggle private↔firm.
func handleSetChecklistTemplateVisibility(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
slug := r.PathValue("slug")
var body struct {
Visibility string `json:"visibility"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
t, err := dbSvc.checklistTemplate.SetVisibility(r.Context(), uid, slug, body.Visibility)
if err != nil {
writeChecklistTemplateError(w, err)
return
}
writeJSON(w, http.StatusOK, t)
}
// DELETE /api/checklists/templates/{slug} — delete authored template.
func handleDeleteChecklistTemplate(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
slug := r.PathValue("slug")
if err := dbSvc.checklistTemplate.Delete(r.Context(), uid, slug); err != nil {
writeChecklistTemplateError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// writeChecklistTemplateError maps service errors to HTTP status. Falls
// through to writeServiceError for unknown errors so the generic
// ErrNotVisible / ErrInvalidInput / ErrForbidden mappings still apply.
func writeChecklistTemplateError(w http.ResponseWriter, err error) {
if errors.Is(err, services.ErrInvalidInput) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": strings.TrimPrefix(err.Error(), "invalid input: ")})
return
}
if errors.Is(err, services.ErrForbidden) {
writeJSON(w, http.StatusForbidden, map[string]string{"error": err.Error()})
return
}
if errors.Is(err, services.ErrNotVisible) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "checklist not found"})
return
}
writeServiceError(w, err)
}

View File

@@ -24,6 +24,13 @@ func handleChecklistsPage(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "dist/checklists.html")
}
// handleChecklistsAuthorPage serves the authoring wizard (new + edit
// share the same bundle; the client reads location.pathname to decide
// create vs edit mode).
func handleChecklistsAuthorPage(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "dist/checklists-author.html")
}
func handleChecklistDetailPage(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
if _, ok := checklists.Find(slug); !ok {
@@ -37,18 +44,105 @@ func handleChecklistInstancePage(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "dist/checklists-instance.html")
}
// handleChecklistsAPI returns the merged catalog: static templates
// (always) plus authored DB templates the caller can see (mig 114).
// Each entry carries origin + visibility + author metadata so the
// frontend can render provenance.
//
// Falls back to the bare static catalog when DB is unavailable so the
// knowledge-platform-only deploy stays functional without DATABASE_URL.
func handleChecklistsAPI(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, checklists.Summaries())
if dbSvc == nil || dbSvc.checklistCatalog == nil {
// Fall back to static summaries shape so the existing frontend
// keeps working in the no-DB deploy.
writeJSON(w, http.StatusOK, checklists.Summaries())
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
entries, err := dbSvc.checklistCatalog.ListVisible(r.Context(), uid)
if err != nil {
writeServiceError(w, err)
return
}
// Frontend expects the existing Summary shape on the index list; map
// the merged entries to Summary + origin/visibility/author fields.
type Summary struct {
checklists.Summary
Origin string `json:"origin"`
Visibility string `json:"visibility"`
OwnerEmail string `json:"owner_email,omitempty"`
OwnerDisplayName string `json:"owner_display_name,omitempty"`
}
out := make([]Summary, 0, len(entries))
for _, e := range entries {
out = append(out, Summary{
Summary: checklists.Summary{
Slug: e.Template.Slug,
TitleDE: e.Template.TitleDE,
TitleEN: e.Template.TitleEN,
DescriptionDE: e.Template.DescriptionDE,
DescriptionEN: e.Template.DescriptionEN,
Regime: e.Template.Regime,
CourtDE: e.Template.CourtDE,
CourtEN: e.Template.CourtEN,
ItemCount: checklists.TotalItems(e.Template),
},
Origin: e.Origin,
Visibility: e.Visibility,
OwnerEmail: e.OwnerEmail,
OwnerDisplayName: e.OwnerDisplayName,
})
}
writeJSON(w, http.StatusOK, out)
}
// handleChecklistAPI returns one template by slug. Looks up static
// catalog first (always visible), then authored DB rows via the
// catalog with visibility check.
func handleChecklistAPI(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
c, ok := checklists.Find(slug)
if !ok {
// Static-first path keeps the no-DB deploy functional and is the
// common case for the curated templates.
if c, ok := checklists.Find(slug); ok {
writeJSON(w, http.StatusOK, c)
return
}
if dbSvc == nil || dbSvc.checklistCatalog == nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "Checkliste nicht gefunden."})
return
}
writeJSON(w, http.StatusOK, c)
uid, ok := requireUser(w, r)
if !ok {
return
}
entry, err := dbSvc.checklistCatalog.Find(r.Context(), uid, slug)
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "Checkliste nicht gefunden."})
return
}
// Re-render as the bilingual Template shape plus a thin meta block.
// Version is included so the instance detail page can decide whether
// to show the "template updated since this instance was created"
// badge (Slice C).
type templateWithMeta struct {
checklists.Template
Origin string `json:"origin"`
Visibility string `json:"visibility"`
OwnerEmail string `json:"owner_email,omitempty"`
OwnerDisplayName string `json:"owner_display_name,omitempty"`
Version int `json:"version"`
}
writeJSON(w, http.StatusOK, templateWithMeta{
Template: entry.Template,
Origin: entry.Origin,
Visibility: entry.Visibility,
OwnerEmail: entry.OwnerEmail,
OwnerDisplayName: entry.OwnerDisplayName,
Version: entry.Version,
})
}
func handleChecklistsFeedback(w http.ResponseWriter, r *http.Request) {

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) {
@@ -23,6 +30,13 @@ func handleDeadlinesDetailPage(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "dist/deadlines-detail.html")
}
// handleDeadlinesCalendarPage 301-redirects the legacy standalone
// calendar route to the canonical /events Kalender tab (t-paliad-224 /
// m/paliad#55). The standalone page was orphaned in navigation since
// t-paliad-110 — Sidebar/BottomNav already point at /events?type=…, and
// the canonical calendar lives inside that page's view chip. The
// redirect preserves bookmarks and external links without a duplicate
// rendering pipeline.
func handleDeadlinesCalendarPage(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "dist/deadlines-calendar.html")
http.Redirect(w, r, "/events?type=deadline&view=calendar", http.StatusMovedPermanently)
}

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

@@ -70,7 +70,11 @@ type Services struct {
EventType *services.EventTypeService
Dashboard *services.DashboardService
Note *services.NoteService
ChecklistInst *services.ChecklistInstanceService
ChecklistInst *services.ChecklistInstanceService
ChecklistCatalog *services.ChecklistCatalogService
ChecklistTemplate *services.ChecklistTemplateService
ChecklistShare *services.ChecklistShareService
ChecklistPromotion *services.ChecklistPromotionService
Mail *services.MailService
Invite *services.InviteService
Agenda *services.AgendaService
@@ -86,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
@@ -114,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,
@@ -144,7 +136,11 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
eventType: svc.EventType,
dashboard: svc.Dashboard,
note: svc.Note,
checklistInst: svc.ChecklistInst,
checklistInst: svc.ChecklistInst,
checklistCatalog: svc.ChecklistCatalog,
checklistTemplate: svc.ChecklistTemplate,
checklistShare: svc.ChecklistShare,
checklistPromotion: svc.ChecklistPromotion,
mail: svc.Mail,
invite: svc.Invite,
agenda: svc.Agenda,
@@ -160,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,
}
@@ -248,11 +245,25 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
protected.HandleFunc("GET /api/tools/gebuehrentabellen/lookup", handleGebuehrentabellenLookup)
protected.HandleFunc("POST /api/tools/gebuehrentabellen/feedback", handleGebuehrentabellenFeedback)
protected.HandleFunc("GET /checklists", handleChecklistsPage)
protected.HandleFunc("GET /checklists/new", handleChecklistsAuthorPage)
protected.HandleFunc("GET /checklists/instances/{id}", handleChecklistInstancePage)
protected.HandleFunc("GET /checklists/templates/{slug}/edit", handleChecklistsAuthorPage)
protected.HandleFunc("GET /checklists/{slug}", handleChecklistDetailPage)
protected.HandleFunc("GET /api/checklists", handleChecklistsAPI)
protected.HandleFunc("GET /api/checklists/{slug}", handleChecklistAPI)
protected.HandleFunc("POST /api/checklists/feedback", handleChecklistsFeedback)
// t-paliad-225 Slice A — user-authored templates (paliad.checklists).
protected.HandleFunc("GET /api/checklists/templates/mine", handleListMyChecklistTemplates)
protected.HandleFunc("POST /api/checklists/templates", handleCreateChecklistTemplate)
protected.HandleFunc("PATCH /api/checklists/templates/{slug}", handleUpdateChecklistTemplate)
protected.HandleFunc("PATCH /api/checklists/templates/{slug}/visibility", handleSetChecklistTemplateVisibility)
protected.HandleFunc("DELETE /api/checklists/templates/{slug}", handleDeleteChecklistTemplate)
// t-paliad-225 Slice B — explicit sharing + admin promotion.
protected.HandleFunc("GET /api/checklists/templates/{slug}/shares", handleListChecklistShares)
protected.HandleFunc("POST /api/checklists/templates/{slug}/shares", handleGrantChecklistShare)
protected.HandleFunc("DELETE /api/checklists/shares/{id}", handleRevokeChecklistShare)
protected.HandleFunc("POST /api/admin/checklists/{slug}/promote", handlePromoteChecklist)
protected.HandleFunc("POST /api/admin/checklists/{slug}/demote", handleDemoteChecklist)
protected.HandleFunc("GET /api/checklists/{slug}/instances", handleListChecklistInstancesForTemplate)
protected.HandleFunc("POST /api/checklists/{slug}/instances", handleCreateChecklistInstance)
protected.HandleFunc("GET /api/checklist-instances", handleListAllChecklistInstances)
@@ -295,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)
@@ -509,10 +522,17 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
protected.HandleFunc("GET /admin/event-types", adminGate(users, gateOnboarded(handleAdminEventTypesPage)))
protected.HandleFunc("GET /api/admin/users", adminGate(users, handleAdminListUsers))
protected.HandleFunc("POST /api/admin/users", adminGate(users, handleAdminCreateUser))
protected.HandleFunc("POST /api/admin/users/full", adminGate(users, handleAdminCreateFullUser))
protected.HandleFunc("GET /api/admin/users/unonboarded", adminGate(users, handleAdminListUnonboarded))
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))

View File

@@ -38,7 +38,11 @@ type dbServices struct {
eventType *services.EventTypeService
dashboard *services.DashboardService
note *services.NoteService
checklistInst *services.ChecklistInstanceService
checklistInst *services.ChecklistInstanceService
checklistCatalog *services.ChecklistCatalogService
checklistTemplate *services.ChecklistTemplateService
checklistShare *services.ChecklistShareService
checklistPromotion *services.ChecklistPromotionService
mail *services.MailService
invite *services.InviteService
agenda *services.AgendaService
@@ -54,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

@@ -32,3 +32,54 @@ func TestRegisterLegacyRedirects_SubProjectsAlias(t *testing.T) {
}
}
}
// t-paliad-224: /deadlines/calendar and /appointments/calendar 301 to
// the canonical /events Kalender tab. Pins the redirect target so a
// future refactor doesn't silently break the bookmark contract.
func TestStandaloneCalendarHandlers_RedirectToEventsKalender(t *testing.T) {
cases := []struct {
name string
handler http.HandlerFunc
want string
}{
{"deadlines", handleDeadlinesCalendarPage, "/events?type=deadline&view=calendar"},
{"appointments", handleAppointmentsCalendarPage, "/events?type=appointment&view=calendar"},
}
for _, tc := range cases {
req := httptest.NewRequest(http.MethodGet, "/x", nil) // path ignored — handler is direct
w := httptest.NewRecorder()
tc.handler(w, req)
if w.Code != http.StatusMovedPermanently {
t.Fatalf("%s: status = %d, want %d", tc.name, w.Code, http.StatusMovedPermanently)
}
if got := w.Header().Get("Location"); got != tc.want {
t.Fatalf("%s: Location = %q, want %q", tc.name, got, tc.want)
}
}
}
// /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

@@ -421,22 +421,32 @@ type Note struct {
AuthorEmail *string `db:"author_email" json:"author_email,omitempty"`
}
// ChecklistInstance is one user's instantiation of a static checklist
// template (defined in internal/checklists). Checkbox state lives in the
// `state` jsonb column.
// ChecklistInstance is one user's instantiation of a checklist template
// (static catalog in internal/checklists OR authored row in
// paliad.checklists). Checkbox state lives in the `state` jsonb column.
//
// Visibility mirrors Appointment: project_id nullable. Personal instances
// (project_id NULL) are creator-only; Project-linked instances follow
// paliad.can_see_project.
//
// TemplateSnapshot captures the template body at instance create time so
// subsequent template edits / visibility narrowing don't affect existing
// instances (t-paliad-225 Slice A). NULL on pre-mig-114 rows; the
// service layer falls back to live catalog lookup in that case.
type ChecklistInstance struct {
ID uuid.UUID `db:"id" json:"id"`
TemplateSlug string `db:"template_slug" json:"template_slug"`
Name string `db:"name" json:"name"`
ProjectID *uuid.UUID `db:"project_id" json:"project_id,omitempty"`
State json.RawMessage `db:"state" json:"state"`
CreatedBy uuid.UUID `db:"created_by" json:"created_by"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
ID uuid.UUID `db:"id" json:"id"`
TemplateSlug string `db:"template_slug" json:"template_slug"`
Name string `db:"name" json:"name"`
ProjectID *uuid.UUID `db:"project_id" json:"project_id,omitempty"`
State json.RawMessage `db:"state" json:"state"`
CreatedBy uuid.UUID `db:"created_by" json:"created_by"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
TemplateSnapshot NullableJSON `db:"template_snapshot" json:"template_snapshot,omitempty"`
// TemplateVersion is the checklists.version at instance create time.
// NULL on pre-Slice-C rows where versioning wasn't captured; the
// "outdated" badge stays off in that case.
TemplateVersion *int `db:"template_version" json:"template_version,omitempty"`
}
// ChecklistInstanceWithProject enriches an instance with its parent Project
@@ -447,6 +457,37 @@ type ChecklistInstanceWithProject struct {
ProjectTitle *string `db:"project_title" json:"project_title,omitempty"`
}
// Checklist is one authored template row in paliad.checklists. Augments
// the static Go catalog (internal/checklists/templates.go) at read time
// via ChecklistCatalogService. Body holds the groups + items as JSONB.
type Checklist struct {
ID uuid.UUID `db:"id" json:"id"`
Slug string `db:"slug" json:"slug"`
OwnerID uuid.UUID `db:"owner_id" json:"owner_id"`
Title string `db:"title" json:"title"`
Description string `db:"description" json:"description"`
Regime string `db:"regime" json:"regime"`
Court string `db:"court" json:"court"`
Reference string `db:"reference" json:"reference"`
Deadline string `db:"deadline" json:"deadline"`
Lang string `db:"lang" json:"lang"`
Body json.RawMessage `db:"body" json:"body"`
Visibility string `db:"visibility" json:"visibility"`
PromotedAt *time.Time `db:"promoted_at" json:"promoted_at,omitempty"`
PromotedBy *uuid.UUID `db:"promoted_by" json:"promoted_by,omitempty"`
Version int `db:"version" json:"version"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
// ChecklistWithOwner enriches a Checklist row with author display fields
// for list views (Meine Vorlagen, Gallery).
type ChecklistWithOwner struct {
Checklist
OwnerEmail string `db:"owner_email" json:"owner_email"`
OwnerDisplayName string `db:"owner_display_name" json:"owner_display_name"`
}
// UserCalDAVConfig holds one user's external CalDAV connection. The password
// is never returned in API responses; only the public fields are exposed.
type UserCalDAVConfig struct {

View File

@@ -0,0 +1,309 @@
package services
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"mgit.msbls.de/m/paliad/internal/checklists"
"mgit.msbls.de/m/paliad/internal/models"
)
// ChecklistCatalogService unifies the static Go template catalog
// (internal/checklists/templates.go) and the user-authored DB catalog
// (paliad.checklists, mig 114) into a single read facade.
//
// Slug uniqueness is enforced across both spaces at write time by
// ChecklistTemplateService (authored slugs get a 'u-' prefix and we
// reject collisions with static slugs). Catalog lookups prefer static
// templates on collision so a stray DB row never shadows curated
// content — see Find().
type ChecklistCatalogService struct {
db *sqlx.DB
staticSlugs map[string]bool
}
// NewChecklistCatalogService wires the service and pre-computes the
// static-slug set used for collision detection at write + read time.
func NewChecklistCatalogService(db *sqlx.DB) *ChecklistCatalogService {
set := make(map[string]bool, len(checklists.Templates))
for _, t := range checklists.Templates {
set[t.Slug] = true
}
return &ChecklistCatalogService{db: db, staticSlugs: set}
}
// CatalogEntry is one unified entry — either a static template or an
// authored DB row. Origin identifies the source so the UI can render
// provenance ("Erstellt von <author>" for authored, plain title for
// static).
type CatalogEntry struct {
Slug string `json:"slug"`
Origin string `json:"origin"` // "static" | "authored"
Visibility string `json:"visibility"`
OwnerID *uuid.UUID `json:"owner_id,omitempty"`
OwnerEmail string `json:"owner_email,omitempty"`
OwnerDisplayName string `json:"owner_display_name,omitempty"`
// Version of the underlying row. 1 for static templates (they
// re-version implicitly with the deploy that ships them); the live
// counter from paliad.checklists.version for authored rows.
Version int `json:"version"`
Template checklists.Template `json:"template"`
}
// IsStaticSlug reports whether the given slug names a curated static
// template. Called by ChecklistTemplateService.Create to reject author
// slugs that would shadow a curated entry.
func (s *ChecklistCatalogService) IsStaticSlug(slug string) bool {
return s.staticSlugs[slug]
}
// ListVisible returns every catalog entry the caller can see — every
// static template (always visible) plus every authored DB row that
// passes paliad.can_see_checklist via RLS.
//
// Ordering: static templates first in their definition order, then
// authored rows alphabetised by title.
func (s *ChecklistCatalogService) ListVisible(ctx context.Context, userID uuid.UUID) ([]CatalogEntry, error) {
out := make([]CatalogEntry, 0, len(checklists.Templates))
for _, t := range checklists.Templates {
out = append(out, CatalogEntry{
Slug: t.Slug,
Origin: "static",
Visibility: "static",
Version: 1,
Template: t,
})
}
if s.db == nil {
return out, nil
}
rows, err := s.fetchVisibleAuthored(ctx, userID)
if err != nil {
return nil, err
}
sort.SliceStable(rows, func(i, j int) bool {
return strings.ToLower(rows[i].Title) < strings.ToLower(rows[j].Title)
})
for _, r := range rows {
// Skip the row if it collides with a static slug — static wins.
if s.staticSlugs[r.Slug] {
continue
}
tpl, err := s.rowToTemplate(r)
if err != nil {
return nil, err
}
ownerID := r.OwnerID
out = append(out, CatalogEntry{
Slug: r.Slug,
Origin: "authored",
Visibility: r.Visibility,
OwnerID: &ownerID,
OwnerEmail: r.OwnerEmail,
OwnerDisplayName: r.OwnerDisplayName,
Version: r.Version,
Template: tpl,
})
}
return out, nil
}
// Find resolves a slug to a single catalog entry, applying visibility
// (RLS for authored rows; static always visible). Returns ErrNotVisible
// if the slug is unknown or the caller can't see the authored row.
func (s *ChecklistCatalogService) Find(ctx context.Context, userID uuid.UUID, slug string) (*CatalogEntry, error) {
if t, ok := checklists.Find(slug); ok {
return &CatalogEntry{
Slug: t.Slug,
Origin: "static",
Visibility: "static",
Version: 1,
Template: t,
}, nil
}
if s.db == nil {
return nil, ErrNotVisible
}
row, err := s.fetchAuthoredBySlug(ctx, userID, slug)
if err != nil {
return nil, err
}
if row == nil {
return nil, ErrNotVisible
}
tpl, err := s.rowToTemplate(*row)
if err != nil {
return nil, err
}
ownerID := row.OwnerID
return &CatalogEntry{
Slug: row.Slug,
Origin: "authored",
Visibility: row.Visibility,
OwnerID: &ownerID,
OwnerEmail: row.OwnerEmail,
OwnerDisplayName: row.OwnerDisplayName,
Version: row.Version,
Template: tpl,
}, nil
}
// SnapshotBody returns the template body as JSONB suitable for storing
// in paliad.checklist_instances.template_snapshot. For static templates
// we marshal the full Template struct; for authored rows we return the
// body column directly (it already has the right shape — groups[]).
func (s *ChecklistCatalogService) SnapshotBody(ctx context.Context, userID uuid.UUID, slug string) (json.RawMessage, error) {
entry, err := s.Find(ctx, userID, slug)
if err != nil {
return nil, err
}
body, err := json.Marshal(entry.Template)
if err != nil {
return nil, fmt.Errorf("snapshot marshal: %w", err)
}
return body, nil
}
// --- internals ------------------------------------------------------------
const authoredWithOwnerSelect = `SELECT c.id, c.slug, c.owner_id, c.title, c.description,
c.regime, c.court, c.reference, c.deadline, c.lang, c.body, c.visibility,
c.promoted_at, c.promoted_by, c.version, c.created_at, c.updated_at,
u.email AS owner_email,
u.display_name AS owner_display_name
FROM paliad.checklists c
JOIN paliad.users u ON u.id = c.owner_id`
// checklistVisibilityPredicate mirrors paliad.can_see_checklist for the
// service-role connection (which bypasses RLS). Covers all 6 branches
// from mig 115: owner + firm/global + global_admin + 4 share-recipient
// kinds (user / office / partner_unit / project).
//
// One positional arg ($userArg) for the caller UUID. Reused several
// times across the branches; that's fine — Postgres positional
// placeholders evaluate the arg once per reference, no extra param
// binding overhead.
func checklistVisibilityPredicate(alias string, userArg int) string {
return fmt.Sprintf(`(%s.owner_id = $%d
OR %s.visibility IN ('firm', 'global')
OR EXISTS (
SELECT 1 FROM paliad.users u
WHERE u.id = $%d AND u.global_role = 'global_admin'
)
OR EXISTS (
SELECT 1 FROM paliad.checklist_shares s
WHERE s.checklist_id = %s.id
AND s.recipient_kind = 'user'
AND s.recipient_user_id = $%d
)
OR EXISTS (
SELECT 1
FROM paliad.checklist_shares s
JOIN paliad.users u ON u.id = $%d
WHERE s.checklist_id = %s.id
AND s.recipient_kind = 'office'
AND (s.recipient_office = u.office
OR s.recipient_office = ANY(u.additional_offices))
)
OR EXISTS (
SELECT 1
FROM paliad.checklist_shares s
JOIN paliad.partner_unit_members pum
ON pum.partner_unit_id = s.recipient_partner_unit_id
AND pum.user_id = $%d
WHERE s.checklist_id = %s.id
AND s.recipient_kind = 'partner_unit'
)
OR EXISTS (
-- Share-to-project resolution: inline ltree walk over
-- paliad.projects.path because paliad.can_see_project
-- reads auth.uid() which is NULL on the service-role
-- connection (same pattern as visibility.go).
SELECT 1
FROM paliad.checklist_shares s
JOIN paliad.projects p
ON p.id = s.recipient_project_id
JOIN paliad.project_teams pt
ON pt.user_id = $%d
AND pt.project_id = ANY(CAST(string_to_array(p.path, '.') AS uuid[]))
WHERE s.checklist_id = %s.id
AND s.recipient_kind = 'project'
))`,
alias, userArg, // owner
alias, // firm/global visibility col
userArg, // global_admin
alias, userArg, // share: user
userArg, alias, // share: office
userArg, alias, // share: partner_unit
userArg, alias, // share: project (ltree walk)
)
}
func (s *ChecklistCatalogService) fetchVisibleAuthored(ctx context.Context, userID uuid.UUID) ([]models.ChecklistWithOwner, error) {
rows := []models.ChecklistWithOwner{}
q := authoredWithOwnerSelect + `
WHERE ` + checklistVisibilityPredicate("c", 1) + `
ORDER BY c.title ASC`
if err := s.db.SelectContext(ctx, &rows, q, userID); err != nil {
return nil, fmt.Errorf("list authored checklists: %w", err)
}
return rows, nil
}
func (s *ChecklistCatalogService) fetchAuthoredBySlug(ctx context.Context, userID uuid.UUID, slug string) (*models.ChecklistWithOwner, error) {
var row models.ChecklistWithOwner
q := authoredWithOwnerSelect + `
WHERE c.slug = $2
AND ` + checklistVisibilityPredicate("c", 1)
err := s.db.GetContext(ctx, &row, q, userID, slug)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("fetch authored checklist: %w", err)
}
return &row, nil
}
func (s *ChecklistCatalogService) rowToTemplate(row models.ChecklistWithOwner) (checklists.Template, error) {
// body jsonb holds { "groups": [...] }. Unmarshal into a thin local
// shape because the full checklists.Template has DE/EN sibling
// fields the author only fills one side of.
var bodyShape struct {
Groups []checklists.Group `json:"groups"`
}
if err := json.Unmarshal(row.Body, &bodyShape); err != nil {
return checklists.Template{}, fmt.Errorf("unmarshal authored body for %s: %w", row.Slug, err)
}
t := checklists.Template{
Slug: row.Slug,
Regime: row.Regime,
Groups: bodyShape.Groups,
ReferenceDE: row.Reference,
ReferenceEN: row.Reference,
DeadlineDE: row.Deadline,
DeadlineEN: row.Deadline,
CourtDE: row.Court,
CourtEN: row.Court,
}
// Author picks one language per template — surface their title /
// description on both sides so the existing bilingual frontend
// renders without a special-case for authored entries.
t.TitleDE = row.Title
t.TitleEN = row.Title
t.DescriptionDE = row.Description
t.DescriptionEN = row.Description
return t, nil
}

View File

@@ -12,7 +12,6 @@ import (
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"mgit.msbls.de/m/paliad/internal/checklists"
"mgit.msbls.de/m/paliad/internal/models"
)
@@ -21,17 +20,23 @@ import (
// Visibility mirrors paliad.appointments (project_id nullable):
// - project_id NULL → creator-only (personal instance)
// - project_id NOT NULL → parent Project's team-based gate
//
// Template resolution goes through ChecklistCatalogService so authored
// templates (paliad.checklists, mig 114) and static templates work
// interchangeably. Instance create captures a template_snapshot so
// subsequent template edits/deletes don't disturb existing instances.
type ChecklistInstanceService struct {
db *sqlx.DB
projects *ProjectService
catalog *ChecklistCatalogService
}
func NewChecklistInstanceService(db *sqlx.DB, projects *ProjectService) *ChecklistInstanceService {
return &ChecklistInstanceService{db: db, projects: projects}
func NewChecklistInstanceService(db *sqlx.DB, projects *ProjectService, catalog *ChecklistCatalogService) *ChecklistInstanceService {
return &ChecklistInstanceService{db: db, projects: projects, catalog: catalog}
}
const checklistInstanceColumns = `ci.id, ci.template_slug, ci.name, ci.project_id, ci.state,
ci.created_by, ci.created_at, ci.updated_at`
ci.created_by, ci.created_at, ci.updated_at, ci.template_snapshot, ci.template_version`
const checklistInstanceWithProjectSelect = `SELECT ` + checklistInstanceColumns + `,
p.reference AS project_reference,
@@ -55,8 +60,11 @@ type UpdateInstanceInput struct {
// ListForTemplate returns every visible instance of a given template.
func (s *ChecklistInstanceService) ListForTemplate(ctx context.Context, userID uuid.UUID, slug string) ([]models.ChecklistInstanceWithProject, error) {
if _, ok := checklists.Find(slug); !ok {
return nil, fmt.Errorf("%w: unknown template slug %q", ErrInvalidInput, slug)
if _, err := s.catalog.Find(ctx, userID, slug); err != nil {
if errors.Is(err, ErrNotVisible) {
return nil, fmt.Errorf("%w: unknown template slug %q", ErrInvalidInput, slug)
}
return nil, err
}
user, err := s.projects.Users().GetByID(ctx, userID)
if err != nil {
@@ -124,11 +132,25 @@ func (s *ChecklistInstanceService) GetByID(ctx context.Context, userID, id uuid.
return inst, nil
}
// Create inserts a new instance.
// Create inserts a new instance. Captures a template_snapshot via the
// catalog so subsequent template edits/deletes don't disturb this row
// (t-paliad-225 Slice A).
func (s *ChecklistInstanceService) Create(ctx context.Context, userID uuid.UUID, slug string, input CreateInstanceInput) (*models.ChecklistInstance, error) {
if _, ok := checklists.Find(slug); !ok {
return nil, fmt.Errorf("%w: unknown template slug %q", ErrInvalidInput, slug)
entry, err := s.catalog.Find(ctx, userID, slug)
if err != nil {
if errors.Is(err, ErrNotVisible) {
return nil, fmt.Errorf("%w: unknown template slug %q", ErrInvalidInput, slug)
}
return nil, err
}
snapshot, err := s.catalog.SnapshotBody(ctx, userID, slug)
if err != nil {
return nil, fmt.Errorf("snapshot template body: %w", err)
}
// Slice C — capture the version we snapshotted from so the instance
// detail page can show "template updated since this instance was
// created" when the live version pulls ahead.
snapshotVersion := entry.Version
name := strings.TrimSpace(input.Name)
if name == "" {
return nil, fmt.Errorf("%w: name is required", ErrInvalidInput)
@@ -153,9 +175,10 @@ func (s *ChecklistInstanceService) Create(ctx context.Context, userID uuid.UUID,
if _, err := tx.ExecContext(ctx,
`INSERT INTO paliad.checklist_instances
(id, template_slug, name, project_id, state, created_by, created_at, updated_at)
VALUES ($1, $2, $3, $4, '{}'::jsonb, $5, $6, $6)`,
id, slug, name, input.ProjectID, userID, now,
(id, template_slug, name, project_id, state, template_snapshot,
template_version, created_by, created_at, updated_at)
VALUES ($1, $2, $3, $4, '{}'::jsonb, $5::jsonb, $6, $7, $8, $8)`,
id, slug, name, input.ProjectID, string(snapshot), snapshotVersion, userID, now,
); err != nil {
return nil, fmt.Errorf("insert checklist_instance: %w", err)
}
@@ -366,7 +389,8 @@ func (s *ChecklistInstanceService) listWithProject(ctx context.Context, query st
func (s *ChecklistInstanceService) getByIDUnchecked(ctx context.Context, id uuid.UUID) (*models.ChecklistInstance, error) {
var inst models.ChecklistInstance
err := s.db.GetContext(ctx, &inst,
`SELECT id, template_slug, name, project_id, state, created_by, created_at, updated_at
`SELECT id, template_slug, name, project_id, state, created_by,
created_at, updated_at, template_snapshot, template_version
FROM paliad.checklist_instances WHERE id = $1`, id)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotVisible

View File

@@ -0,0 +1,153 @@
package services
import (
"context"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
)
// ChecklistPromotionService implements the global_admin-only promote /
// demote flow for paliad.checklists. Promote flips visibility to
// 'global' and stamps promoted_at / promoted_by; demote flips it back
// to a caller-chosen target ('firm' default — preserves visibility for
// already-instantiated users).
type ChecklistPromotionService struct {
db *sqlx.DB
templates *ChecklistTemplateService
audit *SystemAuditLogService
users *UserService
}
func NewChecklistPromotionService(db *sqlx.DB, templates *ChecklistTemplateService, audit *SystemAuditLogService, users *UserService) *ChecklistPromotionService {
return &ChecklistPromotionService{db: db, templates: templates, audit: audit, users: users}
}
// validDemoteTargets — narrowing the visibility back from 'global' is
// only allowed to a state where the row is still meaningful. 'global'
// would be a no-op; 'shared' would orphan existing instance owners who
// already see it without a grant. Default is 'firm'.
var validDemoteTargets = map[string]bool{"firm": true, "private": true}
// Promote flips an authored template to visibility='global'. Caller
// must be global_admin. Emits 'checklist.promoted_global' audit event
// with the prior visibility captured for the demote-undo path.
func (s *ChecklistPromotionService) Promote(ctx context.Context, callerID uuid.UUID, slug string) error {
if err := s.requireGlobalAdmin(ctx, callerID); err != nil {
return err
}
row, err := s.templates.GetBySlug(ctx, callerID, slug)
if err != nil {
return err
}
if row.Visibility == "global" {
return nil
}
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return fmt.Errorf("begin promote tx: %w", err)
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx,
`UPDATE paliad.checklists
SET visibility = 'global',
promoted_at = $2,
promoted_by = $3,
updated_at = $2
WHERE id = $1`, row.ID, time.Now().UTC(), callerID); err != nil {
return fmt.Errorf("promote checklist: %w", err)
}
actorEmail, _ := s.actorEmail(ctx, callerID)
if err := s.audit.WriteChecklistEvent(ctx, tx, ChecklistAuditEvent{
EventType: "checklist.promoted_global",
ActorID: callerID,
ActorEmail: actorEmail,
Metadata: map[string]any{
"checklist_id": row.ID,
"slug": slug,
"owner_id": row.OwnerID,
"prior_visibility": row.Visibility,
},
}); err != nil {
return err
}
return tx.Commit()
}
// Demote narrows visibility from 'global' to target. target defaults to
// 'firm' when empty. promoted_at / promoted_by are cleared.
func (s *ChecklistPromotionService) Demote(ctx context.Context, callerID uuid.UUID, slug, target string) error {
if err := s.requireGlobalAdmin(ctx, callerID); err != nil {
return err
}
row, err := s.templates.GetBySlug(ctx, callerID, slug)
if err != nil {
return err
}
t := strings.ToLower(strings.TrimSpace(target))
if t == "" {
t = "firm"
}
if !validDemoteTargets[t] {
return fmt.Errorf("%w: demote target must be firm | private, got %q", ErrInvalidInput, target)
}
if row.Visibility != "global" {
return fmt.Errorf("%w: checklist is not currently promoted (visibility=%s)", ErrInvalidInput, row.Visibility)
}
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return fmt.Errorf("begin demote tx: %w", err)
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx,
`UPDATE paliad.checklists
SET visibility = $2,
promoted_at = NULL,
promoted_by = NULL,
updated_at = now()
WHERE id = $1`, row.ID, t); err != nil {
return fmt.Errorf("demote checklist: %w", err)
}
actorEmail, _ := s.actorEmail(ctx, callerID)
if err := s.audit.WriteChecklistEvent(ctx, tx, ChecklistAuditEvent{
EventType: "checklist.demoted",
ActorID: callerID,
ActorEmail: actorEmail,
Metadata: map[string]any{
"checklist_id": row.ID,
"slug": slug,
"target_visibility": t,
},
}); err != nil {
return err
}
return tx.Commit()
}
func (s *ChecklistPromotionService) requireGlobalAdmin(ctx context.Context, callerID uuid.UUID) error {
user, err := s.users.GetByID(ctx, callerID)
if err != nil {
return err
}
if user == nil || user.GlobalRole != "global_admin" {
return fmt.Errorf("%w: only global_admin can promote / demote checklists", ErrForbidden)
}
return nil
}
func (s *ChecklistPromotionService) actorEmail(ctx context.Context, userID uuid.UUID) (string, error) {
u, err := s.users.GetByID(ctx, userID)
if err != nil || u == nil {
return "", err
}
return u.Email, nil
}

View File

@@ -0,0 +1,331 @@
package services
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"mgit.msbls.de/m/paliad/internal/offices"
)
// ChecklistShareService is the write surface for paliad.checklist_shares
// (mig 115). Owners grant; owner-or-global_admin revokes. ListGrants is
// owner-only (returning all 4 recipient kinds) — recipients see "this
// is shared with me" only implicitly via the visibility predicate.
type ChecklistShareService struct {
db *sqlx.DB
templates *ChecklistTemplateService
audit *SystemAuditLogService
users *UserService
}
func NewChecklistShareService(db *sqlx.DB, templates *ChecklistTemplateService, audit *SystemAuditLogService, users *UserService) *ChecklistShareService {
return &ChecklistShareService{db: db, templates: templates, audit: audit, users: users}
}
// ShareGrantInput is the POST body for granting a share. Exactly one
// of the recipient_* fields must be set, matching recipient_kind.
type ShareGrantInput struct {
RecipientKind string `json:"recipient_kind"`
UserID *uuid.UUID `json:"recipient_user_id,omitempty"`
Office string `json:"recipient_office,omitempty"`
PartnerUnitID *uuid.UUID `json:"recipient_partner_unit_id,omitempty"`
ProjectID *uuid.UUID `json:"recipient_project_id,omitempty"`
}
// Share is the row shape returned from list / grant calls.
type Share struct {
ID uuid.UUID `db:"id" json:"id"`
ChecklistID uuid.UUID `db:"checklist_id" json:"checklist_id"`
RecipientKind string `db:"recipient_kind" json:"recipient_kind"`
RecipientUserID *uuid.UUID `db:"recipient_user_id" json:"recipient_user_id,omitempty"`
RecipientOffice *string `db:"recipient_office" json:"recipient_office,omitempty"`
RecipientPartnerUnitID *uuid.UUID `db:"recipient_partner_unit_id" json:"recipient_partner_unit_id,omitempty"`
RecipientProjectID *uuid.UUID `db:"recipient_project_id" json:"recipient_project_id,omitempty"`
GrantedBy uuid.UUID `db:"granted_by" json:"granted_by"`
GrantedAt time.Time `db:"granted_at" json:"granted_at"`
// Display-name enrichment for the recipient — owners want to see
// "Sarah Schmidt" not just a UUID on the grants list.
RecipientLabel string `db:"recipient_label" json:"recipient_label"`
}
// Grant creates a new share row. Caller must own the parent checklist
// (or be global_admin). Recipient validity (FK targets exist + kind
// matches the populated recipient_* column) enforced before INSERT.
func (s *ChecklistShareService) Grant(ctx context.Context, callerID uuid.UUID, slug string, input ShareGrantInput) (*Share, error) {
row, err := s.templates.GetBySlug(ctx, callerID, slug)
if err != nil {
return nil, err
}
// Ownership check — Grant is owner-only (global_admin can demote
// global templates but doesn't author shares).
if row.OwnerID != callerID {
return nil, fmt.Errorf("%w: only the owner can grant shares", ErrForbidden)
}
kind := strings.ToLower(strings.TrimSpace(input.RecipientKind))
if err := validateShareInput(kind, input); err != nil {
return nil, err
}
id := uuid.New()
now := time.Now().UTC()
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("begin grant tx: %w", err)
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx,
`INSERT INTO paliad.checklist_shares
(id, checklist_id, recipient_kind, recipient_user_id, recipient_office,
recipient_partner_unit_id, recipient_project_id, granted_by, granted_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
id, row.ID, kind,
input.UserID, nullableString(input.Office), input.PartnerUnitID, input.ProjectID,
callerID, now,
); err != nil {
// Map the partial-unique-index conflict into a friendly 409.
if pqUniqueViolation(err) {
return nil, fmt.Errorf("%w: this recipient already has a grant on this checklist", ErrInvalidInput)
}
return nil, fmt.Errorf("insert checklist_share: %w", err)
}
actorEmail, _ := s.actorEmail(ctx, callerID)
meta := map[string]any{
"checklist_id": row.ID,
"slug": slug,
"share_id": id,
"recipient_kind": kind,
}
switch kind {
case "user":
meta["recipient_user_id"] = input.UserID
case "office":
meta["recipient_office"] = input.Office
case "partner_unit":
meta["recipient_partner_unit_id"] = input.PartnerUnitID
case "project":
meta["recipient_project_id"] = input.ProjectID
}
if err := s.audit.WriteChecklistEvent(ctx, tx, ChecklistAuditEvent{
EventType: "checklist.shared",
ActorID: callerID,
ActorEmail: actorEmail,
Metadata: meta,
}); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit grant: %w", err)
}
return s.getShareByID(ctx, callerID, id)
}
// Revoke deletes a share row. Owner of the parent checklist OR
// global_admin. Audited as 'checklist.unshared' with the recipient meta
// captured pre-delete.
func (s *ChecklistShareService) Revoke(ctx context.Context, callerID uuid.UUID, shareID uuid.UUID) error {
share, err := s.getShareByID(ctx, callerID, shareID)
if err != nil {
return err
}
// Resolve owner of the parent checklist for the authorization gate.
// templates.GetBySlug needs a slug we don't have; inline a minimal
// owner lookup keyed on the share's checklist_id.
var ownerID uuid.UUID
if err := s.db.GetContext(ctx, &ownerID,
`SELECT owner_id FROM paliad.checklists WHERE id = $1`, share.ChecklistID); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return ErrNotVisible
}
return fmt.Errorf("fetch checklist owner: %w", err)
}
if ownerID != callerID {
user, err := s.users.GetByID(ctx, callerID)
if err != nil {
return err
}
if user == nil || user.GlobalRole != "global_admin" {
return fmt.Errorf("%w: only the owner or a global_admin can revoke a share", ErrForbidden)
}
}
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return fmt.Errorf("begin revoke tx: %w", err)
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx,
`DELETE FROM paliad.checklist_shares WHERE id = $1`, shareID); err != nil {
return fmt.Errorf("delete checklist_share: %w", err)
}
actorEmail, _ := s.actorEmail(ctx, callerID)
meta := map[string]any{
"checklist_id": share.ChecklistID,
"share_id": share.ID,
"recipient_kind": share.RecipientKind,
}
switch share.RecipientKind {
case "user":
meta["recipient_user_id"] = share.RecipientUserID
case "office":
meta["recipient_office"] = share.RecipientOffice
case "partner_unit":
meta["recipient_partner_unit_id"] = share.RecipientPartnerUnitID
case "project":
meta["recipient_project_id"] = share.RecipientProjectID
}
if err := s.audit.WriteChecklistEvent(ctx, tx, ChecklistAuditEvent{
EventType: "checklist.unshared",
ActorID: callerID,
ActorEmail: actorEmail,
Metadata: meta,
}); err != nil {
return err
}
return tx.Commit()
}
// ListGrants returns every share row for the checklist. Owner-only —
// recipients only learn about shares affecting them implicitly via the
// visibility predicate.
func (s *ChecklistShareService) ListGrants(ctx context.Context, callerID uuid.UUID, slug string) ([]Share, error) {
row, err := s.templates.GetBySlug(ctx, callerID, slug)
if err != nil {
return nil, err
}
if row.OwnerID != callerID {
user, err := s.users.GetByID(ctx, callerID)
if err != nil {
return nil, err
}
if user == nil || user.GlobalRole != "global_admin" {
return nil, fmt.Errorf("%w: only the owner or a global_admin can list shares", ErrForbidden)
}
}
rows := []Share{}
q := `SELECT s.id, s.checklist_id, s.recipient_kind, s.recipient_user_id,
s.recipient_office, s.recipient_partner_unit_id, s.recipient_project_id,
s.granted_by, s.granted_at,
COALESCE(
CASE s.recipient_kind
WHEN 'user' THEN ru.display_name
WHEN 'office' THEN s.recipient_office
WHEN 'partner_unit' THEN pu.name
WHEN 'project' THEN COALESCE(pr.reference, pr.title)
END,
''
) AS recipient_label
FROM paliad.checklist_shares s
LEFT JOIN paliad.users ru ON ru.id = s.recipient_user_id
LEFT JOIN paliad.partner_units pu ON pu.id = s.recipient_partner_unit_id
LEFT JOIN paliad.projects pr ON pr.id = s.recipient_project_id
WHERE s.checklist_id = $1
ORDER BY s.granted_at DESC`
if err := s.db.SelectContext(ctx, &rows, q, row.ID); err != nil {
return nil, fmt.Errorf("list checklist_shares: %w", err)
}
return rows, nil
}
// --- internals ------------------------------------------------------------
func (s *ChecklistShareService) getShareByID(ctx context.Context, callerID uuid.UUID, shareID uuid.UUID) (*Share, error) {
var row Share
q := `SELECT s.id, s.checklist_id, s.recipient_kind, s.recipient_user_id,
s.recipient_office, s.recipient_partner_unit_id, s.recipient_project_id,
s.granted_by, s.granted_at,
COALESCE(
CASE s.recipient_kind
WHEN 'user' THEN ru.display_name
WHEN 'office' THEN s.recipient_office
WHEN 'partner_unit' THEN pu.name
WHEN 'project' THEN COALESCE(pr.reference, pr.title)
END,
''
) AS recipient_label
FROM paliad.checklist_shares s
LEFT JOIN paliad.users ru ON ru.id = s.recipient_user_id
LEFT JOIN paliad.partner_units pu ON pu.id = s.recipient_partner_unit_id
LEFT JOIN paliad.projects pr ON pr.id = s.recipient_project_id
WHERE s.id = $1`
err := s.db.GetContext(ctx, &row, q, shareID)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotVisible
}
if err != nil {
return nil, fmt.Errorf("fetch checklist_share: %w", err)
}
return &row, nil
}
func (s *ChecklistShareService) actorEmail(ctx context.Context, userID uuid.UUID) (string, error) {
u, err := s.users.GetByID(ctx, userID)
if err != nil || u == nil {
return "", err
}
return u.Email, nil
}
// --- pure helpers ---------------------------------------------------------
func validateShareInput(kind string, input ShareGrantInput) error {
switch kind {
case "user":
if input.UserID == nil {
return fmt.Errorf("%w: recipient_user_id required when recipient_kind=user", ErrInvalidInput)
}
case "office":
off := strings.TrimSpace(input.Office)
if off == "" {
return fmt.Errorf("%w: recipient_office required when recipient_kind=office", ErrInvalidInput)
}
if !offices.IsValid(off) {
return fmt.Errorf("%w: unknown office %q", ErrInvalidInput, off)
}
case "partner_unit":
if input.PartnerUnitID == nil {
return fmt.Errorf("%w: recipient_partner_unit_id required when recipient_kind=partner_unit", ErrInvalidInput)
}
case "project":
if input.ProjectID == nil {
return fmt.Errorf("%w: recipient_project_id required when recipient_kind=project", ErrInvalidInput)
}
default:
return fmt.Errorf("%w: recipient_kind must be user|office|partner_unit|project, got %q", ErrInvalidInput, kind)
}
return nil
}
func nullableString(s string) any {
t := strings.TrimSpace(s)
if t == "" {
return nil
}
return t
}
// pqUniqueViolation reports whether the error is a Postgres
// unique_violation (SQLSTATE 23505). lib/pq exposes it via the .Code()
// method; sqlx surfaces it untouched. We sniff via the error string to
// avoid pulling in lib/pq's Error type here.
func pqUniqueViolation(err error) bool {
if err == nil {
return false
}
msg := err.Error()
return strings.Contains(msg, "23505") || strings.Contains(msg, "duplicate key")
}

View File

@@ -0,0 +1,107 @@
package services
import (
"errors"
"strings"
"testing"
"github.com/google/uuid"
)
func TestValidateShareInput(t *testing.T) {
uid := uuid.New()
puID := uuid.New()
prID := uuid.New()
cases := []struct {
name string
kind string
input ShareGrantInput
wantErr bool
}{
{"user happy", "user", ShareGrantInput{RecipientKind: "user", UserID: &uid}, false},
{"user missing id", "user", ShareGrantInput{RecipientKind: "user"}, true},
{"office happy", "office", ShareGrantInput{RecipientKind: "office", Office: "munich"}, false},
{"office unknown key", "office", ShareGrantInput{RecipientKind: "office", Office: "atlantis"}, true},
{"office empty", "office", ShareGrantInput{RecipientKind: "office"}, true},
{"partner_unit happy", "partner_unit", ShareGrantInput{RecipientKind: "partner_unit", PartnerUnitID: &puID}, false},
{"partner_unit missing id", "partner_unit", ShareGrantInput{RecipientKind: "partner_unit"}, true},
{"project happy", "project", ShareGrantInput{RecipientKind: "project", ProjectID: &prID}, false},
{"project missing id", "project", ShareGrantInput{RecipientKind: "project"}, true},
{"unknown kind", "bogus", ShareGrantInput{RecipientKind: "bogus"}, true},
}
for _, c := range cases {
err := validateShareInput(c.kind, c.input)
if c.wantErr && !errors.Is(err, ErrInvalidInput) {
t.Errorf("%s: expected ErrInvalidInput, got %v", c.name, err)
}
if !c.wantErr && err != nil {
t.Errorf("%s: unexpected error %v", c.name, err)
}
}
}
func TestPredicateIncludesAllShareBranches(t *testing.T) {
pred := checklistVisibilityPredicate("c", 1)
wants := []string{
"c.owner_id = $1",
"c.visibility IN ('firm', 'global')",
"u.global_role = 'global_admin'",
"s.recipient_kind = 'user'",
"s.recipient_kind = 'office'",
"s.recipient_kind = 'partner_unit'",
"s.recipient_kind = 'project'",
"paliad.checklist_shares",
"paliad.partner_unit_members",
"paliad.projects",
"paliad.project_teams",
}
for _, w := range wants {
if !strings.Contains(pred, w) {
t.Errorf("predicate missing %q in:\n%s", w, pred)
}
}
}
func TestPqUniqueViolationDetection(t *testing.T) {
cases := []struct {
err string
want bool
}{
{"pq: duplicate key value violates unique constraint \"checklist_shares_user_uniq\"", true},
{"pq: 23505 something", true},
{"some other error", false},
}
for _, c := range cases {
got := pqUniqueViolation(errors.New(c.err))
if got != c.want {
t.Errorf("pqUniqueViolation(%q) = %v; want %v", c.err, got, c.want)
}
}
if pqUniqueViolation(nil) {
t.Error("nil err should not be a unique violation")
}
}
func TestNullableString(t *testing.T) {
if got := nullableString(""); got != nil {
t.Errorf("empty should map to nil, got %v", got)
}
if got := nullableString(" "); got != nil {
t.Errorf("whitespace should map to nil, got %v", got)
}
if got := nullableString(" munich "); got != "munich" {
t.Errorf("expected trimmed 'munich', got %v", got)
}
}
func TestNormaliseSliceAVisibilityAcceptsShared(t *testing.T) {
for _, v := range []string{"private", "firm", "shared"} {
if _, err := normaliseSliceAVisibility(v); err != nil {
t.Errorf("Slice-B visibility %q rejected: %v", v, err)
}
}
if _, err := normaliseSliceAVisibility("global"); !errors.Is(err, ErrInvalidInput) {
t.Errorf("'global' should be rejected as author-set, got %v", err)
}
}

View File

@@ -0,0 +1,586 @@
package services
import (
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"regexp"
"strings"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"mgit.msbls.de/m/paliad/internal/checklists"
"mgit.msbls.de/m/paliad/internal/models"
)
// ChecklistTemplateService is the write surface for user-authored checklist
// templates (paliad.checklists, mig 114). Create / Update / Delete on
// owner-only paths; SetVisibility on private↔firm only (Slice A — Slice B
// adds 'shared' grants, Slice C adds 'global' via admin promotion).
type ChecklistTemplateService struct {
db *sqlx.DB
catalog *ChecklistCatalogService
audit *SystemAuditLogService
users *UserService
}
func NewChecklistTemplateService(db *sqlx.DB, catalog *ChecklistCatalogService, audit *SystemAuditLogService, users *UserService) *ChecklistTemplateService {
return &ChecklistTemplateService{db: db, catalog: catalog, audit: audit, users: users}
}
// CreateTemplateInput is the POST body for authoring a new template.
//
// Body carries the groups[] / items[] sub-tree as JSONB; the surrounding
// metadata (title, regime, etc.) lives on dedicated columns. The
// handler validates the body shape upstream.
type CreateTemplateInput struct {
Title string `json:"title"`
Description string `json:"description"`
Regime string `json:"regime"`
Court string `json:"court"`
Reference string `json:"reference"`
Deadline string `json:"deadline"`
Lang string `json:"lang"`
Body json.RawMessage `json:"body"`
Visibility string `json:"visibility"`
}
// UpdateTemplateInput patches the owner-editable fields. Any field left
// nil is unchanged.
type UpdateTemplateInput struct {
Title *string `json:"title,omitempty"`
Description *string `json:"description,omitempty"`
Regime *string `json:"regime,omitempty"`
Court *string `json:"court,omitempty"`
Reference *string `json:"reference,omitempty"`
Deadline *string `json:"deadline,omitempty"`
Body *json.RawMessage `json:"body,omitempty"`
}
var (
validRegimes = map[string]bool{"UPC": true, "DE": true, "EPA": true, "OTHER": true}
validLangs = map[string]bool{"de": true, "en": true}
// Author-settable visibilities. 'shared' is implicit (set
// automatically when the first checklist_shares row exists); 'global'
// is admin-only via ChecklistPromotionService.
validVisibilities = map[string]bool{"private": true, "firm": true, "shared": true}
titleMaxLen = 200
descriptionMaxLen = 2000
freeTextMaxLen = 200
slugSafeChars = regexp.MustCompile(`[^a-z0-9-]+`)
)
// Create inserts a new authored template owned by userID. Returns the
// created row; emits a `checklist.authored` audit event.
func (s *ChecklistTemplateService) Create(ctx context.Context, userID uuid.UUID, input CreateTemplateInput) (*models.Checklist, error) {
title, err := requireNonEmptyTrimmed(input.Title, "title", titleMaxLen)
if err != nil {
return nil, err
}
regime, err := normaliseRegime(input.Regime)
if err != nil {
return nil, err
}
lang, err := normaliseLang(input.Lang)
if err != nil {
return nil, err
}
visibility, err := normaliseSliceAVisibility(input.Visibility)
if err != nil {
return nil, err
}
if err := validateBodyShape(input.Body); err != nil {
return nil, err
}
slug, err := s.generateSlug(ctx, title)
if err != nil {
return nil, err
}
now := time.Now().UTC()
id := uuid.New()
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("begin create tx: %w", err)
}
defer tx.Rollback()
_, err = tx.ExecContext(ctx,
`INSERT INTO paliad.checklists
(id, slug, owner_id, title, description, regime, court, reference,
deadline, lang, body, visibility, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::jsonb, $12, $13, $13)`,
id, slug, userID, title,
clampFreeText(input.Description, descriptionMaxLen),
regime,
clampFreeText(input.Court, freeTextMaxLen),
clampFreeText(input.Reference, freeTextMaxLen),
clampFreeText(input.Deadline, freeTextMaxLen),
lang,
string(input.Body),
visibility,
now,
)
if err != nil {
return nil, fmt.Errorf("insert checklist: %w", err)
}
actorEmail, _ := s.actorEmail(ctx, userID)
if err := s.audit.WriteChecklistEvent(ctx, tx, ChecklistAuditEvent{
EventType: "checklist.authored",
ActorID: userID,
ActorEmail: actorEmail,
Metadata: map[string]any{
"checklist_id": id,
"slug": slug,
"visibility": visibility,
},
}); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit create checklist: %w", err)
}
return s.GetBySlug(ctx, userID, slug)
}
// Update mutates an authored template. Owner-only; non-owner attempts
// return ErrForbidden. Emits `checklist.edited` with the names of the
// changed fields in metadata.changed_fields[].
func (s *ChecklistTemplateService) Update(ctx context.Context, userID uuid.UUID, slug string, input UpdateTemplateInput) (*models.Checklist, error) {
row, err := s.requireOwnerOrAdmin(ctx, userID, slug)
if err != nil {
return nil, err
}
sets := []string{}
args := []any{}
next := 1
changed := []string{}
appendSet := func(col string, val any) {
sets = append(sets, fmt.Sprintf("%s = $%d", col, next))
args = append(args, val)
next++
}
if input.Title != nil {
t, err := requireNonEmptyTrimmed(*input.Title, "title", titleMaxLen)
if err != nil {
return nil, err
}
appendSet("title", t)
changed = append(changed, "title")
}
if input.Description != nil {
appendSet("description", clampFreeText(*input.Description, descriptionMaxLen))
changed = append(changed, "description")
}
if input.Regime != nil {
r, err := normaliseRegime(*input.Regime)
if err != nil {
return nil, err
}
appendSet("regime", r)
changed = append(changed, "regime")
}
if input.Court != nil {
appendSet("court", clampFreeText(*input.Court, freeTextMaxLen))
changed = append(changed, "court")
}
if input.Reference != nil {
appendSet("reference", clampFreeText(*input.Reference, freeTextMaxLen))
changed = append(changed, "reference")
}
if input.Deadline != nil {
appendSet("deadline", clampFreeText(*input.Deadline, freeTextMaxLen))
changed = append(changed, "deadline")
}
if input.Body != nil {
if err := validateBodyShape(*input.Body); err != nil {
return nil, err
}
sets = append(sets, fmt.Sprintf("body = $%d::jsonb", next))
args = append(args, string(*input.Body))
next++
changed = append(changed, "body")
}
if len(sets) == 0 {
return row, nil
}
// Version bump (Slice C). Title and body are the meaningful edits
// that warrant a "your snapshot is outdated" badge on existing
// instances. Pure metadata tweaks (description / court / reference
// / deadline) update updated_at but don't bump version — we don't
// want every typo correction to nag users with an outdated badge.
versionBumped := false
for _, f := range changed {
if f == "title" || f == "body" {
versionBumped = true
break
}
}
if versionBumped {
sets = append(sets, "version = version + 1")
}
appendSet("updated_at", time.Now().UTC())
args = append(args, row.ID)
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("begin update tx: %w", err)
}
defer tx.Rollback()
q := fmt.Sprintf(`UPDATE paliad.checklists SET %s WHERE id = $%d`,
strings.Join(sets, ", "), next)
if _, err := tx.ExecContext(ctx, q, args...); err != nil {
return nil, fmt.Errorf("update checklist: %w", err)
}
actorEmail, _ := s.actorEmail(ctx, userID)
if err := s.audit.WriteChecklistEvent(ctx, tx, ChecklistAuditEvent{
EventType: "checklist.edited",
ActorID: userID,
ActorEmail: actorEmail,
Metadata: map[string]any{
"checklist_id": row.ID,
"slug": slug,
"changed_fields": changed,
},
}); err != nil {
return nil, err
}
// Slice C — emit a separate 'checklist.versioned' event when the
// edit actually bumped the version. Dashboards / future popularity
// sort can read this without parsing changed_fields[].
if versionBumped {
if err := s.audit.WriteChecklistEvent(ctx, tx, ChecklistAuditEvent{
EventType: "checklist.versioned",
ActorID: userID,
ActorEmail: actorEmail,
Metadata: map[string]any{
"checklist_id": row.ID,
"slug": slug,
"prior_version": row.Version,
"new_version": row.Version + 1,
},
}); err != nil {
return nil, err
}
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit update checklist: %w", err)
}
return s.GetBySlug(ctx, userID, slug)
}
// SetVisibility flips the visibility level. Slice A allows only the
// private ↔ firm transitions; Slice B opens 'shared' (requires share
// grants); Slice C opens 'global' via the admin promotion service.
func (s *ChecklistTemplateService) SetVisibility(ctx context.Context, userID uuid.UUID, slug string, visibility string) (*models.Checklist, error) {
row, err := s.requireOwnerOrAdmin(ctx, userID, slug)
if err != nil {
return nil, err
}
target, err := normaliseSliceAVisibility(visibility)
if err != nil {
return nil, err
}
if row.Visibility == target {
return row, nil
}
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("begin visibility tx: %w", err)
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx,
`UPDATE paliad.checklists
SET visibility = $2, updated_at = now()
WHERE id = $1`, row.ID, target); err != nil {
return nil, fmt.Errorf("update visibility: %w", err)
}
actorEmail, _ := s.actorEmail(ctx, userID)
if err := s.audit.WriteChecklistEvent(ctx, tx, ChecklistAuditEvent{
EventType: "checklist.visibility_changed",
ActorID: userID,
ActorEmail: actorEmail,
Metadata: map[string]any{
"checklist_id": row.ID,
"slug": slug,
"from": row.Visibility,
"to": target,
},
}); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit visibility: %w", err)
}
return s.GetBySlug(ctx, userID, slug)
}
// Delete removes the authored template. Existing instances survive via
// template_snapshot; new instance creation against this slug fails.
func (s *ChecklistTemplateService) Delete(ctx context.Context, userID uuid.UUID, slug string) error {
row, err := s.requireOwnerOrAdmin(ctx, userID, slug)
if err != nil {
return err
}
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return fmt.Errorf("begin delete tx: %w", err)
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx,
`DELETE FROM paliad.checklists WHERE id = $1`, row.ID); err != nil {
return fmt.Errorf("delete checklist: %w", err)
}
actorEmail, _ := s.actorEmail(ctx, userID)
if err := s.audit.WriteChecklistEvent(ctx, tx, ChecklistAuditEvent{
EventType: "checklist.deleted",
ActorID: userID,
ActorEmail: actorEmail,
Metadata: map[string]any{
"checklist_id": row.ID,
"slug": slug,
"was_visibility": row.Visibility,
},
}); err != nil {
return err
}
return tx.Commit()
}
// ListOwnedBy returns every authored template owned by the caller. Used
// by the 'Meine Vorlagen' tab on /checklists.
func (s *ChecklistTemplateService) ListOwnedBy(ctx context.Context, userID uuid.UUID) ([]models.Checklist, error) {
rows := []models.Checklist{}
if err := s.db.SelectContext(ctx, &rows,
`SELECT id, slug, owner_id, title, description, regime, court, reference,
deadline, lang, body, visibility, promoted_at, promoted_by,
version, created_at, updated_at
FROM paliad.checklists
WHERE owner_id = $1
ORDER BY updated_at DESC`, userID); err != nil {
return nil, fmt.Errorf("list owned checklists: %w", err)
}
return rows, nil
}
// GetBySlug returns one authored template by slug; applies visibility.
func (s *ChecklistTemplateService) GetBySlug(ctx context.Context, userID uuid.UUID, slug string) (*models.Checklist, error) {
var row models.Checklist
q := `SELECT id, slug, owner_id, title, description, regime, court, reference,
deadline, lang, body, visibility, promoted_at, promoted_by,
version, created_at, updated_at
FROM paliad.checklists
WHERE slug = $2
AND ` + checklistVisibilityPredicate("paliad.checklists", 1)
err := s.db.GetContext(ctx, &row, q, userID, slug)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotVisible
}
if err != nil {
return nil, fmt.Errorf("fetch checklist: %w", err)
}
return &row, nil
}
// --- internals ------------------------------------------------------------
// requireOwnerOrAdmin fetches the row and returns it iff caller is owner
// or global_admin. Other callers get ErrForbidden (template visible to
// many users, only some can mutate).
func (s *ChecklistTemplateService) requireOwnerOrAdmin(ctx context.Context, userID uuid.UUID, slug string) (*models.Checklist, error) {
row, err := s.GetBySlug(ctx, userID, slug)
if err != nil {
return nil, err
}
if row.OwnerID == userID {
return row, nil
}
user, err := s.users.GetByID(ctx, userID)
if err != nil {
return nil, err
}
if user != nil && user.GlobalRole == "global_admin" {
return row, nil
}
return nil, fmt.Errorf("%w: only the owner or a global_admin can modify this checklist", ErrForbidden)
}
func (s *ChecklistTemplateService) actorEmail(ctx context.Context, userID uuid.UUID) (string, error) {
u, err := s.users.GetByID(ctx, userID)
if err != nil || u == nil {
return "", err
}
return u.Email, nil
}
// generateSlug builds a 'u-<title-slug>-<6hex>' slug. Three retries on
// collision (against authored table + static catalog). After three
// failures we fall back to a pure-random suffix so the create path
// never wedges.
func (s *ChecklistTemplateService) generateSlug(ctx context.Context, title string) (string, error) {
base := slugifyTitle(title)
if base == "" {
base = "checklist"
}
for attempt := 0; attempt < 3; attempt++ {
suffix, err := randomHex(3)
if err != nil {
return "", err
}
slug := "u-" + base + "-" + suffix
if len(slug) > 64 {
slug = slug[:64]
}
taken, err := s.slugTaken(ctx, slug)
if err != nil {
return "", err
}
if !taken {
return slug, nil
}
}
suffix, err := randomHex(6)
if err != nil {
return "", err
}
return "u-" + suffix, nil
}
func (s *ChecklistTemplateService) slugTaken(ctx context.Context, slug string) (bool, error) {
if s.catalog.IsStaticSlug(slug) {
return true, nil
}
var n int
if err := s.db.GetContext(ctx, &n,
`SELECT count(*) FROM paliad.checklists WHERE slug = $1`, slug); err != nil {
return false, fmt.Errorf("slug taken check: %w", err)
}
return n > 0, nil
}
// --- pure helpers ---------------------------------------------------------
func slugifyTitle(title string) string {
s := strings.ToLower(strings.TrimSpace(title))
s = strings.ReplaceAll(s, "ä", "ae")
s = strings.ReplaceAll(s, "ö", "oe")
s = strings.ReplaceAll(s, "ü", "ue")
s = strings.ReplaceAll(s, "ß", "ss")
s = slugSafeChars.ReplaceAllString(s, "-")
s = strings.Trim(s, "-")
if len(s) > 40 {
s = s[:40]
}
return strings.Trim(s, "-")
}
func randomHex(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("rand: %w", err)
}
return hex.EncodeToString(b), nil
}
func requireNonEmptyTrimmed(v, field string, max int) (string, error) {
t := strings.TrimSpace(v)
if t == "" {
return "", fmt.Errorf("%w: %s is required", ErrInvalidInput, field)
}
if len(t) > max {
return "", fmt.Errorf("%w: %s exceeds %d characters", ErrInvalidInput, field, max)
}
return t, nil
}
func clampFreeText(v string, max int) string {
v = strings.TrimSpace(v)
if len(v) > max {
v = v[:max]
}
return v
}
func normaliseRegime(v string) (string, error) {
r := strings.ToUpper(strings.TrimSpace(v))
if r == "" {
r = "OTHER"
}
if !validRegimes[r] {
return "", fmt.Errorf("%w: regime must be UPC | DE | EPA | OTHER, got %q", ErrInvalidInput, v)
}
return r, nil
}
func normaliseLang(v string) (string, error) {
l := strings.ToLower(strings.TrimSpace(v))
if l == "" {
l = "de"
}
if !validLangs[l] {
return "", fmt.Errorf("%w: lang must be de | en, got %q", ErrInvalidInput, v)
}
return l, nil
}
func normaliseSliceAVisibility(v string) (string, error) {
x := strings.ToLower(strings.TrimSpace(v))
if x == "" {
x = "private"
}
if !validVisibilities[x] {
return "", fmt.Errorf("%w: visibility must be private | firm | shared, got %q (global is admin-only)", ErrInvalidInput, v)
}
return x, nil
}
// validateBodyShape enforces { "groups": [...] } with at least one
// non-empty group and at least one non-empty item somewhere. Authored
// templates aren't useful without content.
func validateBodyShape(body json.RawMessage) error {
if len(body) == 0 {
return fmt.Errorf("%w: body is required", ErrInvalidInput)
}
var shape struct {
Groups []checklists.Group `json:"groups"`
}
if err := json.Unmarshal(body, &shape); err != nil {
return fmt.Errorf("%w: body must be {\"groups\":[...]} (%v)", ErrInvalidInput, err)
}
if len(shape.Groups) == 0 {
return fmt.Errorf("%w: body must contain at least one group", ErrInvalidInput)
}
totalItems := 0
for _, g := range shape.Groups {
totalItems += len(g.Items)
}
if totalItems == 0 {
return fmt.Errorf("%w: body must contain at least one item", ErrInvalidInput)
}
return nil
}

View File

@@ -0,0 +1,129 @@
package services
import (
"encoding/json"
"errors"
"strings"
"testing"
)
func TestSlugifyTitle(t *testing.T) {
cases := []struct{ in, want string }{
{"UPC Klageschrift Strategie", "upc-klageschrift-strategie"},
{"Hülle für Münch (München!)", "huelle-fuer-muench-muenchen"},
{" ", ""},
{"&&&", ""},
{"A really really really really long title that ought to be clamped to forty chars max", "a-really-really-really-really-long-title"},
{"Straße ABC", "strasse-abc"},
{"---leading-and-trailing---", "leading-and-trailing"},
}
for _, c := range cases {
got := slugifyTitle(c.in)
if got != c.want {
t.Errorf("slugifyTitle(%q) = %q; want %q", c.in, got, c.want)
}
}
}
func TestNormaliseRegime(t *testing.T) {
for _, valid := range []string{"upc", "DE", " epa ", "Other", ""} {
if _, err := normaliseRegime(valid); err != nil {
t.Errorf("normaliseRegime(%q) errored unexpectedly: %v", valid, err)
}
}
if _, err := normaliseRegime("bogus"); !errors.Is(err, ErrInvalidInput) {
t.Errorf("normaliseRegime(bogus) expected ErrInvalidInput, got %v", err)
}
}
func TestNormaliseLang(t *testing.T) {
for _, valid := range []string{"de", "EN", " ", ""} {
if _, err := normaliseLang(valid); err != nil {
t.Errorf("normaliseLang(%q) errored: %v", valid, err)
}
}
if _, err := normaliseLang("fr"); !errors.Is(err, ErrInvalidInput) {
t.Errorf("normaliseLang(fr) expected ErrInvalidInput, got %v", err)
}
}
func TestNormaliseSliceAVisibility(t *testing.T) {
// Slice B opened up 'shared' as a valid author-set visibility
// (alongside 'private' and 'firm'). 'global' stays admin-only via
// ChecklistPromotionService.
for _, valid := range []string{"private", "firm", "shared", " ", ""} {
if _, err := normaliseSliceAVisibility(valid); err != nil {
t.Errorf("visibility(%q) errored: %v", valid, err)
}
}
for _, bad := range []string{"global", "public"} {
if _, err := normaliseSliceAVisibility(bad); !errors.Is(err, ErrInvalidInput) {
t.Errorf("visibility(%q) expected ErrInvalidInput, got %v", bad, err)
}
}
}
func TestRequireNonEmptyTrimmed(t *testing.T) {
if _, err := requireNonEmptyTrimmed(" ", "title", 200); !errors.Is(err, ErrInvalidInput) {
t.Errorf("empty title should be rejected, got %v", err)
}
if got, err := requireNonEmptyTrimmed(" hello ", "title", 200); err != nil || got != "hello" {
t.Errorf("expected 'hello', got %q (err=%v)", got, err)
}
if _, err := requireNonEmptyTrimmed(strings.Repeat("x", 201), "title", 200); !errors.Is(err, ErrInvalidInput) {
t.Errorf("over-length title should be rejected, got %v", err)
}
}
func TestValidateBodyShape(t *testing.T) {
// Happy path: at least one group, at least one item.
ok := json.RawMessage(`{"groups":[{"titleDE":"G1","titleEN":"G1","items":[{"labelDE":"X","labelEN":"X"}]}]}`)
if err := validateBodyShape(ok); err != nil {
t.Errorf("valid body rejected: %v", err)
}
// Empty groups.
if err := validateBodyShape(json.RawMessage(`{"groups":[]}`)); !errors.Is(err, ErrInvalidInput) {
t.Errorf("empty groups expected ErrInvalidInput, got %v", err)
}
// Group with no items.
if err := validateBodyShape(json.RawMessage(`{"groups":[{"titleDE":"G","titleEN":"G","items":[]}]}`)); !errors.Is(err, ErrInvalidInput) {
t.Errorf("empty items expected ErrInvalidInput, got %v", err)
}
// Missing field.
if err := validateBodyShape(json.RawMessage(nil)); !errors.Is(err, ErrInvalidInput) {
t.Errorf("nil body expected ErrInvalidInput, got %v", err)
}
// Malformed JSON.
if err := validateBodyShape(json.RawMessage(`{not json`)); !errors.Is(err, ErrInvalidInput) {
t.Errorf("malformed body expected ErrInvalidInput, got %v", err)
}
}
func TestChecklistCatalogIsStaticSlug(t *testing.T) {
// nil DB is fine — we never touch it in this test.
cat := NewChecklistCatalogService(nil)
if !cat.IsStaticSlug("upc-statement-of-claim") {
t.Error("expected static slug to be detected")
}
if cat.IsStaticSlug("u-some-authored-slug") {
t.Error("unexpected static-slug match for authored slug")
}
}
func TestChecklistVisibilityPredicate(t *testing.T) {
got := checklistVisibilityPredicate("c", 1)
for _, want := range []string{"c.owner_id = $1", "c.visibility IN ('firm', 'global')", "u.global_role = 'global_admin'"} {
if !strings.Contains(got, want) {
t.Errorf("predicate missing %q in: %s", want, got)
}
}
}
func TestClampFreeText(t *testing.T) {
if got := clampFreeText(" hello ", 200); got != "hello" {
t.Errorf("expected trimmed 'hello', got %q", got)
}
if got := clampFreeText(strings.Repeat("x", 250), 200); len(got) != 200 {
t.Errorf("expected clamp to 200, got len=%d", len(got))
}
}

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

@@ -12,6 +12,8 @@ func EmailTemplateSampleData(key, lang, slot string) map[string]any {
switch key {
case EmailTemplateKeyInvitation:
return invitationSample(lang)
case EmailTemplateKeyAddUserWelcome:
return addUserWelcomeSample(lang)
case EmailTemplateKeyDeadlineDigest:
return deadlineDigestSample(lang, slot)
case EmailTemplateKeyBase:
@@ -98,6 +100,30 @@ func deadlineDigestSample(lang, slot string) map[string]any {
}
}
// t-paliad-223 Slice B (#49) — sample data for the Add-User welcome mail.
// The variable contract mirrors what UserService.AdminCreateUserFull
// passes to MailService.SendTemplate at runtime.
func addUserWelcomeSample(lang string) map[string]any {
if lang == "en" {
return map[string]any{
"InviterName": "Maria Schmidt",
"InviterEmail": "maria.schmidt@hlc.com",
"ToEmail": "new.colleague@hlc.com",
"MagicLink": "https://supabase.paliad.de/auth/v1/verify?token=…",
"BaseURL": "https://paliad.de",
"Firm": "HLC",
}
}
return map[string]any{
"InviterName": "Maria Schmidt",
"InviterEmail": "maria.schmidt@hlc.com",
"ToEmail": "neu.kollege@hlc.de",
"MagicLink": "https://supabase.paliad.de/auth/v1/verify?token=…",
"BaseURL": "https://paliad.de",
"Firm": "HLC",
}
}
func baseSample(lang string) map[string]any {
subj := "Beispielbetreff"
if lang == "en" {

View File

@@ -41,11 +41,17 @@ const (
EmailTemplateKeyInvitation = "invitation"
EmailTemplateKeyDeadlineDigest = "deadline_digest"
EmailTemplateKeyBase = "base"
// EmailTemplateKeyAddUserWelcome — t-paliad-223 Slice B (#49). Sent when
// a global_admin directly creates a paliad.users + auth.users pair from
// /admin/team's "Konto direkt anlegen" form. Carries a Supabase
// recovery-link so the new colleague can set their own password.
EmailTemplateKeyAddUserWelcome = "add_user_welcome"
)
// CanonicalEmailTemplateKeys is the closed set in canonical display order.
var CanonicalEmailTemplateKeys = []string{
EmailTemplateKeyInvitation,
EmailTemplateKeyAddUserWelcome,
EmailTemplateKeyDeadlineDigest,
EmailTemplateKeyBase,
}
@@ -420,6 +426,10 @@ var defaultSubjects = map[string]map[string]string{
"de": `[Paliad] {{.InviterName}} lädt Sie zu Paliad ein`,
"en": `[Paliad] {{.InviterName}} invites you to Paliad`,
},
EmailTemplateKeyAddUserWelcome: {
"de": `[Paliad] Ihr Paliad-Konto ist bereit`,
"en": `[Paliad] Your Paliad account is ready`,
},
EmailTemplateKeyDeadlineDigest: {
"de": digestSubjectDE,
"en": digestSubjectEN,

View File

@@ -21,6 +21,8 @@ func EmailTemplateVariables(key string) []EmailTemplateVariable {
switch key {
case EmailTemplateKeyInvitation:
return invitationVariables
case EmailTemplateKeyAddUserWelcome:
return addUserWelcomeVariables
case EmailTemplateKeyDeadlineDigest:
return deadlineDigestVariables
case EmailTemplateKeyBase:
@@ -51,6 +53,30 @@ var invitationVariables = []EmailTemplateVariable{
SampleDE: "HLC", SampleEN: "HLC"},
}
// t-paliad-223 Slice B (#49) — variables consumed by the Add-User welcome
// mail. UserService.AdminCreateUserFull populates these at send time.
var addUserWelcomeVariables = []EmailTemplateVariable{
{Name: ".InviterName", Type: "string",
Description: "Anzeigename der/des global_admin, die das Konto angelegt hat.",
SampleDE: "Maria Schmidt", SampleEN: "Maria Schmidt"},
{Name: ".InviterEmail", Type: "string",
Description: "E-Mail-Adresse der/des global_admin.",
SampleDE: "maria.schmidt@hlc.com", SampleEN: "maria.schmidt@hlc.com"},
{Name: ".ToEmail", Type: "string",
Description: "Empfänger:in (E-Mail der neuen Person).",
SampleDE: "neu.kollege@hlc.de", SampleEN: "new.colleague@hlc.com"},
{Name: ".MagicLink", Type: "string",
Description: "Einmaliger Supabase-Recovery-Link zum Passwort-Setzen.",
SampleDE: "https://supabase.paliad.de/auth/v1/verify?token=…",
SampleEN: "https://supabase.paliad.de/auth/v1/verify?token=…"},
{Name: ".BaseURL", Type: "string",
Description: "Öffentliche Paliad-URL (PALIAD_BASE_URL).",
SampleDE: "https://paliad.de", SampleEN: "https://paliad.de"},
{Name: ".Firm", Type: "string",
Description: "Firmenname (FIRM_NAME).",
SampleDE: "HLC", SampleEN: "HLC"},
}
var deadlineDigestVariables = []EmailTemplateVariable{
{Name: ".Slot", Type: "string",
Description: "Trigger-Slot: \"morning\" oder \"evening\". Body verwendet typischerweise .IsEvening.",

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

@@ -94,10 +94,27 @@ type UIDeadline struct {
// UIResponse matches the frontend's DeadlineResponse TypeScript interface.
type UIResponse struct {
ProceedingType string `json:"proceedingType"`
ProceedingName string `json:"proceedingName"`
TriggerDate string `json:"triggerDate"`
Deadlines []UIDeadline `json:"deadlines"`
ProceedingType string `json:"proceedingType"`
ProceedingName string `json:"proceedingName"`
// ProceedingNameEN carries the English label of the proceeding so
// the frontend can switch on lang. Empty when the proceeding has no
// English label populated; the frontend falls back to ProceedingName.
// Added 2026-05-20 (m/paliad#58) — previously the verfahrensablauf
// "Trigger event" label fell back to the DE proceedingName whenever
// the timeline had no root rule (e.g. for sub-track proceedings like
// upc.ccr.cfi that have no native rules).
ProceedingNameEN string `json:"proceedingNameEN,omitempty"`
TriggerDate string `json:"triggerDate"`
Deadlines []UIDeadline `json:"deadlines"`
// ContextualNote / ContextualNoteEN surface a banner above the
// timeline. Populated by sub-track routing (m/paliad#58): when the
// user picks a proceeding that is normally a sub-track of another
// proceeding (e.g. upc.ccr.cfi runs inside upc.inf.cfi with
// with_ccr), the renderer routes to the parent's rules but keeps
// the user-picked code/name as the response identity and surfaces a
// note explaining the framing.
ContextualNote string `json:"contextualNote,omitempty"`
ContextualNoteEN string `json:"contextualNoteEN,omitempty"`
}
// ErrUnknownProceedingType is returned when the UI sends an unrecognised code.
@@ -237,6 +254,42 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
return nil, fmt.Errorf("resolve proceeding %q: %w", proceedingCode, err)
}
// Sub-track routing (m/paliad#58). When the user picks a proceeding
// that has no native rules and is normally a sub-track of another
// proceeding (today: upc.ccr.cfi → upc.inf.cfi + with_ccr), route
// rule lookup to the parent and merge the default flags into the
// user's flag set. The response identity (Code/Name/NameEN) stays
// on the user-picked proceeding so the page header still reads
// "Counterclaim for Revocation", but the timeline body is the
// parent's full flow with the sub-track flag enabled. A note
// surfaces the framing.
var pickedProceeding = pt
var subTrackNote SubTrackRouting
var hasSubTrackNote bool
if route, ok := LookupSubTrackRouting(proceedingCode); ok {
subTrackNote = route
hasSubTrackNote = true
// Re-resolve to the parent proceeding for rule lookup.
err = s.rules.db.GetContext(ctx, &pt,
`SELECT id, code, name, name_en, jurisdiction
FROM paliad.proceeding_types
WHERE code = $1 AND is_active = true`, route.ParentCode)
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("sub-track %q routes to %q which is not active: %w", proceedingCode, route.ParentCode, ErrUnknownProceedingType)
}
if err != nil {
return nil, fmt.Errorf("resolve sub-track parent %q: %w", route.ParentCode, err)
}
// Merge default flags into the user's flag set so the gated
// rules render. User-supplied flags win on conflict (they're
// already in flagSet); default flags only add what's missing.
for _, f := range route.DefaultFlags {
if _, exists := flagSet[f]; !exists {
flagSet[f] = struct{}{}
}
}
}
// Resolve (country, regime) for non-working-day adjustment. Court wins
// when supplied; otherwise default by proceeding regime. UPC proceedings
// default to UPC München (DE+UPC) — most common HLC venue. DPMA / EPA /
@@ -544,12 +597,18 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
deadlines = append(deadlines, d)
}
return &UIResponse{
ProceedingType: pt.Code,
ProceedingName: pt.Name,
TriggerDate: triggerDateStr,
Deadlines: deadlines,
}, nil
resp := &UIResponse{
ProceedingType: pickedProceeding.Code,
ProceedingName: pickedProceeding.Name,
ProceedingNameEN: pickedProceeding.NameEN,
TriggerDate: triggerDateStr,
Deadlines: deadlines,
}
if hasSubTrackNote {
resp.ContextualNote = subTrackNote.NoteDE
resp.ContextualNoteEN = subTrackNote.NoteEN
}
return resp, nil
}
// ErrUnknownRule is returned when CalculateRule can't resolve the

View File

@@ -173,6 +173,53 @@ func TestRenderTemplateInvitation(t *testing.T) {
}
}
// TestRenderTemplateAddUserWelcome — t-paliad-223 Slice B (#49). Catches
// a typo in either add_user_welcome.{de,en}.html: the rendered body must
// contain the inviter, the magic-link, the firm name, and the localised
// fallback subject from defaultSubjects must look right.
func TestRenderTemplateAddUserWelcome(t *testing.T) {
svc, err := NewMailService()
if err != nil {
t.Fatalf("NewMailService: %v", err)
}
for _, lang := range []string{"de", "en"} {
t.Run(lang, func(t *testing.T) {
subject, html, err := svc.RenderTemplate(TemplateData{
Lang: lang,
Name: EmailTemplateKeyAddUserWelcome,
Data: map[string]any{
"InviterName": "Maria Schmidt",
"InviterEmail": "maria@hlc.com",
"ToEmail": "neu.kollege@hlc.de",
"MagicLink": "https://supabase.paliad.de/auth/v1/verify?token=TESTTOKEN",
"BaseURL": "https://paliad.de",
},
})
if err != nil {
t.Fatalf("RenderTemplate: %v", err)
}
for _, want := range []string{
"Maria Schmidt", "neu.kollege@hlc.de",
"https://supabase.paliad.de/auth/v1/verify?token=TESTTOKEN",
"https://paliad.de/login",
// {{.Firm}} placeholder must render — branding default is "HLC".
"HLC",
} {
if !strings.Contains(html, want) {
t.Errorf("[%s] rendered html missing %q", lang, want)
}
}
wantSubject := "[Paliad] Ihr Paliad-Konto ist bereit"
if lang == "en" {
wantSubject = "[Paliad] Your Paliad account is ready"
}
if subject != wantSubject {
t.Errorf("[%s] subject got %q, want %q", lang, subject, wantSubject)
}
})
}
}
// TestBuildMIMEHasBothParts ensures the multipart/alternative structure
// carries both the text and HTML parts — an earlier refactor dropped one
// part by mistake, caught by this.

View File

@@ -132,8 +132,60 @@ func MapLitigationToFristenrechner(litigationCode, jurisdiction string) (fristen
// "Regeln liegen auf upc.inf.cfi (with_ccr=true); wir leiten Sie dorthin
// weiter." in the UI.
func ResolveCounterclaimRouting(code string) (effectiveCode string, defaultFlags []string, routed bool) {
if code == CodeUPCCounterclaim {
return CodeUPCInfringement, []string{"with_ccr"}, true
if route, ok := SubTrackRoutings[code]; ok {
return route.ParentCode, route.DefaultFlags, true
}
return code, nil, false
}
// SubTrackRouting describes a proceeding type that has no native rules
// of its own and is normally rendered inside a parent proceeding's flow
// with one or more condition flags enabled. The Procedure Roadmap
// (verfahrensablauf) routes calc requests for these codes to the parent
// proceeding + default flags, but preserves the user-picked code/name
// in the response identity and surfaces a contextual note explaining
// the framing — see m/paliad#58 and the design doc cited above.
//
// Adding a new sub-track is a data-only change here: extend
// SubTrackRoutings with the (code, parent, flags, note) tuple and the
// renderer picks it up automatically. The note copy lives in this file
// because it's semantic to the routing, not UI chrome.
type SubTrackRouting struct {
// Code is the user-picked proceeding code (e.g. "upc.ccr.cfi").
Code string
// ParentCode is the proceeding whose rules to use (e.g. "upc.inf.cfi").
ParentCode string
// DefaultFlags are merged into the user's flag set so the
// gated rules render. Order is preserved.
DefaultFlags []string
// NoteDE / NoteEN are the contextual banner above the timeline,
// explaining that the proceeding type is normally a sub-track.
// Plain text — the frontend renders them as a banner.
NoteDE string
NoteEN string
}
// SubTrackRoutings — single-source-of-truth registry. Today: just CCR.
// The pattern generalises to other "sub-track" proceeding types (e.g.
// R.30 application to amend the patent as a standalone roadmap, R.46
// preliminary objection) once they have a proceeding-type code of their
// own. New entries here are picked up by the spawn-as-standalone
// renderer in FristenrechnerService.Calculate without further wiring.
var SubTrackRoutings = map[string]SubTrackRouting{
CodeUPCCounterclaim: {
Code: CodeUPCCounterclaim,
ParentCode: CodeUPCInfringement,
DefaultFlags: []string{"with_ccr"},
NoteDE: "Die Nichtigkeitswiderklage läuft normalerweise innerhalb eines UPC-Verletzungsverfahrens mit aktiver Nichtigkeitswiderklage. Diese Zeitleiste zeigt das Verletzungsverfahren mit gesetztem with_ccr-Flag.",
NoteEN: "The counterclaim for revocation normally runs inside a UPC infringement action with the counterclaim flag set. This timeline shows the infringement action with with_ccr automatically enabled.",
},
}
// LookupSubTrackRouting returns the sub-track routing for a proceeding
// code, or (zero, false) if the code is not a sub-track. Used by the
// fristenrechner Calculate path to spawn the parent flow with the sub-
// track's default flags.
func LookupSubTrackRouting(code string) (SubTrackRouting, bool) {
r, ok := SubTrackRoutings[code]
return r, ok
}

View File

@@ -81,3 +81,43 @@ func TestResolveCounterclaimRouting(t *testing.T) {
}
})
}
// TestSubTrackRoutings asserts the registry shape m/paliad#58 depends
// on: every entry's Code matches its map key, has a non-empty
// ParentCode + DefaultFlags + bilingual notes. Drift here silently
// breaks the spawn-as-standalone renderer (a CCR pick would 404 or
// render an empty timeline), so we pin the contract.
func TestSubTrackRoutings(t *testing.T) {
if len(SubTrackRoutings) == 0 {
t.Fatal("SubTrackRoutings is empty — at minimum upc.ccr.cfi must be registered")
}
for key, route := range SubTrackRoutings {
if route.Code != key {
t.Errorf("SubTrackRoutings[%q].Code = %q, want %q (key/value mismatch)", key, route.Code, key)
}
if route.ParentCode == "" {
t.Errorf("SubTrackRoutings[%q] has empty ParentCode", key)
}
if len(route.DefaultFlags) == 0 {
t.Errorf("SubTrackRoutings[%q] has no DefaultFlags — sub-track routing without flags is a no-op", key)
}
if route.NoteDE == "" || route.NoteEN == "" {
t.Errorf("SubTrackRoutings[%q] missing bilingual note: DE=%q EN=%q", key, route.NoteDE, route.NoteEN)
}
}
// CCR is the canonical entry — assert its exact shape so a future
// rename doesn't silently change semantics.
ccr, ok := LookupSubTrackRouting(CodeUPCCounterclaim)
if !ok {
t.Fatal("LookupSubTrackRouting(upc.ccr.cfi) returned ok=false; entry must be registered")
}
if ccr.ParentCode != CodeUPCInfringement {
t.Errorf("CCR.ParentCode = %q, want %q", ccr.ParentCode, CodeUPCInfringement)
}
if !reflect.DeepEqual(ccr.DefaultFlags, []string{"with_ccr"}) {
t.Errorf("CCR.DefaultFlags = %v, want [with_ccr]", ccr.DefaultFlags)
}
if _, miss := LookupSubTrackRouting(CodeUPCInfringement); miss {
t.Error("LookupSubTrackRouting(upc.inf.cfi) returned ok=true; non-sub-track codes must miss")
}
}

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

@@ -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

@@ -0,0 +1,242 @@
// Package services — SupabaseAdminService — thin HTTP client for the
// privileged Supabase Admin API endpoints.
//
// t-paliad-223 Slice B (#49) — the new "Add User" path on /admin/team needs
// to create an auth.users row before inserting paliad.users (paliad.users.id
// is FK-constrained to auth.users.id). The Supabase JS / Go client library
// would be overkill for the three calls we actually make; this file is
// ~150 LoC of plain net/http instead.
//
// Only three Admin-API calls are exercised here:
//
// - POST {SUPABASE_URL}/auth/v1/admin/users
// Create an auth.users row with email_confirm=true so the user can log
// in via a recovery link without going through the email-confirm step.
//
// - POST {SUPABASE_URL}/auth/v1/admin/generate_link
// Mint a recovery link for the new user; paliad emails it via the
// existing MailService template (NOT Supabase's default mail) so the
// welcome message stays paliad-branded.
//
// - DELETE {SUPABASE_URL}/auth/v1/admin/users/{id}
// Best-effort rollback when the paliad.users insert fails after the
// auth.users row has been created. Failure here just leaves an
// unonboarded auth.users row that "Onboard existing" can recover.
//
// All requests carry the service-role key in BOTH the `apikey` header AND
// the `Authorization: Bearer` header — Supabase's PostgREST gateway checks
// the former, the auth admin handlers check the latter.
//
// SECURITY: SUPABASE_SERVICE_ROLE_KEY is one of the most-privileged
// credentials in the deploy. It must NEVER be sent to the browser or
// logged. Storage is Dokploy secret, age-encrypted at rest.
package services
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
"github.com/google/uuid"
)
// Sentinel errors. Handlers map these to HTTP status codes.
var (
// ErrSupabaseAdminUnavailable signals SUPABASE_SERVICE_ROLE_KEY is unset.
// Handlers map to 503 — the Add-User path is the only feature that
// requires it; everything else keeps working.
ErrSupabaseAdminUnavailable = errors.New("supabase admin api unavailable (SUPABASE_SERVICE_ROLE_KEY not set)")
// ErrSupabaseEmailExists is returned by CreateAuthUser when the email
// already exists in auth.users. Handlers map to 409 with a nudge to
// use "Onboard existing".
ErrSupabaseEmailExists = errors.New("auth.users row already exists for this email")
)
// SupabaseAdminClient is the thin HTTP client. Constructed once at server
// boot; the embedded *http.Client is reused for connection pooling.
//
// Enabled() reports whether SUPABASE_SERVICE_ROLE_KEY is configured. When
// it isn't, every call returns ErrSupabaseAdminUnavailable so the rest of
// the boot path stays runnable for deployments that don't need Add-User.
type SupabaseAdminClient struct {
baseURL string
apiKey string
httpClient *http.Client
}
// NewSupabaseAdminClient wires the client. supabaseURL is required (already
// validated at boot for the anon-key flow); serviceRoleKey may be empty.
//
// Timeout is 10s — Supabase Admin API calls are normally sub-second; 10s
// is forgiving enough for cold starts on a slow network but short enough
// that a hung call doesn't block the admin UI indefinitely.
func NewSupabaseAdminClient(supabaseURL, serviceRoleKey string) *SupabaseAdminClient {
return &SupabaseAdminClient{
baseURL: strings.TrimRight(supabaseURL, "/"),
apiKey: strings.TrimSpace(serviceRoleKey),
httpClient: &http.Client{Timeout: 10 * time.Second},
}
}
// Enabled reports whether the client has a service-role key to use.
func (c *SupabaseAdminClient) Enabled() bool {
return c != nil && c.apiKey != ""
}
// CreateAuthUser creates an auth.users row with email_confirm=true and no
// password (the new user signs in via the recovery link emailed later).
// Returns the new auth.users.id.
//
// 422 from Supabase typically means "email already exists" — mapped to
// ErrSupabaseEmailExists so the handler nudges the admin to "Onboard
// existing" instead.
func (c *SupabaseAdminClient) CreateAuthUser(ctx context.Context, email string) (uuid.UUID, error) {
if !c.Enabled() {
return uuid.Nil, ErrSupabaseAdminUnavailable
}
body := map[string]any{
"email": strings.ToLower(strings.TrimSpace(email)),
"email_confirm": true,
}
var resp struct {
ID string `json:"id"`
Msg string `json:"msg,omitempty"`
}
status, raw, err := c.do(ctx, "POST", "/auth/v1/admin/users", body, &resp)
if err != nil {
return uuid.Nil, err
}
if status == http.StatusUnprocessableEntity || status == http.StatusConflict {
// Supabase returns 422 (or sometimes 400 with "already registered"
// in the body) when the email is taken. Lower-case-match the
// substring so we catch both casings.
if strings.Contains(strings.ToLower(string(raw)), "already") {
return uuid.Nil, ErrSupabaseEmailExists
}
return uuid.Nil, fmt.Errorf("supabase admin create user: status=%d body=%s", status, string(raw))
}
if status < 200 || status >= 300 {
return uuid.Nil, fmt.Errorf("supabase admin create user: status=%d body=%s", status, string(raw))
}
id, err := uuid.Parse(resp.ID)
if err != nil {
return uuid.Nil, fmt.Errorf("supabase admin create user: parse id %q: %w", resp.ID, err)
}
return id, nil
}
// GenerateRecoveryLink mints a one-time recovery link for an existing
// auth.users row. The action_link is what we email; clicking it lands the
// user on Supabase's password-reset page (which redirects to paliad.de
// after the user picks a password).
//
// The link type is "recovery" rather than "magiclink" so the user is forced
// to set a password — paliad doesn't support passwordless sign-in today.
func (c *SupabaseAdminClient) GenerateRecoveryLink(ctx context.Context, email string) (string, error) {
if !c.Enabled() {
return "", ErrSupabaseAdminUnavailable
}
body := map[string]any{
"type": "recovery",
"email": strings.ToLower(strings.TrimSpace(email)),
}
var resp struct {
ActionLink string `json:"action_link"`
Properties struct {
ActionLink string `json:"action_link"`
} `json:"properties"`
}
status, raw, err := c.do(ctx, "POST", "/auth/v1/admin/generate_link", body, &resp)
if err != nil {
return "", err
}
if status < 200 || status >= 300 {
return "", fmt.Errorf("supabase admin generate_link: status=%d body=%s", status, string(raw))
}
// Supabase has historically returned the link in both shapes (top-level
// and nested under properties). Accept either.
if resp.ActionLink != "" {
return resp.ActionLink, nil
}
if resp.Properties.ActionLink != "" {
return resp.Properties.ActionLink, nil
}
return "", fmt.Errorf("supabase admin generate_link: response missing action_link: %s", string(raw))
}
// DeleteAuthUser removes an auth.users row by id. Best-effort rollback
// after the paliad.users insert has failed. A failure here is logged but
// doesn't propagate to the caller — the row can be cleaned up later via
// "Onboard existing" or the admin UI.
func (c *SupabaseAdminClient) DeleteAuthUser(ctx context.Context, id uuid.UUID) error {
if !c.Enabled() {
return ErrSupabaseAdminUnavailable
}
status, raw, err := c.do(ctx, "DELETE", "/auth/v1/admin/users/"+id.String(), nil, nil)
if err != nil {
return err
}
if status < 200 || status >= 300 {
return fmt.Errorf("supabase admin delete user: status=%d body=%s", status, string(raw))
}
return nil
}
// do is the shared request helper. Returns (status, raw_body, err). When
// `out` is non-nil and the response is 2xx with a JSON body, decodes into
// it; raw_body is still returned so the caller can inspect error responses.
func (c *SupabaseAdminClient) do(ctx context.Context, method, path string, payload any, out any) (int, []byte, error) {
var rdr io.Reader
if payload != nil {
buf, err := json.Marshal(payload)
if err != nil {
return 0, nil, fmt.Errorf("marshal %s body: %w", path, err)
}
rdr = bytes.NewReader(buf)
}
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, rdr)
if err != nil {
return 0, nil, fmt.Errorf("build %s request: %w", path, err)
}
if rdr != nil {
req.Header.Set("Content-Type", "application/json")
}
req.Header.Set("apikey", c.apiKey)
req.Header.Set("Authorization", "Bearer "+c.apiKey)
req.Header.Set("Accept", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return 0, nil, fmt.Errorf("%s %s: %w", method, path, err)
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return resp.StatusCode, nil, fmt.Errorf("read %s response: %w", path, err)
}
if out != nil && resp.StatusCode >= 200 && resp.StatusCode < 300 && len(raw) > 0 {
if err := json.Unmarshal(raw, out); err != nil {
return resp.StatusCode, raw, fmt.Errorf("decode %s response: %w", path, err)
}
}
return resp.StatusCode, raw, nil
}
// LoadSupabaseAdminClient reads SUPABASE_URL + SUPABASE_SERVICE_ROLE_KEY
// from the environment and returns a client. The key is optional — when
// unset the client still wires (so dependents don't panic on nil-deref)
// but every call short-circuits with ErrSupabaseAdminUnavailable so the
// server boot stays runnable.
func LoadSupabaseAdminClient() *SupabaseAdminClient {
return NewSupabaseAdminClient(
os.Getenv("SUPABASE_URL"),
os.Getenv("SUPABASE_SERVICE_ROLE_KEY"),
)
}

View File

@@ -0,0 +1,154 @@
// Unit tests for the Supabase admin HTTP client. The client is a thin
// shim over net/http; coverage lives at the wire-shape level: header
// presence, request method, body decode, status-code → error mapping.
// No live Supabase call — every test runs against an httptest.Server.
package services
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/google/uuid"
)
func TestSupabaseAdminClient_Disabled(t *testing.T) {
c := NewSupabaseAdminClient("https://example.invalid", "")
if c.Enabled() {
t.Fatal("Enabled() must be false when service-role key is empty")
}
ctx := context.Background()
if _, err := c.CreateAuthUser(ctx, "x@hlc.com"); !errors.Is(err, ErrSupabaseAdminUnavailable) {
t.Errorf("CreateAuthUser must return ErrSupabaseAdminUnavailable, got %v", err)
}
if _, err := c.GenerateRecoveryLink(ctx, "x@hlc.com"); !errors.Is(err, ErrSupabaseAdminUnavailable) {
t.Errorf("GenerateRecoveryLink must return ErrSupabaseAdminUnavailable, got %v", err)
}
if err := c.DeleteAuthUser(ctx, uuid.New()); !errors.Is(err, ErrSupabaseAdminUnavailable) {
t.Errorf("DeleteAuthUser must return ErrSupabaseAdminUnavailable, got %v", err)
}
}
// TestSupabaseAdminClient_CreateAuthUser_Happy pins the wire-shape:
// POST /auth/v1/admin/users, JSON body with email_confirm=true, both
// apikey + Authorization headers present, parses the response id.
func TestSupabaseAdminClient_CreateAuthUser_Happy(t *testing.T) {
wantID := uuid.New()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
t.Errorf("method = %q, want POST", r.Method)
}
if r.URL.Path != "/auth/v1/admin/users" {
t.Errorf("path = %q, want /auth/v1/admin/users", r.URL.Path)
}
if r.Header.Get("apikey") != "service-key" {
t.Errorf("missing apikey header")
}
if r.Header.Get("Authorization") != "Bearer service-key" {
t.Errorf("missing Bearer header")
}
body, _ := io.ReadAll(r.Body)
var got map[string]any
_ = json.Unmarshal(body, &got)
if got["email"] != "x@hlc.com" {
t.Errorf("email = %v, want x@hlc.com", got["email"])
}
if got["email_confirm"] != true {
t.Errorf("email_confirm = %v, want true", got["email_confirm"])
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]string{"id": wantID.String()})
}))
defer srv.Close()
c := NewSupabaseAdminClient(srv.URL, "service-key")
gotID, err := c.CreateAuthUser(context.Background(), " X@HLC.COM ")
if err != nil {
t.Fatalf("CreateAuthUser: %v", err)
}
if gotID != wantID {
t.Errorf("id = %s, want %s", gotID, wantID)
}
}
// TestSupabaseAdminClient_CreateAuthUser_EmailExists pins the 422-with-
// "already" body → ErrSupabaseEmailExists translation. Mapped to 409 by
// the handler.
func TestSupabaseAdminClient_CreateAuthUser_EmailExists(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnprocessableEntity)
_, _ = w.Write([]byte(`{"msg":"A user with this email address has already been registered"}`))
}))
defer srv.Close()
c := NewSupabaseAdminClient(srv.URL, "service-key")
_, err := c.CreateAuthUser(context.Background(), "dup@hlc.com")
if !errors.Is(err, ErrSupabaseEmailExists) {
t.Fatalf("got %v, want ErrSupabaseEmailExists", err)
}
}
// TestSupabaseAdminClient_GenerateRecoveryLink_BothShapes — Supabase has
// historically returned the link at top-level and nested under
// properties. Both shapes must be accepted.
func TestSupabaseAdminClient_GenerateRecoveryLink_BothShapes(t *testing.T) {
for _, tc := range []struct {
name string
body string
want string
}{
{"top-level", `{"action_link":"https://supabase.paliad.de/auth/v1/verify?token=A"}`, "https://supabase.paliad.de/auth/v1/verify?token=A"},
{"nested", `{"properties":{"action_link":"https://supabase.paliad.de/auth/v1/verify?token=B"}}`, "https://supabase.paliad.de/auth/v1/verify?token=B"},
} {
t.Run(tc.name, func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/auth/v1/admin/generate_link" {
t.Errorf("path = %q", r.URL.Path)
}
body, _ := io.ReadAll(r.Body)
if !strings.Contains(string(body), `"type":"recovery"`) {
t.Errorf("body missing type=recovery: %s", body)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(tc.body))
}))
defer srv.Close()
c := NewSupabaseAdminClient(srv.URL, "service-key")
got, err := c.GenerateRecoveryLink(context.Background(), "x@hlc.com")
if err != nil {
t.Fatalf("GenerateRecoveryLink: %v", err)
}
if got != tc.want {
t.Errorf("link = %q, want %q", got, tc.want)
}
})
}
}
// TestSupabaseAdminClient_DeleteAuthUser pins the DELETE-by-id route shape
// + 2xx happy path; the cleanup runs after a paliad.users insert failure
// in AdminCreateUserFull, so the round-trip needs to work even with a
// short context window.
func TestSupabaseAdminClient_DeleteAuthUser(t *testing.T) {
id := uuid.New()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "DELETE" {
t.Errorf("method = %q", r.Method)
}
if r.URL.Path != "/auth/v1/admin/users/"+id.String() {
t.Errorf("path = %q", r.URL.Path)
}
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
c := NewSupabaseAdminClient(srv.URL, "service-key")
if err := c.DeleteAuthUser(context.Background(), id); err != nil {
t.Errorf("DeleteAuthUser: %v", err)
}
}

View File

@@ -0,0 +1,68 @@
package services
import (
"context"
"encoding/json"
"fmt"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
)
// SystemAuditLogService is a thin write helper for paliad.system_audit_log
// (mig 102). Each domain emits its own event_type prefix
// (checklist.* / data_export* / …) so dashboards can group by feature.
//
// The audit row is best-effort INSIDE the caller's transaction — the
// caller passes its in-flight *sqlx.Tx so the audit write rolls back
// with the data change if anything else fails.
type SystemAuditLogService struct {
db *sqlx.DB
}
func NewSystemAuditLogService(db *sqlx.DB) *SystemAuditLogService {
return &SystemAuditLogService{db: db}
}
// ChecklistAuditEvent is the input shape for the WriteChecklistEvent
// helper. Scope defaults to 'org' since template-level events are firm-
// wide; instance-level events stay on paliad.project_events via the
// existing helpers.
type ChecklistAuditEvent struct {
EventType string // e.g. "checklist.authored", "checklist.edited"
ActorID uuid.UUID
ActorEmail string // captured at write time; survives user deletion
Metadata map[string]any
}
// WriteChecklistEvent inserts a row into paliad.system_audit_log with
// scope='org' and scope_root=NULL. Metadata is JSON-encoded.
func (s *SystemAuditLogService) WriteChecklistEvent(ctx context.Context, tx *sqlx.Tx, evt ChecklistAuditEvent) error {
if evt.EventType == "" {
return fmt.Errorf("system_audit_log: event_type required")
}
if evt.Metadata == nil {
evt.Metadata = map[string]any{}
}
mb, err := json.Marshal(evt.Metadata)
if err != nil {
return fmt.Errorf("system_audit_log marshal: %w", err)
}
exec := func(q string, args ...any) error {
if tx != nil {
_, err := tx.ExecContext(ctx, q, args...)
return err
}
_, err := s.db.ExecContext(ctx, q, args...)
return err
}
if err := exec(
`INSERT INTO paliad.system_audit_log
(event_type, actor_id, actor_email, scope, scope_root, metadata)
VALUES ($1, $2, $3, 'org', NULL, $4::jsonb)`,
evt.EventType, evt.ActorID, evt.ActorEmail, string(mb),
); err != nil {
return fmt.Errorf("system_audit_log insert: %w", err)
}
return nil
}

View File

@@ -6,6 +6,8 @@ import (
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/mail"
"strings"
"time"
@@ -56,8 +58,18 @@ var (
// UserService reads paliad.users. Writes happen via the Phase D onboarding
// endpoint and are not exposed here yet.
//
// supabase + mail + baseURL are optional dependencies wired post-construction
// via SetAddUserDeps (t-paliad-223 Slice B). They power the new "Add User"
// path on /admin/team which creates an auth.users row directly and emails
// a paliad-branded welcome message. Older paths (Create / AdminCreateUser /
// AdminUpdateUser / AdminDeleteUser) do not touch these fields and stay
// runnable when supabase admin is unwired.
type UserService struct {
db *sqlx.DB
db *sqlx.DB
supabase *SupabaseAdminClient
mail *MailService
baseURL string
}
// NewUserService wires the service to the pool.
@@ -65,6 +77,17 @@ func NewUserService(db *sqlx.DB) *UserService {
return &UserService{db: db}
}
// SetAddUserDeps injects the dependencies needed for AdminCreateUserFull
// (t-paliad-223 Slice B). Called from cmd/server/main.go once supabase
// admin + mail services + base URL are known. Safe to omit when the
// deploy doesn't need the new "Add User" path — AdminCreateUserFull will
// return ErrSupabaseAdminUnavailable in that case.
func (s *UserService) SetAddUserDeps(supabase *SupabaseAdminClient, mail *MailService, baseURL string) {
s.supabase = supabase
s.mail = mail
s.baseURL = baseURL
}
const userColumns = `id, email, display_name, office, additional_offices, practice_group,
job_title, global_role,
lang, email_preferences,
@@ -584,6 +607,193 @@ func (s *UserService) AdminCreateUser(ctx context.Context, input AdminCreateInpu
return s.GetByID(ctx, authID)
}
// AdminCreateFullInput is the payload for AdminCreateUserFull (t-paliad-223
// Slice B / m/paliad#49) — the "Konto direkt anlegen" path on /admin/team.
//
// Unlike AdminCreateUser this path does NOT require a pre-existing
// auth.users row: it creates that row via the Supabase Admin API before
// inserting paliad.users in the same tx. The two-step nature means an
// auth.users row may exist with no paliad.users row if the second step
// fails — recovery is via "Onboard existing".
type AdminCreateFullInput struct {
Email string `json:"email"` // required
DisplayName string `json:"display_name"` // required
Office string `json:"office"` // required, validated against offices.IsValid
JobTitle string `json:"job_title,omitempty"`
Profession string `json:"profession,omitempty"`
Lang string `json:"lang,omitempty"`
SendWelcomeMail bool `json:"send_welcome_mail"` // default-on at the handler layer
// InviterID + InviterName + InviterEmail describe the global_admin
// performing the create. Used for the welcome-email template variables
// + the system_audit_log row. Filled by the handler from auth.uid()
// before the call, NOT from the request body, so a malicious admin
// can't impersonate another inviter.
InviterID uuid.UUID `json:"-"`
InviterName string `json:"-"`
InviterEmail string `json:"-"`
}
// AdminCreateUserFull creates both an auth.users row (via Supabase Admin
// API) AND a paliad.users row in one operation. Returns the new
// paliad.users row.
//
// Two-step flow with best-effort rollback:
// 1. Validate input (email format, allowed-domain check happens at the
// handler; office + profession + lang validated here).
// 2. POST /auth/v1/admin/users → auth_id. ErrSupabaseEmailExists if taken.
// 3. INSERT paliad.users in a tx; on failure DELETE /auth/v1/admin/users/{id}
// to roll back.
// 4. system_audit_log row written (best-effort; failure logged not raised).
// 5. If SendWelcomeMail: GenerateRecoveryLink + MailService.SendTemplate
// (best-effort; the user-create succeeds regardless).
//
// Returns ErrSupabaseAdminUnavailable when SUPABASE_SERVICE_ROLE_KEY is
// unset (handler maps to 503). Returns ErrUserAlreadyOnboarded if a
// paliad.users row exists for the same email already (defensive — should
// be unreachable given step 2 catches the auth.users dup first).
func (s *UserService) AdminCreateUserFull(ctx context.Context, input AdminCreateFullInput) (*models.User, error) {
if s.supabase == nil || !s.supabase.Enabled() {
return nil, ErrSupabaseAdminUnavailable
}
email := strings.ToLower(strings.TrimSpace(input.Email))
if email == "" {
return nil, fmt.Errorf("%w: email is required", ErrInvalidInput)
}
if _, err := mail.ParseAddress(email); err != nil {
return nil, fmt.Errorf("%w: invalid email %q", ErrInvalidInput, input.Email)
}
displayName := strings.TrimSpace(input.DisplayName)
if displayName == "" {
return nil, fmt.Errorf("%w: display_name is required", ErrInvalidInput)
}
if !offices.IsValid(input.Office) {
return nil, fmt.Errorf("%w: invalid office %q", ErrInvalidInput, input.Office)
}
jobTitle := strings.TrimSpace(input.JobTitle)
if jobTitle == "" {
jobTitle = "Associate"
}
profession := strings.TrimSpace(input.Profession)
if profession == "" {
profession = ProfessionAssociate
}
if !IsValidProfession(profession) {
return nil, fmt.Errorf("%w: invalid profession %q", ErrInvalidInput, profession)
}
lang := strings.ToLower(strings.TrimSpace(input.Lang))
if lang == "" {
lang = "de"
}
if lang != "de" && lang != "en" {
return nil, fmt.Errorf("%w: invalid lang %q", ErrInvalidInput, input.Lang)
}
// Cheap pre-check on paliad.users — catches the rare case where
// paliad has a row but auth.users got swept (e.g. a Supabase support
// purge). The Admin-API call would still succeed and we'd hit a unique
// constraint on the FK in step 3.
var exists bool
if err := s.db.GetContext(ctx, &exists,
`SELECT EXISTS (SELECT 1 FROM paliad.users WHERE lower(email) = $1)`, email); err != nil {
return nil, fmt.Errorf("pre-check email: %w", err)
}
if exists {
return nil, ErrUserAlreadyOnboarded
}
// Step 2 — auth.users via Supabase Admin API. ErrSupabaseEmailExists
// bubbles to the handler unchanged (409 with a "use Onboard existing"
// hint).
authID, err := s.supabase.CreateAuthUser(ctx, email)
if err != nil {
return nil, err
}
// Step 3 — paliad.users insert with rollback. The tx-rollback only
// reverts the paliad insert; the auth.users row needs an explicit
// delete because it lives in a different Postgres schema and is
// managed by Supabase's GoTrue, not our migration set.
rollbackAuth := func() {
// Detached context so a cancelled parent doesn't abort the cleanup.
cleanupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if delErr := s.supabase.DeleteAuthUser(cleanupCtx, authID); delErr != nil {
// Best-effort: log + leave a recoverable orphan rather than
// raising a new error.
slog.Warn("admin_create_full: rollback DeleteAuthUser failed", "auth_id", authID, "err", delErr)
}
}
if _, err := s.db.ExecContext(ctx,
`INSERT INTO paliad.users (id, email, display_name, office, job_title, profession, global_role, lang)
VALUES ($1, $2, $3, $4, $5, $6, 'standard', $7)`,
authID, email, displayName, input.Office, jobTitle, profession, lang,
); err != nil {
rollbackAuth()
return nil, fmt.Errorf("insert paliad.users: %w", err)
}
// Step 4 — audit row. Best-effort; an audit failure shouldn't break
// the user-create. Captured under a fresh context so the row is
// preserved even if the request context is on the verge of timing out.
auditCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
if _, err := s.db.ExecContext(auditCtx,
`INSERT INTO paliad.system_audit_log
(event_type, actor_id, actor_email, scope, scope_root, metadata)
VALUES ('user.added_by_admin', $1, $2, 'org', NULL, $3::jsonb)`,
nullableUUID(input.InviterID), input.InviterEmail,
fmt.Sprintf(`{"created_user_id":"%s","email":"%s","sent_welcome":%t}`,
authID, email, input.SendWelcomeMail),
); err != nil {
slog.Warn("admin_create_full: audit insert failed", "auth_id", authID, "err", err)
}
cancel()
// Step 5 — welcome email. Best-effort; failure logged + returned in
// the result so the admin can retry the recovery-link send separately.
if input.SendWelcomeMail {
if err := s.sendAddUserWelcome(ctx, email, lang, input); err != nil {
slog.Warn("admin_create_full: welcome mail failed", "auth_id", authID, "err", err)
// Surfaced as a non-fatal warning via the returned model's
// caller-visible side channel? For v1 we just log — the
// admin can re-send via /admin/team's "Recovery link" follow-up
// (filed as out-of-scope in design §3).
}
}
return s.GetByID(ctx, authID)
}
// sendAddUserWelcome generates the recovery link and dispatches the
// branded welcome email. Errors propagate so the caller can log them; the
// caller (AdminCreateUserFull) decides whether they're fatal.
func (s *UserService) sendAddUserWelcome(ctx context.Context, email, lang string, input AdminCreateFullInput) error {
if s.mail == nil {
return errors.New("mail service not wired")
}
link, err := s.supabase.GenerateRecoveryLink(ctx, email)
if err != nil {
return fmt.Errorf("generate recovery link: %w", err)
}
baseURL := s.baseURL
if baseURL == "" {
baseURL = "https://paliad.de"
}
return s.mail.SendTemplate(TemplateData{
To: email,
Lang: lang,
Name: EmailTemplateKeyAddUserWelcome,
Data: map[string]any{
"InviterName": input.InviterName,
"InviterEmail": input.InviterEmail,
"ToEmail": email,
"MagicLink": link,
"BaseURL": baseURL,
},
})
}
// AdminUpdateInput is the payload for AdminUpdateUser. Same shape as
// UpdateProfileInput but additionally allows the additional_offices array
// (which the self-service settings page does not expose).

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,
},
}
}

View File

@@ -0,0 +1,12 @@
{{define "content"}}
<h1 style="margin:0 0 16px 0;font-size:20px;line-height:1.3;color:#1c1917;">Willkommen bei Paliad</h1>
<p style="margin:0 0 12px 0;">{{.InviterName}} hat ein Konto f&uuml;r Sie bei Paliad &mdash; der Patent-Praxis-Plattform f&uuml;r {{.Firm}} &mdash; angelegt.</p>
<p style="margin:0 0 20px 0;">Bitte legen Sie ein Passwort fest, um sich zum ersten Mal anzumelden:</p>
<p style="margin:0;">
<a href="{{.MagicLink}}" style="display:inline-block;background:#1c1917;color:#ffffff;padding:12px 24px;border-radius:6px;text-decoration:none;font-weight:600;font-size:14px;">
Passwort festlegen und anmelden
</a>
</p>
<p style="margin:20px 0 0 0;font-size:13px;color:#44403c;">Der Link ist 24 Stunden g&uuml;ltig. Anschlie&szlig;end k&ouml;nnen Sie sich jederzeit unter <a href="{{.BaseURL}}/login" style="color:#1c1917;">{{.BaseURL}}/login</a> mit Ihrer E-Mail-Adresse {{.ToEmail}} und dem neuen Passwort einloggen.</p>
<p style="margin:24px 0 0 0;font-size:12px;color:#78716c;">Angelegt von {{.InviterEmail}}. Falls Sie diese Nachricht unerwartet erhalten, k&ouml;nnen Sie sie ignorieren &mdash; ohne das Festlegen eines Passworts bleibt das Konto unbenutzbar.</p>
{{end}}

View File

@@ -0,0 +1,12 @@
{{define "content"}}
<h1 style="margin:0 0 16px 0;font-size:20px;line-height:1.3;color:#1c1917;">Welcome to Paliad</h1>
<p style="margin:0 0 12px 0;">{{.InviterName}} has created a Paliad account for you &mdash; Paliad is the patent practice platform for {{.Firm}}.</p>
<p style="margin:0 0 20px 0;">Please set a password to sign in for the first time:</p>
<p style="margin:0;">
<a href="{{.MagicLink}}" style="display:inline-block;background:#1c1917;color:#ffffff;padding:12px 24px;border-radius:6px;text-decoration:none;font-weight:600;font-size:14px;">
Set password and sign in
</a>
</p>
<p style="margin:20px 0 0 0;font-size:13px;color:#44403c;">The link is valid for 24 hours. After that, you can always sign in at <a href="{{.BaseURL}}/login" style="color:#1c1917;">{{.BaseURL}}/login</a> with your email {{.ToEmail}} and the new password.</p>
<p style="margin:24px 0 0 0;font-size:12px;color:#78716c;">Created by {{.InviterEmail}}. If you weren't expecting this message you can ignore it &mdash; without setting a password the account stays unusable.</p>
{{end}}