Compare commits

..

106 Commits

Author SHA1 Message Date
mAi
aa435e5435 fix(verfahrensablauf): m/paliad#59 — restore click-to-edit on timeline dates
Per-rule due dates on /tools/verfahrensablauf were rendered as plain
spans with no `frist-date-edit` attrs and no delegated click handler,
so clicking a date did nothing (m's "the timeline dates seem to be fix,
nothing happens when I click on a date"). The wiring existed on
/tools/fristenrechner but had never been mirrored onto the abstract-
browse surface introduced in t-paliad-179.

Fix: lift the inline date editor + delegated click wiring out of
fristenrechner.ts into views/verfahrensablauf-core.ts so both pages
share one implementation:

  - openInlineDateEditor(span, onCommit) — swaps the date span for
    a `<input type=date>`, commits on blur/Enter, cancels on Escape,
    fires `onCommit(ruleCode, newValue)` ("" = revert).
  - wireDateEditClicks(container, onCommit) — idempotent delegated
    click + keyboard handler that resolves `.frist-date-edit
    [data-rule-code]` and opens the editor. Survives innerHTML
    rewrites because the listener lives on the container.

verfahrensablauf.ts now:
  - Owns its own anchorOverrides Map (cleared when proceeding-type
    changes — overrides for one proceeding don't apply to another).
  - Forwards overrides in calculateDeadlines() so downstream rules
    re-anchor on the user's date.
  - Passes `editable: true` to renderColumnsBody + renderTimelineBody.
  - Calls wireDateEditClicks() once on #timeline-container in
    DOMContentLoaded.

fristenrechner.ts shrinks: openInlineDateEditor + the inline click /
keydown blocks are replaced by an `onDateEditCommit` callback handed
to the shared wireDateEditClicks(). No behaviour change there.

Regression test: views/verfahrensablauf-core.test.ts pins the
editable→`data-rule-code` contract on `deadlineCardHtml` so a future
refactor that drops the attrs fails loudly instead of silently
breaking click-to-edit on both pages.
2026-05-20 14:29:58 +02:00
mAi
3966394a39 Merge: t-paliad-219 Slice A — configurable dashboard backend + factory-default render
Slice A of the configurable user dashboard. Backend + factory layout served
on /dashboard; edit-mode + drag/drop come in Slice B.

- A1: paliad.user_dashboard_layouts storage (mig 109 — single-row-per-user
  PK, jsonb layout, RLS owner-only) + UserDashboardService CRUD.
- A2: HTTP handlers + service wiring (GET/PUT /api/user/dashboard).
- A3: widened server windows for the baseline widgets (deadlines 7d→60d
  LIMIT 10→40 with client-side filtering; activity similarly) +
  InboxSummary aggregate so the new inbox-approvals widget has data.
- A4: frontend widget dispatch + the 7 v1 widgets (6 baseline + inbox-
  approvals). 8th widget (pinned-projects) lands in Slice C, gated on the
  Slice C0 pin-machinery pre-req per m's Q3 deviation in the design doc.

Mig 109 lands cleanly via boltzmann's gap-tolerant applied-set tracker.
Edit mode (Anpassen toggle + drag/drop + per-widget settings) is Slice B.
2026-05-20 13:56:14 +02:00
mAi
5dacc97a6b feat(dashboard): t-paliad-219 Slice A4 — frontend widget dispatch + inbox-approvals
Wire the configurable dashboard end-to-end on the frontend side. Factory
render only (edit mode is Slice B).

dashboard.tsx:

- Add data-widget-key to every section that participates in the layout
  (deadline-summary, matter-summary, upcoming-deadlines, upcoming-
  appointments, inline-agenda, recent-activity, inbox-approvals).
- New inbox-approvals section markup with summary line, list, empty
  state, and full-inbox link.
- Triple hydration placeholder: data + layout + catalog spliced as
  separate window.__PALIAD_DASHBOARD_* globals.

dashboard_shell.go + dashboard.go:

- Three placeholder splice instead of one. splicePlaceholder() helper
  consolidates the JS-assignment encoding.
- handleDashboardPage pre-fetches the user's saved layout via
  dashboardLayout.GetOrSeed and inlines the WidgetCatalog (code-
  resident — always inlined so the widget picker can boot on knowledge-
  platform-only deploys too).

dashboard.ts client:

- New InboxSummary / InboxEntry / DashboardLayoutSpec / DashboardWidgetRef
  types mirroring the Go shapes.
- settingsFor(key) reads per-widget settings (count, horizon_days) from
  the active layout; defaults fall back to catalog values.
- Existing renderers (Deadlines, Appointments, Activity, Agenda) thread
  count + horizon settings — backend now returns 60d / LIMIT 40 so the
  client narrows per the user's widget config.
- New renderInbox() renders the inbox-approvals widget with summary
  copy ("N offene Freigaben warten auf dich"), top-N entry list, and
  the empty state.
- applyLayout() walks the saved spec and (a) hides widgets whose
  layout entry is visible:false and (b) reorders visible widgets via
  parent.appendChild within their existing parent — preserves the
  .dashboard-columns 2-up grid for deadlines+appointments.
- filterByHorizonDays() filters list items by date relative to today.
- Boot wiring: read __PALIAD_DASHBOARD_LAYOUT__ at mount; if missing,
  best-effort fetch /api/me/dashboard-layout and re-render once data
  has landed. Factory order baked into dashboard.tsx is the fallback
  so a hydration failure never breaks the dashboard.

i18n: 5 new keys per language for the inbox widget. 2528 → 2533.

go build + go vet + go test ./internal/... -short + bun run build all
clean. Triple placeholder verified present in dist/dashboard.html.

Pixel-identical factory render budget: every previously-visible widget
keeps its DOM markup, classes, IDs, and parent. New widget (inbox-
approvals) lands between agenda and activity per the factory layout
ordering in WidgetCatalog. Visible regression on the factory layout is
+1 section (inbox-approvals), expected per m's Q3 pick.
2026-05-20 13:55:56 +02:00
mAi
15bcba5d7c feat(dashboard): t-paliad-219 Slice A3 — widen windows + add InboxSummary
Two changes to DashboardService for the configurable dashboard:

1) Widen upcoming windows from 7d/LIMIT 10 → 60d/LIMIT 40 for both
   loadUpcomingDeadlines and loadUpcomingAppointments. Per design §18
   Note B, the per-widget horizon dropdown (7/14/30/60 days) filters
   client-side from a single payload — server-side widening preserves
   the Q4 "one big payload" pick without forcing per-widget endpoints.
   Existing tests pass: the dashboard CTE bucket math is unchanged and
   the wider rows-list is a superset of what /api/dashboard returned
   before.

2) Add InboxSummary { pending_count, top: []InboxEntry } to DashboardData
   for the new inbox-approvals widget (Q3 expansion). Powered by
   ApprovalService.PendingCountForUser + ListPendingForApprover with
   Limit=InboxTopCap (10). InboxEntry is the minimum needed to render
   a clickable preview line: request id, entity_type/title, project,
   requester, requested_at.

   ApprovalService is wired post-construction via
   DashboardService.SetApprovalService to avoid a circular constructor
   dependency. When unwired (knowledge-platform-only deployments,
   tests), loadInboxSummary is a no-op and the widget renders its
   empty state.

3 new pure-function tests: nil-approvals no-op, SetApprovalService
wiring, InboxTopCap sanity.

go build + go vet + go test ./internal/... -short all clean.
2026-05-20 13:55:56 +02:00
mAi
48f78a713b feat(dashboard): t-paliad-219 Slice A2 — HTTP handlers + service wiring
Four endpoints for the per-user dashboard layout:

- GET  /api/me/dashboard-layout         (auto-seeds factory on first call)
- PUT  /api/me/dashboard-layout         (validates against catalog)
- POST /api/me/dashboard-layout/reset   (overwrites with factory default)
- GET  /api/dashboard-widget-catalog    (catalog metadata for the picker)

Catalog endpoint is DB-independent by design — knowledge-platform-only
deployments (no DATABASE_URL) still surface the widget metadata. The
layout endpoints 503 when the service is unwired, matching the pattern
established by handleListCardLayouts / handleListPinnedProjects.

Wired through services.Services → handlers.dbServices via the
DashboardLayout field. main.go gains a single NewDashboardLayoutService
call next to NewCardLayoutService.

ErrInvalidInput from the service maps to 400; everything else flows
through writeServiceError for the existing 500/503 fallthrough.

go build + go vet + go test ./internal/services/ -short all clean.
2026-05-20 13:55:56 +02:00
mAi
a421bff856 feat(dashboard): t-paliad-219 Slice A1 — user_dashboard_layouts storage + service
Migration 109 + DashboardLayoutSpec + Service + WidgetCatalog. No HTTP
handlers and no frontend yet — those land in A2/A3/A4 as separate commits
for cleaner review.

Why slot 109 (not 107 from the design doc): leibniz claimed 107 for
caldav_sync_log.binding_id and 108 for caldav_mkcalendar_capability after
the design was filed. Boltzmann's gap-tolerant runner (c85c382) lets any
embedded migration apply regardless of authoring order.

What ships:

- paliad.user_dashboard_layouts table: single-row PK on user_id (Q2 pick
  was single layout per user — no named-layout switcher). RLS owner-only,
  mirrors user_card_layouts / user_views patterns.
- DashboardLayoutSpec: { v: 1, widgets: [{ key, visible, settings? }] }.
  Validation is strict on write (catalog membership + per-widget settings
  schema, duplicate-key check, 32-widget cap, version pin). SanitizeForRead
  is forgiving — unknown keys dropped silently per design §10 versioning
  rule.
- DashboardLayoutService: GetOrSeed (auto-seeds factory default on first
  call, idempotent under concurrent first-load via ON CONFLICT DO NOTHING),
  Update (validates + upserts), ResetToDefault.
- WidgetCatalog: 7 v1 widget defs (deadline-summary, matter-summary,
  upcoming-deadlines, upcoming-appointments, inline-agenda, recent-activity,
  inbox-approvals). Per-widget WidgetSettingsSchema with CountOptions +
  HorizonOptions per design §18 Note B. pinned-projects const reserved
  but omitted from KnownWidgetKeys until Slice C lands its widget module.
- 18 pure-function tests pin: factory layout shape, validation failures
  (wrong version / over cap / unknown key / duplicate / bad settings),
  sanitize-on-read (drop unknown / noop on clean / bump version), JSON
  round-trip, catalog completeness, nil-schema behaviour.
- 4 live-DB tests (skipped without TEST_DATABASE_URL): GetOrSeed
  auto-seeds + idempotent, Update round-trips, Update rejects invalid,
  ResetToDefault overwrites.

Migration SQL dry-run live in BEGIN..ROLLBACK against supabase — clean.
go build + go test ./internal/services/ -short both clean.

Slice C0 (pin-machinery) from the design doc is OBSOLETE — paliad
.user_pinned_projects + PinService already exist (pre-dates t-paliad-219).
Slice C in the original plan becomes a single PR adding the
pinned-projects widget module that reads from the existing service.

Design: docs/design-dashboard-configurable-2026-05-20.md §5 + §18.
2026-05-20 13:55:56 +02:00
mAi
0aa81139a3 Merge: t-paliad-212 Slice 2c — MKCALENDAR + Google-degrade
Completes the CalDAV multi-calendar product. Slice 2 (a + b + c) is now
shipped end-to-end.

- mig 108 — user_caldav_config.supports_mkcalendar (tri-state: NULL=unprobed,
  TRUE=show create radio, FALSE=Google-degrade UX) + mkcalendar_probed_at.
  Capability lives on the server-creds row per Q2 — capability is per-server.
- POST /api/caldav-mkcalendar — issues MKCALENDAR + creates matching binding
  in one tx; 501 if probe=false; 409 on name conflict; 5xx upstream.
- caldav_client.go: OPTIONS probe (Allow: header parse) + synthetic
  fallback (MKCALENDAR against /.paliad-probe-<rand>/ then DELETE) for
  legacy SOGo / misconfigured Radicale that don't expose MKCALENDAR in
  Allow. Probe runs once + caches.
- 'Create new calendar' radio in the add-modal — visible only when
  supports_mkcalendar=TRUE. Slugifies display_name → calendar path with
  -N collision retry; gives up after 3 with 'pick a name yourself' error.
- Google-degrade UX (probe=FALSE): create-button hidden, bilingual notice
  surfaced, manual-URL input with PROPFIND-Depth-0 validation on submit,
  NO OAuth bounce.

t-paliad-212 complete: Slice 1 (mig 101 schema + bootstrap binding) +
Slice 2a (sync engine cut-over + mig 107 binding_id) + Slice 2b (write
APIs + picker UI) + Slice 2c (this). Hierarchy scopes
(client/litigation/patent/case) remain parked for Slice 3 per the master
design.
2026-05-20 13:26:45 +02:00
mAi
fbd087e0cd feat(caldav): Slice 2c MKCALENDAR + Google-degrade (t-paliad-212)
Final Slice 2 sub-slice: users on iCloud / Fastmail / Nextcloud /
Radicale / Baikal / SOGo can now create a brand-new calendar from the
Paliad UI with one click; users on Google CalDAV (and any future
no-MKCALENDAR provider) get a clean degrade UX that nudges them to
create the calendar in their provider's app and paste the URL back.
Per m's Q2 pick, the capability lives on user_caldav_config so the
probe runs once per server change, not per modal open.

Schema (mig 108)
- paliad.user_caldav_config.supports_mkcalendar boolean — NULL =
  unprobed, TRUE = supported, FALSE = degrade.
- paliad.user_caldav_config.mkcalendar_probed_at timestamptz — used
  by the next round of probes after SaveConfig invalidates.
- Idempotent (information_schema column-exists checks) + assertion.

CalDAV client
- ProbeMKCalendar: OPTIONS Allow header first; on absence of
  MKCALENDAR, falls back to a synthetic MKCALENDAR against a
  random .paliad-probe-XX/ path (with DELETE cleanup) to catch
  legacy SOGo / misconfigured Radicale (design §4.2).
- MakeCalendar: issues MKCALENDAR with displayname + VEVENT-only
  supported-components; returns ErrCalendarNameTaken on 405 so
  the service layer can retry with a disambiguating suffix.
- Sentinel errors ErrCalendarNameTaken, ErrMKCalendarUnsupported.

Service
- CalDAVService.ensureMKCalendarProbed: lazy probe on first
  /api/caldav-discover call after credential change; result persisted
  via UPDATE on user_caldav_config. DiscoverCalendars response now
  carries supports_mkcalendar so the UI can show / hide the create-new
  radio.
- CalDAVService.MakeCalendar: re-probes if needed, issues MKCALENDAR
  via the client (with 3-try -XX-suffix retry on name collision),
  creates the matching binding, kicks off PushBindingNow. Returns
  the partial result on push failure so the UI can show "created but
  initial sync failed".
- InvalidateDiscoveryCache now also clears supports_mkcalendar so a
  re-configured server gets re-probed on next open.

HTTP API
- POST /api/caldav-mkcalendar — {display_name, scope_kind, scope_id?,
  include_personal?} → 201 {calendar_path, binding, initial_pushed}.
  Errors: 501 supports_mkcalendar=false, 409 name conflict, 5xx
  upstream. Partial-success (binding created, push failed) carries
  initial_sync_error in the body so the UI can surface both bits.

Frontend
- Add-modal source picker becomes a 3-way radio: "Existierenden
  wählen" / "Neuen Kalender erstellen" / "Eigene URL eingeben".
  Create radio is visible only when supports_mkcalendar=true;
  when false, the bilingual Google-degrade notice is shown
  beneath the source picker.
- Submit dispatches to /api/caldav-mkcalendar (create) or
  /api/caldav-bindings (existing / custom).
- 6 new i18n keys DE+EN under caldav.bindings.modal.source.*
  + caldav.bindings.error.create_*.

Verification
- mig 108 dry-run against live Supabase: both columns added, nullable,
  no constraint surprise.
- go build ./... + go test ./internal/services/ ./internal/handlers/ +
  bun run build all clean.

Slice 2 complete (2a + 2b + 2c). Slice 3 (hierarchy scopes:
client/litigation/patent/case) and Slice 4 (drop legacy scalar
caldav_uid/caldav_etag) remain.
2026-05-20 13:26:23 +02:00
mAi
8bac1b4f88 Merge: t-paliad-212 Slice 2b — CalDAV write APIs + picker UI
- POST /api/caldav-bindings — synchronous first push (Q5 pick); 201 with
  {binding, initial_pushed} (or initial_sync_error on push failure after
  create).
- PATCH /api/caldav-bindings/{id} — lazy cleanup on scope-change (Q6).
- DELETE /api/caldav-bindings/{id} — best-effort remote DELETE → binding
  drop; partial failure disables for next-tick retry (202).
- GET /api/caldav-discover — RFC 6764 chain (current-user-principal →
  calendar-home-set → child PROPFIND); 5-min server-side cache invalidated
  on creds change (Q4 pick). VEVENT-only filter. Surfaces
  degrade_reason='google-cuhs-empty' when Google's CUHS isn't enumerable.
- CalDAVService.EnsureLoop — spawns the per-user goroutine when a user
  creates their first binding.
- Picker UI on /einstellungen/caldav: Kalender section under the existing
  CalDAV-server creds form. Add modal is single-step (Q3) — discovery
  dropdown OR custom URL + scope radio (all_visible / personal_only /
  project). Edit modal preserves source, allows scope + enabled changes.
- ~25 new i18n keys under einstellungen.caldav.bindings.* (DE + EN).

Slice 2c (MKCALENDAR + Google-degrade polish) ships separately.
2026-05-20 13:18:20 +02:00
mAi
1fcfab7791 feat(caldav): Slice 2b write APIs + picker UI (t-paliad-212)
User-visible Slice 2 milestone: the /einstellungen/caldav Kalender
section now lets a user pin multiple calendars to Paliad via a
single-step add modal (Q3 of the Slice 2 brief). m greenlit
"all yes / all R" on 2026-05-20, so this lands with: synchronous
first-push on POST (Q5), lazy cleanup on PATCH scope change (Q6),
5-minute server-side cache on /api/caldav-discover (Q4),
calendar_path retained-but-deprecated (Q7).

Backend
- CalDAVService.PushBindingNow — runs one push pass for a single
  binding synchronously; called from POST /api/caldav-bindings so
  the modal closes with events already landed.
- CalDAVService.RemoveBinding — best-effort remote-event DELETE +
  binding row drop (§2.6 of brief). On partial remote failure,
  the binding is disabled instead of dropped and the handler
  surfaces 202 Accepted.
- CalDAVService.EnsureLoop — spawns the per-user sync goroutine
  for users who didn't have one before this request.
- CalDAVService.DiscoverCalendars — walks current-user-principal
  → calendar-home-set → child PROPFIND (RFC 6764 §6 / RFC 6638
  §10). Cached 5 minutes per user; invalidated on SaveConfig /
  DeleteConfig.
- caldav_client.go gains DiscoverCalendars + propfindHrefs +
  listCalendars + supporting multistatus types. VEVENT-only
  filter skips iCloud reminder lists / addr books.

HTTP API
- POST /api/caldav-bindings — create binding + sync first-push;
  201 with binding + initial_pushed count, or 201 with
  initial_sync_error when the push fails after binding creation.
- PATCH /api/caldav-bindings/{id} — partial update.
- DELETE /api/caldav-bindings/{id} — calls RemoveBinding;
  responds 204 (full cleanup) or 202 (partial — binding disabled
  for next-tick retry).
- GET /api/caldav-discover — returns {calendars, calendar_home}
  for the picker.

Frontend
- /einstellungen/caldav Kalender section: list of binding cards
  with enabled toggle / Edit / Remove. "+ Kalender hinzufügen"
  opens the single-step modal.
- Single-step add modal: source picker (discovery dropdown or
  custom URL toggle) + scope radio (all_visible / personal_only
  / project + project picker) + display name. Edit mode reuses
  the modal with the source field hidden.
- 32 new i18n keys under caldav.bindings.* (DE primary, EN
  parallel) covering modal copy, card actions, error messages,
  delete-confirm, scope labels.

Verification
- Live Supabase BEGIN..ROLLBACK: full CRUD flow exercised
  (create → patch display_name → patch scope → second
  all_visible after the first scope-shifts → delete);
  the partial unique index frees correctly when scope moves
  off all_visible, no race or constraint surprise.
- go build ./... + go test ./internal/... + bun run build all
  clean.
2026-05-20 13:18:00 +02:00
mAi
12ed8bb8da Merge: t-paliad-217 — unified modal primitive + suggest-changes rework
m greenlit 6 picks in §0a (2 divergences from inventor recs: Q1 full-edit
loosens t-paliad-138 4-Augen policy beyond date changes; Q4 broadcast.ts
retrofit included in this PR for primitive validation).

Slice A — components/modal.ts primitive + global.css block. Native <dialog>
substrate (browser-owned top-layer, ESC, focus). openModal({title, body,
primary, secondary, size, onClose}) returns a Promise<T|null>. Backdrop
click closes via target check on dialog click event. History pushState on
open + popstate listener for browser back-button close on mobile. Focus
restoration to previously-focused element on close. Mobile full-screen
takeover with max-height excluding the PWA bottom-nav.

Slice B — counter_payload allowlist expansion in approval_service.go.
Renames buildRevertSetClauses → buildEntityFieldSetClauses; separates the
'revert from pre_image' allowlist (defence-in-depth for Reject) from the
'counter from approver' allowlist (wider, for SuggestChanges). New
editable fields for SuggestChanges: deadline {title, description, notes,
rule_code, event_type_ids — junction table writes}; appointment {title,
description, location, appointment_type}.

Slice C — approval-edit-modal full rewrite using openModal. Every field
in the requester's payload becomes editable; read-only context section
shows project / requester / created_at / approval status pill /
event-type chips (where not editable). Vorschlagskommentar prominent.
Submit disabled until form dirty OR note has content (mirrors
ErrSuggestionRequiresChange server-side guard).

Slice D — broadcast.ts retrofit onto the new primitive. Drops bespoke
.modal-broadcast CSS overrides + the per-modal ESC / close / backdrop
handlers. Demonstrates the primitive's generality.

Slice E (i18n + CSS cleanup) folded into Slice A's commit — all new keys
authored once. Legacy .modal-overlay / .modal-card / .modal-content / .modal
CSS retained for the other 7 unmigrated modals (each migrates in a
follow-up PR).

2489 i18n keys; data-i18n attributes clean. No DB migration.
2026-05-20 13:06:23 +02:00
mAi
7654ce6833 feat(modals): t-paliad-217 Slice D — broadcast.ts onto openModal primitive
m's Q4 lock-in (2026-05-20): retrofit the richest existing modal —
broadcast.ts (bulk team-email compose) — onto the unified primitive to
demonstrate its generality on a real-world surface.

Changes:
  - Body is built imperatively (renderBody + wireBody) and handed to
    openModal as the body element. The submit logic reads form state
    from that element on primary-handler invocation.
  - Drops the per-modal ESC + close + backdrop + overlay-stack handlers
    — the primitive owns them.
  - Drops the bespoke .modal-broadcast { width / max-height / padding /
    label / input / textarea } CSS overrides. The primitive's data-size
    handles width; the existing .form-field rules handle inputs; only
    the textarea's code-monospace font is kept as a broadcast-specific
    override (placeholder syntax needs to read as code).
  - Primary action is "Senden (N)" — clicks invoke the existing
    onSubmit logic which POSTs to /api/team/broadcast and on success
    shows the per-recipient report inline then closes via the
    setTimeout(close, 2500) pattern.

The recipient-list toggle + template dropdown + markdown placeholder
hints are unchanged.

i18n + the .broadcast-recipient-* / .broadcast-recip-* / .broadcast-hint
/ .broadcast-error / .broadcast-success content classes are unchanged.
2026-05-20 13:05:59 +02:00
mAi
f3b947e3ad feat(approvals): t-paliad-217 Slice C — approval-edit-modal full rewrite
Rewrite atop the unified openModal() primitive (Slice A). Drops the
per-modal ESC + focus + backdrop + close-button handlers — the
primitive owns them.

New three-section body per design §2:
  1. Editable fields. Every editable column on the entity, per m's Q1
     Reading A lock-in:
       deadline:    title, due_date, original_due_date, warning_date,
                    rule_code, description, notes, event_type_ids
                    (attached via the existing event-types picker).
       appointment: title, start_at, end_at, location, appointment_type,
                    description.
  2. Read-only context. Project title, requester, requested_at, current
     approval status. Renders as a definition-list with muted dt/dd
     pairs so the eye lands on the editable section first.
  3. Vorschlagskommentar (note). Always present, prominent.

Block labels matching /deadlines/new + views editor — reuses the
existing .form-field shapes for typography + spacing parity with the
rest of the app (m's Q6 lock-in).

inbox.ts gains projectTitle / requesterName / requestedAt hydration
from the per-row API response so the context section has data to
render. Falls back gracefully when missing.

Submit-button gate (in the openModal primary handler): refuses when no
field is dirty AND the note is empty. Mirrors the server's
ErrSuggestionRequiresChange.

CSS .approval-suggest-* classes added to global.css alongside the
modal primitive block (committed in Slice A).
2026-05-20 13:05:59 +02:00
mAi
f0b08e9d06 feat(approvals): t-paliad-217 Slice B — counter_payload allowlist expansion
m's t-paliad-217 Q1 lock-in (2026-05-20): the suggest-changes modal lets
the approver edit EVERY field on the underlying deadline / appointment,
not just the date allowlist that triggers approval. Server-side support
for the wider counter shape:

  - buildCounterSetClauses (new) — the counter-allowlist:
      deadline:    title, due_date, original_due_date, warning_date,
                   description, notes, rule_code (event_type_ids handled
                   separately via junction-table rewrite).
      appointment: title, start_at, end_at, description, location,
                   appointment_type.
  - buildRevertSetClauses (existing) stays narrow — Reject only restores
    what pre_image actually contains (defence-in-depth: a hostile UPDATE
    on the request row must not let arbitrary fields be reverted, and
    pre_image is server-written so what's in there is what we trust).
  - rewriteDeadlineEventTypes — junction-table DELETE+INSERT for the
    deadline_event_types m-to-m when counter_payload carries
    event_type_ids. Runs in the same tx as the entity UPDATE.
  - applyEntityUpdate — switched from buildRevertSetClauses to
    buildCounterSetClauses; gained the event_type_ids branch for
    deadlines.
  - SuggestChanges no-op validator — now uses buildCounterSetClauses
    so the wider field set counts as "differs".
  - title is treated as NOT NULL — whitespace-only counter title
    surfaces ErrSuggestionRequiresChange (defence-in-depth against the
    column's own NOT NULL CHECK).

Tests:
  - TestApprovalService_SuggestChanges_TitleOnlyCounter — title diff
    succeeds; entity title updates.
  - TestApprovalService_SuggestChanges_NotesOnlyCounter — notes diff
    succeeds; entity notes column populates.
  - TestApprovalService_SuggestChanges_EmptyTitleRejected — whitespace-
    only title rejected with ErrSuggestionRequiresChange.

No DB migration needed (counter_payload jsonb already accepts arbitrary
shape; the change is in the column-allowlist switch on read).
2026-05-20 13:05:59 +02:00
mAi
760a0de931 feat(modals): t-paliad-217 Slice A + content additions — unified modal primitive
frontend/src/client/components/modal.ts — new openModal() primitive,
native <dialog>-backed. The browser handles top-layer stacking, ESC,
ARIA, and focus trap. We layer on top:
  - browser back-button closes the modal (history.pushState on open +
    popstate listener, matching m's Q5 lock-in)
  - focus restoration to whatever was focused before open (the native
    <dialog> doesn't do this)
  - backdrop click closes
  - close (×) button mandatory in the header, always rendered

CSS (global.css):
  - dialog.modal + .modal__{header,title,close,body,footer} block. Sizes
    sm/md/lg/full via data-size attr.
  - Phone breakpoint (≤32rem): full-screen takeover sitting ABOVE the
    PWA bottom-nav. max-height accounts for --bottom-nav-height (56px)
    and margin-bottom keeps the nav visible.
  - Legacy .modal-overlay / .modal-card / .modal-content / .modal stay
    in place for the ~7 unmigrated modals — the new BEM-style .modal__*
    avoids colliding with the legacy hierarchy. Cleanup is a follow-up
    PR after the last legacy modal flips.

i18n keys + i18n-keys.ts regenerated:
  - modal.close.label (DE/EN)
  - approvals.suggest.section.editable / .context (DE/EN)
  - approvals.suggest.context.{project,requester,requested_at,approval_status} (DE/EN)
  - approvals.suggest.field.{original_due_date,warning_date,rule_code,description} (DE/EN)
  - approvals.suggest.event_type_picker_unavailable (DE/EN)

(Slice C consumes the suggest.section/context/field keys; bundling them
here keeps the i18n.ts diff coherent.)
2026-05-20 13:05:59 +02:00
mAi
bc8dc9d048 Merge: t-paliad-212 Slice 2a — bindings-driven CalDAV sync (backend cut-over)
Sync engine pivots from scalar user_caldav_config.calendar_path to the
binding-driven loop over paliad.user_calendar_bindings. Invisible-but-shippable:
existing users keep working through the bootstrap binding row mig 101 created
for them; new bindings (Slice 2b UI) plug into the same loop.

- mig 107 — paliad.caldav_sync_log.binding_id (nullable FK ON DELETE SET NULL
  so audit history survives binding deletes) + partial index. Idempotent.
- CalendarBindingService — full CRUD + ListEnabled/ListAllEnabled, scope
  validation mirrors the CHECK constraints from mig 101.
- AppointmentTargetService — UpsertAfterPush, StaleForBinding,
  FindByUIDAndBinding. Authoritative source of per-target state going forward.
- CalDAVService rewritten: per-binding inner loop, ForBinding() scope filter
  (all_visible / personal_only / project — hierarchy scopes parked for Slice 3).
- REPORT calendar-multiget in caldav_client.go — collapses N GETs/min to one
  multistatus REPORT (fits inside iCloud/Google rate windows).
- Read-only GET /api/caldav-bindings (write APIs come in Slice 2b).
- caldav_sync_log writes carry binding_id; pre-mig-107 rows stay NULL.

First migration to land via the new gap-tolerant runner (boltzmann c85c382).
2026-05-20 13:05:46 +02:00
mAi
694c7a53ad feat(caldav): Slice 2a backend cut-over — bindings-driven sync (t-paliad-212)
Cuts the CalDAVService sync engine over from the Phase F scalar
calendar_path to the binding-row model introduced in Slice 1
(mig 101). Invisible-but-shippable: existing Phase F users keep
their backfilled all_visible binding, new users hitting the legacy
PUT /api/caldav-config get an auto-created all_visible binding so
the "configure → it just works" UX survives. Slice 2b adds the
picker UI and write APIs on top.

Schema (mig 107)
- paliad.caldav_sync_log.binding_id (nullable, FK ON DELETE SET NULL
  so audit history survives binding deletes).
- Per-binding index for the read path.
- Idempotent (column-exists DO block) + assertion.

Services
- CalendarBindingService: ListForUser, ListEnabled, ListAllEnabled,
  Get, Create, Update, Delete, SetSyncStatus. Mirrors the table
  CHECK constraints client-side so the API returns useful 400s.
- AppointmentTargetService: UpsertAfterPush, FindByUIDAndBinding,
  ListForBinding, DeleteByAppointmentAndBinding, StaleForBinding.
  Replaces SetCalDAVMeta as the authoritative source of per-target
  state; legacy scalar columns still written for back-compat.
- AppointmentService.ForBinding: scope filter implementing
  all_visible, personal_only, project. Hierarchy scopes
  (client/litigation/patent/case) return ErrUnsupportedScope —
  Slice 3 wires them via the existing path-based descendant
  predicate.

Sync engine rewrite
- CalDAVService.Start iterates ListAllEnabled to discover users
  with at least one enabled binding.
- runSyncOnce loops bindings, writes one caldav_sync_log row per
  (user, binding) tick, rolls the worst-case error up onto
  user_caldav_config.last_sync_error so /api/caldav-config still
  shows aggregate status.
- pushBinding pushes the ForBinding() slice + cleans up
  stale-target rows (project unshared, scope PATCHed).
- pullBinding swaps the N×GET pattern for REPORT calendar-multiget
  (RFC 4791 §7.9; chunked at 100 hrefs to stay inside provider rate
  limits) and reconciles via per-target etag comparison.
- Hooks (OnAppointmentCreated/Updated/Deleted) fan out across the
  user's matching bindings using appointmentInBinding() — best
  effort per binding, same 30s timeout as Phase F.
- SaveConfig auto-creates an all_visible binding on first-time
  configure so Phase F "configure → events appear" survives the
  cut-over.

CalDAV client
- New ReportMultiget verb implementing RFC 4791 §7.9
  calendar-multiget. Chunked at multigetMaxHrefs=100 to fit Google
  Calendar's per-request cap.

HTTP API
- GET /api/caldav-bindings — read-only list of the authenticated
  user's bindings. Slice 2b adds POST/PATCH/DELETE.

Verification
- BEGIN..ROLLBACK against live Supabase (PG 15.8): mig 107 applies
  cleanly + the synthetic two-binding scenario lands the project
  appointment in both bindings while keeping the personal one in
  master only; cascade on appointment-delete drops targets; cascade
  on binding-delete drops targets AND sets sync_log.binding_id NULL.
- go build ./..., go test ./internal/..., bun run build all clean.

Backwards-compat
- paliad.appointments.caldav_uid / caldav_etag still written in
  pushBinding so legacy readers see fresh values. Slice 4 drops
  them after telemetry confirms no path still reads them.
2026-05-20 13:05:27 +02:00
mAi
81cb89f68e Merge: t-paliad-214 Slice 2 — project-subtree Excel export (GET /api/projects/{id}/export)
Generalises Slice 1's writer abstraction to a full project subtree export.

- Backend: ExportService.WriteProject + GET /api/projects/{id}/export?direct_only=0|1
- 24 sheets (16 entity + 8 reference) — projects, project_teams, project_partner_units,
  deadlines, appointments, parties, notes, documents, project_events,
  approval_requests, approval_policies (triple-source attribution: project + ancestor
  + partner-unit default), checklist_instances, partner_units, partner_unit_members,
  users_referenced (restricted), system_audit_log_subset.
- Permission gate: responsibility ∈ {lead, member} or global_admin (m's Q1 pick).
- Cross-subtree FK detection appends warning rows to __meta (m's Q3 pick).
- Filename: paliad-export-project-<slug>-<short-uuid>-<timestamp>.zip with 8-hex
  disambiguator (m's Q5 pick).
- Audit row: scope_root + metadata.root_path (ltree, survives project deletion).
- 403 bilingual DE/EN error copy.
- Frontend: 'Daten exportieren' button on /projects/{id} next to existing tabs.

No new migration. All builds + tests green.
2026-05-20 13:04:14 +02:00
mAi
a6b2979a94 feat(export): t-paliad-214 Slice 2 frontend — Daten exportieren button on /projects/{id}
Adds a Datenexport action button at the end of the project-detail tabs
nav. Hidden by default; revealed when canExportProject() returns true
(global_admin OR direct team responsibility ∈ {lead, member}) — mirror
of the server-side §4 gate. Server re-enforces on the request.

Click handler swaps in a transient <a download> that hits
GET /api/projects/{id}/export — browser handles the download via
Content-Disposition. Same pattern as the personal export in
client/settings.ts.

4 new i18n keys (DE+EN):
  - projects.detail.tab.export (n/a — uses .export.button on the action)
  - projects.detail.export.button = "Daten exportieren" / "Export data"
  - projects.detail.export.tooltip with hint about subtree inclusion

Total i18n keys now 2479.
2026-05-20 13:03:57 +02:00
mAi
8f1f88b517 feat(export): t-paliad-214 Slice 2 backend — project-subtree sync export
Adds GET /api/projects/{id}/export?direct_only=0|1 streaming a
deterministic project-subtree bundle in the same xlsx + JSON + per-sheet
CSV shape as Slice 1's personal export. 16 entity sheets per design §2:
projects + project_teams + project_partner_units + deadlines +
appointments + parties + notes (4-way polymorphism resolved) + documents
(metadata only) + project_events + approval_requests + approval_policies
(triple-source attribution with `source` column for Q4 lock-in) +
checklist_instances + partner_units (attached only) +
partner_unit_members (members of attached units only) + users_referenced
(FK-referenced users only) + system_audit_log_subset. Personal sidecars
explicitly excluded; reference sheets (proceeding_types, event_types,
deadline_rules, courts, …) ship for standalone interpretability.

§4 permission gate enforced server-side:
  - global_admin can export anything, OR
  - direct project_teams membership with responsibility ∈ {lead, member}
  - Observers + Externals + derived-only partner-unit users → 403
    bilingual ("Datenexport ist nur Team-Mitgliedern (Lead / Member)
    vorbehalten / Data export is restricted to project team members").

Cross-subtree FK detection (Q3 lock-in: keep + warn) runs one
lightweight SELECT against projects.counterclaim_of and appends one
warning row to __meta.warnings per outbound reference. Recipients can
choose to keep or strip the FK on re-import.

Filename includes 8-hex-char short-uuid disambiguator (Q5 lock-in):
paliad-export-project-<slug>-<short-uuid>-<ts>.zip — two projects with
identical titles produce different filenames even when archived
together.

Audit row in paliad.system_audit_log (no new migration — already
supports scope='project'): metadata carries root_label + root_path
(ltree) + direct_only flag (Q6 lock-in) so the audit row remains
interpretable after the project is deleted.

__meta sheet + README.txt extended to surface project-scope fields:
scope_root_label, scope_root_path, direct_only.

ExportFilename signature extended to take a rootID; Slice 1 callsite
updated to pass uuid.Nil.

8 new pure-function tests pin: sheet registry shape (24 sheets in
order), triple-source approval_policies SQL tags, direct_only narrows
subtree to root-only, no-personal-sidecars guard, attached-only
partner_units filter, shortUUIDSuffix shape, project-scope meta rows,
short-uuid filename collision avoidance.
2026-05-20 13:03:57 +02:00
mAi
d5c80febb1 Merge: t-paliad-215 Slice 2 (code only) — patent_number_upc helper (UPC patent-number prettifier)
Pure helper + tests, no schema change. Reformats project.patent_number from
'EP 1 234 567 B1' to 'EP 1 234 567 (B1)' for UPC briefs (e.g. SoC / SoD
templates). Mirrors legalSourcePretty's shape: pure function, no schema,
register as {{project.patent_number_upc}} in the variable bag, 8 unit tests.

51 LoC helper + 35 LoC tests (vs the ~40+6 design estimate — small extras
for UPC documentation block and a couple more edge cases).

Slice 2 .docx templates (3 DE-LG + 2 UPC-CFI + 2 family skeletons) are
authored separately in HL/mWorkRepo via the python-docx flow; that side
of Slice 2 follows in a separate commit on the templates repo.
2026-05-20 12:59:56 +02:00
mAi
1765d5e55f feat(submissions): t-paliad-215 Slice 2 — patent_number_upc helper
UPC briefs parenthesise the patent kind code ("EP 1 234 567 (B1)")
where the DE convention runs it inline ("EP 1 234 567 B1"). Slice 2
adds the {{project.patent_number_upc}} placeholder for the new UPC
templates (Q-S2-4 locked at 'all yes' on 2026-05-20).

Pure function alongside legalSourcePretty. Trailing single-letter +
single-digit kind code regex; everything else preserved. Pass-through
on unrecognised shapes — the lawyer's draft never sees a number worse
than the source value.

Wired into addProjectVars so every render exposes both forms
({{project.patent_number}} and {{project.patent_number_upc}}). UPC
templates pull the parenthesised form; DE templates ignore it.

8 test cases (more than the 6 in the brief) covering:
- EP B1 / EP A1 — common case
- DE national with kind code
- No kind code → pass-through
- Whitespace trimming
- Empty input
- WO publication number (no kind-code shape) → pass-through
- Two-digit kind code (B12) → pass-through (intentional — real EP
  kind codes are single-letter + single-digit)

No schema change, no migration, no var-bag namespace additions
beyond the one new placeholder.
2026-05-20 12:59:40 +02:00
mAi
c85c382b1b Merge: t-paliad-218 — gap-tolerant migration runner with applied-set tracker
Replace single-counter golang-migrate tracker with a hand-rolled runner
over embed.FS that tracks applied versions as a set in
paliad.applied_migrations. Fixes the 2026-05-20 production drift where
mig 103 was silently skipped (fermi's mig 104/105 deployed first → counter
jumped past 103 → Slice A schema never installed; recovered manually).

Now: any embedded migration not present in applied_migrations gets
applied on next deploy, regardless of authoring order. Race can't repeat.

- New hand-rolled ApplyMigrations over embed.FS in internal/db/migrate.go.
  - Acquires pg_advisory_lock(hash('paliad.applied_migrations') → int64).
  - Creates paliad.applied_migrations(version, name, applied_at, checksum) if missing.
  - Bootstraps from paliad.paliad_schema_migrations.version=106 when
    applied_migrations is empty (INSERT 1..106 with ON CONFLICT DO NOTHING,
    checksum=NULL — verified by hand for mig 103 which was manually applied).
  - Scans embed.FS for \d+_*.up.sql.
  - Hard-fails on version collisions (≥2 files at same version) and on
    rename mismatch (DB name vs disk name for already-applied version).
  - Applies pending ASC, each in one tx with INSERT + sha256(file_bytes).
- Drops github.com/golang-migrate/migrate/v4 from go.mod.
- Test suite updated: internal/db/migrate_test.go and cmd/server/main_smoke_test.go
  read paliad.applied_migrations. Dirty-flag check removed.

Drift-detection verify is deferred (checksums populated but not verified
in v1). Down-migrations remain on-disk as reference but not callable from
the runner (no v1 use case). Legacy tracker tables drop in a follow-up
mig 108 after burn-in.

Priority merge: unblocks parallel migration work by leibniz (mig 107/108
on t-paliad-212 CalDAV Slice 2) and newton (mig 107 on t-paliad-219
dashboard).
2026-05-20 12:59:35 +02:00
mAi
7a359989a9 feat(db): t-paliad-218 — gap-tolerant migration runner with applied-set tracker
Replaces the golang-migrate single-counter tracker with a hand-rolled
runner over embed.FS that tracks applied state as a set in
paliad.applied_migrations (version PK, name, applied_at, checksum).

Closes the parallel-merge skip-hole the 2026-05-20 mig-103 incident
exposed (m/paliad#44): a migration whose version is missing from
applied_migrations runs on the next deploy regardless of which higher
versions are already applied. Gaps are first-class.

Slice 1 of the design at docs/design-migration-runner-applied-set-2026-05-20.md.
All eight design decisions m-picked = inventor recommendation.

Runner contract:
- Ensure paliad schema → pg_advisory_lock(hash('paliad.applied_migrations'))
  → CREATE TABLE IF NOT EXISTS applied_migrations.
- bootstrapFromLegacyTracker: if applied_migrations is empty and the legacy
  paliad.paliad_schema_migrations row is present and clean, INSERT rows
  1..N for every on-disk version with checksum=NULL via ON CONFLICT DO
  NOTHING. Hard-fail if legacy tracker is dirty (operator must recover).
- scanEmbeddedMigrations: hard-fail on two .up.sql files sharing a version
  prefix — the failure mode the post-mortem exposed.
- checkNameAgreement: hard-fail on rename-after-apply mismatch (disk name
  for an already-applied version != DB name).
- applyOne: SQL body + INSERT(version, name, now(), sha256(file_bytes))
  in one transaction. All-or-nothing per migration.

Checksums populated on apply for future drift detection; rows backfilled
from the legacy tracker carry NULL (we can't fabricate a hash for what
golang-migrate applied historically). Verify-on-deploy intentionally
deferred to a focused follow-up — single if-block flip when m wants it.

Up-only runner. .down.sql files stay in embed.FS as reference; manual
roll-back path is psql + DELETE FROM paliad.applied_migrations WHERE
version=N. Zero call sites for migrate.Down in the codebase today.

Drops github.com/golang-migrate/migrate/v4 from go.mod (no other
importers; verified via grep).

Tests:
- internal/db/migrate_test.go: TestMigrations_DryRun walks pending =
  on_disk \\ applied (read from paliad.applied_migrations, missing-table
  → empty set), runs each in BEGIN/ROLLBACK against the scratch DB.
- cmd/server/main_smoke_test.go: TestBootSmoke asserts the applied set
  equals the on-disk set exactly (not just max-version-match) — catches
  the skip class the post-mortem documented. Dirty-flag check removed
  (rows are committed or absent, not 'dirty').
- All 45 service-test call sites of db.ApplyMigrations work unchanged
  (same signature, same fresh-DB behavior).

Follow-up: mig 108_drop_legacy_trackers (DROP paliad.paliad_schema_migrations
and public.paliad_schema_migrations) after one or two deploys of burn-in
on this slice.
2026-05-20 12:59:16 +02:00
mAi
1a8eee2a10 docs(t-paliad-207): fermi close-out assessment — verdict (A) DONE 2026-05-20 10:46:48 +02:00
mAi
4472faf224 docs(t-paliad-207): close-out assessment — verdict (A) DONE
Read-only audit of the t-paliad-207 surface per paliadin's 2026-05-20
re-engage instruction. Six commits shipped under this task are now
merged. Two larger follow-ups (m/paliad#39 youpc-laws ingest + #41 DE
combined timeline) are filed with concrete scope. Remaining tail is
optional polish, best handled as discrete issues rather than a parked
inventor.
2026-05-20 10:46:48 +02:00
mAi
2504e50f29 Merge: t-paliad-216 — hertz Slice C — Verlauf labels for *_approval_changes_suggested events
Final slice on the suggest-changes feature. Pure i18n addition (4 keys,
DE + EN). The existing convention-based translateEvent() helper picks
up event.title.{slug} / event.description.{slug} keys by event_type, so
the new deadline_approval_changes_suggested + appointment_approval_
changes_suggested events light up the Verlauf timeline (projects-
detail.ts) and the admin audit log without any renderer changes.

t-paliad-216 complete across all 3 slices:
  - Slice A (backend)  → merged 6a20241
  - Slice B (frontend) → merged 741cab4
  - Slice C (Verlauf)  → this merge

Feature is now live: approver opens an editable modal on a pending
update-request, edits the requester's proposed values into a counter-
proposal + writes a Vorschlagskommentar, submits → server in one tx
closes the old row as changes_requested, reverts the entity, spawns a
new pending approval_request authored by the suggesting approver with
counter_payload as its new payload. Original requester now sees the
counter in /inbox and can approve / reject / counter-suggest back.
2026-05-20 10:07:04 +02:00
mAi
d244ff5158 i18n(approvals): t-paliad-216 Slice C — Verlauf labels for changes_suggested
Adds the DE + EN event title + description keys for the two new
*_approval_changes_suggested event_types emitted by SuggestChanges
(Slice A). The existing translateEvent() helper picks up
event.title.{event_type} and event.description.{event_type} keys by
convention — no renderer code changes are needed; this slice is pure i18n.

Surfaces covered:
  - projects-detail.ts Verlauf tab (per-project timeline)
  - admin-audit-log.ts admin audit table
Both call translateEvent() which now resolves the new keys.

No icon system is tied to event_type slugs; no server-side event_types
registry needs the new types pre-registered (the slugs are emitted into
project_events.event_type as free text by the service layer, then
localised at read time).

This is the final slice for t-paliad-216. Slice A (mig 103 + service +
handler + tests), Slice B (frontend modal + /inbox UI + back-link
hydration), Slice C (Verlauf i18n) all on main now.
2026-05-20 10:06:33 +02:00
mAi
741cab4d25 Merge: t-paliad-216 — hertz Slice B — "Suggest changes" frontend modal + /inbox UI
Slice B per docs/design-approval-suggest-changes-2026-05-19.md §3.4-3.6.
Slice A backend (mig 103 + service + handler + tests) already merged at
6a20241; this is the user-facing layer.

- i18n — DE + EN keys for suggest-changes UI: button label
  ("Änderungen vorschlagen" / "Suggest changes"), changes_requested
  status pill ("Abgelehnt mit Vorschlag" / "Declined with changes"),
  modal title + buttons + validation errors, filter chip, back-link.

- shape-list + filter chip — 4th action button alongside
  approve/reject/revoke, only rendered for lifecycle='update' rows
  (the only entity-mutation flow worth counter-proposing on; create
  and delete don't make sense). `changes_requested` filter chip added
  to /inbox.

- approval-edit-modal component — new
  frontend/src/client/components/approval-edit-modal.ts. Renders the
  requester's payload as editable fields per entity_type (deadlines:
  due_date / title / description; appointments: title / start_time /
  end_time / location). Free-text Vorschlagskommentar textarea at the
  bottom. Submit gated until form is dirty OR note has text — mirrors
  the server's ErrSuggestionRequiresChange guard so the user can't
  send a no-op.

- /inbox wiring — clicking the suggest-changes button opens the modal
  (not window.prompt). POST to the new endpoint; on success, refresh
  the bar: old row flips to changes_requested, new pending row
  appears.

- Server-side back-link hydration — extended the ApprovalRequestView
  with a computed next_request_id (reverse lookup on
  previous_request_id) so the changes_requested row can render
  "→ Neuer Vorschlag von {approver}" without an extra round trip.
  Single LEFT JOIN added to the hydrator.

Bun build clean (2473 i18n keys). Go build + tests green.

Slice C (Verlauf integration — *_approval_changes_suggested event
rendering on the project / deadline / appointment timelines) remains.
2026-05-20 10:03:24 +02:00
mAi
0263a0e932 feat(approvals): t-paliad-216 — server-side hydration for back-link
Server-side additions so /inbox can render the suggest-changes back-link
without an extra client round-trip:

  - ApprovalRequestView gains NextRequestID. Hydrated via correlated
    subquery on previous_request_id; mig 103's partial index makes the
    lookup O(1) per row.
  - view_service.go approvalRowSubtitle picks up the changes_requested
    case ("Abgelehnt mit Vorschlag von <decider>").
  - filter_spec.go validRequestStatuses includes "changes_requested" so
    user-views can filter on it.
  - handlers/approvals.go isValidInboxStatus accepts "changes_requested"
    on the /api/inbox/{mine,pending-mine}?status= query. Test case added
    to TestParseInboxFilter_DropsUnknownStatus.
2026-05-20 10:02:36 +02:00
mAi
0fd02bf033 feat(approvals): t-paliad-216 — wire suggest-changes click in /inbox
Suggest-changes branches off into handleSuggestChanges() instead of the
generic POST-with-prompt-note path. It:

  1. Fetches the full request payload + pre_image via GET
     /api/approval-requests/{id} — list payload may be stale.
  2. Opens approval-edit-modal with the entity_type-appropriate fields
     pre-populated.
  3. On submit, POSTs to /api/approval-requests/{id}/suggest-changes
     with {counter_payload, note}.
  4. On success, refreshes the filter bar (the OLD row flips to
     changes_requested, the NEW pending row appears) and the inbox badge.
  5. Stamps data-spawned-request-id on the OLD row's <li> so downstream
     code / tests can locate the counter without a re-query.

The error mapping (mapApprovalError) gains the two new codes
suggestion_requires_change + suggestion_lifecycle_invalid plus the
generic codes already handled. Body reads now go through `body.code`
preferentially with `body.error` fallback — the server uses {code,
message} envelopes.
2026-05-20 10:02:36 +02:00
mAi
dce98e273b feat(approvals): t-paliad-216 — approval edit modal component
New module: frontend/src/client/components/approval-edit-modal.ts.

The approver clicks "Änderungen vorschlagen" on a pending update-lifecycle
row; this modal opens with the requester's original payload pre-populated
in editable date inputs (per entity_type allowlist):
  - deadline:    due_date, original_due_date, warning_date
  - appointment: start_at, end_at

The pre_image value for each field renders as a "Vorher" hint so the
approver sees what's being changed before they commit a counter.

A free-text "Vorschlagskommentar" textarea sits below the inputs. The
submit button stays disabled until the form is dirty OR the note has
non-whitespace content — mirrors the server's ErrSuggestionRequiresChange
no-op guard so the user doesn't bounce off a server-side 400.

API: openApprovalEditModal({entityType, lifecycleEvent, payload,
preImage}) returns Promise<{counterPayload, note} | null>. null = user
cancelled (ESC, overlay click, Cancel button). counterPayload contains
only fields that the user changed; unchanged keys are omitted (the
server's buildRevertSetClauses ignores absent keys cleanly).

Lifecycles other than "update" are guarded with an alert + resolve null —
shape-list.ts hides the button for them, but the modal is defence-in-
depth.
2026-05-20 10:02:36 +02:00
mAi
c1c5532d52 feat(approvals): t-paliad-216 — Slice B shape-list + filter chip
shape-list.ts:
  - Pending-row action group extends to four buttons. suggest_changes is
    only rendered for lifecycle='update' rows (the backend rejects other
    lifecycles with ErrSuggestionLifecycleInvalid).
  - ApprovalAction union widened to "approve" | "reject" | "revoke" |
    "suggest_changes". Disabled-reason logic shared with approve/reject
    (viewer_can_approve gate).
  - Status pill renders "Abgelehnt mit Vorschlag" for changes_requested
    via the existing approval-pill--historic style — no new colour token.
  - ApprovalDetail picks up counter_payload + next_request_id. When a
    row is changes_requested AND a next_request_id is present, render a
    back-link "→ Neuer Vorschlag von {name}" pointing at the new pending
    row (server-side hydrated via correlated subquery on
    previous_request_id, indexed by mig 103's partial index).

filter-bar/axes.ts:
  - APPROVAL_STATUSES gains "changes_requested" — the chip shows up in
    the /inbox filter cluster alongside pending/approved/rejected/revoked.
2026-05-20 10:02:36 +02:00
mAi
ee837815e1 i18n(approvals): t-paliad-216 — keys for suggest-changes UI
Adds DE + EN strings for the fourth approval action:
  - approvals.action.suggest_changes              — button label
  - approvals.status.changes_requested            — pill / row label
  - approvals.suggest.{modal_title,intro,note_label,note_placeholder,
                      submit,cancel,submit_disabled_hint,
                      next_request_link,unsupported_lifecycle}
  - approvals.error.{suggestion_requires_change,suggestion_lifecycle_invalid}
  - approvals.disabled.suggest_lifecycle
  - views.bar.approval_status.changes_requested   — filter chip

i18n-keys.ts is regenerated by frontend/build.ts (2473 keys now).
2026-05-20 10:02:36 +02:00
mAi
e035512e70 Merge: add Madrid to firm office list (mig 106) 2026-05-20 09:52:32 +02:00
mAi
6401a8198d feat(offices): add Madrid as a firm office (mig 106)
m's ask 2026-05-20 09:42. Eighth HLC office alongside Munich,
Düsseldorf, Hamburg, Amsterdam, London, Paris, Milan.

- `internal/offices/offices.go` — append Madrid to All[] (display
  order: end of list, after Milan). Doc comment refreshed to point at
  the actual current CHECK constraints (users mig 002 + partner_units
  mig 018/024/027), not the obsolete akten reference from before
  projects-v2.
- `internal/offices/offices_test.go` — add `madrid` to the valid-keys
  table.
- mig 106 — extend the two CHECK constraints on users.office and
  partner_units.office. Idempotent (DROP IF EXISTS), audit_reason
  set_config at top, dry-run validated against the live youpc paliad
  schema (BEGIN; ALTER...; ROLLBACK).

Frontend picks up Madrid automatically via GET /api/offices.

Admin UI for managing firm office list is a separate longer-term
issue — m's "for now, just add Madrid already" path.
2026-05-20 09:52:28 +02:00
mAi
6a202411f6 Merge: t-paliad-216 — hertz Slice A — "Suggest changes" approval action (backend)
Design doc + Slice A backend per docs/design-approval-suggest-changes-2026-05-19.md.
m greenlit design 2026-05-20 09:35; hertz delivered Slice A 09:48.

- Design doc (2 commits) — full 8-Q decision table in §0a, implementation
  sketch §3, slice plan §4. Two material divergences from inventor recs
  captured: Q4 counter_payload jsonb (real counter-proposal, not just a
  note) + Q6/Q7 counter-as-new-pending-request model.

- mig 103 — extend approval_requests.status CHECK to allow
  'changes_requested' + add counter_payload jsonb + previous_request_id
  uuid FK with partial index. Non-blocking (metadata-only).

- Service — SuggestChanges(ctx, requestID, callerID, counterPayload,
  note) single tx: lock pending → guard no-op → close old row as
  changes_requested → applyRevert (entity from pre_image) → deadlock-
  check new (requester=caller, role) → INSERT new pending row →
  re-apply counter to entity → emit *_approval_changes_suggested +
  *_approval_requested events. 3-layer self-approval guard intact.

- Handler — POST /api/approval-requests/{id}/suggest-changes with
  full error mapping (400 suggestion_requires_change /
  invalid_counter_payload, 403 self_approval_blocked / not_authorized,
  404, 409 request_not_pending / no_qualified_approver).

- Tests — 8 service tests (real-DB) covering happy path, no-op guard,
  self-approval block, request-not-pending, deadlock on new row,
  counter_payload validation. 2 handler tests for error mapping.

Migration dry-run validated against live youpc paliad schema
(BEGIN; ALTER...; ROLLBACK). Build + tests green.

Slice B (frontend modal + 4th button + status pill + i18n) and
Slice C (Verlauf integration) remain.
2026-05-20 09:50:34 +02:00
mAi
d924ab9743 test(approvals): t-paliad-216 SuggestChanges service + handler error mapping
Service-level (real DB, gated on TEST_DATABASE_URL like the rest of the
approval suite):
  - HappyPath: OLD row → changes_requested; NEW row pending with
    previous_request_id back-pointer; entity reflects counter payload;
    two project_events emitted (changes_suggested + requested).
  - NoOpRejected: identical counter + empty note → ErrSuggestionRequiresChange.
  - NoteOnlyAccepted: identical counter + non-empty note succeeds; entity
    keeps the original counter values.
  - SelfApprovalBlocked: original requester cannot suggest on their own row.
  - RequestNotPending: already-decided row rejects suggest-changes.
  - LifecycleInvalid: create-lifecycle pending → ErrSuggestionLifecycleInvalid.
  - OriginalRequesterCanApproveCounter: m's Q6 model — after the approver
    suggests changes, the ORIGINAL REQUESTER (now no longer the new row's
    requested_by) can approve the counter themselves provided their
    profession qualifies.
  - CounterApproverCannotSelfApprove: 4-Augen still holds — the suggesting
    approver cannot approve their own counter (ErrSelfApproval on the new row).

Handler-level (pure-Go, no DB):
  - SuggestionRequiresChange400: error code mapping.
  - SuggestionLifecycleInvalid400: error code mapping.
2026-05-20 09:50:07 +02:00
mAi
fb2896c836 feat(approvals): t-paliad-216 POST /api/approval-requests/{id}/suggest-changes
Wires the HTTP handler for the new action. Body shape:

    {"counter_payload": { ...allowlist fields... }, "note": "..."}

Returns 200 {"status": "ok", "new_request_id": "<uuid>"} on success.

Error mapping (via mapApprovalError):
    400 suggestion_requires_change   — ErrSuggestionRequiresChange
    400 suggestion_lifecycle_invalid — ErrSuggestionLifecycleInvalid
    403 self_approval_blocked        — ErrSelfApproval
    403 not_authorized               — ErrNotApprover
    404                              — not visible / not found (service)
    409 request_not_pending          — ErrRequestNotPending
    409 no_qualified_approver        — ErrNoQualifiedApprover

Route registered alongside the existing approve / reject / revoke trio
in handlers.go.
2026-05-20 09:50:07 +02:00
mAi
705e1a2e79 feat(approvals): t-paliad-216 SuggestChanges service method
ApprovalService.SuggestChanges is the fourth approval action — in one
transaction:

  1. Validates the OLD pending row (caller satisfies canApprove,
     lifecycle in update/complete only, counter differs from old.payload
     OR note is non-empty).
  2. Closes the OLD row as 'changes_requested' with decision_note +
     counter_payload + decided_by + decided_at + decision_kind.
  3. Reverts the entity from old.pre_image (reuses applyRevert — same
     code path Reject runs).
  4. Runs the deadlock check for the NEW row (excluding the suggesting
     caller; original requester is no longer excluded).
  5. Re-applies the counter_payload to the entity row (via
     applyEntityUpdate, mirroring the write-then-approve write).
  6. INSERTs a NEW pending approval_requests row authored by the caller
     with previous_request_id pointing back at the OLD row.
  7. Marks the entity pending + pending_request_id → new row.
  8. Emits two project_events: *_approval_changes_suggested + a fresh
     *_approval_requested for the new row.

4-Augen still holds: the suggesting caller is the new row's
requested_by, so self-approval on the new row is blocked by the standard
3-layer guard. The ORIGINAL requester is no longer the requested_by of
the new row — if their profession satisfies the required_role they can
now approve the counter themselves.

Adds:
  - const RequestStatusChangesRequested = "changes_requested"
  - var  ErrSuggestionRequiresChange   = "suggestion requires counter diff or note"
  - var  ErrSuggestionLifecycleInvalid = "suggest is only valid for update/complete"
  - models.ApprovalRequest.CounterPayload + PreviousRequestID
  - Per-row read paths (getRequestForUpdate, approvalRequestViewColumns)
    populate the new columns.
2026-05-20 09:50:07 +02:00
mAi
d8acbd613c feat(approvals): t-paliad-216 mig 103 — suggest-changes schema
Adds the schema scaffolding for the fourth approval action (alongside
Approve / Reject / Revoke):

  1. Extends approval_requests.status CHECK to include 'changes_requested'.
  2. Adds counter_payload jsonb — the approver's edited values on a
     changes_requested row (the basis of the new row's payload).
  3. Adds previous_request_id uuid FK — back-pointer from a SuggestChanges-
     spawned row to its source. Partial index on the FK supports chain
     traversal.

Non-blocking: extending a CHECK constraint is metadata-only on Postgres;
adding NULLable columns + a NULLable FK is metadata-only. Safe for live
deploy.

Dry-run validated against the live youpc paliad schema via BEGIN/ROLLBACK
(migration tracker at 102 pre-apply; schema unchanged post-rollback).
2026-05-20 09:50:07 +02:00
mAi
c01f3f2db8 docs(approvals): t-paliad-216 — fold m's decisions, rewrite §3 implementation
§0a captures m's locked picks across all 8 questions. Two divergences from
inventor recommendations reshape the model:

- Q4: hybrid — approver edits the proposed values (counter-payload) AND/OR
  leaves free-text in decision_note. Adds counter_payload jsonb column.
- Q6/Q7: the counter is treated as a NEW pending approval_request authored
  by the approver, not an "edit and resubmit" CTA on the requester side.
  Original requester sees the old row as changes_requested ("Abgelehnt mit
  Vorschlag") and the new row as pending — they can approve it themselves
  if eligible (they're no longer the requested_by). 4-Augen still holds.

§3 implementation sketch rewritten: SuggestChanges atomically closes the
old row, applyRevert's the entity, spawns a new pending row with
counter_payload as payload + previous_request_id linking back, re-applies
the counter via write-then-approve, emits both *_approval_changes_suggested
and *_approval_requested events. Migration 103 adds the CHECK value plus
counter_payload jsonb + previous_request_id FK + index. Slice plan trimmed
to backend / frontend-modal / Verlauf-integration.
2026-05-20 09:50:07 +02:00
mAi
2fa47278ce docs(approvals): t-paliad-216 — design doc for "Suggest changes" action
Inventor draft of the fourth approval action alongside approve / reject /
revoke. Open questions in §2 will be resolved via AskUserQuestion before
any coder work. Recommendations folded in inline.

Verified live state before designing: status enum already carries an
unused 'superseded' value; entity approval_status is approved/pending/legacy
only; decision_note exists as free text; the existing decide() kernel
handles approve / reject / revoke with a single switch.
2026-05-20 09:50:07 +02:00
mAi
6c7e9ef44d Merge: t-paliad-207 — fermi's parked verfahrensablauf followups (mig 104 + mig 105 + notes toggle)
Three parked commits from fermi's 2026-05-18 interactive session, never
engaged at the time; m greenlit 2026-05-20 09:43:

- mig 104 (was 101): strip rule-cite brackets from Einspruch names + flip
  CCR priority informational → optional. m's 18:01 + 18:08 corrections.
- mig 105 (was 102): track-aware sequence reshuffle for upc.inf.cfi
  (infringement → revocation → amendment within tied-date groups). m's
  18:08 ask about Replik-before-Erwiderung-NichtigkeitsWK ordering.
- Notes toggle UI: per-rule notes default to compact ⓘ hover hint;
  "Hinweise anzeigen" switch in the toggle bar expands them inline.
  Shared localStorage between verfahrensablauf and fristenrechner.
  m's 18:21 ask.

Renumbered from 101/102 because leibniz CalDAV claimed mig 101 and
archimedes system_audit_log claimed mig 102 between fermi's parked
session and now. Mig 103 reserved for hertz suggest-changes Slice A
(in flight). Both go and frontend builds green.
2026-05-20 09:47:36 +02:00
mAi
17cd5b3b0c feat(t-paliad-207): notes toggle — compact ⓘ hover by default, expand inline when "Hinweise anzeigen" is checked
m's ask 2026-05-18 18:21: per-rule descriptive notes ("Innerhalb von 1
Monat ab Zustellung der Klage. Drei mögliche Gründe…") are noisy in the
default timeline view. Make them optional — small ⓘ icon next to the
meta line by default with full text on hover; switch in the toggle bar
expands them inline when the user wants the wall of text.

**Renderer (verfahrensablauf-core.ts)** — `CardOpts.showNotes?: boolean`
gates two render paths:
- on  → `<div class="timeline-notes">…</div>` (today's behaviour)
- off → `<span class="timeline-note-hint" tabindex=0 role=note
        aria-label=… title=…>ⓘ</span>` inside the meta line (browser
        title for hover, aria-label for screen readers, tabindex for
        keyboard accessibility)

Pass-through wired in renderColumnsBody too so the columns view picks
up the toggle equally.

**Toggle UI** — added a checkbox row to the existing `fristen-view-toggle`
bar on both /tools/verfahrensablauf and /tools/fristenrechner:
"Hinweise anzeigen" / "Show details". CSS modifier
`.fristen-notes-option` separates it from the radio view-picker with
a leading border-left.

**State** — `paliad.fristen.notes-show` localStorage key (shared
between both pages so the preference carries across), default off,
re-render on flip.

i18n: 1 new key DE + EN (deadlines.notes.show). Build clean.
2026-05-20 09:47:14 +02:00
mAi
d127c768f7 feat(t-paliad-207): mig 105 — track-aware sequence reshuffle for upc.inf.cfi (infringement → revocation → amendment)
m's ask 2026-05-18 18:08: 'the infringement parts (like Replik) should
show above the part for the revocation (Erwiderung Nichtigkeitswider-
klage)'. Three tracks (infringement / revocation / amendment) coexist
on upc.inf.cfi once with_ccr / with_amend are set. They share tied
calendar dates because R.29/R.30/R.32 all key off the SoD or its
descendants. Current sequence_orders (post-mig 100) interleave them
arbitrarily; user sees Erwiderung-zur-CCR before Replik even though
Replik is the infringement-side response to the same triggering event.

**Re-sequencing** keeps the existing soc=0, prelim=5, sod=10 head and
the interim=40 / oral=50 / decision=60 / cost_app=70 / appeal_spawn=80
tail untouched. The 10 reshuffled rules move into a track-aware
arrangement:

  10-19 infringement: sod=10, reply=12, rejoin=14
  20-29 revocation:   ccr=20, def_to_ccr=22, reply_def_ccr=24, rejoin_reply_ccr=26
  30-39 amendment:    app_to_amend=30, def_to_amend=32, reply_def_amd=34, rejoin_amd=36

Tied-date ordering after the reshuffle:
  D+3mo: sod(10), ccr(20)                            — SoD then its CCR
  D+5mo: reply(12), def_to_ccr(22), app_to_amend(30) — inf → rev → amd
  D+7mo: reply_def_ccr(24), def_to_amend(32)         — rev → amd
  D+8mo: rejoin_reply_ccr(26), reply_def_amd(34)     — rev → amd

**Two-phase swap** — every reshuffled rule first parks at sequence
1000+number, then jumps to its final value. Prevents transient
sequence-collisions if Postgres evaluates UPDATEs in parallel within
the same statement. Each UPDATE is keyed by submission_code AND the
SOURCE sequence_order, so re-apply is a no-op.

audit_reason set_config at top per mig 099 hotfix pattern.

Renumbered from mig 102 → mig 105 to avoid collision with archimedes
system_audit_log mig 102 (merged between fermi's parked session and
now); follows mig 104 (Einspruch name + CCR priority).
2026-05-20 09:47:14 +02:00
mAi
dab06e068f fix(t-paliad-207): mig 104 — strip rule cite from Einspruch names + flip CCR priority informational→optional
Two corrections to mig 100's merged-state:

1. **CCR priority informational → optional**. m's correction
   2026-05-18 18:01. The fermi amend (e8d658a) flipping this didn't
   land — paliadin merged the pre-amend c10f8cf. The Nichtigkeits-
   widerklage is a substantive defensive choice, rendered unchecked
   in the save modal so user opts in if they want to track it.

2. **Strip rule-cite brackets from Einspruch names**. m's
   correction 2026-05-18 18:08. Every other rule name in the corpus
   carries the act-name without a parenthetical rule cite — the two
   Einspruch rules were outliers:
     upc.inf.cfi.prelim  'Einspruch (R. 19 VerfO)'             → 'Einspruch'
     upc.rev.cfi.prelim  'Einspruch (R. 19 i.V.m. R. 46 VerfO)' → 'Einspruch'
   plus EN equivalents. The legal_source / rule_code columns already
   carry the citation in the meta line, so the name stays clean.

Idempotent: priority UPDATE guarded on 'informational'; name UPDATEs
guarded on the current parenthetical-bearing values. audit_reason
set_config at top per mig 099 hotfix pattern.

Renumbered from mig 101 → mig 104 to avoid collision with leibniz
CalDAV mig 101 + archimedes system_audit_log mig 102 (both merged
between fermi's parked session and now); mig 103 reserved for hertz.
2026-05-20 09:47:14 +02:00
mAi
defa516e4f Merge: t-paliad-215 — copernicus submission generator Slice 1 (in-house .docx render engine + template registry + Schriftsätze tab + Klageerwiderung end-to-end) 2026-05-19 13:43:11 +02:00
mAi
6ff26e8a6e feat(submissions): t-paliad-215 Slice 1 — Schriftsätze tab on project detail
New "Schriftsätze" tab on /projects/{id}, lazy-loaded by the
existing tab switcher (same pattern as the Checklisten tab — only
hits the API when the user actually opens it). Lists the project's
filing rules in a 4-column table: name (with submission_code under
it), party, legal basis, action button.

Action column shows [Generieren] for rules with a resolvable
template and "Keine Vorlage" / "No template" for rules without one.
The generate button fetches the .docx via XHR, parses the
Content-Disposition filename, creates an object URL, and triggers
the browser download via a hidden <a download>. Disabled
mid-flight to prevent double-submits.

The table opts into the `.entity-table--readonly` modifier — rows
themselves don't navigate; only the inline button does (avoids the
"clickable row that isn't" UX lie called out in the project
CLAUDE.md frontend conventions).

11 new i18n keys per language. New CSS block for the submission-row
typography (name + dim-grey code stacked vertically, right-aligned
action cell, italic no-template hint).
2026-05-19 13:42:51 +02:00
mAi
2c94420a4b feat(submissions): t-paliad-215 Slice 1 — HTTP layer + wiring
Two endpoints under /api/projects/{id}/:

  GET /submissions
       Lists the project's filing-type rules (event_type='filing',
       lifecycle_state='published') for the project's proceeding,
       each annotated with has_template via the registry's cheap
       SHA-only probe. Powers the SubmissionsPanel.

  GET /submissions/{code}/generate
       Renders the .docx and streams it back as an attachment with
       Content-Disposition: attachment; filename="…". Writes three
       audit records: paliad.system_audit_log (event_type=
       'submission.generated'), paliad.project_events (event_type=
       'submission_generated', surfaces in Verlauf / SmartTimeline),
       and paliad.documents (doc_type='generated_submission',
       file_path NULL — bytes are regenerable from inputs per m's
       Q3 pick, no server-side binary). All three writes use a 10s
       background context so the user still gets the download if
       audit insertion races a slow DB.

File naming follows §7 of the design:
  {rule.name}-{project.case_number}-{YYYY-MM-DD}.docx with locale-
  aware rule.name and slash→underscore sanitisation on
  case_number. Empty case_number falls back to an 8-hex-char id from
  the project UUID.

Visibility: ProjectService.GetByID gates every request; 404 (not
403) on no-access to avoid project enumeration. No profession floor
— matches every other write surface in paliad.

Wired into handlers.Services + dbServices + cmd/server/main.go.
Singletons constructed once at boot; no per-request allocation. No
migration needed — paliad.documents has no CHECK on doc_type, so
'generated_submission' is purely additive.
2026-05-19 13:42:51 +02:00
mAi
3677c81fbe feat(submissions): t-paliad-215 Slice 1 — template registry + variable bag
TemplateRegistry (services/submission_templates.go) walks the
m-locked Q4 fallback chain — templates/{FIRM_NAME}/{code}.docx →
templates/_base/{code}.docx → templates/_base/{family}.docx →
templates/_base/_skeleton.docx — against the Gitea repo
HL/mWorkRepo. SHA-cache + 5-min refresh check, identical pattern to
internal/handlers/files.go's HL Patents Style proxy. Distinguishes
"no template" (chain fallthrough) from "Gitea down" so the handler
can render different UI for each.

SubmissionVarsService (services/submission_vars.go) assembles the
~30-placeholder bag from project + parties + rule + next-deadline +
user + firm + today. Locale-aware long-date forms (DE + EN) and a
legal_source pretty-printer that rewrites DE.ZPO.276.1 → "§ 276 Abs.
1 ZPO" / "Section 276(1) ZPO" for the prefixes the 254-rule corpus
uses today. Unknown prefixes pass through unchanged.

Visibility inherits from ProjectService.GetByID
(paliad.can_see_project) — unauthorised callers get the same
ErrNotVisible that every project surface returns.
2026-05-19 13:42:51 +02:00
mAi
8ea3509b98 feat(submissions): t-paliad-215 Slice 1 — in-house .docx render engine
Pure-Go {{path.dot.notation}} placeholder engine + unit tests
(t-paliad-215, design docs/design-submission-generator-2026-05-19.md
§6). Chosen over github.com/lukasjarosch/go-docx because that library
treats sibling placeholders inside one <w:t> run as nested and
refuses to replace them — patent submissions routinely carry multiple
placeholders per paragraph (party blocks especially), so the library
is a non-starter.

Two-pass strategy preserves run-level formatting on the common path:

 1. Pass 1: regex replace inside each <w:t>…</w:t> independently —
    no format loss for the 99% case where placeholders are intact.
 2. Pass 2: paragraph-level merge for paragraphs that still contain
    orphan "{{" or "}}" markers (Word fragmented the placeholder
    across runs).

Missing placeholders render [KEIN WERT: <key>] / [NO VALUE: <key>]
markers so the lawyer sees the gap in Word rather than getting a 400.

Tests cover: single-run, multi-per-run (the go-docx failure mode),
cross-run merge, missing-marker (DE+EN), XML escaping of special
chars, non-document zip entries preserved, placeholder regex
grammar.
2026-05-19 13:42:51 +02:00
mAi
5ff637ab70 Merge: t-paliad-215 — copernicus submission-generator design doc + decisions 2026-05-19 13:21:00 +02:00
mAi
265f240151 docs(submission-generator): t-paliad-215 inventor design
DESIGN READY FOR REVIEW — copernicus inventor pass on the submission
generator (t-paliad-215). 5 questions answered with m's picks captured
in §2; awaiting head's go/no-go on coder shift.

Locked decisions:
- Scope: template-render to .docx (no LLM in v1)
- Template registry: Gitea (mWorkRepo proxy, same pattern as
  HL Patents Style)
- Output: direct download, no server-side binary persistence
- Mapping: fallback chain (firm → base/code → base/family → skeleton)
- Slice 1: one template end-to-end on one project
  (de.inf.lg.erwidg / Klageerwiderung)

No code, no migrations, no schema additions. Read-only design phase
per inventor SKILL.md.
2026-05-19 13:20:59 +02:00
mAi
1039680878 Merge: patentstyle info page 2026-05-19 13:17:07 +02:00
mAi
773654523e feat(patentstyle): real info page (replaces placeholder)
Replaces the one-sentence "endpoint" stub with a proper landing: features list, update flow explainer, fresh-install download link, contact line. Renders the served version live from version.json. Paliad palette (midnight/lime). This is what the HL Patents Style ribbon's Info dialog now links to on OK.
2026-05-19 13:17:07 +02:00
mAi
f7585376df Merge: t-paliad-214 fix — xlsx docProps Created/Modified + complete pane XML (resolves Excel 'repairs required' + wrong Modified date) 2026-05-19 13:06:08 +02:00
mAi
f9ff7b93e8 fix(export): xlsx docProps + pane XML — Excel "repairs required" + wrong Modified date
m hit two bugs opening the Slice 1 export in Excel / Windows:

1. **Excel showed a "Repairs required" prompt** on open. Root cause:
   the SetPanes call passed only `{Freeze: true, YSplit: 1}` — the
   obvious-but-wrong shape. The resulting <pane> XML missed the
   `topLeftCell` and `activePane` attributes that Excel requires for
   a frozen-row pane (excelize's parser is permissive on re-read but
   Excel is strict). Fix: complete the Panes struct (TopLeftCell="A2",
   ActivePane="bottomLeft", Selection on bottomLeft) and surface
   SetPanes errors instead of `_ =`-ignoring them.

2. **Windows Explorer / Excel's File→Info showed Modified=2006-09-16
   ("xuri")** — excelize's hardcoded first-commit defaults. Root cause:
   buildXLSX never called SetDocProps so the canned defaults leaked.
   Fix: SetDocProps({Created, Modified} = meta.GeneratedAt;
   Creator = "Paliad (<firm>)"; Title/Description scoped per export).

3. **Bonus**: the outer-zip entry mtimes were stamped 2000-01-01 (the
   deterministic constant) so extracted files showed a Y2K Modified
   date in Explorer. Now stamped meta.GeneratedAt, which preserves
   determinism within an export (same row state + same GeneratedAt →
   same bytes, the actual m's-Q6 contract).

Also: set the active sheet to __meta (index 0) after sheet creation so
a future code path that adds/removes sheets can't leave an out-of-range
active-sheet index that would trip a separate "repairs required" path.

Regression tests in dump_export_test.go pin all three fixes by re-opening
the generated xlsx via excelize.OpenReader and asserting:
- docProps Created/Modified == meta.GeneratedAt (RFC 3339 UTC)
- docProps Creator contains "Paliad"
- xlsx bytes never contain "2006-09-16T00:00:00Z" or "<dc:creator>xuri</dc:creator>"
- sheet2/sheet3 raw XML carries topLeftCell + activePane + state=frozen
- outer-zip entries' Modified is within ±2s of GeneratedAt
- developer hatch: DUMP_EXPORT=1 writes /tmp/paliad-export-debug.{zip,xlsx}
  for opening in real Excel.
2026-05-19 13:05:54 +02:00
mAi
86d20ed6d4 Merge: spaced filename on /patentstyle/ download 2026-05-19 13:05:28 +02:00
mAi
1639b3919a feat(handlers): serve /patentstyle/HL-Patents-Style.dotm as "HL Patents Style.dotm" via Content-Disposition
URL keeps the dashed name for cleanliness; the on-disk filename PA users land in their Downloads folder has the canonical spaces.
2026-05-19 13:05:28 +02:00
mAi
bf31935767 Merge: t-paliad-214 — archimedes Excel-export Slice 1 (mig 102 system_audit_log + personal /api/me/export + xlsx/json/csv writer + Datenexport tab on /settings) 2026-05-19 12:52:25 +02:00
mAi
aee177a303 feat(export): t-paliad-214 Slice 1 frontend — Datenexport tab on /settings
Adds a 4th tab "Datenexport" to /settings (after Profil /
Benachrichtigungen / CalDAV) with a single-button card that triggers
GET /api/me/export. Browser handles the download via
Content-Disposition: attachment.

i18n: 12 new keys under einstellungen.export.* (DE primary, EN
secondary) — subtitle, bullets per format, scope notice, audit
notice, button label, post-click hint.

The tab is loaded lazily (idempotent loadExportTab) like every other
settings tab, and the runExport handler swaps in a transient <a download>
to use the browser's normal download pipeline.
2026-05-19 12:51:52 +02:00
mAi
28c7215458 feat(export): t-paliad-214 Slice 1 backend — personal sync export endpoint + xlsx/json/csv writer
Adds GET /api/me/export streaming a deterministic .zip bundle of the
caller's RLS-visible projection (per design §2.3): projects, deadlines,
appointments, parties, notes, documents (metadata), audit events,
approval requests, checklist instances + personal sidecars (me row,
caldav config without ciphertext, views, pins, card layouts, paliadin
turns) + reference data (proceeding_types, event_types, deadline_rules,
courts, countries, holidays …) + restricted users_referenced sheet.

Bundle shape: paliad-export.xlsx + paliad-export.json + per-sheet
CSVs (UTF-8 BOM, RFC 4180) + README.txt + __meta.json. Outer zip is
byte-deterministic — sorted file list, fixed Modified time on every
entry, sorted JSON keys. Two runs at same row-state → identical bytes.

ExportService.WritePersonal owns the SQL recipe + column discovery
+ PII deny-regex (?i)secret|token|password|api[_-]?key|private[_-]?key
+ per-sheet DropColumns belt-and-braces (e.g. user_caldav_config
.password_encrypted explicitly dropped on top of the regex). Audit row
written to paliad.system_audit_log before the run, patched with
row_counts + file_size_bytes after.

Migration 102 creates paliad.system_audit_log (generic event_type +
actor_id/email + scope + scope_root + metadata jsonb). Idempotent
CREATE TABLE IF NOT EXISTS + indexes; RLS enabled with self-read +
admin-read policies. AuditService.ListEntries gains a 6th UNION branch
so the new table surfaces on /admin/audit-log.

excelize/v2 added to go.mod for xlsx generation.

Pure-function tests pin formatCellValue value-coercion, PII regex,
CSV quoting + BOM + umlaut survival, JSON shape, meta key order
stability, filename slugify, and byte-determinism of the bundle
assembly.

Design: docs/design-paliad-data-export-2026-05-19.md §7 Slice 1.
2026-05-19 12:51:52 +02:00
mAi
9aebe5780b Merge: t-paliad-212 — leibniz CalDAV Slice 1 (mig 101 user_calendar_bindings + appointment_caldav_targets + backfill, RLS, idempotent) 2026-05-19 12:45:50 +02:00
mAi
8a43aed100 feat(caldav): mig 101 — multi-calendar binding schema + backfill (t-paliad-212 Slice 1)
Schema-only landing for Slice 1 of the CalDAV multi-calendar design
(docs/design-caldav-multi-calendar-2026-05-19.md). Sync engine NOT
touched — Slice 2 wires the per-binding fan-out. After this migration:

- paliad.user_calendar_bindings — N bindings per user with scope_kind
  ∈ {all_visible, personal_only, project, client, litigation, patent,
  case}. Hierarchy scopes anchor scope_id at paliad.projects(id).
  Partial unique indexes enforce one binding per (user, scope_kind,
  scope_id) for hierarchical scopes and one per (user, scope_kind)
  for the scope-less roots. RLS mirrors user_caldav_config.
- paliad.appointment_caldav_targets — per-(appointment, binding) join
  carrying caldav_uid + caldav_etag. UID stays canonical per
  appointment so the same event in N cals shares one UID.
- Backfill — one all_visible binding per existing user_caldav_config
  row, one target row per appointment already pushed. Maps target to
  the creator's binding, matching today's Phase F semantics where the
  creator's goroutine owns the etag.

Legacy paliad.appointments.caldav_uid / caldav_etag columns are
untouched (kept as denormalised pointers through Slice 1+2; dropped
in Slice 4 after telemetry).

Dry-run verified against live Supabase (PG 15.8): synthetic config +
appointment backfill creates exactly 1 binding + 1 target; re-run is a
no-op; all CHECK + unique-index constraints enforce as designed; final
assertions pass with 0 missing rows.

Prod impact at landing: 0 rows in user_caldav_config and 0 appointments
with caldav_uid — backfill is a true no-op. Slice 1 ships invisible.
2026-05-19 12:44:27 +02:00
mAi
52b3feb9d2 Merge: t-paliad-213 — mendel test-strategy Slice 1 (Make targets, migration dry-run gate, boot smoke, /healthz) 2026-05-19 12:41:33 +02:00
mAi
586ba29b86 feat(test): migration dry-run gate + boot smoke (Slice 1)
Slice 1 of docs/design-paliad-test-strategy-2026-05-19.md — the test
infrastructure that would have caught mig 098 (digit-regex) and mig 099
(missing audit_reason) before the deploy hit prod.

Three new files + one route addition:

- Makefile: `make verify-migrations` (alias `verify-mig`) runs the
  per-migration dry-run + boot smoke against TEST_DATABASE_URL. Fails
  fast with a clear error if TEST_DATABASE_URL is unset so CI can't
  silently pass a missing env var. `make test` and `make test-go`
  cover the rest of the short / full Go suites.

- internal/db/migrate_test.go (TestMigrations_DryRun): walks every
  pending *.up.sql in numeric order, applies each inside its own
  BEGIN..ROLLBACK transaction, fails on the first SQL error with the
  file name + Postgres error. "Pending" = greater than the scratch
  DB's current tracker version, so fresh-DB CI runs verify everything
  while developer scratch DBs only re-verify the new pending migration.
  Always non-destructive — the rollback runs even on success.

- cmd/server/main_smoke_test.go (TestBootSmoke): boots the apply path
  end-to-end, asserts (a) db.ApplyMigrations returns nil, (b) the
  tracker advanced to the highest *.up.sql version on disk with
  dirty=false, (c) GET /healthz on the registered mux returns 200.
  The dry-run catches per-migration syntax errors; this catches the
  apply+bind path the container actually runs.

- internal/handlers/handlers.go: adds a GET /healthz public route — a
  no-auth, no-DB liveness probe. Used by the boot smoke; also safe
  for any future orchestrator or uptime check.

Both live-DB tests gate on TEST_DATABASE_URL and skip cleanly without
it, matching the rest of paliad's live-DB test pattern.

Verification: go build ./... clean, go vet ./... clean,
go test -short ./internal/... ./cmd/... clean (all packages pass,
live-DB tests skip), bun run build clean (2436 i18n keys unchanged).

Per CLAUDE.md inventor → coder gate, NOT self-merged.
2026-05-19 12:41:15 +02:00
mAi
0b57ec5257 Merge: t-paliad-214 — archimedes Excel-export decisions addendum (9 Qs answered) 2026-05-19 12:37:08 +02:00
mAi
2007ad39bb docs(export): §12 addendum — m's decisions on the 9 §11 questions
t-paliad-214. m walked all 9 questions live; deviated on Q2 (project-scope
floor = any team member, not associate), Q3 (retention 90d, not 7d), Q5
(paliadin_turns hard-excluded from org scope, not opt-in). Other 6
matched inventor picks. Net slice-plan deltas captured in §12.
2026-05-19 12:36:49 +02:00
mAi
b7c4de9ac9 Merge: t-paliad-212 — leibniz CalDAV decisions addendum (6 Qs answered) 2026-05-19 10:43:37 +02:00
mAi
8e0e4c9dcc docs(caldav): fold m's decisions on the 6 open Qs into the design (t-paliad-212)
Addendum after §10 captures m's picks (2026-05-19, via AskUserQuestion):
§8.1 bidirectional default: YES; §8.2 personal_only: KEEP first-class;
§8.3 MKCALENDAR: Slice 2 with Google-degrade; §8.4 soft caps: NONE in
v1 (add later if telemetry warrants); §8.5 admin view: don't ship;
§8.6 approval-flow remote-edit gap: separate task under t-138.

Net effect: drops the 20-warn/80-block UI guards from §6 and the
`read_only` flag from §3; Slice 2 gains MKCALENDAR + binding-count
telemetry; §8.6 fix filed separately so multi-cal slices stay clean.
2026-05-19 10:43:20 +02:00
mAi
023f32d4f2 Merge: t-paliad-213 — mendel test-strategy decisions addendum (all 6 Qs answered, picks match inventor recs) 2026-05-19 10:31:03 +02:00
mAi
621fe35d79 docs(test-strategy): fold m's §10 decisions addendum
m's 2026-05-19 picks via AskUserQuestion interview:
- Q1 budget: 60–90s gate, 3–4min full (inventor's call — m deferred)
- Q2 CI: Gitea Actions, gate tier only
- Q3 test DB: YouPC for devs + ephemeral docker for CI
- Q4 coverage: critical-path only, no % gate
- Q5 floor: Slices 1+4+5 before new feature work
- Q6 ownership: head decides + rotate per profile

All six matched inventor's recommendation. Slice 1 (migration
dry-run + boot smoke) starts first; Slices 4+5 in parallel after.
2026-05-19 10:30:25 +02:00
mAi
139c4a6406 Merge: t-paliad-214 — archimedes Excel data-export design doc 2026-05-19 10:12:24 +02:00
mAi
6e8e2e7653 Merge: t-paliad-213 — mendel test-strategy design doc 2026-05-19 10:11:26 +02:00
mAi
de20356cec docs(export): inventor design for scoped Excel data export (org / project-subtree / personal)
t-paliad-214. Covers scope definitions, format choices (xlsx + JSON + CSV
in one zip, deterministic, schema_version 1), authorization model
(global_admin / project-team-with-associate-floor / authenticated-self),
trigger model (sync personal+project, async org), storage on
PALIAD_EXPORT_DIR with 7-day retention, PII/GDPR posture, 3-slice plan,
and 9 open questions for m. No code touches — design only.
2026-05-19 10:10:59 +02:00
mAi
8414aa4c14 docs(test-strategy): inventor design for production-grade test pyramid
t-paliad-213 — six-layer pyramid (migration dry-run, Go/frontend unit,
frontend DOM, service live-DB, handler integration, Playwright E2E),
audit of current coverage (323 test funcs, 24 untested services, 53
untested handlers, 4/90 frontend modules), eight-slice tracer-bullet
roll-out, six open questions for m.

Read-only design phase per CLAUDE.md inventor gate — no test files,
make targets or CI configs touched. Awaiting m go/no-go on §5 slice
plan + §6 open questions before any coder shift.
2026-05-19 10:10:23 +02:00
mAi
1e1c84b0f6 Merge: t-paliad-212 — leibniz CalDAV multi-calendar design doc 2026-05-19 10:07:40 +02:00
mAi
e1b91a9481 docs(caldav): design for multi-calendar binding model (t-paliad-212)
Inventor design for letting users connect Paliad's CalDAV sync to N
external calendars per user, with scope filters (master / personal /
per-project / per-client / per-litigation / per-patent / per-case)
rather than today's single-target push. Splits credentials (per user,
unchanged) from bindings (new join table). Adds a per-target join for
push state so the same Appointment can live in multiple calendars at
once. Includes per-provider limit research (iCloud 100, Google ~100,
Fastmail no cap, Nextcloud 30 default), a 4-slice rollout plan, and 6
open questions for m. READ-ONLY design — no schema or code changes.
2026-05-19 10:06:58 +02:00
mAi
92780cf726 fix(events): default Termine filter to 'upcoming' so past events don't show by default
m's call 2026-05-19: opening /events with type=appointment was
defaulting status='all' which surfaces every past appointment in
the corpus. The default should hide past events; 'Alle (auch
vergangene)' is opt-in for the one user who actually wants the
historical view.

Replaces the default with the existing DeadlineFilterUpcoming bucket
(already implemented backend-side at internal/services/deadline_service.go:132
as 'today + future'). New status option 'upcoming' at the top of the
appointment list; existing 'all' moves to the bottom with a clearer
label that calls out 'incl. past'.

Deadlines unaffected — they still default to 'pending'.

i18n keys added in both DE + EN slots (events.filter.status.upcoming
'Ab heute' / 'From today'; .all reframed as 'Alle (auch vergangene)'
/ 'All (incl. past)').
2026-05-19 09:56:05 +02:00
mAi
a0082d2b0d fix(index): drop Downloads section from anon landing — the dotm card was the only visible affordance for unauth visitors
m's call 2026-05-19: the /files/hl-patents-style.dotm link on the
anonymous frontpage shouldn't tempt visitors to try downloading. The
/files/{filename} route IS already auth-gated (302 to /login on
anon click), and the macro-update endpoint at /patentstyle/* stays
public for the in-Word update logic per m's note ('with knowledge
of the direct source link it needs to be available').

Authenticated users never see this page anyway — handleRootPage 302s
them to /dashboard. So removing the section costs them nothing and
removes the obvious affordance for anon visitors. ICON_DOWNLOAD
const dropped along with it.

The Downloads page itself (/downloads + Sidebar nav entry) stays —
that's auth-gated and works for logged-in users.

Leftover surface: /patentstyle/HL-Patents-Style.dotm is still anon-
downloadable (necessary for the Word macro's auto-update poll).
That's m's stated requirement — flagged as the known leak path for
anyone who knows the URL.
2026-05-19 09:05:36 +02:00
mAi
c921925c68 Merge: hlpat /patentstyle/ endpoint 2026-05-18 21:00:46 +02:00
mAi
22cfdb909f feat(handlers): serve /patentstyle/ for HL Patents Style auto-update
Hosts the manifest + .dotm that the Word ribbon's Check-for-Updates button polls. paliad.msbls.de is the primary endpoint; hihlc.msbls.de mirrors it (hihlc/main b871ded). Files live in frontend/public/patentstyle/, copied into dist/ by the frontend build. Cache-Control: no-cache via noCacheAssets so version.json never serves stale after a release.
2026-05-18 21:00:46 +02:00
mAi
4ddcd28d26 Merge: t-paliad-207 — mig 100 (upc.inf.cfi.ccr informational rule, makes CCR filing visible on timeline when with_ccr is set) 2026-05-18 17:46:55 +02:00
mAi
c10f8cff70 feat(t-paliad-207): mig 100 — make CCR filing visible in calc output when with_ccr is set
m's observation 2026-05-18 (interactive session): toggling "Mit Nichtig-
keitswiderklage" surfaces the response rules (def_to_ccr, reply, rejoin,
…) but the triggering event itself — the act of filing the CCR — is
invisible. Per R.25 VerfO the CCR is filed AS PART OF the Statement of
Defence with the same 3-month deadline, so the corpus author (mig 028)
skipped it. UX problem: users see consequences without the cause.

**New rule** `upc.inf.cfi.ccr`:
- parent: `upc.inf.cfi.soc` (root anchor, same as SoD)
- duration: 3 months (same as SoD — no separate deadline)
- party: defendant
- legal_source: `UPC.RoP.25.1`
- condition_expr: `{"flag":"with_ccr"}`
- priority: **`informational`** — renders as a notice card, no save
  action, no duplicate write into paliad.deadlines (the SoD's row
  already covers the calendar date).

**Sequence reshuffle** — inserting at sequence_order=11 pushes
def_to_ccr 11→12 and app_to_amend 12→13 so the timeline reads
SoD → CCR → def_to_ccr → app_to_amend (cause before effect).

**Idempotency** — INSERT uses NOT EXISTS keyed on
(proceeding_type_id, submission_code, lifecycle_state='published');
UPDATEs are guarded by the source sequence_order so re-apply is a
no-op. audit_reason set via set_config('paliad.audit_reason', ...,
true) at the top per the mig 099 hotfix pattern.

Migration counter re-checked against origin/main + ls
internal/db/migrations/ | tail before picking 100 — per the friction
note from msg 2016.

Build hygiene: go build/vet clean; bun run build clean (no i18n
changes). Down.sql restores both sequence values + DELETEs the new
row. Branch: mai/fermi/interactive-session.
2026-05-18 17:46:08 +02:00
mAi
5ae1e5ad01 Merge: t-paliad-211 — Custom Views polish (calendar week/day + click-drill + aligned grid, timeline zoom + lane-label clamping, filter-bar transfer) 2026-05-18 17:45:44 +02:00
mAi
06c826a818 feat(t-paliad-211): mount filter-bar on Custom Views runner
The /views/{slug} runner now mounts the same FilterBar primitive that
/events and /inbox use. The saved view's filter_spec becomes the bar's
baseline, axes are picked client-side per the view's data sources so a
deadline-only view exposes deadline_status, an approval-driven view
exposes approval_viewer_role + approval_status + approval_entity_type,
etc. Universal axes (time, personal_only, sort) always render.

Per-session tweaks overlay the saved baseline without mutating the
stored row; the URL round-trips state through the bar's existing codec
so deep-links share the active narrow. "Speichern als Sicht" stays
available on user-owned views so a tweaked narrow can be forked into a
new saved view.

Shape axis is intentionally excluded from the bar — the existing
top-of-page shape chip cluster (list / cards / calendar / timeline)
already plays that role and switching now mutates the cached render
spec without re-hitting the substrate.

Empty-state hint reuses the saved filter summary as before; the bar's
onResult handler hides all shape hosts when the rows array is empty.
2026-05-18 17:45:30 +02:00
mAi
8020cb2ddb feat(t-paliad-211): timeline shape adds zoom toolbar and clamped lane labels
shape-timeline-cv now wraps the chart host with a toolbar carrying
+/- zoom buttons and 1y/2y/all chips. Active zoom persists in the URL as
?tl_zoom=1y|2y|all (URL > render-spec range_preset > "1y" default), so
saved views still control the initial zoom but per-session navigation is
deep-linkable.

shape-timeline-chart paints lane labels inside a foreignObject containing
an HTML <div> with overflow:hidden + text-overflow:ellipsis + a title
attribute carrying the full text. Long project names no longer bleed
across the chart canvas; hover reveals the full label.

i18n: views.timeline.zoom.{label,in,out,1y,2y,all} (DE+EN).
2026-05-18 17:45:30 +02:00
mAi
a5b94739b4 feat(t-paliad-211): calendar shape adds week + day views and aligned grid
shape-calendar now renders month, week, and day views with a chip switcher
above the grid. Active view + anchor date persist in the URL as
?cal_view=month|week|day&cal_date=YYYY-MM-DD so per-view navigation is
deep-linkable.

Month view: weekday header row now lives inside the same CSS grid as the
day cells (one shared grid-template-columns: repeat(7,1fr)), so day labels
no longer drift relative to the columns below. Day-number is a button
that switches to day view scoped to that date; +N more pill also drills
to day view. Individual row pills route to /deadlines/{id} /
/appointments/{id} via inner anchors with click stopPropagation so they
don't trigger the day-drill.

Week view: 7 columns, full row list per column (no 3-row cap), per-column
vertical scroll for busy days.

Day view: single chronological list. Prev/next-day nav reuses the same
toolbar; week/day views also expose a "Zurück zum Monat" link.

i18n: cal.view.month|week|day + per-view prev/next labels +
cal.day.back_to_month + cal.day.open_day + cal.day.no_entries (DE+EN).
2026-05-18 17:45:30 +02:00
mAi
283c9e8f67 fix(mig 099): add missing audit_reason wrapper
Mig 099 (drop_with_po_flag) crash-looped paliad.de prod immediately
after deploy: the mig 079 trigger on paliad.deadline_rules raises
EXCEPTION 'audit reason required' on UPDATE when paliad.audit_reason
is unset. Original file (fermi, t-paliad-207) only had the UPDATE,
no set_config wrapper.

Patch: prepend the standard 'SELECT set_config(paliad.audit_reason,
...)' at the top so the trigger sees the reason. Same shape as every
other migration that mutates deadline_rules.

Manual recovery already applied via head MCP — UPDATE'd the 2 rows
with audit_reason set, marked tracker version=99 dirty=false,
force-restarted the container which booted clean. This commit aligns
the in-repo file with the recovered prod state. Idempotent: the
WHERE clause matches only rows that still carry with_po, so re-apply
is a no-op.
2026-05-18 17:33:01 +02:00
mAi
dece61107b Merge: t-paliad-207 — fermi's polish session (jurisdiction prefix + trigger-event label + flag rows + youpc rule links + DE sub-group headers + R.19 Einspruch as always-available; mig 099 NULLs with_po flag on RoP.019.1 rows) 2026-05-18 17:29:43 +02:00
mAi
8bf1626997 fix(mig): renumber drop_with_po_flag 098 → 099 (number collision with submission_codes_prefix_and_rename) 2026-05-18 17:29:21 +02:00
mAi
7f49851abf fix(t-paliad-207): drop with_po flag — R.19 Einspruch is always available, not flag-gated (mig 098)
m's correction 2026-05-18: the R.19 Einspruch (preliminary objection)
should not be flag-gated. It's an always-available optional submission
the defendant can make once the SoC is served — same logic as the
appeal-spawn rules in t-paliad-203 F2.3 ("the appeal is always a
possibility"). Removing the gate makes the row a normal optional rule:
priority='optional' (unchanged, set by mig 095) gives the save-modal
the existing pre-uncheck behaviour without a separate checkbox.

**Migration 098** (idempotent): NULLs condition_expr on the two RoP.019.1
rows pinned by proceeding code (`upc.inf.cfi` + `upc.rev.cfi`). Re-apply
is a no-op via the WHERE clause matching the live shape. Live DB row
state will sync when Dokploy applies the migration on next deploy — no
raw prod-write this turn (lesson from the previous shift's friction note).

**Frontend cleanup** — removes the two flag rows added to
verfahrensablauf.tsx + fristenrechner.tsx in the parent t-paliad-207
commit (inf-po-flag-row, rev-po-flag-row), the readFlags()/calculate()
push branches, the syncFlagRows() show/hide entries, and the change
listeners. Drops the 4 i18n keys (deadlines.flag.inf_po + rev_po,
DE + EN). Bun build clean: 2417 keys (was 2419, -2 keys × 2 langs).

Branch: mai/fermi/interactive-session @ third commit on top of Path A.
2026-05-18 17:29:14 +02:00
mAi
518b2d9617 feat(t-paliad-207): DE proceeding picker — sub-group headers + parallel labels (Path A)
m's 2026-05-18 ask: the 5 DE proceeding tiles followed three different
labelling conventions ("Verletzungsklage (LG)" / "Berufung OLG" /
"Nichtigkeitsverfahren" — instance in brackets vs not vs not even
present). Path A reshapes both the picker and the labels so a user
scanning "Deutsche Gerichte" sees the type→instance hierarchy at a
glance and every tile reads <court> (<procedural role>) in parallel.

**Picker structure (verfahrensablauf.tsx + fristenrechner.tsx):**
Inside the existing `<.proceeding-group data-forum="de">` block, the
single flat row of 5 tiles is now two sub-groups with mixed-case h5
headings — Verletzungsverfahren over LG/OLG/BGH, Nichtigkeitsverfahren
over BPatG/BGH. DE_TYPES split into DE_INF_TYPES (3) + DE_NULL_TYPES (2)
in both page shells.

**Labels (i18n.ts, DE + EN parallel):**
| Code           | Old DE                       | New DE                |
|---             |---                           |---                    |
| de.inf.lg      | Verletzungsklage (LG)        | LG (1. Instanz)       |
| de.inf.olg     | Berufung OLG                 | OLG (Berufung)        |
| de.inf.bgh     | Revision/NZB BGH             | BGH (Revision / NZB)  |
| de.null.bpatg  | Nichtigkeitsverfahren        | BPatG (1. Instanz)    |
| de.null.bgh    | Berufung BGH (Nichtigk.)     | BGH (Berufung)        |

Two new i18n keys carry the sub-group headings:
- deadlines.de.group.inf  — "Verletzungsverfahren" / "Infringement proceedings"
- deadlines.de.group.null — "Nichtigkeitsverfahren" / "Nullity proceedings"

**CSS (global.css):**
New `.proceeding-subgroup` + `.proceeding-subgroup-heading` rules,
co-located with `.proceeding-group h4`. Sub-heading sits one tier below
the h4 (mixed-case, no upper-tracking) so the two-level hierarchy reads
at a glance.

**What this does NOT do** — the "one long sequence" combined-timeline
behaviour (m's same ask, larger scope: spawn rules + de-duplication +
multi-instance UI) is filed as m/paliad#41 and stays a separate
delivery. Per-instance tiles keep their meaning either way.

Build hygiene: go build/vet clean; bun run build clean (2419 keys, +2).
2026-05-18 17:29:14 +02:00
mAi
4131d2e2a6 feat(t-paliad-207): Verfahrensablauf + Fristenrechner polish (jurisdiction prefix, trigger-event, flag rows, rule links, R.19 label)
Five intertwined fixes m surfaced in the interactive session:

1. **Jurisdiction prefix on the picked proceeding** — the collapsed
   summary chip and the result header now read "UPC Verletzungsverfahren"
   / "DE Verletzungsklage (LG)" instead of the bare proceeding name.
   Disambiguates the 4 redundancies in the corpus once the picker
   collapses. Driven by .proceeding-group[data-forum] which is already
   on every group.

2. **Trigger Event label = root rule** — step 2's "Auslösendes Ereignis"
   line now shows the first event in the proceeding (e.g. Klageerhebung,
   Nichtigkeitsklage) instead of the proceeding name. Populated from
   the calc response (isRootEvent=true) on every render; em-dash
   placeholder while step 3 hasn't rendered yet. lang-change keeps it
   coherent.

3. **Flag rows on /tools/verfahrensablauf** — Slice 1 of t-paliad-179
   stripped the with_ccr / with_amend / with_cci toggles when it lifted
   the shared renderer; they never came back. Lifted the 4 existing
   rows from fristenrechner.tsx plus 2 new with_po rows (RoP 19.1
   preliminary objection, mig 095) — same wiring + show/hide rules on
   both surfaces. with_amend stays nested under with_ccr on upc.inf.cfi
   (R.30 only with a CCR).

4. **Rule references → youpc.org/laws links** — new
   BuildLegalSourceURL(src) maps the structured legal_source code to
   the youpc permalink for the UPC corpus (UPCRoP / UPCA / UPCS today;
   39 of 91 active rules carry UPC.RoP.* and now link). DE/EPA/EU
   bodies have no youpc home yet and render as plain display text —
   filed as m/paliad#39. Wired through UIDeadline.LegalSourceDisplay +
   LegalSourceURL so deadlineCardHtml can render <a target="_blank"
   rel="noopener"> when the URL is set.

5. **R.19 label: "Vorab-Einrede" → "Einspruch"** — m's correction. DE
   only (EN canonical UPC RoP term stays "Preliminary objection").
   Client-side change only — i18n + JSX fallbacks. The matching DB
   rename on the two rule-name rows folds into joule's broader mig 097
   (legal-citation backfill, t-paliad-208 follow-up). The live UPDATE
   applied during the session is captured under that audit reason; the
   no-op when joule's mig re-applies is harmless.

Build hygiene:
- go build ./... + go vet ./... clean
- new test TestBuildLegalSourceURL covers UPC corpus + DE/EPA/EU
  fall-through + edge cases (empty input, malformed source)
- bun run build clean (2417 i18n keys total)

Rebased on origin/main @ d126913 (ohm's submission_code rename
workstream B) — no conflicts in this commit's surface area.

Branch: mai/fermi/interactive-session. NOT self-merged.
2026-05-18 17:29:14 +02:00
mAi
d507db22a7 fix(mig 098): exempt orphan rules from §6.2 NULL-check (proceeding_type_id IS NULL)
Recovery during the prod outage uncovered a second mig 098 bug: §6.2
assertion '0 NULL submission_code on active+published rows' counted
the 77 orphan rules (proceeding_type_id IS NULL, cross-cutting
Wiedereinsetzung / Schriftsatznachreichung pattern) and rejected the
migration. Patch: gate the NULL count on `proceeding_type_id IS NOT
NULL` so orphans pass through. Migration already applied to prod via
manual recovery with the same patched assertion; this commit aligns
the in-repo file with the deployed state.
2026-05-18 17:28:19 +02:00
mAi
a0a3ec32a3 fix(mig 098): relax submission_code shape regex to allow digits in suffix
Mig 098 (t-paliad-209, ohm) crash-looped paliad.de prod for ~2h: §6.1
assertion regex `^[a-z_]+\.[a-z_]+\.[a-z_]+\.[a-z_]+(\..*)?$` rejects
EPA rule codes that carry the statutory rule number in the suffix —
e.g. `epa.opp.boa.r106`, `epa.grant.exa.r71_3`, `epa.opp.opd.r116`,
`epa.opp.opd.r79_further`, `epa.opp.boa.entsch2`, `epa.opp.boa.r116`.
Migration's UPDATE step succeeds against these rows; the transactional
assertion blows them up; rollback leaves the migration tracker dirty
at version 98 and the container refuses to start.

Patch: allow `[a-z_0-9]` per segment instead of `[a-z_]` in both the
SQL assertion (mig 098 §6.1) and the matching Go shape regex
(submission_codes_shape_test.go). Same change in both spots so the
runtime sanity test stays aligned with the SQL invariant.

Manual recovery already applied: forced
`paliad.paliad_schema_migrations.version` back to 97 with `dirty=false`
so the next deploy retries mig 098 from scratch against the patched
file. No data state changed (mig 098 ran inside a transaction and
fully rolled back — snapshot table, prefix UPDATE, and column rename
all reverted).

go build ./... clean. TestProceedingCodeShapeRegexStandalone green.
2026-05-18 16:52:38 +02:00
mAi
f9d32a90e7 Merge: t-paliad-207 — fermi's polish (jurisdiction prefix, trigger-event label, flag rows, youpc rule links, R.19 Einspruch label) 2026-05-18 16:37:54 +02:00
mAi
a18b825bee feat(t-paliad-207): Verfahrensablauf + Fristenrechner polish (jurisdiction prefix, trigger-event, flag rows, rule links, R.19 label)
Five intertwined fixes m surfaced in the interactive session:

1. **Jurisdiction prefix on the picked proceeding** — the collapsed
   summary chip and the result header now read "UPC Verletzungsverfahren"
   / "DE Verletzungsklage (LG)" instead of the bare proceeding name.
   Disambiguates the 4 redundancies in the corpus once the picker
   collapses. Driven by .proceeding-group[data-forum] which is already
   on every group.

2. **Trigger Event label = root rule** — step 2's "Auslösendes Ereignis"
   line now shows the first event in the proceeding (e.g. Klageerhebung,
   Nichtigkeitsklage) instead of the proceeding name. Populated from
   the calc response (isRootEvent=true) on every render; em-dash
   placeholder while step 3 hasn't rendered yet. lang-change keeps it
   coherent.

3. **Flag rows on /tools/verfahrensablauf** — Slice 1 of t-paliad-179
   stripped the with_ccr / with_amend / with_cci toggles when it lifted
   the shared renderer; they never came back. Lifted the 4 existing
   rows from fristenrechner.tsx plus 2 new with_po rows (RoP 19.1
   preliminary objection, mig 095) — same wiring + show/hide rules on
   both surfaces. with_amend stays nested under with_ccr on upc.inf.cfi
   (R.30 only with a CCR).

4. **Rule references → youpc.org/laws links** — new
   BuildLegalSourceURL(src) maps the structured legal_source code to
   the youpc permalink for the UPC corpus (UPCRoP / UPCA / UPCS today;
   39 of 91 active rules carry UPC.RoP.* and now link). DE/EPA/EU
   bodies have no youpc home yet and render as plain display text —
   filed as m/paliad#39. Wired through UIDeadline.LegalSourceDisplay +
   LegalSourceURL so deadlineCardHtml can render <a target="_blank"
   rel="noopener"> when the URL is set.

5. **R.19 label: "Vorab-Einrede" → "Einspruch"** — m's correction. DE
   only (EN canonical UPC RoP term stays "Preliminary objection").
   Client-side change only — i18n + JSX fallbacks. The matching DB
   rename on the two rule-name rows folds into joule's broader mig 097
   (legal-citation backfill, t-paliad-208 follow-up). The live UPDATE
   applied during the session is captured under that audit reason; the
   no-op when joule's mig re-applies is harmless.

Build hygiene:
- go build ./... + go vet ./... clean
- new test TestBuildLegalSourceURL covers UPC corpus + DE/EPA/EU
  fall-through + edge cases (empty input, malformed source)
- bun run build clean (2417 i18n keys total)

Rebased on origin/main @ d126913 (ohm's submission_code rename
workstream B) — no conflicts in this commit's surface area.

Branch: mai/fermi/interactive-session. NOT self-merged.
2026-05-18 15:58:26 +02:00
mAi
7d275cac6b Merge: t-paliad-210 — mig 097 legal-citation backfill (huygens HIGH/MED + m's FLAG walk-through) 2026-05-18 15:54:47 +02:00
mAi
21727bf1ca feat(db): mig 097 — legal-citation backfill (huygens HIGH/MED + m's FLAG walk-through)
t-paliad-210 / paliadin-head msg 2002 + 2006. Applies huygens's HIGH/MED
proposals from docs/proposals/legal-citation-backfill-2026-05-18.md
(commit 391be09) plus m's FLAG walk-through:

  § 1  Easy wins                — 6 rows (rule_code only).
  § 2  HIGH/MED proceeding-typed — 15 rows.
  § 3  HIGH/MED orphans         — 47 rows.
  § 4  FLAG-A dedup (clean only) — 1 canonical fill + 3 archives
                                  (Wiedereinsetzung §123-PatG twin,
                                  Berufungsschrift, Berufungsbegründung).
                                  Mängelbeseitigung 6× and Beginn-
                                  Hauptsache 2× DEFERRED pending m's call
                                  on distinct-context rule_codes[].
  § 5  FLAG-B court-scheduled    — 26 rows. RoP.111 / RoP.118 / § 285 ZPO
                                  / § 300 ZPO / § 47 PatG etc.
  § 6  FLAG-C/D rubber-stamp     — 5 rows. RoP.52 / RoP.235.1 / § 273 ZPO.
  § 7  FLAG-E service triggers   — 6 rows. § 317 ZPO / § 99 / 47 / 79 PatG
                                  / R. 111 EPÜ.
  § 8  FLAG-F combined-pleading  — 5 rows via rule_codes[] multi-cite.
  § 9  FLAG-G/H/I + RoP.271.b    — 13 rows. Patentänderung INF/REV split,
                                  H sub-paragraphs, RoP.069 by analogy,
                                  + RoP.271.b secondary cite on 5 UPC
                                  initial submissions.
  § 10 R.19 label rename         — defensive backstop for fermi's prod
                                  write (t-paliad-207 consolidated).
  § 11 RoP.49.1 → RoP.049.1      — padding normalization on rev.defence.

FLAG-J 3 rows (d124c95b / 002c2ba7 / 902cc5d5) left NULL for m's
/admin/rules pickup. 11 rows total stay NULL post-mig (3 FLAG-J + 8
deferred dedup).

Snapshot table paliad.deadline_rules_pre_097 preserves pre-mig state
including the distinct rule_codes[] on the deferred Mängelbeseitigung +
Beginn-Hauptsache sets.

Dry-run on supabase produced expected counts:
  null_count=11, old_outlier=0, new_padded=2

Idempotent: re-applying matches no rows. Audit-trail through mig 079
trigger via set_config(paliad.audit_reason, ..., true).
2026-05-18 15:39:03 +02:00
mAi
d126913185 Merge: t-paliad-209 — workstream B (submission_code rename + prefix + Rechtsgrundlage column) 2026-05-18 15:11:52 +02:00
mAi
ea29165d2f feat(t-paliad-209): rename Code → Submission Code + add Rechtsgrundlage column
Workstream B frontend sweep — matches mig 098 + the Go sweep. The
/admin/rules surfaces now distinguish submission_code (the rule's
filing identifier within a proceeding, e.g. upc.inf.cfi.soc) from
rule_code (the legal citation, e.g. RoP.013.1).

Admin rules list (/admin/rules):
- Column header renamed "Code" → "Submission Code / Einreichung-Kennung"
- New "Rechtsgrundlage" column shows rule_code alongside the submission
  code; the old single-column fallback (rule_code || code) is gone.
- Filter-search placeholder updated to "Name, Submission Code,
  Rechtsgrundlage…"
- Rule interface: code → submission_code field.

Admin rules edit (/admin/rules/{id}/edit):
- f-code → f-submission-code; input is now read-only with a
  upc.inf.cfi.soc-style placeholder (consistent with the backend
  RulePatch which doesn't allow editing the submission code).
- Labels reframe rule_code as "Rechtsgrundlage (Kurzform)" and
  legal_source as "Rechtsgrundlage (Langform)" so the legal-citation
  pair is named consistently with the list column.
- Rule interface: code → submission_code field.

i18n: new keys admin.rules.col.submission_code,
admin.rules.col.legal_citation, admin.rules.edit.field.submission_code
in both DE + EN; old admin.rules.col.code + admin.rules.edit.field.code
removed.

bun run build clean.
2026-05-18 15:06:18 +02:00
mAi
bc5b3557d0 feat(t-paliad-209): rename DeadlineRule.Code → SubmissionCode across Go layer
Workstream B Go sweep — matches mig 098. Every place the deadline-rules
service reads/writes the per-rule identifier now uses the new column
name and the new struct field. Distinct from rule_code (legal citation)
and from proceeding_types.code (the proceeding's 3-segment code).

Touch points:
- models.DeadlineRule.Code → SubmissionCode (db + json tags renamed
  in lockstep — JSON contract `submission_code` is the new shape).
- deadline_rule_service: ruleColumns SELECT list updated.
- rule_editor_service: CreateRuleInput.Code → SubmissionCode (json tag
  too), INSERT + CloneAsDraft SELECT updated.
- projection_service: lookupRuleByCode → lookupRuleBySubmissionCode
  (SQL WHERE clause + error message); every r.Code / parent.Code /
  rule.Code / first.Code / src.rule.Code read renamed.
- fristenrechner: r.Code / prev.Code / rule.Code reads renamed in
  Calculate (parent-anchor + override-key + computed-by-code map) and
  in CalculateRule's LocalCode emission; the proceeding-code+submission-
  code resolver query uses `submission_code = $2`.
- event_trigger_service / deadline_calculator: r.Code reads renamed.

UIDeadline.Code (the calculator's wire response) is unchanged — that
field is a separate API contract pointing at the same value; renaming
it would force every frontend deadline-renderer through a contract
break that isn't part of this workstream.

Test fixtures updated to the new SubmissionCode field name; live-DB
tests updated to the post-mig-098 prefixed values (`inf.sod` →
`upc.inf.cfi.sod` etc.). New submission_codes_shape_test asserts
every active+published row matches the 4+-segment proceeding-prefixed
shape (sibling of TestProceedingCodeShape; mirrors mig 098 §6.1).

go build ./... clean. go test ./internal/... green.
2026-05-18 15:06:04 +02:00
mAi
bd2c7a217e feat(t-paliad-209): mig 098 prefix submission codes + rename code → submission_code
m's 2026-05-18 call (workstream B): the paliad.deadline_rules.code field
is a SUBMISSION identifier (the filing/event within a proceeding), not
the legal-citation rule code (which lives in rule_code / legal_source).
Two cleanups land in this migration:

1. DATA — prefix every existing submission code with its proceeding
   code so submission codes carry the full hierarchical shape:
       inf.soc       (on upc.inf.cfi)  → upc.inf.cfi.soc
       de_inf.klage  (on de.inf.lg)    → de.inf.lg.klage
       de_inf_bgh.revision (on de.inf.bgh) → de.inf.bgh.revision
   Idempotent: WHERE NOT LIKE pt.code || '.%' skips already-prefixed
   rows so re-running is a no-op.

2. SCHEMA — rename paliad.deadline_rules.code → submission_code so
   future devs don't conflate it with rule_code (legal citation) or
   proceeding_types.code. The rename is guarded by a column-existence
   check, idempotent on a second run.

Drops + recreates the deadline_search materialized view because its
SELECT bakes `dr.code AS rule_local_code` (mig 051 §4); the rebuild
sources from `dr.submission_code` and reproduces every index from mig
051 verbatim.

Backup snapshot table paliad.deadline_rules_pre_098 captures the rows
before the prefix step; serves as the audit anchor and the down's
source.

Hard assertions (§6) gate the migration on:
- every active+published row matches the 4+-segment proceeding-prefixed
  shape regex
- no NULL submission_code on active+published rows
- the column was actually renamed
2026-05-18 15:05:46 +02:00
mAi
edcf41d203 Merge: t-paliad-208 — legal-citation backfill proposal (huygens, doc only) 2026-05-18 14:57:25 +02:00
128 changed files with 20132 additions and 873 deletions

73
Makefile Normal file
View File

@@ -0,0 +1,73 @@
# Paliad — developer entrypoints.
#
# Targets here are the gate tier from the test-strategy design
# (docs/design-paliad-test-strategy-2026-05-19.md). Slice 1 lands:
#
# make verify-migrations — dry-run every pending migration (BEGIN..ROLLBACK)
# plus the full boot smoke (apply + tracker
# advances + /healthz returns 200).
# make verify-mig — alias for verify-migrations.
# make test — short test pass: go test ./internal/... -short
# plus the cmd/server package. Includes the
# live-DB tests when TEST_DATABASE_URL is set,
# skips them otherwise.
# make test-go — go test ./... -race (full Go suite).
#
# Future slices will extend this with:
# make test-frontend — bun test (Slice 3 / Slice 6)
# make e2e — Playwright golden-path suite (Slice 4)
#
# All targets are idempotent. None of them write to the filesystem outside
# the test runner's working dirs. None of them touch internal/db/migrations/
# files.
.PHONY: help verify-migrations verify-mig test test-go
help:
@echo "Paliad — developer targets"
@echo ""
@echo " verify-migrations Dry-run pending migrations + boot smoke (needs TEST_DATABASE_URL)"
@echo " verify-mig Alias for verify-migrations"
@echo " test Short test pass — covers gate tier"
@echo " test-go Full Go suite with race detector"
@echo ""
@echo "Set TEST_DATABASE_URL to enable live-DB tests. Example:"
@echo " export TEST_DATABASE_URL=postgres://paliad:...@localhost:11833/paliad_test"
# Gate target — the test that would have caught mig 098 / mig 099 before
# deploy. Combines:
# - TestMigrations_DryRun (internal/db): per-migration BEGIN..ROLLBACK
# - TestBootSmoke (cmd/server): apply-end-to-end + tracker advances
# + /healthz 200
#
# Requires TEST_DATABASE_URL. Without it, both tests skip and the target
# is effectively a no-op — guard against that explicitly so CI doesn't
# silently green a missing env var.
verify-migrations:
@if [ -z "$$TEST_DATABASE_URL" ]; then \
echo "ERROR: TEST_DATABASE_URL is not set."; \
echo " The migration gate cannot run without a scratch DB."; \
echo " Set TEST_DATABASE_URL to a Postgres URL the test can"; \
echo " open transactions against, e.g."; \
echo " export TEST_DATABASE_URL=postgres://paliad:PW@localhost:11833/paliad_test"; \
exit 2; \
fi
@echo "==> migration dry-run (per-mig BEGIN..ROLLBACK)"
go test -count=1 -run TestMigrations_DryRun ./internal/db/
@echo "==> boot smoke (apply + tracker + /healthz)"
go test -count=1 -run TestBootSmoke ./cmd/server/
verify-mig: verify-migrations
# Gate-tier test pass. -short skips the slow live-DB tests when the
# author opts out via `if testing.Short() { t.Skip(...) }`; today most of
# paliad's live-DB tests gate on TEST_DATABASE_URL instead, so -short is
# forward-compatible rather than load-bearing.
test:
go test -short ./internal/... ./cmd/...
# Full Go suite with race detection. Slower but catches concurrent-map
# regressions that -short would skip; intended for the merge-to-main gate
# (full suite, not per-PR).
test-go:
go test -race ./...

View File

@@ -117,7 +117,9 @@ func main() {
}
appointmentSvc := services.NewAppointmentService(pool, projectSvc)
caldavSvc = services.NewCalDAVService(pool, cipher, appointmentSvc)
bindingSvc := services.NewCalendarBindingService(pool)
targetSvc := services.NewAppointmentTargetService(pool)
caldavSvc = services.NewCalDAVService(pool, cipher, appointmentSvc, bindingSvc, targetSvc)
// Wire the push hook so user-driven mutations sync to the external
// calendar without waiting for the next 60-second tick.
appointmentSvc.SetCalDAVPusher(caldavSvc)
@@ -143,6 +145,7 @@ func main() {
Deadline: deadlineSvc,
Appointment: appointmentSvc,
CalDAV: caldavSvc,
CalDAVBindings: bindingSvc,
Rules: rules,
Calculator: services.NewDeadlineCalculator(holidays),
Users: users,
@@ -175,10 +178,37 @@ func main() {
UserView: services.NewUserViewService(pool),
Broadcast: services.NewBroadcastService(pool, mailSvc, users, teamSvc, emailTemplateSvc),
Pin: services.NewPinService(pool, projectSvc),
CardLayout: services.NewCardLayoutService(pool),
Projection: services.NewProjectionService(pool, projectSvc, deadlineSvc, appointmentSvc, services.NewFristenrechnerService(rules, holidays, courts), rules),
CardLayout: services.NewCardLayoutService(pool),
DashboardLayout: services.NewDashboardLayoutService(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
// embedded README.
Export: services.NewExportService(pool, branding.Name),
}
// t-paliad-219 Slice A3 — stitch DashboardService → ApprovalService
// for the inbox-approvals widget. Done post-construction to avoid
// a circular constructor dependency (ApprovalService doesn't need
// the dashboard, and DashboardService can render its other widgets
// without approvals — so keeping this a setter keeps both
// constructors simple).
svcBundle.Dashboard.SetApprovalService(svcBundle.Approval)
// 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()
// Paliadin backend selection.
//
// PALIADIN_BACKEND (t-paliad-194 / m/paliad#38):

View File

@@ -0,0 +1,210 @@
// Boot smoke test — assert paliad reaches a serving state.
//
// Three checks against TEST_DATABASE_URL:
//
// 1. db.ApplyMigrations does not panic and returns nil.
// 2. paliad.applied_migrations covers every on-disk *.up.sql — no
// migration was silently skipped, no version is missing. The set
// contract is stronger than the old single-counter check: applied
// set must EQUAL on-disk set, not just reach the max version.
// 3. The handler mux (with /healthz mounted) responds 200 to GET /healthz.
//
// This is the lightweight cousin of the migration dry-run gate
// (internal/db/migrate_test.go): the dry-run catches per-migration syntax
// errors before merge; this smoke confirms the apply+bind path the
// container actually runs at boot. Together they cover the mig-098 /
// mig-099 class of crash-loops end-to-end, plus the mig-103 parallel-merge
// skip-hole that t-paliad-218 closed (m/paliad#44).
//
// Skipped without TEST_DATABASE_URL — matches the rest of the live-DB tests.
//
// Design: docs/design-paliad-test-strategy-2026-05-19.md §5 Slice 1 and
// docs/design-migration-runner-applied-set-2026-05-20.md §6.
package main
import (
"database/sql"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"testing"
_ "github.com/lib/pq"
"mgit.msbls.de/m/paliad/internal/auth"
"mgit.msbls.de/m/paliad/internal/db"
"mgit.msbls.de/m/paliad/internal/handlers"
)
func TestBootSmoke(t *testing.T) {
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
t.Skip("TEST_DATABASE_URL not set — skipping boot smoke")
}
// (1) Apply migrations end-to-end. The same code path the prod
// container runs at boot before `http.ListenAndServe`. A regression
// like mig-098's digit-regex would surface here as a non-nil error.
if err := db.ApplyMigrations(url); err != nil {
t.Fatalf("db.ApplyMigrations: %v", err)
}
// (2) Assert the applied set equals the on-disk set. The new runner
// tracks applied state per-migration; a silently-skipped version
// would surface as a row missing from paliad.applied_migrations even
// though max(version) matches. Comparing sets — not just max —
// catches the failure mode the t-paliad-218 post-mortem documented.
onDisk := embeddedMigrationVersions(t)
applied := appliedMigrationVersions(t, url)
if missing := setDiff(onDisk, applied); len(missing) > 0 {
t.Errorf("paliad.applied_migrations missing %d on-disk versions: %v "+
"(a migration was skipped — investigate before deploying)",
len(missing), missing)
}
if extra := setDiff(applied, onDisk); len(extra) > 0 {
t.Errorf("paliad.applied_migrations has %d versions with no on-disk file: %v "+
"(orphan rows — either restore the file or DELETE the row)",
len(extra), extra)
}
// (3) Mount the public handlers (the same Register call main() makes,
// minus the DB-backed Services bundle which the /healthz route doesn't
// need) and assert /healthz returns 200. This is the bind-and-serve
// half of the smoke: catches a regression that would make /healthz
// 404 or break the mux registration order.
//
// We deliberately do not boot the full main() — that would require
// SUPABASE_URL, SUPABASE_ANON_KEY, SUPABASE_JWT_SECRET, an open
// listening socket and a real auth client. The /healthz handler is
// auth-independent by design, and Register registers it on the outer
// mux before any DB-backed route, so this minimal setup exercises the
// exact code path main() takes.
mux := http.NewServeMux()
authClient := auth.NewClient("https://test.invalid", "anon-key", []byte("test-secret"))
handlers.Register(mux, authClient, "", nil)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("GET /healthz: status=%d, body=%q; want 200 OK", rec.Code, rec.Body.String())
}
if body := strings.TrimSpace(rec.Body.String()); body != "ok" {
t.Errorf("GET /healthz: body=%q; want \"ok\"", body)
}
}
// embeddedMigrationVersions returns every N where N_*.up.sql exists in
// internal/db/migrations/ on disk. The boot smoke compares this set
// against paliad.applied_migrations to detect skipped or orphan
// migrations.
//
// Read from disk (not the embed.FS inside the db package — it's unexported)
// since the test runs from the repo. The two views must agree for the
// build to be self-consistent; if they diverge, the smoke test is the
// wrong place to learn about it (the build is). We trust them to match.
func embeddedMigrationVersions(t *testing.T) []int {
t.Helper()
root, err := repoRoot()
if err != nil {
t.Fatalf("locate repo root: %v", err)
}
dir := filepath.Join(root, "internal", "db", "migrations")
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatalf("read migrations dir %s: %v", dir, err)
}
var versions []int
for _, e := range entries {
name := e.Name()
if !strings.HasSuffix(name, ".up.sql") {
continue
}
base := strings.TrimSuffix(name, ".up.sql")
underscore := strings.IndexByte(base, '_')
if underscore <= 0 {
continue
}
v, err := strconv.Atoi(base[:underscore])
if err != nil {
continue
}
versions = append(versions, v)
}
if len(versions) == 0 {
t.Fatalf("no *.up.sql files found in %s", dir)
}
sort.Ints(versions)
return versions
}
// appliedMigrationVersions reads paliad.applied_migrations and returns
// the sorted list of versions. Fails the test if the table doesn't exist —
// db.ApplyMigrations is supposed to have created it by this point.
func appliedMigrationVersions(t *testing.T, url string) []int {
t.Helper()
conn, err := sql.Open("postgres", url)
if err != nil {
t.Fatalf("open: %v", err)
}
defer conn.Close()
rows, err := conn.Query(`SELECT version FROM paliad.applied_migrations ORDER BY version`)
if err != nil {
t.Fatalf("read applied_migrations: %v", err)
}
defer rows.Close()
var out []int
for rows.Next() {
var v int
if err := rows.Scan(&v); err != nil {
t.Fatalf("scan: %v", err)
}
out = append(out, v)
}
if err := rows.Err(); err != nil {
t.Fatalf("rows: %v", err)
}
return out
}
// setDiff returns the elements of a that are not in b. Inputs are sorted
// ascending; output preserves that ordering.
func setDiff(a, b []int) []int {
bset := make(map[int]bool, len(b))
for _, v := range b {
bset[v] = true
}
var out []int
for _, v := range a {
if !bset[v] {
out = append(out, v)
}
}
return out
}
// repoRoot walks upward from the test binary's working directory until it
// finds a go.mod. `go test` runs in the package dir, so we typically have
// to climb a couple of levels.
func repoRoot() (string, error) {
dir, err := os.Getwd()
if err != nil {
return "", err
}
for {
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
return dir, nil
}
parent := filepath.Dir(dir)
if parent == dir {
return "", os.ErrNotExist
}
dir = parent
}
}

View File

@@ -0,0 +1,332 @@
# Design — "Suggest changes" action on approval flow
**Author:** hertz (inventor)
**Date:** 2026-05-19
**Task:** t-paliad-216 (m/paliad in-flight)
**Branch:** `mai/hertz/inventor-suggest-changes`
**Status:** DESIGN — open questions await m before any coder shift.
---
## 0. TL;DR
Add a fourth action **"Änderungen vorschlagen"** ("Suggest changes") to the approval flow, alongside Approve / Reject / Revoke. Use case: the approver doesn't want to accept the proposed change as-is, but doesn't want to reject outright — they edit the proposed values into a counter-proposal and submit it back into the same approval flow.
**Mental model (m, 2026-05-19):** suggest-changes is not "ping the requester to fix it" — it's the approver **authoring a counter-proposal** that gets re-injected into the approval flow as a fresh `pending` row. The original requester (now potentially an eligible approver of the counter, since they're no longer the requested_by) sees:
- the **old row** in their /inbox as `changes_requested` ("Abgelehnt mit Vorschlag" / "Declined with changes") — historical record of their original attempt;
- the **new row** in /inbox as `pending` — the counter, which they can approve, reject, revoke (n/a, not theirs), or suggest changes back on. Everyone else eligible sees the new row too. 4-Augen still holds: the counter's requested_by (the approver who suggested it) cannot self-approve.
Click flow:
1. Approver opens an editable modal on the pending row showing the requester's proposed values. Edits any field. Writes a free-text note ("Bitte den Termin um 9:00 statt 8:00, weil der Raum sonst kollidiert").
2. POST `/api/approval-requests/{id}/suggest-changes` with `{note, counter_payload}`.
3. Server, in one tx: closes the old row (`changes_requested`, `decision_note=note`), reverts the entity from `pre_image`, then immediately inserts a **new** `pending` approval_requests row authored by the approver with `payload=counter_payload`, re-applies the counter to the entity, marks `pending_request_id` to the new row, emits two events (`*_approval_changes_suggested` + `*_approval_requested`). `previous_request_id` FK links new → old for chain traversal.
The pending audience for the new row is the same as any fresh `Submit*` — the existing notification + visibility plumbing handles it without special-casing.
---
## 0a. m's decisions (2026-05-19)
| # | Header | m picked | Reasoning note (when different from recommendation) |
|---|---|---|---|
| Q1 | State machine | **(a) New status `changes_requested`.** | As recommended. |
| Q2 | Entity state | **(a) Reverts to pre_image, same as Reject.** | As recommended. The counter is then re-applied in the same tx by the new approval row's write-then-approve cycle. |
| Q3 | Chain depth | **(a) Yes, across chained rows.** | As recommended. |
| Q4 | Note shape | **Hybrid: approver can edit the proposed values (counter-proposal) AND/OR leave free-text in `decision_note`.** | Differs from (a). Inventor picked free-text-only; m's twist: the suggestion should ALSO carry concrete edits. This adds a `counter_payload jsonb` column on `approval_requests` and turns "suggest-changes" into an action that authors a real counter-proposal, not just a hint. |
| Q5 | Surface | **(a) /inbox only — v1.** | As recommended. Email + entity-detail badge are Phase 2. |
| Q6 | Requester actions | **Different model: the counter is a NEW pending approval_request row, not an "edit + resubmit" CTA on the requester side.** | Differs from (a). m's reframing: instead of routing back to the requester to act on, the suggestion IS the next request. Original requester sees the old row as `changes_requested` (status pill "Abgelehnt mit Vorschlag" or similar). Original requester then sees the NEW row in /inbox like any pending — and **may approve it themselves**, because they are no longer the row's requested_by (the suggesting approver is). Everyone else eligible sees it too. Cleaner workflow, removes the "edit-and-resubmit CTA" from the requester role entirely. |
| Q7 | Notifications | **(b) Notify all eligible approvers + the original requester for the NEW pending row.** | Consistent with Q6. The counter is a fresh `pending` request, so the existing Submit*-notification audience applies. The original requester needs the ping because they're now an eligible approver of the counter — no special-case path. |
| Q8 | Audit shape | **(a) New event_type `*_approval_changes_suggested` per entity.** | As recommended. The new row also emits a normal `*_approval_requested` event, so the Verlauf chronology naturally captures the chain. |
The decisions above lock the design. §3 has been rewritten to reflect them; §2 (open questions) is retained as the historical record of what was open before the decisions.
---
## 1. Context — what's already in the code (verified 2026-05-19)
- **State machine** in `internal/services/approval_service.go`:
- `paliad.approval_requests.status` CHECK is already `('pending', 'approved', 'rejected', 'revoked', 'superseded')` — the `superseded` value is defined as a Go constant `RequestStatusSuperseded` but never written by the live service (reserved).
- `paliad.{deadlines,appointments}.approval_status` CHECK is `('approved', 'pending', 'legacy')` — three values only.
- Shared kernel `decide(requestID, callerID, finalStatus, note)` powers Approve / Reject / Revoke. Approve invokes `applyApproved`; Reject + Revoke invoke `applyRevert` (restores entity from `pre_image`).
- Self-approval blocked at 3 layers: `canApprove` Go gate, `approval_requests_no_self_approval` DB CHECK, deadlock-check excludes requester from pool.
- **Handlers** in `internal/handlers/approvals.go`:
- `POST /api/approval-requests/{id}/approve`
- `POST /api/approval-requests/{id}/reject`
- `POST /api/approval-requests/{id}/revoke`
- `GET /api/approval-requests/{id}` — single hydrated request
- **Per-viewer flags** (t-paliad-202, shipped): every row carries `viewer_can_approve` + `viewer_is_requester` resolved server-side so the UI can grey out buttons the server would reject. Server still enforces — the flags are a UX hint.
- **Frontend**:
- `frontend/src/client/inbox.ts` wires three buttons per pending row (approve/reject/revoke). Reject opens `window.prompt()` for the note; approve+revoke don't.
- `frontend/src/client/views/shape-list.ts` (row_action="approve") stamps the row with action buttons + diff + `decision_note` display if present.
- **Audit**: event types `*_approval_requested`, `*_approval_approved`, `*_approval_rejected`, `*_approval_revoked` emitted to `paliad.project_events` (one per entity_type prefix).
- **Decision note**: `paliad.approval_requests.decision_note text` — a single free-text column, last-write-wins. Already populated on Reject (Approve also accepts an optional note).
---
## 2. Design questions (the open list — see §6 for answered)
Pre-recommendations from inventor. m will pick via AskUserQuestion.
### State machine
**Q1 — Where does "suggest changes" sit on the lifecycle?**
- **(a) New status `changes_requested` (RECOMMENDED).** The approval_requests row transitions pending → changes_requested. Sibling of approved/rejected/revoked/superseded. The row is terminal in that status; a re-submit creates a fresh row (linked via `previous_request_id`).
- (b) Reuse `rejected` with `is_revisable=true` flag. Cheap, but conflates two semantically distinct outcomes ("we'll never want this" vs. "tweak X and try again").
- (c) Auto-revoke the current row, mark the entity for edit, requester creates a new approval row when ready. Reuses existing plumbing — but loses the approver's note as a first-class thing (it'd just be a comment on the project_events row).
- (d) Other (you'll tell us).
Recommend (a) — keeps the audit lifecycle clear, gives us a clean place to hang the suggestion note, and is the smallest schema change (one new value in a CHECK constraint).
**Q2 — What happens to the entity (deadline/appointment) while in "changes requested"?**
- **(a) Entity reverts to pre_image — same as Reject (RECOMMENDED).** approval_status flips back to `approved`. The requester edits the entity in the normal flow; saving fires a fresh `Submit*` cycle.
- (b) Entity stays at `approval_status=pending` carrying the proposed values; requester edits "in place" through a new "amend the pending request" endpoint that mutates the same approval_request row + entity fields.
- (c) Entity goes to a new `approval_status=draft` (would require a new value on the entity-level CHECK + UI work to handle a third entity state).
Recommend (a) — minimum schema change, reuses every existing path (entity edit, Submit*, applyRevert, project_events emission). The trade-off is one extra approval_requests row per cycle; we link via `previous_request_id` so the chain stays inspectable.
**Q3 — Can the approver suggest changes multiple times (across a chain)?**
- **(a) Yes, across chained rows (RECOMMENDED).** Each row is terminal after suggest-changes; the requester resubmits → new pending row → approver can suggest changes again. Chain depth unbounded.
- (b) No — one chance per entity-lifecycle; if the requester comes back, the only options are approve or reject (the suggest-changes button is hidden for the second submission).
Recommend (a) — bounded by the requester's patience, not by the system. Multi-round review is the norm in legal-doc workflows.
**Q4 — Note shape on the suggestion**
- **(a) Free-text — reuse `decision_note` (RECOMMENDED).** Same column the existing Reject path already populates. Last-write-wins per row (but rows are terminal after suggest-changes, so there's no real "last write").
- (b) Thread of notes — new `paliad.approval_notes` table, ordered, multi-author. Lets the requester respond inline, the approver clarify, etc.
- (c) Structured per-field suggestions (`[{"field": "due_date", "current": "...", "suggested": "..."}]`) — a "diff-style" view.
Recommend (a) — matches the existing Reject UX, no new schema. (b) is right if the team wants to discuss; (c) is over-engineered for v1.
### UX
**Q5 — Where does the requester see the suggestion?**
- **(a) /inbox under `a_role=self_requested` (RECOMMENDED for v1).** Same surface they already use to see rejected. New status pill "Änderungen vorgeschlagen" + the note + a CTA "Bearbeiten und erneut einreichen".
- (b) A new badge on the entity's detail page (e.g. on the deadline detail page itself).
- (c) Email + push notification.
- (d) All of the above.
Recommend (a) for v1. Email reminder is a natural Phase-2 add-on (it'd reuse the existing reminder-mail plumbing). The entity-detail badge is nice but the user is already seeing the row in /inbox.
**Q6 — What action(s) does the requester have on a `changes_requested` row?**
- **(a) Edit and resubmit (RECOMMENDED).** Primary action. Opens the entity's edit form pre-populated with the original `payload`. Saving fires `Submit*` → new pending request with `previous_request_id` linking back.
- (b) Withdraw (= dismiss the row from inbox, no DB change). Mostly UI-only — the row is already terminal; "withdraw" would just be a "mark as not-pursuing" toggle.
- (c) Both.
Recommend (a). The row is already terminal once status=`changes_requested`; the requester either acts on the suggestion (a) or lets the row sit in their inbox history (no action needed). Adding a "dismiss" button is a UI nice-to-have but doesn't change the data model; can defer.
### Notifications
**Q7 — Who gets notified when "suggest changes" fires?**
- **(a) Just the requester (RECOMMENDED for v1).** Email-reminder path is reused: requester gets a mail "X hat Änderungen vorgeschlagen für …" with the note inline + a link to /inbox.
- (b) Requester + any other potential approvers (they need to know the request is closed, not pending).
- (c) Requester + approval-policy-defined watchers (would require a new `approval_policies.watchers` column).
Recommend (a). The request is terminal so other approvers don't need a "this is now your problem" ping — they wouldn't have anything to act on. They see it in /inbox under "Alle sichtbaren" anyway if curious.
### Audit
**Q8 — Audit row shape on `project_events`**
- **(a) New event_type `*_approval_changes_suggested` per entity (RECOMMENDED).** Parallel to the existing 4 (requested/approved/rejected/revoked). Two new event types: `deadline_approval_changes_suggested`, `appointment_approval_changes_suggested`. Note text goes in metadata.
- (b) Bundle with the resubmission — single composite event "approved-with-revisions" when the chain eventually approves.
Recommend (a). Each transition gets its own event row — that's how the existing audit chain already works (one event per state change). It also gives the Verlauf timeline a row to render the approver's note.
---
## 3. Implementation sketch (decisions-locked, see §0a)
### 3.1 Migration `103_approval_suggest_changes.up.sql`
```sql
-- 1. Extend approval_requests.status CHECK to allow 'changes_requested'.
ALTER TABLE paliad.approval_requests
DROP CONSTRAINT IF EXISTS approval_requests_status_check;
ALTER TABLE paliad.approval_requests
ADD CONSTRAINT approval_requests_status_check
CHECK (status IN ('pending', 'approved', 'rejected', 'revoked', 'superseded', 'changes_requested'));
-- 2. Add counter_payload — the approver's edited values, becomes the
-- `payload` of the NEW pending row spawned in the same tx as the
-- suggest-changes call. Stored on the OLD (now changes_requested) row
-- too so the audit chain can show "approver edited X, Y, Z" without
-- joining to the next row.
ALTER TABLE paliad.approval_requests
ADD COLUMN counter_payload jsonb NULL;
-- 3. Add previous_request_id FK so the new row links back to its origin.
ALTER TABLE paliad.approval_requests
ADD COLUMN previous_request_id uuid NULL
REFERENCES paliad.approval_requests(id) ON DELETE SET NULL;
CREATE INDEX approval_requests_previous_idx
ON paliad.approval_requests (previous_request_id)
WHERE previous_request_id IS NOT NULL;
```
`.down.sql`: drop the index + columns, restore the original CHECK (would reject existing `changes_requested` rows — that's normal for a breaking-change down).
### 3.2 Service layer
`SuggestChanges` is the only new public method on `ApprovalService`. It runs in **one transaction** and does five things:
```go
const RequestStatusChangesRequested = "changes_requested"
var ErrSuggestionRequiresChange = errors.New("suggestion_requires_change")
// SuggestChanges closes the pending request as `changes_requested`,
// reverts the entity, then immediately inserts a new pending
// approval_request authored by the caller carrying `counterPayload` as
// its new payload. The new row enters the standard pending flow — anyone
// eligible (including the original requester) can approve, reject,
// suggest-changes-again, etc.
//
// Authorization: caller satisfies canApprove on the OLD row (same gate
// as Approve / Reject). For the NEW row, the caller is the requested_by
// — self-approval is blocked by the standard 3-layer guard. Deadlock
// check (qualified-approver-exists-other-than-caller) runs on the new
// row to avoid spawning an unapprovable request.
//
// counterPayload must differ from the old row's payload OR a non-empty
// note must be present. A no-op suggest (same values, no note) is
// indistinguishable from "I have no opinion" and gets rejected with
// ErrSuggestionRequiresChange.
func (s *ApprovalService) SuggestChanges(
ctx context.Context,
requestID, callerID uuid.UUID,
counterPayload []byte, // jsonb-marshaled
note string,
) (newRequestID *uuid.UUID, err error) {
// 1. Begin tx, lock old row, validate status=pending + canApprove.
// 2. Validate: counterPayload differs from old payload OR note != "".
// 3. Update old row: status='changes_requested', decided_by=callerID,
// decision_note=note, counter_payload=counterPayload.
// 4. applyRevert on the entity (uses old row's pre_image).
// 5. Deadlock-check on the new row's required_role + projectID,
// excluding callerID.
// 6. INSERT new approval_requests row: requested_by=callerID,
// pre_image=<entity-state-as-just-reverted> (= old.pre_image),
// payload=counterPayload, required_role=old.required_role,
// lifecycle_event=old.lifecycle_event, entity_type=old.entity_type,
// entity_id=old.entity_id, status='pending',
// previous_request_id=requestID.
// 7. Re-apply the new payload to the entity (write-then-approve):
// apply the counter_payload's field updates + mark
// approval_status='pending' + pending_request_id=newRequestID.
// 8. Emit *_approval_changes_suggested project_events row
// (metadata: note, counter_payload diff vs original).
// 9. Emit *_approval_requested project_events row for the new
// request (same shape Submit* normally emits).
// 10. Commit.
}
```
Steps 6 + 7 reuse the existing `Submit*` plumbing structurally — the cleanest implementation factors out an "insert approval row + apply payload to entity" helper that both `Submit*` and `SuggestChanges` call. **decide()** does not need to know about `changes_requested` because suggest-changes is not a decision-kernel transition — it's its own end-to-end action.
### 3.3 HTTP layer
```
POST /api/approval-requests/{id}/suggest-changes
Body: {
"counter_payload": { ...same shape as Submit*'s payload... },
"note": "free-text explanation, optional iff counter_payload differs from original"
}
Returns: 200 { "new_request_id": "uuid" }
Errors:
400 "suggestion_requires_change" — counter_payload == old payload AND note empty
400 "invalid_counter_payload" — schema validation failure
403 "self_approval_blocked" — caller == old row's requested_by
403 "not_authorized" — caller doesn't satisfy canApprove
404 — request not found / not visible
409 "request_not_pending" — old row already decided
409 "no_qualified_approver" — deadlock on the new row (only caller is eligible)
```
Register in `internal/handlers/handlers.go` alongside the existing three:
```go
protected.HandleFunc("POST /api/approval-requests/{id}/suggest-changes", handleSuggestChangesApprovalRequest)
```
### 3.4 Frontend
`frontend/src/client/views/shape-list.ts` — extend the pending-row action group to four buttons:
```ts
actions.appendChild(approvalActionBtn("approve", detail));
actions.appendChild(approvalActionBtn("suggest_changes", detail));
actions.appendChild(approvalActionBtn("reject", detail));
actions.appendChild(approvalActionBtn("revoke", detail));
```
The `action` union type gains `"suggest_changes"`. Disabled-reason logic is identical to approve/reject (`viewer_can_approve` gate). i18n: `approvals.action.suggest_changes` → DE "Änderungen vorschlagen" / EN "Suggest changes".
`frontend/src/client/inbox.ts` — clicking the suggest-changes button opens a **modal**, not a `window.prompt` (the existing reject prompt is OK because reject only needs a note; suggest-changes needs an editable form). The modal:
- Renders the same fields the entity edit form would show, pre-populated from `detail.payload` (the requester's proposed values).
- Adds a free-text "Vorschlagskommentar" textarea at the bottom (the note).
- On submit: POST `/api/approval-requests/{id}/suggest-changes` with `{counter_payload: {...editedFields}, note}`.
- On success: refresh the bar — the old row flips to `changes_requested`, the new row appears as `pending`.
Where the modal's field-editor lives: a new `client/components/approval-edit-modal.ts` that takes `entity_type` + `payload` + `pre_image` and returns the edited payload. For v1 it can be a thin wrapper over the existing entity-edit form components (Frist date picker, Termin start/end pickers). Don't build a generic field-editor framework — just deadlines + appointments, hard-coded fields per entity_type.
**Status pill for `changes_requested`** — i18n keys + colour:
- `approvals.status.changes_requested` → DE "Abgelehnt mit Vorschlag" / EN "Declined with changes"
- Reuse the existing `approval-pill--historic` style; no new colour token needed for v1.
**The "Edit and resubmit" CTA on the requester's row is NOT needed** (m's Q6 reframing) — the requester just sees the new pending row in /inbox, same as any other.
### 3.5 Inbox filter
The /inbox `approval_status` filter chip cluster gains `changes_requested`. The `self_requested` viewer-role default already includes terminal statuses, so the original requester sees their `changes_requested` row without changing the default filter.
### 3.6 Linkage from old row to new row in /inbox
When showing a `changes_requested` row in /inbox, add a small "→ Neuer Vorschlag von {approver}" link below the note that scrolls / filters to the new pending row (it'll be visible to anyone eligible, including the original requester). The new row has `previous_request_id` pointing at the old one — so the API response for the old row can hydrate `next_request_id` (computed: `SELECT id FROM approval_requests WHERE previous_request_id = $1 LIMIT 1`).
### 3.7 Email notification (Phase 2 — defer until v1 ships)
The new row triggers the existing `*_approval_requested` notification path (whatever that is for Submit*) — same audience, same template. No new code. The old row's transition to `changes_requested` doesn't need its own mail; the new-row mail already tells the audience "X suggested changes to your earlier submission" through the body.
Out of scope for v1: a bespoke "your submission was declined with a counter-proposal" email aimed at the original requester. The new-row mail covers it functionally.
---
## 4. Slice plan
Three reviewable slices, each one PR. Combined scope is small/medium.
1. **Slice A — backend.** Migration 103 (CHECK extension + `counter_payload jsonb` + `previous_request_id` FK + index) + `SuggestChanges` service method + HTTP handler + service tests (happy path, no-op-suggestion guard, deadlock on new row, self-approval block, request_not_pending). Migration is non-blocking on Postgres; safe for live deploy.
2. **Slice B — frontend.** 4th button on /inbox + the edit modal (deadline-fields variant + appointment-fields variant) + status pill `changes_requested` ("Abgelehnt mit Vorschlag") + i18n keys (DE + EN) + the "→ Neuer Vorschlag" link from old row to new row. End-to-end browser smoke test via Playwright.
3. **Slice C — Verlauf integration.** Make sure the `*_approval_changes_suggested` event renders on the project / deadline / appointment Verlauf timeline alongside the existing 4 approval event types. May or may not need code change depending on how generic the Verlauf row renderer is — likely just an i18n key + an icon mapping.
Don't ship a chain-traversal UI in v1. The `previous_request_id` FK is captured so the data is there; surfacing the full chain history (n hops back) is a Phase-2 polish.
---
## 5. Risks / open considerations
- **Chain depth runaway.** Nothing stops an "I keep suggesting / they keep counter-suggesting" loop. Same risk as comment threads on GitHub PRs. Out of scope to cap; the social pressure (each round is a 4-Augen action with a name attached) is the natural brake.
- **Concurrent suggestions on the same pending row.** Two approvers click "suggest changes" at the same time? The existing `getRequestForUpdate` row-lock serialises them; the second caller gets `ErrRequestNotPending` (the first already flipped it). Same guarantee as Approve/Reject today.
- **Deadlock on the new row.** If the suggesting approver is the only qualified approver other than the original requester, the new row's deadlock check returns "no qualified approver" — because the original requester IS now eligible (they're no longer the requested_by), but might not have a high-enough role. The check needs to recognise: caller's pool = "anyone other than the new requester who can canApprove". Original requester counts if they hit the required-role bar. This is just the existing deadlock predicate run against the new (requester, role) tuple; no special-case logic. Surfaced as `409 "no_qualified_approver"` to the suggesting approver, with the standard global_admin override path still available.
- **Counter-payload schema validation.** Server must validate `counter_payload` against the same schema as a normal `Submit*` for that entity_type + lifecycle_event. Otherwise a malicious approver could write garbage values via the suggestion path that wouldn't fly through `Submit*`. Reuse the existing payload-schema validator from the entity services; don't write a parallel.
- **No-op suggestion guard.** Approver clicks suggest-changes but doesn't actually edit anything AND leaves the note empty? Server rejects with `ErrSuggestionRequiresChange`. UI guards too (the submit button stays disabled until either the form is dirty OR the note has text).
- **Migration safety.** Non-blocking. Adding a value to a CHECK constraint is a metadata-only change; adding a NULLable column + a NULLable FK is also metadata-only.
- **What about a structured per-field suggestion (Q4c)?** The `counter_payload` jsonb IS structured — each entity_type has fixed fields. There's no need for a separate "{field, current, suggested}" shape because the diff is computable from `pre_image → counter_payload` on the new row.
- **What about thread-of-notes (Q4b)?** Implicit in the chain — each row's `decision_note` is one "note" by one author; following `previous_request_id` backwards reconstructs the full back-and-forth. A future "thread view" UI is layered on top of this without schema change.
---
## 6. m's decisions
See §0a (decisions table) — filled in after the AskUserQuestion phase on 2026-05-19.
---
## 7. Out of scope for this design
- Email + push notifications (Phase 2; see §3.7).
- Structured per-field suggestion shape (Phase 2 enhancement).
- Approval-policy `watchers` column for notification fan-out.
- "Dismiss this row from my inbox" UI toggle (UX-only, not a data-model change).
- Cross-entity suggest-changes (e.g. project, party). Same as the original approval scope — deadlines + appointments only.

View File

@@ -0,0 +1,597 @@
# CalDAV multi-calendar sync — design
**Task:** t-paliad-212
**Inventor:** leibniz (2026-05-19)
**Branch:** mai/leibniz/inventor-caldav-multi
**Status:** READY FOR REVIEW — m's decisions on the §8 open questions captured in the addendum below (2026-05-19).
---
## §0 — One-paragraph summary
Paliad's CalDAV sync today is a single-target push: every user has one
`paliad.user_caldav_config` row, and every Appointment they can see gets
PUT into that one calendar. m wants users to pick their own organization —
one cal with everything, one cal per project (or per client / litigation /
patent / case), or any hybrid. This design splits the model in two:
**credentials stay per user** (one CalDAV server, one auth blob) and
**bindings become first-class rows** (a join table `paliad.user_calendar_bindings`
that points an Appointment-filter scope at a specific `calendar_path`).
Push/pull state migrates from scalar `appointments.caldav_uid`/`caldav_etag`
columns to a per-(appointment, binding) join table
`paliad.appointment_caldav_targets`, so the same Appointment can live in
N external calendars at once. The 60-second per-user sync goroutine survives
unchanged in shape; inside it the inner loop iterates bindings instead of
hard-coding `cfg.CalendarPath`. Sliced for safe rollout: Slice 1 introduces
the new tables behind a backfill that auto-creates one binding per
existing config row (zero behaviour change); Slice 2 ships the
binding-picker UI; Slice 3 wires scope-aware filtering (one cal per project).
Bidirectional sync stays exactly as it works today (last-write-wins on ETag,
Paliad-owned UIDs only) — multi-calendar does not change the conflict
model.
---
## §1 — What's already built (verified live, 2026-05-19)
Verified against the codebase, not the project's CLAUDE.md.
- **Schema** — `paliad.user_caldav_config` is one row per user with
`(user_id PK, url, username, password_encrypted bytea, calendar_path,
enabled, last_sync_at, last_sync_error, created_at, updated_at)`. The
scalar `calendar_path` is the only handle on which external calendar
receives events. Per direct `information_schema` query.
- **Appointment binding** — `paliad.appointments` carries scalar
`caldav_uid text` and `caldav_etag text` (nullable). Set once after a
successful PUT via `AppointmentService.SetCalDAVMeta`. This is the
single-target assumption baked into the row itself.
- **Sync engine** — `internal/services/caldav_service.go:298502`. One
goroutine per enabled user, 60s ticker, `runSyncOnce``syncOnce`
`pushAll` (`AppointmentService.AllForUser` × `cli.PutEvent`) +
`pullAll` (`cli.PropfindCalendar``cli.GetEvent` → reconcile by UID).
`AllForUser` returns *every* personal-or-visible-project appointment
for the user; today they all funnel into the single `calendar_path`.
- **UID convention** — `paliad-appointment-<uuid>@paliad.de`
(`caldav_ical.go:3134`). Foreign UIDs are intentionally skipped on
pull (`caldav_service.go:436442`).
- **Hooks** — `OnAppointmentCreated/Updated/Deleted` push directly to
the configured `cfg.CalendarPath` on a 30s-timeout background goroutine
so user requests don't block (`caldav_service.go:510558`).
- **Approval flow (t-138)** — project-attached appointments may be
`approval_status = 'pending'`. CalDAV push already runs after approval
in `AppointmentService.Update` paths; `ApplyRemoteUpdate` from a remote
edit currently bypasses the approval gate. That's a pre-existing hole
flagged here only because multi-calendar makes "which calendar's edit
wins" more visible — fix belongs in t-138 follow-ups, not in this
design.
- **CalDAV verbs supported** — PUT / DELETE / GET / PROPFIND (depth 0
and 1). No MKCALENDAR, no REPORT, no calendar-multiget. Tested
against Nextcloud, Radicale, Baikal, mailcow SOGo per
`caldav_client.go:2224`.
**What is _not_ baked in and is therefore free to extend:**
- The 60s ticker is per-*user*, not per-*calendar*. Adding bindings does
not multiply tickers.
- `cfg.CalendarPath` is referenced in exactly two places (`pushAll`,
`pullAll`) plus the three hooks. Replacing it with a binding loop is
a contained edit.
- Credentials are server-scoped, not calendar-scoped — every binding
for the same user shares the existing decrypted credential, so the
encryption layer (`caldav_crypto.go`) is untouched.
---
## §2 — Per-provider calendar-count limits (verified 2026-05-19)
Real numbers, from current docs, so the design knows its envelope.
| Provider | Per-account / per-user limit | Source |
|---|---|---|
| **iCloud** | **100** calendars + reminder-lists combined | [Apple Support 103188](https://support.apple.com/en-us/103188) |
| **Google Calendar** | **~100 owned** (soft recommendation, post-Nov-2025 ownership model) | [Workspace Updates 2026-01](https://workspaceupdates.googleblog.com/2026/01/automatic-addition-owned-secondary-calendars.html), [usecarly.com summary](https://www.usecarly.com/blog/how-many-calendars-google-account/) |
| **Fastmail** | **No documented cap on calendars.** 100 000 events/user. | [Fastmail account-limits page](https://www.fastmail.help/hc/en-us/articles/1500000277382-Account-limits) |
| **Nextcloud** | **30 per user** default; admin-configurable, `-1` = unlimited. Rate limit: 10 calendar-creations/hour. | [Nextcloud admin manual — Calendar](https://docs.nextcloud.com/server/stable/admin_manual/groupware/calendar.html) |
| **Radicale / Baikal / mailcow SOGo** | No published per-account cap (file-system / DB bound). | server defaults |
**Implications for the design:**
- "One calendar per project" is comfortably within all providers'
envelopes for typical HLC caseloads. A senior PA who tracks 40
litigations would land 40+ calendars, still inside iCloud's 100 and
Nextcloud's default 30 (would need an admin bump on Nextcloud — flag
in onboarding).
- "One calendar per case" can blow past Nextcloud's default 30 fast and
is a real risk on iCloud at the 60+ mark when combined with the
user's existing personal calendars + reminder lists. We should
**soft-cap** scope choices at the UI layer (warn at 20 bindings, hard
block at 80) rather than discover the limit by 5xx on PUT.
- Google Calendar's CalDAV endpoint does **not** support `MKCALENDAR`
reliably — calendars must be pre-created in the Google UI. iCloud,
Fastmail, Nextcloud, Radicale, Baikal, SOGo all accept `MKCALENDAR`.
So the "auto-create a calendar per project" affordance is provider-
dependent and must degrade gracefully ("we couldn't create it for
you — please make `Project X` in your calendar app and paste its
URL").
---
## §3 — Proposed data model
Three schema changes, no destructive migrations. The scalar
`appointments.caldav_uid` / `caldav_etag` columns survive as a
denormalised "default-binding" pointer through Slice 1 and 2; Slice 4
drops them after telemetry confirms no path still reads them.
### §3.1 New table: `paliad.user_calendar_bindings`
```sql
CREATE TABLE paliad.user_calendar_bindings (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES paliad.users(id) ON DELETE CASCADE,
calendar_path text NOT NULL, -- absolute URL or path under user_caldav_config.url
display_name text NOT NULL DEFAULT '', -- the label discovered via PROPFIND <displayname/>; what we show in the UI
scope_kind text NOT NULL, -- 'all_visible' | 'personal_only' | 'project' | 'client' | 'litigation' | 'patent' | 'case'
scope_id uuid REFERENCES paliad.projects(id) ON DELETE CASCADE, -- NULL for 'all_visible' / 'personal_only'
include_personal boolean NOT NULL DEFAULT false, -- only meaningful when scope_kind <> 'all_visible'/'personal_only'
enabled boolean NOT NULL DEFAULT true,
last_sync_at timestamptz,
last_sync_error text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (user_id, calendar_path), -- can't bind one calendar twice for the same user
UNIQUE (user_id, scope_kind, scope_id), -- one binding per scope per user — but a project can also be covered by 'all_visible'
CHECK ((scope_kind IN ('all_visible','personal_only') AND scope_id IS NULL)
OR (scope_kind NOT IN ('all_visible','personal_only') AND scope_id IS NOT NULL))
);
CREATE INDEX user_calendar_bindings_user_idx ON paliad.user_calendar_bindings(user_id) WHERE enabled;
-- RLS: row visible/writable only when auth.uid() = user_id (mirrors user_caldav_config).
```
**Why per-scope unique but not per-appointment unique:** an Appointment in
project P is allowed to land in both the user's `all_visible` calendar
AND their `project=P` calendar — that's the explicit "master + per-project"
hybrid m asked about. What we forbid is two different `project=P` bindings
for the same user, which would have no useful semantics.
**`scope_kind = 'personal_only'`** is a separate scope from `'all_visible'`
because the existing pushAll already covers both personal and visible-project
appointments; users may want a "personal only" calendar that does *not*
get the noisy team events. Without this, every binding either includes
personal events or doesn't, and there's no way to say "the master
calendar = everything except personal".
### §3.2 New table: `paliad.appointment_caldav_targets`
```sql
CREATE TABLE paliad.appointment_caldav_targets (
appointment_id uuid NOT NULL REFERENCES paliad.appointments(id) ON DELETE CASCADE,
binding_id uuid NOT NULL REFERENCES paliad.user_calendar_bindings(id) ON DELETE CASCADE,
caldav_uid text NOT NULL, -- still 'paliad-appointment-<uuid>@paliad.de' — same for all bindings of one appointment
caldav_etag text NOT NULL,
last_pushed_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (appointment_id, binding_id)
);
CREATE INDEX appointment_caldav_targets_binding_idx ON paliad.appointment_caldav_targets(binding_id);
-- RLS: visible/writable when the underlying binding's user_id = auth.uid().
```
**UID stays per-appointment, not per-binding.** That keeps the iCal UID
canonical (still `paliad-appointment-<uuid>@paliad.de`), so when a user
removes a binding and re-adds it later, the same UID rebinds without
spurious duplicates. The `.ics` filename in the calendar — `<uid>.ics`
— is also identical across bindings, which means the same UUID
shows up in different calendars on the same server but never collides
because they're under different `calendar_path` collections.
### §3.3 Row examples for the four common organisations
| Organisation | Rows in `user_calendar_bindings` |
|---|---|
| **A — one cal, everything** | 1 row: `scope_kind='all_visible'`, `calendar_path='/cal/work'` |
| **B — one cal per project** | N rows, all `scope_kind='project'`, distinct `(scope_id, calendar_path)` |
| **C — master + per-project hybrid** | 1 row `scope_kind='all_visible'` + N rows `scope_kind='project'`. Each project event appears in both. |
| **D — personal split from work** | 1 row `scope_kind='personal_only'``/cal/personal` + 1 row `scope_kind='all_visible'` (which will include the same personal events, so the user will more commonly pair `personal_only` with a `scope_kind='client'` per-client work view instead). |
### §3.4 What stays unchanged
- `paliad.user_caldav_config` — still holds the server URL, username,
encrypted password, and a per-user `enabled` flag. The existing
`calendar_path` column becomes a hint for the **default binding** we
auto-create on migration and is no longer read by sync logic after
Slice 1 ships. We keep it nullable-on-read for forwards-compat then
drop in Slice 4.
- `paliad.caldav_sync_log` — still per-user; sync entries gain a
`binding_id` column (nullable for legacy rows) so the UI can show
per-calendar last-sync state.
- iCal serialisation (`caldav_ical.go`) — unchanged. Same VEVENT
formatter feeds every binding.
- AES-GCM credential encryption (`caldav_crypto.go`) — unchanged.
---
## §4 — Sync engine implications
The shape of the per-user goroutine stays. The body of `syncOnce`
moves from "push to one path / pull from one path" to "for each
enabled binding, push the scope-filtered slice / pull from that path".
### §4.1 Push fan-out
```go
// pseudocode for the new pushAll body
bindings := s.bindings.ListEnabled(ctx, userID) // 1..N rows
for _, b := range bindings {
appts := s.appointments.ForBinding(ctx, userID, b) // scope-filtered
for _, a := range appts {
body := formatAppointment(&a)
etag, err := cli.PutEvent(ctx, b.CalendarPath, terminUID(a.ID), body)
if err != nil { continue } // best-effort, per-binding error
s.targets.Upsert(ctx, a.ID, b.ID, terminUID(a.ID), etag)
}
// Remove events from this calendar that no longer belong to the scope.
for _, stale := range s.targets.DanglingForBinding(ctx, b.ID, currentIDs(appts)) {
cli.DeleteEvent(ctx, b.CalendarPath, stale.CalDAVUID)
s.targets.Delete(ctx, stale.AppointmentID, b.ID)
}
}
```
`ForBinding(userID, b)` is the scope filter:
- `all_visible` → existing `AllForUser(userID)`
- `personal_only` → appointments with `project_id IS NULL AND created_by = userID`
- `project` → appointments where `project_id = scope_id` AND visible to user
- `client` / `litigation` / `patent` / `case` → appointments where the
ancestor at the relevant hierarchy level = `scope_id` AND visible to user
- when `include_personal = true`, union with personal events on top of the above (only for non-`all_visible`/`personal_only` scopes)
This reuses the existing `can_see_project()` predicate (per project
CLAUDE.md, team-based RLS), so visibility shrinkage on a project unshare
falls out naturally: next push sees the appointment is no longer in
`ForBinding(...)`, sees a dangling target row, issues `DeleteEvent`.
### §4.2 Pull reconciliation
Each binding has its own pull pass against `b.CalendarPath`. The
matching key is still `caldav_uid` — same UID across all bindings, so
`appointments.FindByCalDAVUID(uid)` resolves the local row. The
**ETag check is per-target row** now, not per-appointment: a remote
edit in calendar X bumps the etag in `appointment_caldav_targets` for
binding X only. The local Appointment is updated once (last-write-wins
on Appointment.updated_at), the next push tick re-syncs the other
bindings with the new payload (they see their stored etag is older
than the appointment's `updated_at` and re-PUT).
**One subtle change:** the foreign-UID skip (`extractAppointmentID == ""`)
still applies per-binding pull. That preserves the v1 "Paliad owns its
UIDs" property — multi-calendar does not open the door to importing
events the user creates in their calendar app. (If/when that becomes
in-scope, it's a separate t-paliad-* design.)
### §4.3 Hooks (instant push)
`OnAppointmentCreated/Updated/Deleted` fan out across all the user's
enabled bindings that match the appointment's scope. Same 30s-timeout
background goroutine. The user-facing request still returns
immediately; the failure mode is identical (best-effort per binding,
logged on `slog.Warn`).
### §4.4 Bandwidth & rate limits
- Per user per tick: **N bindings × 1 PROPFIND + per-event GETs**.
The pull GET is the dominant cost; a 50-binding user with 20 events
per calendar is ~1 000 GETs/min, which is fine over HTTP/1.1 to a
decent CalDAV server but **does** put us inside iCloud's
~throttle-friendly band and risks Google's quota model.
- Mitigation: switch pull to **`REPORT` `calendar-multiget`** so each
binding's events come back in one round-trip. That's a single
iteration on `caldav_client.go` (the same multistatus parser
already handles the body) and pays for itself the moment a user
has >10 events per binding. We deliberately deferred this in
Phase F (one calendar, low volume) — multi-calendar makes it
table-stakes. Plan to land it in **Slice 2** alongside the picker.
- Rate limiting on the Paliad side: keep the 60s ticker, but stagger
per-binding pulls so we never fire N concurrent PROPFINDs against
the same provider. Sequential per binding is fine; we already do
this implicitly with the per-user goroutine.
### §4.5 Server-side cleanup on binding delete
User deletes a binding → service:
1. Lists every (appointment, binding) target row for that binding.
2. Issues `DELETE` per `.ics` on the remote calendar (best effort).
3. Deletes the target rows.
4. Deletes the binding row (or relies on `ON DELETE CASCADE` from
target FK — cleaner to delete remotely first, then drop the row,
so a half-failed cleanup leaves rows we can retry on next tick).
A "leave events behind in the external calendar" toggle is a real
ask (users sometimes archive bindings without wanting their calendar
app to suddenly empty). Plumb it as `binding.cleanup_on_delete bool`
in Slice 2 if there's demand; default `true` (delete).
---
## §5 — Bidirectional vs one-way
**Recommendation: stay bidirectional, identical to today's semantics,
per-binding.** Reasons:
1. **m's stated workflow expects round-trip.** Drag a deadline in
Outlook → Paliad sees the new date → approval flow triggers
(t-138). One-way push breaks that. Multi-calendar doesn't change
this expectation; if anything, it strengthens it (the user picked
the project-cal binding *because* they intend to edit there).
2. **The conflict model is already in place.** Last-write-wins on
ETag, foreign-UID skip, `LogConflict` audit append. Multi-calendar
adds one new question: "if the user edits the same event in two
different bindings between ticks, which wins?" Answer: the one
that lands first in our pull pass. Bindings are iterated in
`created_at` order so the behaviour is deterministic, and the
second edit gets overwritten on the next tick when we re-push the
resolved appointment to it. Acceptable trade-off; would only show
up if a user actually edits the same event in two of their own
calendars within 60s, which is vanishingly rare.
3. **Approval-flow integration is unchanged.** Pending-approval
events have the `[PENDING APPROVAL]` marker baked into the iCal
summary by `caldav_ical.go:76+`. That marker survives multi-binding
fan-out untouched; an external edit on a pending event still has
the pre-existing bypass-the-gate hole (flagged §1, not in scope).
**Tee-up for m's call:** if multi-calendar is the wrong moment to
keep bidirectional (e.g. because per-project calendars are about
**read-only visibility for partners**, not editing), we'd add a
`binding.read_only bool` column and skip the pull pass for that
binding. Cheap to add now or later. **I recommend defaulting
`read_only = false` (bidirectional like today) and only making it
optional if m's first session with the UI surfaces the need.**
---
## §6 — User-facing config model
Surface on `/einstellungen/caldav` (already exists for Phase F creds).
Two sections, in this order:
1. **Server** (existing) — URL, username, password, "test connection".
Unchanged.
2. **Calendars** (new) — list of bindings as cards / rows. For each:
`display_name`, `calendar_path`, `scope_kind` chip (master /
personal / project / …), `enabled` toggle, last-sync status, action
buttons "Edit scope" / "Remove".
3. **Add a calendar** — flow:
- **a)** click "Add". Modal opens. We do a `PROPFIND
<calendar-home-set>` against the user's server to discover their
existing calendars; show as a picker. (RFC 6638 / 4791 calendar
home set discovery — supported by iCloud, Fastmail, Nextcloud,
Radicale, Baikal, SOGo. Google CalDAV does not expose this
reliably; for Google users we degrade to a manual path entry box.)
- **b)** user picks an existing calendar, or chooses "Create new
calendar". Create-new attempts `MKCALENDAR` (works on iCloud,
Fastmail, Nextcloud, Radicale, Baikal, SOGo; fails on Google →
friendly error with copy-paste instruction).
- **c)** user picks the **scope**: a radio between "Everything I can
see", "Personal only", "One project", and (later) "One client /
litigation / patent / case". Project picker uses the existing
`/api/projects?…` autocomplete.
- **d)** "Save" → POST `/api/caldav-bindings`. The next 60s tick
starts pushing into the new calendar; the UI shows "Initial
sync running…" with a live last-sync indicator (already polled
by the existing `caldav-config` page).
4. **Quick-add affordances** (Slice 3 polish, not v1):
- On a project's `/projects/<id>` page: "Open in calendar app" link
if a binding already exists for that project, "Pin to a new
calendar" if none does (deep-links to the Add-a-calendar modal
pre-filled).
- Bulk action "Create one calendar per active litigation" on
`/einstellungen/caldav` (requires `MKCALENDAR` support; gated
behind a server-capability probe at first PROPFIND).
5. **Soft limits in the UI:**
- At **20 bindings**: yellow info banner "Most users keep ≤ 20
calendars; review your list before adding more."
- At **80 bindings**: red error, block adding new (we don't know
the user's provider for sure; 80 is a safe ceiling for iCloud
and Nextcloud-default).
- Provider hint surfaced under the Server form: parsed from the
URL host, with a "your provider's documented limit" line —
pure courtesy, not enforced.
### §6.1 What the API contract looks like
| Verb + Path | Body / Returns | Notes |
|---|---|---|
| `GET /api/caldav-bindings` | array of binding rows + sync status | replaces having to interpret `user_caldav_config.calendar_path` |
| `POST /api/caldav-bindings` | `{calendar_path, display_name, scope_kind, scope_id?, include_personal?}` → created binding | triggers immediate sync goroutine wake-up |
| `PATCH /api/caldav-bindings/{id}` | partial; toggle `enabled` or change `scope_*` | re-runs `pushAll` for this binding |
| `DELETE /api/caldav-bindings/{id}` | — | deletes external events first, then row |
| `GET /api/caldav-discover` | array of `{href, displayname}` from server `<calendar-home-set>` | populates the picker; cached 5 min |
| `POST /api/caldav-mkcalendar` | `{display_name, color?}` → `{calendar_path}` | issues `MKCALENDAR`; returns 501 on Google |
`GET /api/caldav-config` still works (back-compat for the server-creds
section); its `calendar_path` field is documented as "deprecated, see
/api/caldav-bindings".
---
## §7 — Slice plan
Tracer-bullet slices so each is independently shippable, safe to
revert, and gives the user something they can see.
**Slice 1 — Schema + backfill (no UI change).**
- Migration: create `user_calendar_bindings`, `appointment_caldav_targets`.
- Backfill: for every existing `user_caldav_config` row, insert one
`bindings` row `(user_id, calendar_path, display_name='', scope_kind='all_visible', enabled)`.
For every Appointment with non-null `caldav_uid`, insert one
`appointment_caldav_targets` row pointing at the user's new default
binding.
- Refactor `CalDAVService.syncOnce` / `pushAll` / `pullAll` to drive
off bindings (loop of length 1 per existing user). Behaviour
observably identical: same calendars, same events, same logs.
- `appointments.caldav_uid` / `caldav_etag` columns still exist and
are written for compatibility (treat them as denormalised pointers
to the default binding's target row). UI unchanged.
- **Exit criterion:** existing users see no change in their calendar;
`caldav_sync_log.binding_id` is populated for all new rows; manually
inserted second binding via SQL syncs correctly end-to-end on a
staging account.
**Slice 2 — Binding-picker UI + multi-binding support.**
- `/api/caldav-bindings` CRUD + `/api/caldav-discover` (PROPFIND
`calendar-home-set`) + `/api/caldav-mkcalendar`.
- New "Calendars" section on `/einstellungen/caldav` with the modal
flow from §6.
- **Land `REPORT calendar-multiget` pull** alongside (per §4.4).
Required, not optional, for the bandwidth profile multi-binding
introduces.
- Scope kinds enabled in v1: `all_visible`, `personal_only`, `project`.
Hierarchy scopes (`client`, `litigation`, `patent`, `case`) parked
for Slice 3.
- **Exit criterion:** m can pin a second calendar via the UI on
staging; events for project X appear only in the X-bound calendar
if his master binding is disabled, and in both if it's enabled.
**Slice 3 — Hierarchy scopes + project-page quick-adds.**
- Enable `scope_kind ∈ {client, litigation, patent, case}` — pure
filter-predicate change in `ForBinding(...)` using the existing
project-tree walker.
- "Pin to a new calendar" button on `/projects/<id>` and on the
/einstellungen page.
- Bulk "calendar-per-active-litigation" provisioner (with
`MKCALENDAR` capability probe).
- **Exit criterion:** real HLC PA can set up "one cal per
litigation" in <5 min on first try without inventor help.
**Slice 4 — Polish + cleanup.**
- Drop `appointments.caldav_uid` / `caldav_etag` after instrumentation
shows zero readers outside `CalDAVService` (`grep` + a one-week
query-log audit on the read replica).
- Soft-limit banners (20 / 80).
- `binding.read_only` and `binding.cleanup_on_delete` toggles if
asked for by then.
- **Exit criterion:** schema is final; no legacy paths remain in
`caldav_service.go`.
**(Out of scope across all four slices:** foreign-UID import, custom
event types per binding, per-binding colour mapping, MKCALENDAR for
Google. These are easy to add later if the data says so.)
---
## §8 — Open questions for m
1. **Bidirectional default for new bindings: yes/no?** I recommend
**yes** (matches today's single-cal behaviour and the round-trip
workflow expectation). A `read_only` per-binding flag is cheap to
add later if a real use case shows up. Decide now → Slice 1; decide
later → Slice 4.
2. **`personal_only` scope — keep or drop?** It's useful for users
who want a "noisy team master + clean personal" split, but it's
redundant for users who only use the master calendar. I'd keep
it; trivial to remove if m disagrees.
3. **`MKCALENDAR` (auto-create calendar) — ship in Slice 2 or defer
to Slice 3?** Shipping it in Slice 2 means we need the
capability-probe + Google-degrade UX up-front. Deferring means
Slice 2 users have to pre-create the calendar in their app and
paste the URL — workable but clunky. Default plan: **Slice 2,
with a clean Google-degrade message**.
4. **Soft cap numbers (20 / 80) — sensible?** Picked from §2
provider limits + "most paliad users will pick 15". m may
want different numbers — easy to tune.
5. **`/admin/caldav-bindings` view for support debugging?** Not in
the slice plan; useful if a user calls confused about which
calendar holds which event. Add if m wants it.
6. **Approval-flow + remote-edit gap (§1, the bypass) — fix scope?**
Pre-existing in single-cal Phase F. Multi-cal makes it more
visible. Should this be a follow-up under t-138, or folded into
Slice 3? I'd file as a separate task.
---
## §9 — Why this is the right shape
- **Single CalDAV server per user, N bindings.** Matches every real
provider's auth model (one auth blob covers all the user's
calendars) and keeps `caldav_crypto.go` and `user_caldav_config`
untouched.
- **Binding scope is a row, not a static config.** Users compose
the organisation they want without us guessing; defaults (one
master binding on migration) preserve current behaviour.
- **UID stays per-appointment.** Means an event re-binding (move
from project-cal to master-cal) is just shuffling target rows,
not minting new UIDs. Re-importing into the same calendar later
rebinds cleanly.
- **Sync engine shape is unchanged.** Same per-user goroutine, same
60s tick, same hooks. The blast radius of multi-binding is one
inner loop, gated behind a feature that backfills to a no-op for
every existing user.
- **Slices give m a vertical demo at each step.** Slice 1 is
invisible-but-shippable; Slice 2 is the first user-facing change
("you can pin a second calendar"); Slice 3 is "now organise by
project tree"; Slice 4 is cleanup.
- **No new external dependencies.** Same hand-rolled CalDAV client.
Adds one new verb (`MKCALENDAR`) and one new report
(`calendar-multiget`) — both small, both already half-tested
against `caldav_client.go`'s patterns.
---
## §10 — Sources
- [Apple Support — Limits for iCloud Contacts, Calendars, Reminders, Bookmarks, and Maps](https://support.apple.com/en-us/103188) — iCloud 100 combined calendars + reminder lists.
- [Google Workspace Updates — Automatic addition of owned secondary calendars, Jan 2026](https://workspaceupdates.googleblog.com/2026/01/automatic-addition-owned-secondary-calendars.html) — Google ~100 owned recommendation.
- [Fastmail — Account limits](https://www.fastmail.help/hc/en-us/articles/1500000277382-Account-limits) — 100k events/user, no documented calendar count cap.
- [Nextcloud admin manual — Calendar / CalDAV](https://docs.nextcloud.com/server/stable/admin_manual/groupware/calendar.html) — default 30, configurable, 10/hr rate limit.
- Live verification against `internal/services/caldav_*.go` and `paliad.user_caldav_config` / `paliad.appointments` schema on the youpc Supabase instance.
---
## Addendum — m's decisions (2026-05-19)
Walked through §8.1§8.6 with m via AskUserQuestion. Decisions are
locked in for the coder shift; revisit only on Slice-3 feedback.
| Q | Decision | Implication for the slice plan |
|---|---|---|
| **§8.1 — Bidirectional default** | **Yes — bidirectional by default** | No `read_only` flag in Slice 13. Multi-cal inherits Phase F's last-write-wins / foreign-UID-skip semantics unchanged. Per-binding `read_only` only added later if a real use case shows up. |
| **§8.2 — `personal_only` scope** | **Keep — first-class scope** | Ships in Slice 2 as one of the picker's radio options (`Everything I can see` / `Personal only` / `One project`). One enum value, one `ForBinding()` branch. |
| **§8.3 — MKCALENDAR timing** | **Slice 2 with Google-degrade UX** | Slice 2 includes `POST /api/caldav-mkcalendar` + capability probe. Google users get a friendly "create the calendar in your Google UI, paste the URL" fallback. iCloud / Fastmail / Nextcloud / Radicale / Baikal / SOGo get one-click "Create new calendar". |
| **§8.4 — Soft caps** | **No caps in v1, add later if data warrants** | Drop the 20-warn / 80-block UI guards from §6. Instrument `count(*)` on `user_calendar_bindings` per user as a Slice 2 telemetry add. Revisit if/when real distributions land. |
| **§8.5 — `/admin/caldav-bindings` view** | **Don't ship in v1** | Stays out of the slice plan. Support debugging goes via Supabase SQL until a real ticket lands. Frees Slice 4 polish for the legacy-column drop only. |
| **§8.6 — Approval-flow remote-edit gap** | **Separate task under t-138** | Out of scope for all four multi-cal slices. File the gap as a new `t-paliad-*` follow-up under t-138 so multi-cal stays clean and reverter-friendly. Pre-existing hole, surfaced not fixed. |
### Net effect on §7 slice plan
- **Slice 1** unchanged — schema + backfill, behaviour-equivalent.
- **Slice 2** = picker UI + `REPORT calendar-multiget` + **MKCALENDAR
with capability probe + Google-degrade message** + binding-count
telemetry. No `read_only` flag, no soft caps, no admin view.
Scopes enabled: `all_visible`, `personal_only`, `project`.
- **Slice 3** = hierarchy scopes (`client` / `litigation` / `patent` / `case`)
+ per-project quick-adds. **No** approval-gap fix folded in.
- **Slice 4** = drop legacy `appointments.caldav_uid` / `caldav_etag`.
Soft-cap banners only if Slice 2 telemetry says we need them.
### Net effect on §3 schema
No change. `user_calendar_bindings` still ships with the full
`scope_kind` enum (including `personal_only`). `appointment_caldav_targets`
unchanged. No `read_only` column in v1.
### Follow-ups to file as separate tasks
1. **`t-paliad-*` (under t-138):** approval-flow + CalDAV remote-edit
gap. `ApplyRemoteUpdate` bypasses the approval gate when an external
client edits a pending-approval event. Pre-existing in single-cal
Phase F. Owner: t-138 maintainer.
2. **(maybe) `t-paliad-*`:** soft-cap UI if Slice 2 telemetry shows
any user near the iCloud-100 / Nextcloud-30 envelope. Not pre-filed
— only opens if data warrants.

View File

@@ -0,0 +1,603 @@
# Paliad data export — Excel-first, scoped (org / project-subtree / personal)
Design: archimedes (inventor), 2026-05-19.
Task: **t-paliad-214**.
Branch: `mai/archimedes/inventor-excel-data`.
Status: READY FOR REVIEW — no code yet, awaiting m go/no-go on §11 open questions.
---
## 0. Premise check (live state, 2026-05-19)
Verified directly against the youpc Postgres `paliad` schema rather than against memory or older design docs.
**Migration tracker.** Latest applied is `100_ccr_visible_rule`; next is **101**.
**Row counts (org-wide today):**
| table | rows |
|------------------------|-----:|
| users | 47 |
| projects | 11 |
| deadlines | 26 |
| appointments | 5 |
| parties | 0 |
| notes | 4 |
| documents | 0 |
| project_events (audit) | 93 |
| project_teams | 3 |
| approval_requests | 8 |
| approval_policies | 160 |
| checklist_instances | 4 |
| deadline_rules | 254 |
| user_views | 2 |
| partner_units | 11 |
A full org export today is **< 600 rows of user content** plus reference data synchronous streamed download is plausible for every scope. We design for an order-of-magnitude head-room.
**Auth.** Passwords live in Supabase Auth (separate `auth` schema, not `paliad`). The `paliad.users` table has **no `password_hash` column** so the "don't export credentials" rule from the brief is enforced by absence, not by a column-deny list. Good.
**Visibility.** Row-level via `paliad.can_see_project(project_id)` (subtree-aware through ltree path). Already used as the predicate that gates every list endpoint. We reuse it for the **personal** and **project** scopes; the **org** scope bypasses it under `global_admin`.
**Documents.** Table exists, 0 rows. Phase H (AI Frist-Extraktion) is deferred per m's 2026-04-16 call. No `ANTHROPIC_API_KEY` on Dokploy. Therefore **this design does not concern itself with binary attachments** only with the metadata row when documents start landing.
**Audit trail.** Lives in `paliad.project_events` (93 rows). One row per lifecycle event with `event_type`, `metadata jsonb`, `event_date`, `created_by`. The auditing union (`AuditService.ListEntries`) joins 5 sources (project_events, partner_unit_events, deadline_rule_audit, policy_audit_log, reminder_log). For the export we treat `project_events` as primary; the four auxiliary logs are scope-specific.
**Existing export precedent.** `/admin/rules/export` + `/admin/api/rules/export-migrations` (handlers/admin_rules.go) admin-gated, streams a generated SQL artifact. Same shape as what we want for the Excel exports. Re-use the gating helper.
**No Go xlsx library on `go.mod` today.** This design picks **`github.com/xuri/excelize/v2`** in §3.
---
## 1. Why this exists
Two motivations, both load-bearing:
1. **Safety / backup.** A workbook on disk is a portable artifact independent of the running app. If paliad.de is down, a partner needs the matter file. If the Dokploy compose corrupts, IT needs a recent dump. If a deadline gets accidentally deleted, we want a recoverable snapshot.
2. **No lock-in.** A team or an entire org choosing to leave paliad must be able to walk away with their entire dataset in a format anyone can open. We promise this in writing as a trust signal exactly because the alternative (silently locking customers in) is what we built paliad to *not* be.
The export is therefore not a "nice analytics feature" it is **a contractual guarantee that the data is yours**. That framing shapes the design: completeness > convenience, portability > polish, every export auditable.
---
## 2. Scope definitions (precise)
Three scopes. The boundary is **what the caller is allowed to see**, joined with **what makes the artifact interpretable standalone**.
### 2.1 `org` scope
**Caller:** `global_role='global_admin'` only. There is no firm-admin role distinct from global_admin in paliad today (see §4).
**Content:** literally everything in the `paliad` schema that is user content or reference data the workbook needs to be readable. Specifically:
| sheet | source table(s) | notes |
|------------------------|-------------------------------------------------------------------|-------|
| `projects` | `paliad.projects` (all rows) | Full project tree including soft-deleted (status='deleted' / 'closed' if any). |
| `project_teams` | `paliad.project_teams` | profession + responsibility (post-t-148). |
| `project_partner_units`| `paliad.project_partner_units` | Derivation grants. |
| `deadlines` | `paliad.deadlines` | Including completed, cancelled. |
| `appointments` | `paliad.appointments` | Including completed. |
| `parties` | `paliad.parties` | All client / opposing-party data. |
| `notes` | `paliad.notes` | All four polymorphic targets resolved into the `target_kind`/`target_id` columns. |
| `documents` | `paliad.documents` metadata (file_path, file_size, mime_type, ai_extracted) | Binaries excluded (open Q1). |
| `audit_events` | `paliad.project_events` | Full audit trail per project. |
| `approval_requests` | `paliad.approval_requests` | Including completed / rejected, with `requester_kind` + `agent_turn_id`. |
| `approval_policies` | `paliad.approval_policies` | Both project-scoped and partner-unit-defaults. |
| `policy_audit_log` | `paliad.policy_audit_log` | Source #5 of the audit union. |
| `partner_units` | `paliad.partner_units` | Org chart. |
| `partner_unit_members` | `paliad.partner_unit_members` | Including unit_role. |
| `partner_unit_events` | `paliad.partner_unit_events` | Org-chart audit. |
| `checklist_instances` | `paliad.checklist_instances` | Per-project completion state. |
| `invitations` | `paliad.invitations` (status, role, expires_at) | Without raw tokens (open Q7). |
| `users` | `paliad.users` (id, email, display_name, office, profession, …) | Excludes `email_preferences` jsonb only if it carries channel secrets — none do today, but checked at export time. |
| `user_views` | `paliad.user_views` | Saved filters / custom layouts. |
| `user_card_layouts` | `paliad.user_card_layouts` | Project-card layouts. |
| `user_pinned_projects` | `paliad.user_pinned_projects` | Per-user pins. |
| `user_caldav_config` | `paliad.user_caldav_config` **without** the ciphertext column | URL + calendar IDs + last_sync; passwords NEVER exported. |
| `reminder_log` | `paliad.reminder_log` | Outbound digest history. |
| `caldav_sync_log` | `paliad.caldav_sync_log` | Per-user sync runs. |
| `paliadin_turns` | `paliad.paliadin_turns` | **Excluded by default** in org export (privacy — see §6) — admins opt in per Q5. |
| `email_broadcasts` | `paliad.email_broadcasts` | Outbound broadcast history. |
| `email_templates` + `_versions` | both | Custom firm templates. |
| **reference (read-only):** | `proceeding_types`, `event_types`, `event_categories`, `deadline_rules`, `deadline_concepts`, `deadline_concept_event_types`, `deadline_event_types`, `event_category_concepts`, `trigger_events`, `holidays`, `courts`, `countries` | One sheet per table, prefixed `ref__`. Embedded so the workbook is interpretable without paliad context. |
| **deferred audit (admin opt-in):** | `deadline_rule_audit`, `policy_audit_log`, `partner_unit_events`, `caldav_sync_log`, `paliadin_turns` | Behaviour per Q5/Q6. |
**Excluded unconditionally:**
- `auth.*` (Supabase Auth schema — not ours; the user can request their auth record from Supabase directly).
- `paliad_schema_migrations` (operational, no business meaning).
- `*_pre_NNN` shadow / pre-migration backup tables (rows are duplicates; the live table is canonical).
- Any future `*_secret` / `*_token` columns (see §6 deny-list mechanism).
**Edge cases:**
- **Soft-deleted rows:** paliad currently has no soft-delete columns (`deleted_at` etc.). When that lands, the org export includes them by default with a `deleted_at` column populated. Until then, this is a no-op.
- **Archived projects:** `projects.status` can be `'closed'` or future `'archived'` — export includes them (the whole point of backup is recoverability of closed matters).
- **Counterclaims:** `projects.counterclaim_of` is a self-FK. Export carries the column as-is; the relationship is reconstructable via the `id` column.
### 2.2 `project` scope
**Caller:** any team member of the project who passes the §4 profession-tier gate.
**Content:** one project + **all descendants** along the ltree path. The descendant walk is `WHERE path <@ root.path` (subtree-inclusive of root). Every entity gets filtered through `WHERE project_id IN (subtree_ids)`.
Per-sheet inclusion:
- `projects` (root + descendants, one row each)
- `project_teams` (membership for those projects)
- `project_partner_units` (derivation attachments)
- `deadlines`, `appointments`, `parties`, `notes`, `documents` (metadata), `audit_events`, `approval_requests`, `checklist_instances` — all scoped to subtree
- **users sheet — restricted columns:** only `id, email, display_name, office, profession` for users referenced by any FK in the export (created_by, assigned, etc.). Don't dump all 47 users when you only need 4. (Avoids accidental org-chart leak in a project-scope export shared externally.)
- **reference data:** `ref__proceeding_types`, `ref__event_types`, `ref__deadline_rules`, `ref__deadline_concepts`, `ref__courts`, `ref__countries`, `ref__holidays`. Same as org but a smaller universe is acceptable too — the v1 ships the full reference tables for simplicity (every row count is ≤ 300; size is moot).
- **Cross-project references** (e.g., a party referenced by a project outside the subtree): out of scope by the predicate. The export carries the foreign UUID so a re-import or merge could re-link, but the foreign row itself is not in the workbook. Edge case is rare — `counterclaim_of` is the only known cross-project pointer today.
**Edge cases:**
- **Partner-unit data:** `partner_units` is org-wide; project export carries only the unit ids attached via `project_partner_units`. The unit name + membership are loaded into the workbook on `partner_units` and `partner_unit_members` sheets (filtered to the attached units only).
- **Policies:** `approval_policies` rows include both project-scoped (the project + ancestors) **and** partner-unit-defaults attached to this project. Same MAX-of-sources logic as runtime.
- **Audit:** `project_events` for the subtree + (admin opt-in only) `deadline_rule_audit` rows whose rule was used by any deadline in the subtree. Default off — these are firm-wide curation logs and don't belong in a per-project handoff.
### 2.3 `personal` scope
**Semantics:** "everything I can see right now in paliad, framed as my data."
That definition resolves the ambiguity in the brief: personal scope is **not** "rows where I am `created_by`" — that misses everything I see by being on a team. It is **the RLS-visible projection of the schema for caller=me**, plus a handful of explicitly-personal sidecars (caldav config, my pins, my views).
Per-sheet inclusion:
| sheet | rows |
|---|---|
| `projects` | `WHERE paliad.can_see_project(id)` for the caller. |
| `project_teams` | Rows where `user_id = me` OR the row's project is in my visible set. |
| `deadlines` | Same project-visibility filter. |
| `appointments` | Same. |
| `parties`, `notes`, `documents` metadata, `audit_events`, `checklist_instances` | Same. |
| `approval_requests` | Rows where `requested_by = me` OR `decided_by = me` OR project ∈ visible set. |
| `me` (single-row sheet) | Caller's `users` row (id, email, display_name, office, profession, reminder_*, lang, escalation_contact_id). |
| `my_caldav_config` | The caller's `user_caldav_config` row **without** the encrypted password column — sync URL, calendar IDs, last_sync_at. |
| `my_views` | Caller's `user_views` rows. |
| `my_pinned_projects` | Caller's `user_pinned_projects` rows. |
| `my_card_layouts` | Caller's `user_card_layouts` rows. |
| `my_paliadin_turns` | Caller's `paliadin_turns` rows (currently restricted to `PaliadinOwnerEmail` = m, so this sheet is empty for everyone else). Sensitive: AI prompts + responses. **Default on for personal scope** — it's literally the caller's data. |
| `users_referenced` | Restricted: id + display_name + email for users referenced as FKs in the export. |
| reference tables | Same set as project scope. |
**Edge cases:**
- **Caller leaves a team:** the export reflects the moment-in-time visibility. A `generated_at` timestamp in the workbook header (`__meta` sheet) anchors this.
- **Caller is a global_admin:** their personal export is the entire org (because their visible set = all projects). This is by design — but we surface a banner ("Sie sehen alles, weil Sie global_admin sind. Ein org-scope-Export wäre identisch.") so they don't get confused thinking the personal-scope endpoint is broken.
- **Caller has no team memberships:** export contains the empty workbook + the `me` row + their caldav config + views/pins. Still useful — they can save their preferences.
### 2.4 Common columns across all scopes
Every export workbook contains a `__meta` sheet:
```
schema_version: 1
firm_name: HLC # from internal/branding.Name
scope: org | project | personal
scope_root_id: uuid or NULL # the project id for project-scope, NULL otherwise
generated_at: 2026-05-19T14:23:00Z
generated_by_user: <uuid> <email> # the caller
generated_by_label: archimedes / m / ... # display_name
row_counts: JSON {"projects": 11, ...}
paliad_version: <git sha at server build>
notes: free-form, e.g., "documents binaries excluded by design"
```
This pins provenance + reproducibility + diffability.
---
## 3. Format choices
### 3.1 xlsx as the primary format
**Library: `github.com/xuri/excelize/v2`.** De-facto Go xlsx library, pure-Go (no cgo, no external libreoffice), MIT, streaming writer for large workbooks, broad format-feature support (number formats, freeze panes, hyperlinks, sheet hide). The streaming writer (`NewStreamWriter`) is what we use — it writes rows one at a time without holding the whole sheet in memory. At 11-projects scale this is unnecessary; at 11k-projects scale it's essential, so we set the pattern now.
**Why not the alternatives:**
- `tealeg/xlsx` — older, unmaintained, no streaming.
- `qax-os/excelize` — same project as xuri/excelize (the github org renamed); xuri is the upstream.
- `360EntSecGroup-Skylar/excelize` — defunct fork.
**Workbook structure:** one **sheet per entity type**, *never* a mixed-type sheet with conditional columns. Reasons:
- Excel users sort + filter by column; a column that means "deadline due_date" on row 4 and "appointment start_at" on row 12 is unusable.
- The "self-describing" promise (no-lock-in) is satisfied by a workbook where every sheet is a flat table with stable column headers, not by a polymorphic blob.
- Cross-sheet relationships are represented by **UUIDs in foreign-key columns** + a `__lookup` sheet pairing UUID → display label (project title, user email) for the workbook's lifetime. This makes the workbook self-joining in Power Query / pivot tables.
**Sheet conventions:**
- Sheet names use `snake_case` matching SQL table names (`deadlines`, not `Fristen`). Reference tables prefixed `ref__`. Personal sidecars prefixed `my_`. Meta sheet `__meta`. The `__lookup` sheet sits last.
- Row 1 = column headers; frozen.
- Column 1 of every entity sheet is `id` (uuid).
- Dates: ISO 8601 UTC for timestamptz; `YYYY-MM-DD` for `date`. Always as Excel strings (not Excel date types) — Excel-date interpretation differs by locale (DE: `Tag.Monat.Jahr`, EN: `Month/Day/Year`) and silently corrupts on round-trip. A pinned ISO string is unambiguous and re-importable. Open Q4 covers whether to *also* mirror to native Excel dates for human convenience.
- Booleans: literal `TRUE` / `FALSE` strings, same reason.
- `jsonb` columns: serialised as compact JSON one-liners in the cell. Cell type = string. Power Query can `Json.Document` them.
- Arrays (e.g., `additional_offices text[]`): semicolon-joined string. Excel's CSV-array convention is the comma but our office codes use commas; semicolon avoids the collision.
- `text[uuid[]]` paths (the projects.path ltree): exported as the canonical dotted-uuid string.
**Encoding:** UTF-8 always. Excelize handles the xlsx packaging which is unicode-native. Umlaute round-trip correctly (verified pattern with tesla's CSV export in t-paliad-177).
### 3.2 CSV + JSON siblings
Per the no-lock-in promise, **xlsx is not enough on its own** — Excel is a proprietary format owned by Microsoft, and a workbook is opaque without a tool that understands it. For genuine portability we also produce:
- **CSV:** one file per entity sheet (no reference sheets — those go as JSON), UTF-8 with BOM (`\xEF\xBB\xBF`) for Excel-DE compat, RFC 4180 quoting, headers row 1. Identical column shape to the xlsx sheet.
- **JSON:** a single `paliad-export.json` per scope, top-level `{"meta": {...}, "tables": {"projects": [...], "deadlines": [...], ...}}`. Easiest for programmatic re-ingest. Reference tables included.
**Delivery shape:** all three formats live inside one `.zip` per export:
```
paliad-export-<scope>-<timestamp>.zip
├── README.txt # human-readable: what this is, how to read it
├── paliad-export.xlsx # canonical workbook
├── paliad-export.json # JSON twin (machine-readable)
├── csv/
│ ├── projects.csv
│ ├── deadlines.csv
│ ├── ...
│ └── ref/
│ ├── proceeding_types.csv
│ └── ...
└── __meta.json # standalone meta (same content as __meta sheet)
```
The `.zip` is the artifact users download. Default content is "all three" — there's no UI knob to pick (open Q1: should there be? Inventor pick = no, zip-only).
**Filename convention:**
```
paliad-export-{scope}-{timestamp}.zip
scope = org | project-<root-short> | personal
timestamp = YYYY-MM-DDTHHMMZ # UTC, no colons (Windows-safe)
```
Examples: `paliad-export-org-2026-05-19T1423Z.zip`, `paliad-export-project-Siemens-AG-2026-05-19T1423Z.zip`, `paliad-export-personal-2026-05-19T1423Z.zip`. The project-short is `slugify(root.title)` capped 40 chars.
**Determinism (Q6 question).** Two exports of the same scope at the same row state must produce **byte-identical** workbooks. xlsx is internally a zip of XML — file order in the zip is significant; excelize's default zip writer is non-deterministic. We can make this deterministic by sorting the file list before writing. JSON: keys sorted alphabetically. CSV: rows ordered by `id ASC` (stable). The only inherently non-deterministic field is `generated_at`; we externalise it to the filename and the `__meta` sheet, but the rest of the workbook is byte-stable. **Inventor pick: yes, deterministic.** Lets users diff exports and prove "nothing changed between Tuesday and Thursday."
### 3.3 Future-proofing — schema_version
`__meta.schema_version = 1`. When we add columns (e.g., projects.archived_at lands), we bump to 2 and note the additions in a `docs/export-schema-changelog.md`. Importers (us in the future, or a re-importer at a different firm) read schema_version first.
---
## 4. Authorization model
**Tightly mirrored to existing paliad role surfaces.** No new roles introduced.
| Scope | Required auth |
|---|---|
| `org` | `paliad.users.global_role = 'global_admin'`. Same gate as `/admin/*` pages (`auth.RequireAdminFunc` in `handlers.go:417`). |
| `project` | Caller must (a) pass `can_see_project(root_id)`, AND (b) have effective project profession ≥ **associate** on the root. The associate floor mirrors the conservative seed in `approval_policies` (t-154); paralegals + PA can see data but not extract it. m-tunable per Q2. |
| `personal` | Any authenticated user. No additional gate. |
**Profession ladder check** for project scope uses the existing `DerivationService.EffectiveProjectRole` (t-139 phase 2) — direct membership > ancestor > derived via partner-unit. Same surface that gates approvals; same surface gates extracts.
**Audit row written on every export run.** A new event_type into `paliad.project_events` for project-scope (so it appears on the project's Verlauf), `partner_unit_events` for org-scope (so it appears on the partner-unit audit log of the firm-admin's home unit), and `policy_audit_log` is too narrow — we likely want a **new** audit table for org-wide actions, OR we widen `project_events` to allow `project_id = NULL` org-wide rows. **Inventor pick: new table `paliad.system_audit_log`** — clean separation, integrates into the existing 5-source AuditService union as source #6. Migration 101 adds it.
`system_audit_log` columns:
```sql
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
event_type text NOT NULL, -- 'data_export'
actor_id uuid REFERENCES paliad.users(id),
actor_email text NOT NULL, -- captured at write time, survives user deletion
scope text NOT NULL, -- 'org' | 'project' | 'personal'
scope_root uuid, -- project_id for project scope, NULL otherwise
metadata jsonb NOT NULL DEFAULT '{}'::jsonb, -- {"formats":["xlsx","json","csv"], "row_counts":{...}, "file_size_bytes":12345, "filename":"..."}
created_at timestamptz NOT NULL DEFAULT now()
```
The audit row is written **before** the export runs (so failed exports are still recorded) and **updated** with `file_size_bytes` + final `row_counts` on success. Failure case: separate `event_type='data_export_failed'` row with the error string in metadata. **The audit chain is the trust signal** — m sees who exfiltrated what, when, and how much.
**Headers on the response:**
- `Content-Disposition: attachment; filename="paliad-export-<scope>-<ts>.zip"`
- `X-Paliad-Export-Audit-Id: <system_audit_log.id>` — so an automated client can reference the audit row.
---
## 5. Trigger model
Three trigger surfaces:
### 5.1 On-demand button
- **Personal:** `/settings` → "Daten exportieren" card → button. POST `/api/me/export` → 200 with `Content-Type: application/zip`. Synchronous.
- **Project:** `/projects/{id}` → settings/cog menu → "Daten dieses Projekts exportieren". POST `/api/projects/{id}/export` → 200 zip. Synchronous. Includes a "Inkl. Unterprojekte" toggle hint (it's always subtree-inclusive — the toggle is purely informational, no off switch).
- **Org:** `/admin/data-export` (new page, card on `/admin`) → "Org-Export erstellen" button. POST `/api/admin/export/org`**async** by default (see §6.1). Returns 202 + `job_id`. UI polls `/api/admin/export/org/jobs/{id}` for status.
**Why org is async even at today's scale:** the principle isn't "is it slow now" — it's "the trigger model should not change as the firm grows." If the partner with the firm-wide button gets a different UX from the associate with the project button, we'd retrofit later. Sync at 600 rows works fine; the wrapping is `goroutine + channel + Server-Sent Events for live progress`, no new infra needed. See §6.1.
### 5.2 Scheduled exports
**Inventor pick — defer to slice 4.** Out of v1 scope. The reasoning: scheduling sits on storage + delivery + retention, all of which are *also* deferred to slice 3+. Building the scheduler before we know how + where the artifact lives is premature.
When it lands (slice 4), the model is:
- A new `paliad.scheduled_exports` table: `(id, scope, scope_root_id, owner_user_id, cadence, last_run_at, next_run_at, delivery)` where `delivery` is `{kind: 'email-link' | 'caldav-style-webdav', config: jsonb}`.
- A daily cron (mai cron or a `time.Ticker` goroutine) checks `next_run_at < now()`, runs the export, posts the link via the configured delivery channel.
- Cadence: weekly + monthly + on-status-change (e.g., "export when project closes" — a webhook from `projects.status` triggers).
For now (slice 1-2), users can right-click the on-demand button and bookmark the URL — that's the **only** scheduled-export-y thing we offer, and it's intentional: get the manual flow rock-solid before adding cadence.
### 5.3 API endpoint
Same endpoints as §5.1, callable directly with the standard cookie / bearer auth. We don't add a separate "API key" surface in v1 — paliad doesn't have personal access tokens today. If a user wants to script their personal export weekly, they can use cookie auth from `m/paliad` automation; that's enough until power-user volume justifies a real PAT surface.
For machine ergonomics: the `/api/...export` endpoints accept `?format=zip` (default), `?format=xlsx`, `?format=json`, `?format=csv-zip` query params. Only `zip` is documented; the others are internal but reachable for automation.
---
## 6. Storage + delivery
### 6.1 Synchronous vs async — per-scope picks
**Personal, project:** **Synchronous, streamed.** The handler holds the HTTP connection open, writes the zip directly to `http.ResponseWriter`. For 1MB-class exports (today's reality at every scale up to thousands of rows per entity) this is the right call — no persistence, nothing to garbage-collect, nothing leaking onto disk. Excelize's `NewStreamWriter` flushes rows as they're written so RAM stays bounded.
**Org:** **Asynchronous, in-process queue, on-disk artifact.**
- Submit (`POST /api/admin/export/org`) writes a `system_audit_log` row with status `pending` and dispatches a goroutine.
- The goroutine writes the zip to `/var/lib/paliad/exports/{audit_id}.zip` (configurable via `PALIAD_EXPORT_DIR`; on Dokploy this is a mounted volume).
- The goroutine updates the audit row's metadata with progress, then status `done` with `file_size_bytes` on success.
- The user polls `GET /api/admin/export/org/jobs/{audit_id}` (SSE or simple JSON) — when ready, a download link `GET /api/admin/export/org/jobs/{audit_id}/download` serves the file.
- Download deletes the file by default (one-shot link), or keeps it per Q3.
**Why not S3-style bucket?** Paliad already has a `documents` table that *will* need a binary store, eventually. Coupling export storage to that future store is right — but the future store doesn't exist yet, and we don't want to provision MinIO on mlake purely for exports. **Inventor pick: local disk in `PALIAD_EXPORT_DIR`** until/unless we provision a real object store; at that point the export storage moves there transparently.
### 6.2 Retention (Q3)
**Inventor pick: 7 days, then auto-delete.** Justifications:
1. Exports contain sensitive client data — minimising the retention window minimises blast radius if the Dokploy host is compromised.
2. 7 days covers a holiday-week round-trip ("I exported Friday, want to look at it Monday next week, missed the day-1 link").
3. The audit row in `system_audit_log` persists forever — you can always tell that an export happened, even after the artifact is deleted.
A cleanup goroutine runs daily, lists `system_audit_log` rows older than 7 days with non-NULL `file_path`, deletes the file, sets `metadata.deleted_at`. Audit row stays.
The `PALIAD_EXPORT_RETENTION_DAYS` env var is the knob (default `7`). m-tunable per firm.
### 6.3 PII / GDPR
This is where the design gets serious.
**At-rest encryption.** Files in `PALIAD_EXPORT_DIR` are plaintext on the Dokploy volume. The volume itself is encrypted at the host layer (Hostinger VPS disk encryption). We **do not** layer additional file-level encryption on the artifact — that would require a per-user key, key escrow, key rotation, all of which is over-engineered for a 7-day-retention exfil where the link is single-use behind cookie auth. The disk encryption + 7-day TTL + audit log is the trust boundary.
**In-transit encryption.** TLS via Dokploy + Traefik — paliad.de is Let's Encrypt-served. No raw HTTP path.
**Download authentication.** The download link `/api/admin/export/org/jobs/{audit_id}/download` requires the same cookie auth as the submit. No public signed URLs in v1 (deferred per Q8). When we add scheduled exports + email delivery (slice 4), we'll need expiring signed URLs — that design is captured then, not now.
**Data-subject requests.** A user invoking `/api/me/export` is, in effect, performing a self-serve GDPR Art. 15 data-portability request. Audit row records the request. If the firm receives a *third-party* DSR ("export the data my client Mr. Müller asked for"), a global_admin can run a project-scope export filtered to projects involving that client; this is a manual workflow we don't automate in v1 (open Q9).
**Right-to-erasure.** Out of scope. Erasure is a write path; export is read-only. They share no code.
**External sharing of export files.** A user who downloads an export and emails it to an external party has done so on their own authority and outside paliad's protection. We don't watermark the file (debated and rejected: watermarking introduces non-determinism, breaks diffability, and gives false security — anyone reading the zip can strip metadata). What we *do* document in the embedded `README.txt`:
> Diese Datei enthält möglicherweise vertrauliche Mandantsdaten. Sie wurde
> erzeugt am {generated_at} durch {actor_email} aus Paliad ({firm_name}).
> Die Weitergabe an Dritte erfolgt in eigener Verantwortung des Empfängers.
A simple "you broke the seal" notice is what we offer. It's a contract, not a control.
**PII column deny-list.** Hard-coded in `internal/services/export_service.go`:
- `paliad.users.password_hash` — doesn't exist, but the deny-list is the safety net if it ever does.
- `paliad.user_caldav_config.encrypted_password` — explicit drop.
- Any column whose name matches `(?i)secret|token|password|api[_-]?key|private[_-]?key` — caught at column-discovery time, errors loudly into `system_audit_log.metadata.warnings`.
- `paliadin_turns.assistant_response` — present in personal export of caller's own data; **off** in org export by default (m's call per Q5).
### 6.4 GDPR-completeness note
The export of one user's personal scope is **a partial Art. 15 disclosure** — it contains what's *in paliad's* control. Other systems (Supabase Auth row, mlake logs, CalDAV provider) are out of paliad's scope and not in the export. The embedded README states this explicitly so the user knows the workbook is the paliad-side answer, not a complete personal-data dump from "the firm."
---
## 7. Slice plan
Tracer-bullet shipping. Each slice is independently shippable and reviewable. The first slice closes the no-lock-in promise for the smallest, lowest-risk scope; later slices widen.
### Slice 1 — personal export, synchronous, xlsx + JSON
- Adds `excelize/v2` to `go.mod`.
- New `internal/services/export_service.go` with the column-discovery + writer plumbing for xlsx + JSON.
- New `internal/handlers/export.go` with `POST /api/me/export`.
- New `/settings` UI: "Daten exportieren" card + button.
- Migration 101: `paliad.system_audit_log` + `AuditService.ListEntries` 6th union branch.
- i18n keys (`settings.export.*`, `__meta.*`).
- Tests: `export_service_test.go` covers xlsx structure (one row each kind), JSON shape, PII deny-list.
Ships the no-lock-in promise for every user immediately. ~600-800 LoC + ~25 i18n keys.
### Slice 2 — project export, synchronous, xlsx + JSON + CSV-zip
- Generalises the export_service to scope-aware queries (the visibility predicate gets injected per scope).
- New `POST /api/projects/{id}/export`, gated by §4.
- Adds CSV writer alongside xlsx + JSON; bundles all three into `.zip`.
- Project-detail UI gets the export menu entry.
- README.txt template embedded.
- Tests + e2e (Playwright) on the project page button.
~800-1000 LoC. The CSV path generalises the xlsx column-discovery so the marginal cost is low. After this slice, two of three scopes are shipped and synchronous serves both.
### Slice 3 — org export, async with job tracking
- Adds the goroutine + on-disk artifact path + `PALIAD_EXPORT_DIR` env.
- `POST /api/admin/export/org` + job status + download endpoints.
- New `/admin/data-export` page (card on `/admin/`).
- Cleanup goroutine (daily, deletes artifacts > `PALIAD_EXPORT_RETENTION_DAYS`).
- Refactor: extract the now-common "writeExportToWriter" core from the synchronous path so async re-uses it.
~600-800 LoC. After this slice, all three scopes ship + audit trail is complete.
### Slice 4 — scheduled exports (deferred, not v1)
Designed in §5.2; building deferred until at least 2 firms ask. The contract surface is the `scheduled_exports` table + cadence + delivery channel.
### Slice 5 — API ergonomics (deferred)
Personal Access Tokens (the "I want to cron my own export" surface). Until there's a customer, we don't build the PAT issuer + revocation + audit.
### Slice 6 — GDPR DSR helpers (deferred)
A `/admin/data-subject-request` workflow to assemble a per-natural-person export across projects. Built on Slice 1-3 primitives; not blocked by them.
### Slice 7 — document binary inclusion (deferred until documents have rows)
When the `documents` table starts holding real files, the export adds a `documents/` subdir in the zip with the actual files, keyed by filename = `{document_id}.{ext}`. The metadata sheet links by id. Adds ~150 LoC + an env var for the file backend.
**Critical-path slices for v1: 1 + 2 + 3.** Everything after is layered, optional, m-prioritised when there's a real customer pull.
---
## 8. Trade-offs flagged
1. **xlsx-first means we own the `excelize` dependency forever.** Mitigation: excelize is the canonical Go xlsx — replacing it would be a multi-thousand-LoC migration, but the upstream is healthy (MIT, 17k+ stars, monthly releases). Acceptable lock-in.
2. **Determinism (sorted file order, sorted JSON keys, row-id-ordered CSV) is implementation discipline, not a library default.** Test that breaks if any future change introduces non-determinism is essential (helps reviewers + prevents regressions).
3. **Synchronous personal + project means a runaway export can block a request goroutine for seconds.** At today's data shape this is sub-second. Watchdog: a 30s context deadline on synchronous exports; over that, return 503 with "export too large — contact admin for async." Triggers slice 3 → slice 4 of the user's mental model.
4. **Per-scope endpoints triplicate similar code paths.** Mitigated by the shared `ExportSpec` struct + scope-aware predicate injection. Read carefully in code review — this is the place subtle scope leaks creep in.
5. **JSON twin is genuinely redundant for human users.** It's there for the no-lock-in promise (a Python script can re-ingest without Excel). The cost is one extra file in the zip + one extra serialisation pass. Acceptable.
6. **No diff tooling — yet.** Determinism enables `diff -r` between two extracted zips, but no in-app surface. Slice 4+ may layer "show me what changed between Monday's and Friday's export" once exports are scheduled and stored.
7. **`paliadin_turns` privacy default.** Currently restricted to `PaliadinOwnerEmail` so the table is empty for every other user. Personal export carries them by default ("your AI history"); org export by default does NOT (admin opt-in via `?include=paliadin_turns`). When Paliadin opens past owner-only (post-API cutover), revisit.
8. **Reference-data inclusion bloats every export.** 254 deadline_rules + 102 trigger_events + 56 concepts + … = ~1000 reference rows in every workbook regardless of scope. At zip-compressed sizes this is < 100KB and worth the standalone-interpretability. If the workbook gets too large later, ship reference data as a separate "paliad-reference-snapshot.zip" once + reference it from each export's README.
9. **Org export volume at firm-scale.** A 10k-project firm has ~50k deadlines and ~200k audit events. Even at 200 bytes/row average that's < 100MB comfortable for the async path with 4GB Dokploy RAM. Threshold concerns kick in at 1M+ rows, which is firm-class-of-100-attorneys territory. Designed for, not blocked on.
10. **Audit-log explosion.** A nightly cron + 47 users self-exporting = 50 audit rows / day. At a year that's 18k rows. Still trivial. No retention on the audit chain (the artifact retention does NOT touch audit-log retention the audit chain is the trust signal, see §4).
---
## 9. Recommended implementer
**Single PR, layered slices 1 → 2 → 3 as separate commits.** No DB-heavy migrations; the only schema add is `system_audit_log` (one table, one trigger if any). The hard work is in the writer abstraction.
- **Slice 1:** pattern-fluent Sonnet coder. ~600-800 LoC, mostly bookkeeping. Tests pin the shape.
- **Slice 2:** same hands as slice 1 (continuity matters here the writer abstraction is set in slice 1 and the project scope generalises it).
- **Slice 3:** same hands again. The async path is its own subsystem but the writer is unchanged.
**NOT cronus** per memory directive 2026-05-06 (retired from paliad).
**NOT m** this is a coder task end-to-end.
---
## 10. Inventor → coder transition (GATED per project CLAUDE.md)
Per `.claude/CLAUDE.md`: design phase ends here. No code touches the tree from inventor. Head's `mai-head` skill gates the coder shift after m's go on §11 open questions.
When approved, the coder shift opens on `mai/<coder-name>/data-export-slice-1` (fresh branch off main, NOT off the design branch design doc commit is the only artifact this branch carries forward via cherry-pick).
---
## 11. Open questions for m
The brief lists 8 candidate questions. After live-state verification I've collapsed + sharpened to 9, each with an inventor pick + reasoning. Will be asked sequentially via AskUserQuestion (paliad dogma no `## §X.Y` markdown dump on m, per t-paliad-154 lesson).
### Q1 — Bundle xlsx + CSV + JSON in one zip, or let user pick format?
**Inventor pick: bundle all three in one zip, no UI knob.**
Reasoning: the no-lock-in promise *requires* the JSON twin (Excel-independent re-ingest); the xlsx is the human-readable default; CSV is the universal lingua franca. Picking only one breaks the promise for some user. Bundle size at today's scale is < 1MB; even at firm-scale it's well under the email-attachment limit. The cost of a checkbox UI is more than the cost of three extra files.
Alternative: offer `?format=xlsx-only|json-only|csv-only` query params for the API surface, default to bundle. Documented in README only. We do this in v1 anyway since multi-format is what generates the zip in the first place.
### Q2 — Project-scope profession floor: associate (inventor pick) or member?
**Inventor pick: associate floor.**
A project export carries party names, addresses, decision-history, draft strategy notes. That's "I can write a paper for the partner" data, not "I can see the deadline calendar" data. Member is the bare-visibility tier (you got added to the team). Export is exfiltration needs the next tier up.
Alternative: gate by `responsibility ∈ {lead, member}` (no profession floor, only the project-team responsibility check). Cleaner architecturally separates the "can see" axis from the "can extract" axis using the same fields. Less restrictive in practice.
Worth choosing now because the gate text in the audit row mentions the tier.
### Q3 — Org-export artifact retention: 7 days (pick) or 30 / 90?
**Inventor pick: 7 days.**
Default conservative. m-tunable per firm via env var.
### Q4 — Excel dates: ISO strings only (pick) or also a mirrored native-Excel-date column?
**Inventor pick: ISO strings only.**
Native Excel dates are locale-poisoned (DE vs EN epoch interpretation flips, round-trip corruption when re-saved). ISO is the universal answer. Power users who want a sortable native-date column can derive it once in their workbook but the canonical export stays unambiguous.
### Q5 — `paliadin_turns` in org export: opt-in only (pick), or include by default?
**Inventor pick: opt-in via `?include=paliadin_turns` query.**
Today it's m-only data (`PaliadinOwnerEmail` gate), so the privacy stakes are low but the *moment* Paliadin opens beyond owner-only, the AI conversation history per user is the most sensitive personal data we carry. Setting the off-by-default precedent now means we don't accidentally start dumping it later.
### Q6 — Deterministic byte-for-byte exports: yes (pick) or accept timestamp drift in zip metadata?
**Inventor pick: yes, deterministic.**
Lets users diff exports across time. Cost: ~50 lines of `sort.Strings` + a custom zip writer with stable ordering. Worth it.
### Q7 — Invitation tokens in org export: drop them entirely (pick) or include as hash?
**Inventor pick: drop entirely.**
Tokens grant signup access. Including them in a backup creates a vulnerability surface an exfiltrated backup could be used to sign up as someone-else with their pending invite. Hashing doesn't help because the hash is what the URL contains. The invitation **row** (recipient, role, expiry, sent_at) is in the export; the token is not. If you need to re-issue, you do so from paliad's invite UI.
### Q8 — Public signed-URL downloads (for scheduled/email delivery): yes / not in v1 (pick)?
**Inventor pick: not in v1.**
Defer to slice 4. v1's download is cookie-authenticated only. Signed URLs are useful when the recipient is asynchronously notified (email link), which is the scheduled-export model and that whole subsystem ships later.
### Q9 — GDPR Art. 15 DSR helper UI: not in v1 (pick)?
**Inventor pick: not in v1.**
A global_admin can already assemble a DSR manually using project-scope exports filtered by client. v1 ships the primitives; v2 ships the workflow.
### Closing question for m: implementer
> Recommend pattern-fluent Sonnet for all three slices, same hands across (continuity matters for the writer abstraction). Specific name = your call.
---
## 12. m's decisions (addendum, 2026-05-19)
m walked the §11 questions live via AskUserQuestion. Results below these supersede the inventor picks where they differ.
- **Q1 Bundle format:** Bundle xlsx + JSON + CSV in one `.zip` per export. matches pick.
- **Q2 Project-scope floor:** **Any team member** (`responsibility ∈ {lead, member}`). **Deviation** from associate-floor pick m chose the looser axis-split gate. **Implementation update for §4:** project-scope auth becomes `(a) can_see_project(root_id) AND (b) caller is on project_teams for the root with responsibility ∈ {lead, member}`. The DerivationService profession check is dropped from the export gate; observers + externals + derived-only members still cannot extract. `system_audit_log.metadata` records the responsibility value the caller held at export time.
- **Q3 Org-export retention:** **90 days**. **Deviation** from 7-day pick. **Implementation update for §6.2:** `PALIAD_EXPORT_RETENTION_DAYS` default flips from `7` to `90`. The cleanup goroutine still runs daily; the threshold is just longer. Audit row unaffected (still persists forever).
- **Q4 Date format:** ISO 8601 strings only. matches pick.
- **Q5 paliadin_turns in org export:** **Never include in org export.** **Tighter** than opt-in pick. **Implementation update for §2.1 + §6.3:** the `paliadin_turns` row drops from the org-scope sheet table entirely no `?include=paliadin_turns` query param. Personal scope still carries the caller's own paliadin_turns (it's literally their data). The hard exclusion is enforced in `export_service.go`'s scope-aware sheet registry, not just in column-discovery, so a future schema addition can't accidentally re-include it.
- **Q6 Deterministic exports:** Yes. matches pick. (m answered freeform "1" alongside the batching request first option = deterministic.)
- **Q7 Invitation tokens:** Drop entirely. matches pick.
- **Q8 Signed URLs in v1:** Not in v1. matches pick.
- **Q9 GDPR DSR helper UI in v1:** Not in v1. matches pick.
**Net effect on slice plan:** unchanged shape, three modifications:
- Slice 2 gate logic uses `project_teams.responsibility` only (no profession lookup).
- Slice 3 default retention is 90 days (one env-var value change).
- Slice 1 + 3 sheet registry omits `paliadin_turns` from org scope entirely.
No other slice deltas. v1 still ships slices 1+2+3.
**Coder shift gating:** head still gates the implementation handoff; m's decisions here close §11 but don't auto-trigger coder work.
---
## 13. Adjacent / out-of-scope
- **Import path** explicitly out per brief. A round-trip "export then re-import" is appealing but is its own design (rebinding UUIDs, conflict resolution, schema_version migrations). Don't conflate.
- **Postgres replacement** the Excel workbook is a *backup* + *portability artifact*, not a data-model alternative. Postgres stays canonical.
- **t-paliad-212 (leibniz, CalDAV multi-calendar):** personal export already carries the caller's caldav config (minus ciphertext). When leibniz designs multi-calendar, the personal export's `my_caldav_config` sheet becomes a list rather than a single row handled by column-discovery automatically. No design conflict; flagged for confirmation when leibniz's design lands.
- **t-paliad-213 (mendel, test strategy):** export service warrants pure-function tests for column discovery, deny-list, scope predicate, plus one e2e (Playwright) per scope endpoint. Slice tests pin the contract; mendel's overall strategy decides framework choice.
---
## 14. References
- `docs/design-data-model-v2.md` projects + mandanten + ltree path + can_see_project predicate.
- `docs/design-approval-policy-ui-2026-05-07.md` 5-source audit union (this design adds the 6th source).
- `docs/design-profession-vs-project-role-2026-05-07.md` profession ladder for the §4 project gate.
- `internal/handlers/admin_rules.go:303` `handleAdminExportRuleMigrations` (precedent for admin-gated export-as-download).
- `internal/services/project_service.go:15` visibility predicate.
- `internal/services/derivation_service.go` `EffectiveProjectRole` for the project gate.
- `github.com/xuri/excelize/v2` chosen xlsx library.
---
**END OF DESIGN. Status: READY FOR REVIEW.**
Inventor parks until m's go/no-go on §11. No code touches the tree from this branch.

View File

@@ -0,0 +1,582 @@
# Design — Paliad Test Strategy (production-grade)
**Author:** mendel (inventor)
**Date:** 2026-05-19
**Task:** t-paliad-213
**Branch:** `mai/mendel/inventor-test-strategy`
**Status:** DESIGN READY FOR REVIEW. No test files / Make targets / CI configs touched. Awaiting m go/no-go on §5 slice plan + §6 open questions before any coder shift.
---
## 0. TL;DR
Paliad has accidental test discipline today: 59 `_test.go` files / 323 test functions in Go (≈45 % of services tested, ≈12 % of handlers tested) and 4 frontend test files for 90+ client modules (≈4 %). There is no committed end-to-end suite and no CI — every smoke pass is human-driven via the manual reports in `tests/`. The `mig 098` prod crash-loop, the `t-paliad-036` triple-bug after the German→English rename, and a long tail of UX regressions (deadline-done modal, calendar column drift) would all have been caught by a 10-test boot-and-click smoke pass.
This design proposes a six-layer test pyramid with a concrete tool per layer (stdlib `testing` + bun's built-in `bun:test` + `playwright` for E2E — nothing third-party we don't already use). It pins three lessons paliad has paid for in commits:
1. **No mocks at the service↔DB boundary.** Live-DB tests against a per-developer Postgres are the floor; in-memory mocks for `paliad.*` would have hidden every rename-after-DROP-CASCADE bug. Project preference is already in this direction (27/44 service tests are live-DB-gated); we double down rather than reverse.
2. **Migrations must dry-run before they merge.** Every recent prod-down (mig 098, mig 020-after-rename, mig 099 audit_reason gap) was a migration that compiled, passed `go test ./...` (which skips without `TEST_DATABASE_URL`), and broke on first apply against the real schema. A `make verify-migrations` target that does BEGIN/apply/ROLLBACK in CI fixes the entire failure mode.
3. **Browser-shaped bugs need a browser.** The fristenrechner cascade, shape-timeline render, calendar grid, inline paliadin widget — these are JS state machines. Bun's stdlib `bun:test` covers the pure parser/codec code; Playwright covers the auth-gated DOM. Don't try to substitute one for the other.
Six slices roll the strategy out as tracer-bullet PRs, each independently shippable. Slice 1 (migration dry-run harness) and Slice 4 (Playwright golden-path smoke) buy the most outage-prevention per LoC; the rest is widening proven patterns.
Six open questions for m at §6. Most surface a coverage-vs-cost trade-off — the picks that need m's call before any code lands are CI infrastructure choice (Q2), per-PR run-time budget (Q1), and live-DB-vs-dockerised Postgres (Q3).
---
## 1. Audit — what exists today
Counts taken on `mai/mendel/inventor-test-strategy` @ HEAD (2026-05-19, 100 migrations applied).
### 1.1 Go test inventory
| Package | Source files | Test files | Test functions | Notes |
|---|---|---|---|---|
| `internal/services` | 56 | 44 | ~200 | 26 live-DB-gated (`TEST_DATABASE_URL`), 18 pure-Go. 24 services have **no test file at all** — see §1.4. |
| `internal/handlers` | 59 | 7 | ~30 | Only auth-domain check, search, audit-parse, approval-error-mapping, redirects, verfahrensablauf-redirect, chart-404 covered. **53 handlers have no test file.** |
| `internal/auth` | small | 2 | ~10 | Session middleware + require-admin. |
| `internal/branding` | small | 1 | small | Firm-name override. |
| `internal/offices` | small | 1 | small | Office enum. |
| `internal/changelog` | small | 1 | small | Pure parser. |
| `internal/calc` | small | 1 | small | Fees / fee tables. |
| `cmd/server` | 1 | 1 | small | `main_paliadin_backend_test.go` covers env-gate selection. |
| **Total** | **133** | **58** | **323** | |
`go test ./...` runs all 58 files. Without `TEST_DATABASE_URL` set, 27 of them silently skip their live-DB cases — the suite still passes, but coverage of mutation paths drops to near zero.
### 1.2 Frontend test inventory
| Path | Test files | Tested |
|---|---|---|
| `frontend/src/client/filter-bar/url-codec.test.ts` | 1 | FilterBar URL codec round-trip. |
| `frontend/src/client/views/format.test.ts` | 1 | Date/time formatters (regression for t-paliad-153). |
| `frontend/src/client/views/shape-timeline-chart.test.ts` | 1 | Chart layout pure function. |
| `frontend/src/client/views/shape-timeline-cv.test.ts` | 1 | Continuous-view shape layout. |
| **Total** | **4** | Out of ~90 client modules (`frontend/src/client/*.ts`). |
All four use bun's built-in `bun:test` (no extra dep). No DOM/jsdom tests. No Playwright. No `bun test` script in `package.json` (`bun run build` is the only script).
### 1.3 End-to-end / smoke
- `tests/smoke-2026-04-25.md`, `tests/smoke-auth-2026-04-25.md`, `tests/smoke-auth-2026-04-26-cleanup.md` — human-written reports with screenshots committed under `tests/screenshots-*`. No code. No re-runnable script.
- `mai-tester` skill uses Playwright for ad-hoc runs; nothing committed.
- No `e2e/`, no `.gitea/workflows/`, no `.github/workflows/`, no `Makefile`.
### 1.4 Critical service paths with no test file
These are `internal/services/*.go` for which no `*_test.go` sibling exists:
| Service | Risk class | Why it matters |
|---|---|---|
| `caldav_service.go`, `caldav_client.go`, `caldav_crypto.go`, `caldav_ical.go` | High | Per-user push/pull goroutines + AES-GCM at rest. One pure parser test (`caldav_ical_timeline_test.go`) exists but the service + crypto + WebDAV client are blind. |
| `agenda_service.go` | High | Dashboard agenda query; reused by `/agenda` page. Exercised transitively by visibility tests but no direct test. |
| `dashboard_service.go` | High | Traffic-light + summary counts. Same story — transitively covered via visibility, no direct test. |
| `derivation_service.go` | Medium | Project-tree derivation (the new t-paliad-194-era subtree machinery). |
| `team_service.go` | Medium | Team membership / inheritance. |
| `partner_unit_service.go` | Medium | Dezernat replacement (t-paliad-070). |
| `party_service.go`, `note_service.go`, `link_service.go`, `checklist_instance_service.go` | Medium | All do project-scoped CRUD with the same RLS+audit pattern that `t-paliad-036` proved easy to break. |
| `appointment_service.go` | High | Hot — every calendar mutation. Exercised through approval tests but has no own test file. |
| `view_service.go` | Medium | Powers the substrate (`/views/*`). |
| `paliadin_jwt.go` | Medium | Per-turn JWT mint for the aichat path (`t-paliad-194`). No call sites in tests today. |
| `markdown.go` | Low | Glossary + checklist content render. |
### 1.5 Handlers with no test file
53 of 59. Notably: **`auth.go` itself** (login / logout / session creation), **`projects.go`** (the most-mutated entity), **`deadlines.go` / `appointments.go`** (writes), **`paliadin.go` / `paliadin_suggest.go`** (m-only routes — never click-tested), **`fristenrechner.go` / `fristenrechner_search.go` / `fristenrechner_event_categories.go`** (the cascade users live in), **`dashboard.go` / `agenda.go`** (landing), **`onboarding.go` / `onboarding_gate.go`** (every new user's first three minutes), **`invite.go`** (rate-limited write path). The currently-tested handlers (search, audit-parse, approval error mapping, etc.) are the cheap pure-Go ones; every handler that touches the DB is untested at handler level.
### 1.6 Live-DB test scaffold — is it sound?
The pattern (read from `internal/services/visibility_test.go`):
```go
url := os.Getenv("TEST_DATABASE_URL")
if url == "" { t.Skip("TEST_DATABASE_URL not set — skipping live DB test") }
if err := db.ApplyMigrations(url); err != nil { t.Fatalf(...) }
pool, _ := sqlx.Connect("postgres", url)
defer pool.Close()
// per-test seed + cleanup via DELETE + defer cleanup()
```
Verdict: **sound, but has rough edges that need addressing before we widen.**
- ✅ Migrations apply at test startup against the test DB — catches every "you forgot to add a CHECK" / "you reference a column that doesn't exist" before a real-DB-touching test runs.
- ✅ Per-test cleanup via `DELETE FROM ... WHERE id IN ($1,...)` is explicit and idempotent.
- ✅ The `paliad.paliad_schema_migrations` tracker collision noted in memory `0b900afa…` is a pre-existing issue, not introduced by this design.
- ⚠️ Cleanup-via-DELETE is fragile: a test that creates a row referenced by FK from another table needs to remember to clean both. A few existing tests (see `audit_service_test.go`) already chain 5+ DELETEs.
- ⚠️ Tests can't run in parallel against the same `TEST_DATABASE_URL` because they share schema state. `go test ./...` defaults to `-parallel` per-package; same-package tests with overlapping cleanup IDs can interfere.
- ⚠️ No CI today actually exercises `TEST_DATABASE_URL` — so every live-DB test is effectively run only on the author's laptop or not at all. Half the value is paid-for but unbilled.
### 1.7 Migration tooling
- `internal/db/migrate.go` embeds `migrations/*.sql` and applies on server boot via `golang-migrate/v4` with the `paliad_schema_migrations` tracker in `public` schema.
- 100 migrations on disk (`001``100`).
- **No dry-run gate today.** A bad migration breaks `paliad.de` at boot (Dokploy crash-loops the container). Recent prod incidents: mig 098 (submission code rename), mig 099 (with_po flag drop missed audit_reason gap), mig 020 (function rename without body rewrite — see memory `49a05cfa…`).
- `down.sql` exists for every migration but no test ever exercises it.
### 1.8 CI / deploy loop
- No CI. Push-to-main → Gitea webhook → Dokploy auto-builds the Dockerfile and replaces the container. The Dockerfile runs `bun run build` then `go build`. **Neither `go test` nor `bun test` runs in the build pipeline.**
- Pre-commit hooks: none in repo. Each worker runs `go build / go vet / go test / bun run build` by convention (see memories — every shipped task report ends with "build hygiene held").
---
## 2. Test pyramid — recommended shape
```
┌─────────────────┐
│ E2E (Playwright)│ ~10 flows
│ L6 │
└─────────────────┘
┌─────────────────────────┐
│ Handler integration │ ~30 routes
│ L5 (httptest + real DB)│
└─────────────────────────┘
┌──────────────────────────────────┐
│ Service-layer (live DB) │ ~60 tests
│ L4 (BEGIN/ROLLBACK harness) │
└──────────────────────────────────┘
┌──────────────────────────────────────────┐
│ Frontend DOM / cascade (bun:test+jsdom) │ ~15 modules
│ L3 │
└──────────────────────────────────────────┘
┌──────────────────────────────────────────────────────┐
│ Frontend unit (bun:test pure TS) │ ~30 modules
│ L2 │
└──────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────┐
│ Go unit (stdlib testing, table-driven, pure functions) │ ~150 tests
│ L1 │
└──────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────┐
│ Migration dry-run (make verify-migrations) │ 100 mig
│ L0 — gate on every PR │
└──────────────────────────────────────────────────────────────┘
```
### Layer 0 — Migration dry-run
**What:** Every `*.up.sql` in `internal/db/migrations/` is applied inside a single `BEGIN ... ROLLBACK` transaction against a scratch Postgres, in numeric order. The harness asserts each statement succeeds *and* asserts no statement leaves the schema in a `paliad_schema_migrations.dirty=true` state. A second pass applies all up-migrations end-to-end (no rollback) and then re-applies the latest up-migration to assert idempotency (every paliad migration since `t-paliad-070` has been written to be idempotent — this enforces it).
**Tool:** stdlib `testing` package, no third-party. Pattern: `internal/db/migrate_test.go` with a `TestMigrations_DryRun` driven from `TEST_DATABASE_URL`. A `make verify-migrations` target wraps it.
**Why this layer matters most:** Every recent prod-down was a migration. Catching them on a CI run before merge is the highest-leverage test investment paliad can make. Cost: one ~100-line Go file + one Postgres in CI.
**Coverage target:** 100 % of `*.up.sql` files. Hard gate on PR — no exceptions.
### Layer 1 — Go unit (pure)
**What:** `go test ./...` against pure functions — formatters, parsers, validators, calculators, fee tables, deadline calculators, projection lookahead clamping, codec round-trips. No DB, no HTTP.
**Tool:** stdlib `testing`. Table-driven `cases := []struct{...}{...}` style is already the house pattern (see `auth_test.go` / `projection_anchor_test.go`). **Do not introduce testify or any matcher library** — the current code reads cleanly without one, and 323 existing test functions don't need a rename pass.
**What's already there:** 19 pure-Go test files (calculator, mapping, codec, holiday, fees, etc.). Density is good; targeted infill rather than re-architecture.
**Coverage target:** Every pure function in `internal/services/`, `internal/handlers/`, `internal/calc/`, `internal/changelog/`. Aim for "every branch in a decision table has at least one test row." Don't chase % — chase "the obvious edge that would burn a coworker".
### Layer 2 — Frontend unit (pure)
**What:** `bun test` against pure TS modules — URL codecs (`filter-bar/url-codec`), formatters, parsers, i18n key correctness (every `data-i18n` attribute used in TSX has a key in `i18n.ts`), view-spec parsers, projection-row mapping helpers.
**Tool:** `bun:test` (built into bun, no install). Already in use in 4 files — extend the same pattern. Add `bun test` to `package.json` `scripts`.
**What to add:**
- i18n key audit (every `t("foo.bar")` and `data-i18n="foo.bar"` resolves in both `de` and `en`).
- `filter-bar/` types + render helpers (paliad has shipped 4 FilterBar slices; coverage is one codec test).
- `paliadin-context.ts` route table + entity extraction (the `[ctx …]` envelope is a stable contract paliadin's SKILL.md depends on; any drift here is a silent failure).
- `paliadin-starters.ts` registry — every route maps to ≥1 starter; every starter is bilingual.
- View-spec parsers in `views/`.
**Coverage target:** Every pure TS module in `frontend/src/client/`. Pages (TSX renderers) are E2E concern, not unit concern.
### Layer 3 — Frontend DOM (cascade / jsdom)
**What:** `bun test` with jsdom global, exercising the interactive cascade modules — the fristenrechner cascade builder, the shape-timeline render, the FilterBar UI (chips, panels), the calendar grid, the inline Paliadin widget message stream, the inbox-row click handler, the dashboard activity item navigation.
These modules contain enough state that pure-function tests miss real bugs (e.g. the t-paliad-098 `.entity-table` row-cursor lie was a CSS+DOM bug; t-paliad-099's modal close was a DOM-event bug; t-paliad-103's `::before` overlay click-swallow was a DOM bug).
**Tool:** bun + `happy-dom` is the lighter choice; if it can't handle event ordering, fall back to `jsdom`. Both are ESM-clean and bun-friendly. **Pick one and stick with it — running both means twice the dependency surface.** Default pick: `happy-dom` (smaller, paliad doesn't need legacy IE semantics).
**Pattern:** import the cascade module, build a minimal DOM (`document.body.innerHTML = …`), dispatch synthetic events, assert resulting state. Reuses the production renderers — no test-only fakes.
**Coverage target:** ~15 modules. Specifically:
- `client/filter-bar/index.ts` chip render + active-state.
- `client/fristenrechner.ts` cascade — most complex JS in the codebase; depend chains light up every UPC bug we know.
- `client/shape-timeline.ts` lane mode + track mode (envelope wire shape brittle to refactor).
- `client/projects-detail.ts` row click + Verlauf render.
- `client/paliadin-widget.ts` + `paliadin-context.ts` interaction.
- `client/inbox.ts` row-action click routing.
- `client/dashboard.ts` activity-item nav.
- `client/deadlines-calendar.ts` / `appointments-calendar.ts` column layout (the calendar-column-drift bug class).
Not unit tests; not E2E. They are the missing middle.
### Layer 4 — Service-layer (live DB)
**What:** Go service methods against a real Postgres, using the existing `TEST_DATABASE_URL` pattern. Two improvements:
1. **Replace per-test DELETE cleanup with a per-test transaction harness** — open a transaction, run the test inside it, ROLLBACK. Faster, isolating, no cleanup forgotten. Already viable because the service layer accepts `*sqlx.DB`-or-tx-shaped interfaces in many places; needs a small `internal/services/internal/testdb` package that exposes `WithTx(t *testing.T, fn func(*sqlx.Tx))`. Migration is mechanical, can happen alongside infill.
*Caveat:* some service methods open their own transactions internally (`approval_service.submit` is one). Those keep DELETE cleanup; the tx harness is a default, not a mandate.
2. **Make `TEST_DATABASE_URL` mandatory in CI.** Today these tests are skipped on every machine that doesn't `export TEST_DATABASE_URL=…` — i.e. they don't run on autoatic pipelines because there's no pipeline. Once CI exists (§3.5), it becomes a required env var.
**Tool:** stdlib `testing` + `sqlx` (already in `go.mod`). **No mocks at the service↔DB boundary.** This is m's hardest line — see global CLAUDE.md memory pattern and `t-paliad-036` (the bug that masked two other bugs would have been caught instantly by a real-DB test).
**Where to invest first:** Approval (already heavy), Projection (already heavy), Fristenrechner (already heavy), DeadlineService Create/Update/Complete/Delete with `pending_request_id` interplay, AppointmentService same, ProjectService visibility predicate, CalDAV push (the four CalDAV `*.go` files have zero direct test).
**Coverage target:** Every service method that mutates the DB has at least one happy-path live-DB test. RLS predicate (`visibilityPredicatePositional`) has one test per role (global_admin, member, non-member).
### Layer 5 — Handler integration (httptest + real DB)
**What:** Spin a real `services.DBService`, mount the protected mux, drive `httptest.NewRequest` + `ServeHTTP` against it. Auth via a fake session cookie produced by a `testauth.Login(t, userID)` helper that mints the same Supabase JWT shape `auth.UserIDFromContext` expects.
**Why:** The 53 untested handlers are where the request shape ↔ service interaction lives. Examples that would have caught real bugs:
- `t-paliad-036`'s "`/projects/{id}` 404 while `/api/projects/{id}` 200" mismatch — a 5-line handler test would have failed before the migration ran.
- mig 020's three-stacked bug — a handler test that POSTs a deadline and asserts a 200 + read-back row would have failed at submit-time, not boot-time.
- The audit-log query timezone bug — handler test asserts the JSON contains the expected `event_date`.
**Tool:** stdlib `net/http/httptest`. **No new framework.** Pattern: handler tests live next to the handler file (`internal/handlers/deadlines_test.go` next to `deadlines.go`).
**Coverage target:** Every handler that gates a state-changing route — `POST/PATCH/DELETE` flavour. Plus `GET` handlers that compose a non-trivial query (dashboard, agenda, search, audit-log).
### Layer 6 — End-to-end (Playwright)
**What:** A small Playwright suite (~10 flows) committed at `e2e/` with a `bun run e2e` entry. Targets a local `./paliad` against a scratch Postgres (the same `TEST_DATABASE_URL`). Each test logs in, drives the UI through one user journey, asserts visible state.
**Why ~10 not 100:** Per-PR budget caps at ~2 min total (§6 Q1). Playwright tests are the most expensive minute-per-confidence in this stack; they pay for themselves on the *golden path* and nothing else. The deep-coverage layer is L5; E2E is *"is the app still alive end to end?"*.
**Tool:** `playwright` (npm; bun installs cleanly). No third-party test runner — Playwright ships its own. Tests live in `e2e/*.spec.ts`. **Not bun:test.** Playwright's runner is purpose-built for browser-driving and integrates with their tracing — don't fight it.
**Cap:** 10 flows. If a new test wants in, an existing one must drop out (or we have a real reason to widen). This is the cheapest discipline available: it forces the suite to remain a smoke pass, not a regression-test dumping ground.
**Coverage target:** See §4.
---
## 3. Tooling — concrete picks per layer
| Layer | Tool | Already in deps? | Install? |
|---|---|---|---|
| L0 — migration dry-run | stdlib `testing` + `migrate/v4` | yes | no |
| L1 — Go unit | stdlib `testing` | yes | no |
| L2 — Frontend unit | `bun:test` | yes (built into bun) | no |
| L3 — Frontend DOM | `bun:test` + `happy-dom` | bun yes, happy-dom **new** | `bun add -d happy-dom` (one dep, ~200 KB) |
| L4 — Service live-DB | stdlib + sqlx | yes | no |
| L5 — Handler integration | stdlib `net/http/httptest` + sqlx | yes | no |
| L6 — E2E | `@playwright/test` | **new** | `bun add -d @playwright/test` + `npx playwright install chromium` |
Net new deps: **2** (happy-dom + playwright). Both are mainstream, both have small surface area, both align with bun's ecosystem.
Explicit rejects:
-**testify** — current tests read cleanly with stdlib; adding it forces a rename pass nobody wants.
-**vitest** — bun's built-in test runner is faster and the tests are already in `bun:test` shape.
-**dockertest / testcontainers-go** — m's preference is real-DB tests against the existing Postgres; spinning ephemeral Docker Postgres per package run adds latency and surface area for marginal isolation gain. See Q3.
-**sqlmock / gomock for DB** — banned by §0 lesson 1.
-**cypress** — Playwright is the better tool today, and the team's existing skill (`/mai-tester`) already uses it.
### 3.1 Per-PR run-time budget
Target (subject to m's call in Q1): **≤ 90 s for the gating tier (L0+L1+L2+L4 subset+L5 happy-path)**, ≤ 4 min for the full suite (add L3+L4 full+L6). The gating tier blocks merge; the full suite blocks deploy.
Indicative times (estimated, validate when slice 1 lands):
| Tier | Layers | Est. time | Blocks |
|---|---|---|---|
| **Gate (every PR)** | L0 + L1 + L2 + L5 happy-path + L4 critical | 6090 s | merge |
| **Full (every merge to main)** | + L4 full + L3 + L6 | 34 min | deploy |
### 3.2 CI — proposal, not commitment
paliad has no CI today. Two routes:
- **Gitea Actions** (m's stack already runs `mgit.msbls.de`). Self-hosted; same auth model as the rest of mAi. Adds a `.gitea/workflows/test.yml`. Postgres comes from a service container.
- **Stay click-deploy.** No CI. Workers run tests locally; Dokploy auto-deploys on green-main convention.
Recommendation: **Gitea Actions for the gate tier only** (L0 + L1 + L2), driven by a single short workflow. The L3-L6 expansion can be a follow-up once the gate tier proves stable. Deferred to Q2 for m's call.
### 3.3 Test DB — live YouPC vs ephemeral
The `paliad` schema lives on the shared YouPC Postgres (port 11833). Three options:
| Option | Pros | Cons |
|---|---|---|
| **Per-developer separate DB on YouPC** (`TEST_DATABASE_URL` per laptop) | Closest to prod; existing pattern. | Cleanup discipline matters; cross-developer contention possible. |
| **Ephemeral docker postgres per CI run** | Full isolation; parallel-safe; reset for free. | New infra; ~5 s container startup per CI invocation. |
| **Dedicated test DB on a paliad-only Postgres** | Isolated; cheap. | New infra to maintain. |
Recommendation: **option 1 for developers (no-op change), option 2 for CI** (Gitea Actions postgres service container). Deferred to Q3 for m's call.
### 3.4 Coverage targets
Don't gate on percentage. Gate on critical-path coverage (§4). Add `go test -coverprofile=` output to CI for visibility, not as a merge gate. Coverage % gating produces tests-for-tests'-sake; we want the tests that catch the bugs we've shipped.
---
## 4. Critical journeys — what MUST be covered
These are the golden-path flows. Anything not on this list is L1-L5 territory, not L6. The list is intentionally short; if it grows beyond 10, we are doing E2E wrong.
| # | Flow | Why it's critical | Layer mix |
|---|---|---|---|
| 1 | **Login → dashboard renders → traffic-light counts match** | Every user does this every day; broken auth = paliad is offline. | L6 (Playwright) + L5 handler (auth.go) |
| 2 | **Create project (Client → Litigation → Patent → Case)** | Hierarchy with team inheritance — the data model's spine. | L6 + L5 + L4 (project_service) |
| 3 | **Submit deadline → routes to /inbox → approver approves → state flips** | The 4-eye flow (t-paliad-138). Most-mutated paliad surface. | L6 + L5 (deadlines, approvals) + L4 (approval_service) |
| 4 | **Fristenrechner: pick proceeding → cascade fires → result shows** | The platform's flagship interactive tool. JS cascade. | L6 + L3 (fristenrechner cascade) + L4 (fristenrechner) |
| 5 | **SmartTimeline: anchor a projected row → predecessor-missing-error handled** | Recent Slice-2 work (t-paliad-173 / #31). High-touch surface. | L6 + L3 (shape-timeline) + L4 (projection_service) |
| 6 | **CalDAV sync: PUT a Termin → external client sees it, edits there → pull reconciles** | Owned-event semantics + foreign-UID skip rule from Phase F. Untested today. | L4 (caldav_service push/pull) — gated on Q3 (live YouPC vs ephemeral) |
| 7 | **Paliadin chat: anon visit hits 404; m's session opens widget; turn renders** | Owner-gated `/paliadin` is the only m-only surface. Quiet failures here are silent. | L6 (smoke) + L5 (paliadin_suggest) + L4 (paliadin / aichat_paliadin) |
| 8 | **/admin/rules: filter → edit one rule → lifecycle transition → audit log row** | Rules drive the cascade; bad edits break every user's fristenrechner. | L6 + L5 (admin_rules) + L4 (rule_editor_service) |
| 9 | **Onboarding: new user with allowed email → onboarding form → first project membership** | The new-user funnel; gateOnboarded middleware traps. | L6 + L5 (onboarding, invite) |
| 10 | **Migration boot smoke: spin paliad against an empty DB → server binds 8080** | Catches every mig-N crash-loop. | L0 (migration dry-run) + L4 boot-smoke variant |
Picks 1, 3, 4 and 10 are the highest-value-per-cost — they cover the routes most regressions land on (auth, mutation, cascade, boot).
---
## 5. Slice plan — tracer-bullet roll-out
Each slice is a shippable PR with a concrete deliverable, in order of expected outage-prevention payoff. Sized for a single coder shift unless flagged. No slice depends on a later one being merged. Hour estimates intentionally omitted (per global CLAUDE.md).
### Slice 1 — Migration dry-run harness + boot smoke (highest leverage)
**Branch:** `mai/<coder>/test-strategy-slice-1-migrations`
**Deliverable:**
- `internal/db/migrate_test.go``TestMigrations_DryRun` (per-mig BEGIN/ROLLBACK), `TestMigrations_EndToEnd` (full apply, then re-apply latest to assert idempotency), `TestMigrations_Down` (apply N→0).
- `Makefile` with `make verify-migrations` (the gate target), `make test` (run everything), `make test-go`, `make test-frontend`.
- `cmd/server/main_paliadin_backend_test.go` already exists; extend with a `TestMain_BindsHTTPAfterMigrate` that boots the full server against `TEST_DATABASE_URL`, asserts `:8080` is listening, then shuts down. Catches the mig-098-class crash-loop in a single test.
- README section: how to set `TEST_DATABASE_URL` locally.
**Catches:** Every mig-98-class crash-loop; every drop-cascade-with-stale-policy-name regression (t-paliad-036).
### Slice 2 — Service-layer infill: critical mutators
**Branch:** `mai/<coder>/test-strategy-slice-2-services`
**Deliverable:**
- Test files for the three highest-impact untested services:
- `internal/services/agenda_service_test.go` (live-DB, dashboard agenda query)
- `internal/services/dashboard_service_test.go` (traffic-light counts)
- `internal/services/team_service_test.go` (membership + inheritance — RLS-load-bearing)
- Tighten existing `approval_service_test.go` + `deadline_service_test.go` coverage of the create/update/complete/delete × pending-request matrix where there are demonstrable gaps.
- Add `internal/services/internal/testdb/withtx.go` — the per-test tx harness (optional adoption; existing tests stay).
**Catches:** RLS regressions, approval interplay regressions, dashboard count drift after schema renames.
### Slice 3 — Frontend bun:test setup + L2 infill
**Branch:** `mai/<coder>/test-strategy-slice-3-frontend-unit`
**Deliverable:**
- `frontend/package.json` `scripts.test = "bun test"`.
- New tests under `frontend/src/client/`:
- `paliadin-context.test.ts` (route table, entity extraction, selection truncation).
- `paliadin-starters.test.ts` (every route ≥1 starter, every starter bilingual).
- `filter-bar/index.test.ts` (chip render + active state — pure DOM-less helpers).
- i18n key audit: `frontend/scripts/i18n-audit.test.ts` parses every `data-i18n="…"` from `dist/` HTML and every `t("…")` call from `src/`, asserts both `de` and `en` resolve. Runs as part of `bun test`.
- `make test-frontend` wires `cd frontend && bun test`.
**Catches:** i18n drift (untranslated key shipped to user), context-envelope contract drift (paliadin SKILL.md depends on it), starter-registry regressions.
### Slice 4 — Playwright golden-path smoke
**Branch:** `mai/<coder>/test-strategy-slice-4-e2e`
**Deliverable:**
- `e2e/` directory at repo root.
- `playwright.config.ts` pointing at `http://localhost:8080` (paliad started by the test, not assumed).
- Five Playwright `*.spec.ts` files covering critical journeys 1, 3, 4, 7, 9 from §4.
- `make e2e` target that:
1. starts paliad against `TEST_DATABASE_URL`,
2. waits for `:8080` to be live,
3. runs `npx playwright test`,
4. tears the server down.
- `bun add -d @playwright/test` + `npx playwright install chromium`.
**Catches:** Auth regressions, deadline-mutation regressions, fristenrechner cascade regressions, owner-gated /paliadin leaks, onboarding-gate misbehaviour.
### Slice 5 — Handler integration tests for the 5 most-touched routes
**Branch:** `mai/<coder>/test-strategy-slice-5-handlers`
**Deliverable:**
- `internal/handlers/auth_test.go` extended with `TestLogin_HappyPath` + `TestLogout_ClearsCookie` (real DB).
- `internal/handlers/projects_test.go``TestProjectsCreate` (POST 200, row inserted, audit emitted), `TestProjectsGetByID_RespectsVisibility` (404 for non-member).
- `internal/handlers/deadlines_test.go``TestDeadlinesCreate_TriggersApproval` (verifies pending pill).
- `internal/handlers/appointments_test.go` — same shape.
- `internal/handlers/paliadin_test.go``TestPaliadinPage_404ForNonOwner`, `TestPaliadinPage_200ForOwner`.
- Shared `internal/handlers/testauth/testauth.go` — mints a session cookie for `userID` so handler tests don't reinvent auth seeding.
**Catches:** Handler ↔ service wiring drift, visibility-predicate handler-side bugs (t-paliad-036 bug 2 was exactly this), owner-gate bypass.
### Slice 6 — Frontend L3 (DOM) cascade tests
**Branch:** `mai/<coder>/test-strategy-slice-6-frontend-dom`
**Deliverable:**
- `bun add -d happy-dom`.
- DOM-driven tests for the three most-touched cascades:
- `client/fristenrechner.test.ts` (cascade activate → row appears → date-set fires fetch).
- `client/shape-timeline.test.ts` (lane render, track render, projected-row click).
- `client/filter-bar/index.test.ts` (chip click toggles state, URL params update).
**Catches:** The whole class of "the function exists and is unit-tested but the cascade in the browser doesn't fire it" bugs. This is the layer that catches t-paliad-098 / 099 / 102 / 103.
### Slice 7 — CI wiring (deferred — Q2 dependent)
**Branch:** `mai/<coder>/test-strategy-slice-7-ci` (gated on m's Q2 pick)
**Deliverable:**
- `.gitea/workflows/test.yml` (or stay click-deploy if m picks that).
- Gate tier runs on every PR; full suite runs on merge to main.
- Postgres service container provides `TEST_DATABASE_URL`.
- Slack/Gotify ping on red main.
**Catches:** Drift between "tests pass on my laptop" and prod reality.
### Slice 8 — Coverage reporting + dashboard (lowest priority)
**Branch:** `mai/<coder>/test-strategy-slice-8-coverage`
**Deliverable:**
- `go test -coverprofile=` aggregated into a single `coverage.html`.
- Bun's coverage output similarly.
- A `docs/coverage.md` index updated by CI.
- **Not a merge gate.** Visibility only.
**Catches:** Slow drift; nice-to-have once the floor is in.
### Slice order rationale
1, 4, 5 are the highest outage-prevention per LoC: migration dry-run kills crash-loops, E2E kills regressions, handler tests kill wiring drift. 2, 3, 6 widen the floor; 7-8 are infrastructure.
---
## 6. Open questions for m
These need m's call before any coder shift starts (or before specific slices start, where noted).
### Q1 — Per-PR test-run budget
How long is acceptable to wait on the gate tier before merge?
- 30 s — only L0 + L1 (no L2+ on the gate).
- **6090 s (recommended)** — L0 + L1 + L2 + L5 happy-path + L4 critical.
- 2 min — add L3 + L4 full.
- 4+ min — add L6 (E2E on gate).
The pick determines whether E2E gates merge or only deploy.
### Q2 — CI infrastructure
- **Gitea Actions** (self-hosted, gate tier only, recommended) — minimal new infra; aligns with m's existing stack.
- **Stay click-deploy** — workers run tests locally; merge discipline enforced by convention. Today's reality; we keep it.
- **Both:** start with click-deploy, add Gitea Actions in Slice 7 once gate tier proves stable.
### Q3 — Live-DB vs ephemeral docker Postgres for tests
- **Per-developer YouPC DB (current pattern)** — closest to prod; existing tests work unchanged.
- **Ephemeral docker postgres in CI, YouPC for devs (recommended hybrid)** — keeps local-dev simple, gives CI deterministic isolation.
- **YouPC everywhere** — simplest, but parallel CI runs would contend.
### Q4 — Coverage targets — % or critical-path?
- **Critical-path only (recommended)** — §4's 10 flows + every state-mutating service method has a test. No % gate.
- **% gate** — set a floor (e.g. 60 % lines, 50 % branches) and refuse merges below it.
- **Both** — critical-path is mandatory, % is informational.
m's prior preference (memory pattern: "tests that catch real bugs > coverage theatre") points at critical-path-only. Confirming.
### Q5 — Which slices land before paliad is "production-grade"?
paliad is already live at `paliad.de` and being used by HLC colleagues. "Production-grade" here means "next time someone ships, we don't go down."
Picks:
- **Slices 1 + 4 + 5 are the production-grade floor (recommended).** Migration dry-run + golden-path E2E + handler integration tests cover the failure modes that hit prod since the rebrand.
- Add Slice 2 + 3 + 6 as widening passes, on their own cadence.
- Slice 7-8 are nice-to-haves.
Confirming the floor pick — and whether m wants all three to land before any new feature work, or whether they roll out alongside.
### Q6 — Who owns each slice?
Recommendation: rotate coder slots so the same person isn't on every slice. Suggested assignment (head can override):
| Slice | Profile fit |
|---|---|
| 1 — migrations | Backend-heavy coder (knuth, gauss, cronus). |
| 2 — service infill | Backend-heavy coder; whoever owns approval/projection. |
| 3 — frontend unit | Frontend-heavy coder. |
| 4 — Playwright E2E | Cross-stack coder; ideally one familiar with `/mai-tester`. |
| 5 — handler integration | Backend coder. |
| 6 — frontend DOM | Frontend coder (same person as 3 makes sense). |
Inventor does **not** decide assignments; head + m do.
---
## 7. Out of scope (explicit)
- **No rewrite of any existing test.** The 323 existing test functions stay. New tests use the new patterns; old tests are migrated only when their files are touched for unrelated reasons.
- **No third-party framework where stdlib + bun:test suffice** (testify, vitest, etc. — see §3).
- **No mocks at the service↔DB boundary.** This is the lock-in. Mocks lie; the live-DB tests we already have are paliad's most useful safety net.
- **No new feature work in this strategy.** The doc proposes infra; feature scope is unchanged.
- **No retirement of the `tests/smoke-*.md` human-written reports.** Those are great for one-shot regression hunts; they coexist with the automated suite.
---
## 8. Implementation notes for the eventual coder
(For whichever coder picks up a slice. Not exhaustive.)
- **Test-name collisions in Go's flat package namespace bite when a service grows N implementations.** Memory note from `t-paliad-194` already records this. Prefix tests with the service name (e.g. `TestAichatPaliadin_RunTurn_…` not `TestRunTurn_…`).
- **`httptest.NewRequest` does not URL-encode** — use `url.QueryEscape` for any `?q=…` argument. Memory note from `t-paliad-026`.
- **sqlx v1.4.0 `Named` parser strips one colon from `::uuid[]`** — known pitfall, repro lives at `internal/services/project_service.go`. Use `CAST(... AS uuid[])` in new query strings.
- **Live-DB cleanup must DELETE FKs first.** Order matters (auth.users last). Look at `audit_service_test.go` for the chain pattern.
- **`paliad.paliad_schema_migrations` tracker collision** is documented but unresolved. Slice 1 should add a `make reset-test-db` target that drops both `public.paliad_schema_migrations` *and* `paliad.paliad_schema_migrations` to keep developers unblocked.
- **`bun:test` matchers are Jest-compatible** — `expect().toEqual()`, `expect().toHaveBeenCalled()`, etc. No deps needed.
- **happy-dom does not implement** every DOM method (notably some `<dialog>` semantics). If a cascade test fails on something missing, jsdom is the escape hatch.
---
## 9. Decision summary — pick list for m
| # | Question | Inventor recommends |
|---|---|---|
| Q1 | Per-PR budget | 6090 s gate, 34 min full |
| Q2 | CI infra | Gitea Actions, gate tier only |
| Q3 | Test DB | YouPC for devs, ephemeral docker for CI |
| Q4 | Coverage target | Critical-path only, no % gate |
| Q5 | Production-grade floor | Slices 1 + 4 + 5 before new feature work |
| Q6 | Slice ownership | Rotate per profile; head decides |
If m's calls match inventor's, the implementer's brief writes itself: Slice 1 first, then 4 + 5 in parallel, then 2/3/6 as widening passes.
---
**Status:** DESIGN READY FOR REVIEW. Awaiting m go/no-go on §5 slice plan + §6 open questions before any coder shift starts.
---
## 10. m's decisions (2026-05-19, locked)
Walked through §6 with m via the AskUserQuestion interview (per head's 2026-05-19 workflow rule: inventor questions are resolved before parking, not after). Six picks locked, all matching inventor's recommendation.
| # | Question | m's answer | Effect on plan |
|---|---|---|---|
| Q1 | Per-PR test-run budget | **Inventor's call** (m deferred). Pick: **6090 s gate, 34 min full.** | Gate tier = L0 + L1 + L2 + L5 happy-path + L4 critical. L6 E2E gates deploy, not merge. |
| Q2 | CI infrastructure | **Gitea Actions, gate tier only.** | Slice 7 adds `.gitea/workflows/test.yml` running the gate tier; full suite stays on merge-to-main. |
| Q3 | Test DB topology | **YouPC for devs + ephemeral docker for CI.** | Local dev unchanged. Slice 7 wires Postgres service container in Gitea Actions. |
| Q4 | Coverage target | **Critical-path only, no % gate.** | §4's 10 flows + every state-mutating service method gets a test. Coverage % output is informational in Slice 8, never a merge gate. |
| Q5 | Production-grade floor | **Slices 1 + 4 + 5 before new feature work.** | These three land before any new paliad feature gets a coder shift. Slices 2, 3, 6 widen the floor on their own cadence. Slices 7-8 are nice-to-haves. |
| Q6 | Slice ownership | **Head decides + rotate per profile.** | Backend slices (1, 2, 5) → backend-heavy coder. Frontend slices (3, 6) → frontend-heavy coder. E2E (4) → cross-stack. Head picks at dispatch time. |
**Implementer brief (post-m-decisions):**
1. **Slice 1 starts first** — migration dry-run harness + `make verify-migrations` + boot-smoke variant of `cmd/server/main_paliadin_backend_test.go`. Backend-heavy coder.
2. **Slice 4 + Slice 5 in parallel** once Slice 1 is merged — Playwright golden-path (cross-stack coder, 5 specs) and handler integration (backend coder, auth/projects/deadlines/appointments/paliadin).
3. Slice 7 (Gitea Actions wiring) follows once Slice 1 gate tier is proven locally.
4. Slices 2, 3, 6 enter rotation alongside feature work — not blocking.
5. Slice 8 (coverage reporting) lowest priority.
**Status:** DESIGN APPROVED — awaiting head's dispatch of Slice 1 coder shift.

View File

@@ -0,0 +1,784 @@
# Design — Submission generator (t-paliad-215)
**Author:** copernicus (inventor)
**Date:** 2026-05-19
**Issue:** m/paliad (task t-paliad-215, no Gitea issue filed yet)
**Branch:** `mai/copernicus/inventor-submission`
**Status:** DESIGN READY FOR REVIEW
---
## §0 TL;DR
Each row in `paliad.deadline_rules` represents a SUBMISSION — a filing,
hearing, or decision inside a proceeding (`submission_code` shape
`de.inf.lg.erwidg`, `upc.inf.cfi.soc`, …). The submission generator
takes a project + a submission_code, pulls a `.docx` template from
Gitea, merges in project variables (party names, court, case number,
patent number, our_side, deadline date, legal_source citation, firm
header), and streams the result to the browser as a download.
- **Scope (locked by m):** template-render to `.docx`. No LLM in v1.
- **Template registry (locked):** Gitea — same proxy pattern as the
existing HL Patents Style `.dotm` in `internal/handlers/files.go`.
- **Output (locked):** direct download, NO server-side binary
persistence. One audit row per generation; the bytes themselves are
regenerable from inputs on demand.
- **Lookup (locked):** fallback chain — firm-specific override →
base for the exact `submission_code` → generic for the proceeding
family → ultra-generic skeleton.
- **Slice 1 (locked):** one template, end-to-end, on one project.
Pick `de.inf.lg.erwidg` (Klageerwiderung) as the proof template.
- **AI-drafted body:** explicitly OUT of scope for this task. Lives
in §11 as a follow-up sketch only.
This design is read-only. No code, no migrations, no schema
additions. Implementation gate is m's go/no-go on this doc.
---
## §1 Premises verified live (2026-05-19)
Anchored against the running paliad codebase + youpc Supabase, not
against CLAUDE.md or memory. Where a claim load-bears the design, it
was checked against the live system.
| Claim | Verification |
|---|---|
| Migration tracker at **102** (next is 103) | `ls internal/db/migrations/``102_system_audit_log` is the latest applied. |
| `paliad.documents` table exists, is empty, no code writes to it yet | `SELECT COUNT(*) FROM paliad.documents` → 0 rows. Columns: `id, title, doc_type, file_path NULLABLE, file_size, mime_type, ai_extracted jsonb, uploaded_by, created_at, updated_at, project_id NOT NULL`. `grep` shows only `export_service.go` (audit-export only) and a comment in `render_spec.go`. No `document_service.go`, no `/api/documents` handler. |
| `paliad.deadline_rules` carries the submission corpus | 254 total rows, 158 unique `submission_code`s, 214 `published`. Per-row fields used by the generator: `name`, `name_en`, `submission_code`, `primary_party` (claimant/defendant/court/both), `event_type` (filing/hearing/decision), `legal_source` (e.g. `DE.ZPO.276.1`, `UPC.RoP.23.1`), `is_bilateral`. |
| Slice 1 target row exists in published state | `SELECT … WHERE submission_code='de.inf.lg.erwidg'``{name:"Klageerwiderung", name_en:"Statement of Defence", primary_party:"defendant", legal_source:"DE.ZPO.276.1"}`. |
| Project rows carry all variables we need to merge | `paliad.projects` has `case_number, court, patent_number, filing_date, grant_date, our_side, instance_level, proceeding_type_id, title, reference, client_number, matter_number`. |
| Party rows carry party variables | `paliad.parties` has `name, role, representative, contact_info jsonb` and is project-scoped via `project_id`. |
| The HL Patents Style proxy pattern is reusable | `internal/handlers/files.go`: `fileRegistry` map → Gitea raw URL + SHA-based cache + 5-min refresh check + binary download response with `Content-Disposition`. Cache is in-process (`sync.Mutex` over a `map[string]*cacheEntry`). Single web replica today (`docker-compose.yml`), so in-process cache is fine. |
| Email templates already use `{{.VarName}}` placeholders + a "variable contract" sidebar pattern | `internal/services/email_template_variables.go``EmailTemplateVariable{Name, Type, Description, SampleDE, SampleEN}` rendered in `/admin/email-templates`. Submission generator can copy this contract pattern. |
| Audit infrastructure landed in mig 102 | `paliad.system_audit_log(id, event_type, actor_id, actor_email, scope, scope_root, metadata jsonb, created_at, updated_at)` — submission_generated events slot straight in. |
| Branding source is `internal/branding.Name` | Default `"HLC"`, overridable via `FIRM_NAME`. Inlined into client bundles by `frontend/build.ts`. Submission templates honour this via the `{{firm.name}}` placeholder. |
| `paliad.can_see_project(project_id)` is the canonical visibility predicate | mig 055; `internal/services/visibility.go` mirrors it. Generator gates on this; no new auth surface. |
| Paliadin runs on the aichat backend (mRiver) with persona system | `internal/services/aichat_paliadin.go` + `personas.yaml` in `m/mAi/internal/aichat/persona/`. Owner-gated to `PaliadinOwnerEmail = matthias.siebels@hoganlovells.com`. A future AI-drafted body would be a new persona, not a new Go service. |
**Doc-vs-live conflicts found:** none material for this design.
`docs/project-status.md` still lists "Phase H AI Frist-Extraktion
deferred" — this design does NOT revive Phase H (different surface;
this is template merge, not document understanding).
---
## §2 m's decisions (2026-05-19)
Locked via AskUserQuestion before drafting the rest of the design.
| # | Question | m's pick | Inventor recommended? |
|---|---|---|---|
| Q1 | Generator scope (template / AI-draft / brief / other) | **Template-render to `.docx`** | ✅ yes |
| Q2 | Template registry (Gitea / paliad DB / hybrid) | **Gitea** | ✅ yes |
| Q3 | Output flow (download-only / persist binary / attach to Frist) | **Direct download, no server-side binary** | ✅ yes |
| Q4 | Mapping (fallback chain / 1:1 / 1:N user picks) | **Fallback chain** | ✅ yes |
| Q5 | Slice 1 scope (1 template / 35 templates / full corpus / skeleton-only) | **One template, end-to-end on one project** (`de.inf.lg.erwidg` Klageerwiderung) | ✅ yes |
Inventor-defaulted (not asked because there's a clear right answer or
because the question is implementation-level, not architecture-level):
| # | Topic | Default | Reasoning |
|---|---|---|---|
| D1 | Variable engine | `{{path.dot.notation}}` placeholders in the .docx body, replaced via a Go library that handles run-fragmentation | Matches the existing email-template `{{.Var}}` shape lawyers already see in `/admin/email-templates`. See §6. |
| D2 | Authorization | Project-team visibility only (`paliad.can_see_project`) + audit row | Matches every other write surface in paliad. No profession floor (generation is read-only on source data and produces a draft, not a binding action). |
| D3 | Naming convention | `{rule.name}-{project.case_number}-{YYYY-MM-DD}.docx`, slashes → underscores, FIRM_NAME-aware | Mirrors how lawyers name files manually. See §7. |
| D4 | Missing-variable behaviour | Render `[KEIN WERT: {field}]` / `[NO VALUE: {field}]` marker inline | Lets the lawyer see the gap in Word, fix in paliad, regenerate. Better than 400ing. |
| D5 | Editor surface | Gitea-only for v1 (admin edits .docx in Word, commits to mWorkRepo) | A paliad uploader UI is Phase 2 affordance if Gitea round-trip is painful. |
| D6 | AI-drafted body | OUT of scope for this task | §11 sketches the natural follow-up shape (new aichat persona) but does not commit to it. |
---
## §3 Architecture overview
```
┌────────────────────────────────────────────────────────────────────────┐
│ Project detail page │
│ ├─ "Submissions" panel (or button row) │
│ │ [Generate Klageerwiderung] [Generate Klageerhebung] [...] │
│ │ Each button enabled iff a template exists for that │
│ │ submission_code AND user passes paliad.can_see_project. │
│ └─ Click → POST /api/projects/{id}/submissions/{code}/generate │
└──────────────────────────────────┬─────────────────────────────────────┘
┌────────────────────────────────────────────────────────────────────────┐
│ handlers/submissions.go (NEW) │
│ 1. Auth: UserIDFromContext + can_see_project gate │
│ 2. Load deadline_rule by submission_code │
│ 3. Resolve template via fallback chain (TemplateRegistry) │
│ 4. Build variable bag (services/submission_vars.go) │
│ 5. Render via SubmissionRenderer (services/submission_render.go) │
│ 6. Write paliad.documents audit row (NO file_path) │
│ 7. Write paliad.system_audit_log entry (event_type= │
│ 'submission.generated') │
│ 8. Stream .docx bytes with Content-Disposition: attachment │
└──────────────────────────────────┬─────────────────────────────────────┘
│ (template fetch)
┌────────────────────────────────────────────────────────────────────────┐
│ TemplateRegistry (services/submission_templates.go) — NEW │
│ • In-process cache (same shape as handlers/files.go cacheEntry) │
│ • Lookup path: │
│ (1) templates/{FIRM_NAME}/{submission_code}.docx │
│ (2) templates/_base/{submission_code}.docx │
│ (3) templates/_base/{proceeding_family}.docx (e.g. upc.inf.cfi) │
│ (4) templates/_base/_skeleton.docx │
│ • Fetched from m/mWorkRepo via Gitea raw URL │
│ • 5-min SHA refresh check (identical pattern to files.go) │
└──────────────────────────────────┬─────────────────────────────────────┘
Gitea: m/mWorkRepo
templates/HLC/de.inf.lg.erwidg.docx
templates/_base/de.inf.lg.erwidg.docx
templates/_base/de.inf.lg.docx
templates/_base/_skeleton.docx
```
**No new tables.** `paliad.documents` already exists; we write audit
rows there but leave `file_path` NULL. The fallback chain uses
filesystem-style paths inside the existing Gitea repo; no
`submission_templates` table needed for Slice 1.
---
## §4 Slice 1 — what ships first
Locked by Q5: **one template, end-to-end, on one project.**
### 4.1 Target submission
**`de.inf.lg.erwidg`** — Klageerwiderung (DE Verletzungs-LG).
Reasoning:
- High-frequency submission in patent practice; lawyers draft these
often enough that the tool earns its keep on day 1.
- `primary_party='defendant'` — exercises the our_side variable.
- `legal_source='DE.ZPO.276.1'` — exercises citation injection.
- Pure-DE (no UPC complexity); easier first template for HLC's
Munich/Düsseldorf practice to author and review.
- Klageerhebung (`de.inf.lg.klage`) is an obvious alternative; either
works. m can flip the target in his decision review if Klageerhebung
is the better proof case.
### 4.2 Surfaces in Slice 1
- **Project detail page** — new "Submissions" panel listing every
submission_code from the project's `proceeding_type` (via existing
`DeadlineRuleService`) with a `[Generieren]` button per row. Button
is enabled iff a template resolves AND `event_type='filing'` (no
`[Generieren]` on hearings/decisions — those don't have submissions).
- **Project detail API** — `GET /api/projects/{id}/submissions` returns
the list of (submission_code, name, has_template) so the frontend
can render enabled/disabled state.
- **Generate endpoint** — `POST /api/projects/{id}/submissions/{code}/generate`
returns `application/vnd.openxmlformats-officedocument.wordprocessingml.document`
with `Content-Disposition: attachment; filename="..."`.
Slice 1 does NOT add:
- A `/admin/submission-templates` editor (Gitea is the editor).
- A Frist-detail "Generate" button (project-detail only in Slice 1;
Frist-level surface is a Slice 2 affordance).
- A "Submissions" tab as a dedicated page (project-detail panel only).
- Per-firm overrides beyond `templates/HLC/...` (the fallback chain is
WIRED but the only override directory exercised in Slice 1 is HLC).
- The variable-contract sidebar UI (mirrors email-template editor) —
the contract is documented in §6 as code constants, surfaced as a
Slice 2 admin affordance.
### 4.3 Slice 1 LoC estimate (informational, no time estimate)
| File | Approx |
|---|---|
| `internal/handlers/submissions.go` (NEW) | 180 |
| `internal/services/submission_templates.go` (NEW — registry + Gitea proxy, reuses files.go cache idea) | 200 |
| `internal/services/submission_vars.go` (NEW — variable bag builder) | 220 |
| `internal/services/submission_render.go` (NEW — docx merge engine wrapper) | 120 |
| `internal/services/submission_render_test.go` (placeholder coverage + missing-var marker) | 180 |
| `frontend/src/components/SubmissionsPanel.tsx` (NEW) | 80 |
| `frontend/src/client/submissions.ts` (NEW — fetch + download) | 60 |
| Wiring in `cmd/server/main.go` + `internal/handlers/handlers.go` | 30 |
| i18n keys (`submissions.*`) DE+EN | 20 |
| **Total** | **~1090 LoC** |
Plus: ONE `.docx` template authored by HLC at
`m/mWorkRepo/templates/HLC/de.inf.lg.erwidg.docx`, lawyer-reviewed
before Slice 1 closes.
---
## §5 Template registry (Gitea-backed)
### 5.1 Gitea layout
```
m/mWorkRepo (existing repo)
└── templates/
├── HLC/ # FIRM_NAME-keyed override dir
│ └── de.inf.lg.erwidg.docx # Slice 1 ships THIS file
├── _base/ # Cross-firm baseline
│ ├── de.inf.lg.erwidg.docx # (Phase 2+)
│ ├── de.inf.lg.docx # proceeding-family fallback
│ ├── upc.inf.cfi.docx # (Phase 2+)
│ └── _skeleton.docx # ultra-generic fallback
└── README.md # placeholder reference for authors
```
Naming convention is the submission_code with a `.docx` suffix.
Proceeding-family fallback is the submission_code's first two
dot-segments (`de.inf.lg` from `de.inf.lg.erwidg`).
### 5.2 Lookup algorithm
```go
// services/submission_templates.go
func (r *TemplateRegistry) Resolve(ctx context.Context, code string) (Template, error) {
firm := branding.Name // "HLC", or whatever FIRM_NAME is
family := familyOf(code) // "de.inf.lg" from "de.inf.lg.erwidg"
candidates := []string{
fmt.Sprintf("templates/%s/%s.docx", firm, code),
fmt.Sprintf("templates/_base/%s.docx", code),
fmt.Sprintf("templates/_base/%s.docx", family),
"templates/_base/_skeleton.docx",
}
for _, path := range candidates {
if tmpl, ok := r.fetch(ctx, path); ok {
return tmpl, nil
}
}
return Template{}, ErrNoTemplate
}
```
`fetch` does the same SHA-cache dance `handlers/files.go` already
does, scoped to the templates subtree.
### 5.3 Gitea auth
Reuses `GITEA_TOKEN` env var that already exists for the HL Patents
Style proxy. `m/mWorkRepo` is the same repo, same access token. No
new secret to configure.
### 5.4 What happens when no template resolves
The fallback chain ends at `_skeleton.docx`. The skeleton is an
intentionally bare-bones .docx (firm letterhead + party block + court
address + case number + signature stub) that ships as part of the
initial template set. In practice every Generate request resolves to
something — but if even the skeleton 404s (misconfigured repo), the
generator returns `503` with a clear error, the SubmissionsPanel
button surfaces "Vorlagen-Repository nicht erreichbar".
---
## §6 Variable interpolation
### 6.1 Engine
Plain text replacement of `{{path.dot.notation}}` placeholders in the
.docx body. Whitespace inside braces is trimmed
(`{{ project.case_number }}``{{project.case_number}}`).
Implementation: a Go library that handles Word's run-fragmentation
correctly (Word may split `{{project.case_number}}` across multiple
`<w:r>` runs during editing; naive find/replace breaks). Candidates:
- **`github.com/lukasjarosch/go-docx`** (~2k stars, MIT, pure Go,
maintained). Handles run-merging before replacement. **Inventor
recommendation.**
- `github.com/nguyenthenguyen/docx` — older, less active.
- Custom in-house implementation — ~200 LoC for a minimal robust
replacer that walks the document XML and merges runs that fall
inside a `{{…}}` span. Fallback if the library doesn't pan out.
Slice 1: try `lukasjarosch/go-docx` first; if it has dealbreaker bugs
(e.g. blows up on Word's autocorrect runs), fall back to the in-house
~200 LoC walker. The library choice is an implementation detail; the
placeholder syntax stays the same either way.
### 6.2 Variable contract (v1 placeholder set)
```
{{firm.name}} — HLC (or whatever FIRM_NAME is)
{{firm.signature_block}} — Phase 2; v1 renders empty string
{{today}} — 2026-05-19 (ISO)
{{today.long_de}} — "19. Mai 2026"
{{today.long_en}} — "19 May 2026"
{{user.display_name}} — "Maria Schmidt"
{{user.email}} — "maria.schmidt@hlc.com"
{{user.office}} — "Munich"
{{project.title}} — paliad.projects.title
{{project.reference}} — paliad.projects.reference
{{project.case_number}} — paliad.projects.case_number
{{project.court}} — paliad.projects.court
{{project.patent_number}} — paliad.projects.patent_number
{{project.filing_date}} — ISO date
{{project.grant_date}} — ISO date
{{project.our_side}} — "claimant" | "defendant"
{{project.our_side_de}} — "Klägerin" | "Beklagte"
{{project.instance_level}} — "lg" | "olg" | "bgh" | ...
{{project.proceeding.code}} — e.g. "de.inf.lg"
{{project.proceeding.name}} — Verletzungsklage am Landgericht
{{project.client_number}} — paliad.projects.client_number
{{project.matter_number}} — paliad.projects.matter_number
{{parties.claimant.name}} — first paliad.parties row with role='claimant'
{{parties.claimant.representative}} — paliad.parties.representative
{{parties.defendant.name}} — first row with role='defendant'
{{parties.defendant.representative}} — paliad.parties.representative
{{parties.other.name}} — first row with role NOT IN ('claimant','defendant') — court, intervener, etc.
{{rule.submission_code}} — "de.inf.lg.erwidg"
{{rule.name}} — "Klageerwiderung"
{{rule.name_en}} — "Statement of Defence"
{{rule.legal_source}} — "DE.ZPO.276.1"
{{rule.legal_source_pretty}} — "§ 276 Abs. 1 ZPO"
{{rule.primary_party}} — "defendant"
{{rule.event_type}} — "filing"
{{deadline.due_date}} — date of the next pending deadline for this rule on this project
{{deadline.due_date_long_de}} — "26. Juni 2026"
{{deadline.computed_from}} — anchor description (e.g. "Klageerhebung am 12.05.2026 +6 Wochen")
```
Per-firm extensions (e.g. `{{firm.signature_block}}` filled from a
table) are Phase 2.
### 6.3 Variable bag construction
`services/submission_vars.go` builds a flat `map[string]string`
keyed by the dotted-path placeholders above. One pass over:
1. `branding.Name` for `{{firm.*}}`
2. `time.Now()` (with `Europe/Berlin` locale for the long forms) for
`{{today.*}}`
3. `userService.GetByID()` for `{{user.*}}`
4. `projectService.GetByID()` for `{{project.*}}`
5. `partyService.ListByProject()` for `{{parties.*}}`
6. `deadlineRuleService.GetByCode()` for `{{rule.*}}`
7. `deadlineService.NextByRuleOnProject()` for `{{deadline.*}}`
Missing values render as `[KEIN WERT: {dotted.path}]` (DE) or
`[NO VALUE: {dotted.path}]` (EN) based on user locale. This is by
design — the lawyer sees the gap in Word, fixes it (either in Word
or in paliad and regenerates), rather than getting a 400 with a list
of missing fields they then have to chase.
### 6.4 Pretty-printing the legal_source
`legal_source` in the rule corpus is shorthand
(`DE.ZPO.276.1`, `UPC.RoP.23.1`). Lawyers don't want that in a brief;
they want `§ 276 Abs. 1 ZPO` or `Rule 23.1 RoP UPC`.
Slice 1 ships a small pretty-printer (`legalSourcePretty`) that knows
the families we currently use:
| Prefix | Pretty form (DE) | Pretty form (EN) |
|---|---|---|
| `DE.ZPO.<§>.<Abs>` | `§ <§> Abs. <Abs> ZPO` | `Section <§>(<Abs>) ZPO` |
| `DE.ZPO.<§>` | `§ <§> ZPO` | `Section <§> ZPO` |
| `UPC.RoP.<Rule>.<Sub>` | `Regel <Rule>.<Sub> VerfO UPC` | `Rule <Rule>.<Sub> RoP UPC` |
| `UPC.RoP.<Rule>` | `Regel <Rule> VerfO UPC` | `Rule <Rule> RoP UPC` |
| `DE.PatG.<§>` | `§ <§> PatG` | `Section <§> PatG` |
| `EPC.<Art>` | `Art. <Art> EPÜ` | `Art. <Art> EPC` |
| (unknown) | original string | original string |
Unrecognised prefixes pass through unchanged (better than an
incorrect prettification). The function is pure and unit-tested.
---
## §7 File naming
Generated file name:
```
{rule.name}-{project.case_number}-{YYYY-MM-DD}.docx
```
Concrete example for the Slice 1 happy path:
```
Klageerwiderung-2 O 123_25-2026-05-19.docx
```
Rules:
- `rule.name` honours user locale (`Klageerwiderung` for DE,
`Statement of Defence` for EN).
- `project.case_number` slash/backslash → underscore (Word file name
hygiene), other characters preserved.
- Date is ISO at server-local (`Europe/Berlin`) date.
- If `project.case_number` is empty → fall back to a short hash of
`project_id` (8 hex chars) so the file still has a stable identifier
the lawyer can rename without losing track.
---
## §8 Authorization
- **Visibility gate:** `paliad.can_see_project(project_id)` — anyone
who can see the project can generate. Matches every other write
surface on the project. The endpoint inlines the predicate;
unauthorised callers get 404 (not 403, to avoid project
enumeration).
- **No profession floor.** A paralegal can generate a draft of a
Klageerwiderung; the draft is a Word doc that needs the associate's
approval downstream (in Word, on the document itself). Adding an
approval gate on generation would slow the workflow without
preventing anything that paliad's existing approval system doesn't
already cover at the substantive-act layer.
- **Owner gate (Paliadin) does NOT apply.** This is the
submission-template engine, not Paliadin. All paliad users get the
feature once a template exists for the proceeding their project is
in.
---
## §9 Audit trail
Two records per generation:
### 9.1 `paliad.documents` row (audit-only, no binary)
```sql
INSERT INTO paliad.documents (id, title, doc_type, file_path, file_size,
mime_type, ai_extracted, uploaded_by,
project_id)
VALUES (gen_random_uuid(),
'{rule.name} (generiert {YYYY-MM-DD})',
'generated_submission', -- new doc_type value
NULL, -- no on-disk path
NULL, -- no file size (binary not persisted)
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
jsonb_build_object(
'submission_code', $1,
'template_path', $2, -- the gitea path we resolved
'template_sha', $3, -- pinned SHA from the cache fetch
'firm', $4),
$user_id,
$project_id);
```
- `doc_type='generated_submission'` is a new value; no CHECK constraint
on doc_type today so this is additive.
- `file_path NULL` is the marker that says "regenerate from inputs on
demand". The /api/projects/{id}/documents listing UI (Phase 2) will
surface a `[Erneut generieren]` action for these rows.
- `ai_extracted` jsonb is repurposed for generation provenance
(template SHA, firm at time of generation). Naming is unfortunate
but the column shape fits; renaming the column is out of scope for
this task.
### 9.2 `paliad.system_audit_log` row
```sql
INSERT INTO paliad.system_audit_log (event_type, actor_id, actor_email,
scope, scope_root, metadata)
VALUES ('submission.generated',
$user_id,
$user_email,
'project',
$project_id::text,
jsonb_build_object(
'submission_code', $1,
'template_path', $2,
'template_sha', $3,
'document_id', $document_id,
'firm', $4));
```
Mirrors the existing `system_audit_log` event_type convention
(`*.created`, `*.updated`, etc., from t-paliad-214).
### 9.3 Verlauf entry (project event)
`paliad.project_events` gets a row with `event_type='submission_generated'`
and `timeline_kind='custom_milestone'` so the generation surfaces in
SmartTimeline's audit-log toggle and on the project's Verlauf list.
This is the user-visible footprint; the `system_audit_log` entry is
the admin-visible audit footprint.
---
## §10 Frontend surface
### 10.1 Slice 1 — SubmissionsPanel on project detail
A new panel below the existing Verlauf / Deadlines panels on
`/projects/{id}`:
```
┌─────────────────────────────────────────────────────────────────┐
│ Schriftsätze │
├─────────────────────────────────────────────────────────────────┤
│ Klageerhebung [— Vorlage fehlt] │
│ Klageerwiderung [Generieren ↓] │
│ Replik [— Vorlage fehlt] │
│ Duplik [— Vorlage fehlt] │
└─────────────────────────────────────────────────────────────────┘
```
- Filter: only `event_type='filing'` rules from the project's
`proceeding_type` are listed. Hearings and decisions don't have
submissions.
- Per-row state: `has_template` returned by
`GET /api/projects/{id}/submissions`. Disabled buttons show the
"Vorlage fehlt" hint (German default, English in EN locale).
- Click `[Generieren ↓]` → POST → browser triggers download.
- `aria-busy="true"` on the panel while a generation is in flight
(cheap, but lawyers feel slow networks).
### 10.2 Out of scope for Slice 1
- A standalone `/submissions` index page.
- A Frist-detail "Generate" button.
- A picker for template variants (1:N) — locked to fallback chain
(Q4), which is 1:1 from the user's perspective.
- An "edit project, then regenerate" loop on the same UI.
---
## §11 AI-drafted body (deferred — sketch only)
NOT in scope for t-paliad-215. Documented here so the next inventor
picking up the "AI Klageerwiderung body" task has a clear starting
shape.
The natural fit: a new aichat persona (e.g. `paliadin-draft`) on
mRiver, parallel to the existing `paliadin` persona.
```
{{ai.draft_body}} # placeholder in the template
→ generator detects {{ai.*}} placeholders in the template
→ POSTs to aichat with persona=paliadin-draft + context:
- project state (variables already built)
- relevant project notes (paliad.notes)
- the deadline_rule corpus (rule + family)
- HL Patents Style guide chunks (RAG, eventually)
→ aichat returns Markdown body
→ generator injects into the .docx as one or more <w:p> paragraphs
(Word-friendly Markdown → docx mapping needed; substantive
formatting question for that follow-up)
```
Open shape questions for that follow-up (NOT for this design):
- One persona per submission type, or one persona that branches on
`submission_code` in its system prompt?
- Owner gate (m only) like current paliadin, or open to all
authenticated users?
- Approval gate before the AI body lands in the .docx?
- Cost accounting per generation?
- Where does the prose context come from (notes / uploaded patent
spec / prior pleadings)?
Re-uses, when that task fires:
- This task's template engine, variable contract, fallback chain,
audit trail — all unchanged.
- Just a new placeholder family (`{{ai.*}}`) + a new aichat persona +
a new admin gate.
---
## §12 Slice plan beyond Slice 1
| Slice | Scope |
|---|---|
| 1 | One template (`de.inf.lg.erwidg`), engine + fallback chain + audit + SubmissionsPanel on project detail. THIS DESIGN. |
| 2 | 35 more templates (Klageerhebung, SoC `upc.inf.cfi.soc`, SoD `upc.inf.cfi.sod`, Berufungsbegründung `de.inf.olg.begruendung`). Template authoring effort, no new architecture. |
| 3 | Variable-contract sidebar in a new `/admin/submission-templates` page (mirrors `/admin/email-templates` shape). Shows what placeholders exist, with samples. Does NOT add an uploader UI — Gitea remains the editor. |
| 4 | Per-firm override directory exercised (first non-HLC firm onboarded). |
| 5 | Frist-detail "Generate" button + paliad.documents.deadline_id FK (mig 103+) for per-Frist draft history. |
| 6 | (Separate task) AI-drafted body via Paliadin persona — see §11. |
| 7 | (Future) Paliad UI uploader as alternative to Gitea, if the round-trip is friction. |
Slices 25 are roadmap markers, not commitments — m decides cadence.
---
## §13 Trade-offs flagged
1. **No binary persistence is a deliberate retention choice.** If a
lawyer regenerates after the project state changes (party renamed,
case_number corrected), the "regenerated" doc differs from the
"original generated" doc. This is a feature, not a bug — the source
of truth is paliad's project state, and the .docx is a derivative.
But the lawyer needs to be aware: there is no "what did I generate
last Thursday" recovery without re-saving locally. The
`paliad.documents` audit row records WHAT was generated (template
SHA + project state hash, optionally), but not the bytes.
2. **Gitea round-trip for template edits is friction.** Template
authors edit `.docx` in Word, save, drag to Gitea web UI (or push
from a local clone). The 5-min SHA cache means edits surface
within 5 minutes (or instantly via `POST /api/files/refresh`
already wired for the HL Patents Style template). If lawyers
complain, Phase 7 adds an in-paliad uploader. Until then, Gitea is
the editor.
3. **Variable contract changes are coordinated edits.** Adding a new
`{{project.*}}` placeholder needs both a code change (var bag) AND
template edits (templates won't auto-discover new placeholders).
The variable-contract sidebar (Slice 3) is the mitigation —
template authors see what's available without reading the Go code.
4. **`lukasjarosch/go-docx` library risk.** ~2k stars, MIT, maintained
— but it's a third-party dep we haven't used before. Fallback is
the in-house ~200-LoC walker. The placeholder syntax doesn't change
either way; Slice 1 can swap engines without touching templates or
callers.
5. **`paliad.documents.ai_extracted` is repurposed for generation
provenance.** Slightly ugly naming because the column was added for
Phase H (AI Frist-Extraktion), which never shipped. Renaming the
column to something like `metadata` is out of scope for this task
but should be folded into the migration that lands when Phase 5
adds `deadline_id`.
6. **`paliad.parties.role='claimant'`** — multiple claimants on a
project (multi-party suit) → Slice 1 picks the first row. v1
shortcut. Templates needing multi-claimant blocks become Phase 2
work (with a `{{#each parties.claimants}}` shape on top of
`lukasjarosch/go-docx`'s loop support).
7. **No Word-side `MERGEFIELD` support.** Lawyers who insert Word
merge fields (via Insert → Quick Parts → Field) instead of typing
`{{…}}` will get untouched MERGEFIELD codes in the rendered output.
Decision: standardise on `{{…}}` syntax (cheap to type, visible
in the template, predictable). Document this in the `templates/
README.md`.
8. **No template versioning UI.** Gitea provides git history; that's
the canonical version trail. Bumping to "use template X as of
commit Y" for an old project is a manual git-checkout-and-pin
exercise. Phase 2+ if anyone asks; not today.
---
## §14 Open follow-ups (NOT blocking)
These items are NOT m-decisions; they're follow-ups for the coder
shift or future inventor passes:
- **Template authoring effort.** Slice 1 needs HLC to author/review
the actual Klageerwiderung template. That's a legal-review task that
can run in parallel with the engine code (template uploaded last
before the slice ships). Coordinate with m on who reviews.
- **English version of `legalSourcePretty`.** Pretty-printer table in
§6.4 needs an EN column for every prefix — populated from existing
glossary entries where possible.
- **i18n key sweep.** `submissions.*` namespace; ~20 keys for Slice 1
(panel title, button labels, "Vorlage fehlt" hints, error messages
for 503/404/422).
- **README for template authors.** A `templates/README.md` in
m/mWorkRepo listing the available placeholders + naming convention
+ a screenshot of a working template. Coder ships this alongside
Slice 1.
- **CLAUDE.md update.** Add a "Submission templates" section
documenting the Gitea proxy, placeholder syntax, and the
`submission.generated` audit event_type.
- **Cleanup task for `ai_extracted` naming.** Issue + Phase 5 mig.
---
## §15 What this design does NOT do
To set the scope boundary cleanly:
- ❌ Generate PDFs.
- ❌ Generate emails or any non-.docx format.
- ❌ Edit `.docx` files inside paliad (no in-browser Word editor).
- ❌ Upload .docx to NetDocuments or any external DMS.
- ❌ Translate templates DE↔EN automatically.
- ❌ Validate the generated draft against any legal rule.
- ❌ Sign, certify, or notarise the output.
- ❌ Send the draft to court / e-filing.
- ❌ AI-draft any prose. (See §11.)
- ❌ Provide a paliad-UI template editor. (Gitea is the editor.)
- ❌ Persist generated .docx bytes server-side. (Audit row only.)
- ❌ Add a new database table. (`paliad.documents` is enough for v1.)
- ❌ Require a database migration. (Slice 1 is migration-free.)
Each of these is a defensible future-scope item; none belong in
Slice 1.
---
## §16 Recommended implementer
Pattern-fluent Sonnet coder. The substrate is well-trodden:
- Gitea proxy + cache: `internal/handlers/files.go` is the template
to lift.
- Variable contract pattern: `internal/services/email_template_variables.go`
is the template to mirror (different surface, identical shape).
- Visibility gate: `internal/services/visibility.go` +
`paliad.can_see_project()` — standard everywhere.
- Audit insert: `paliad.system_audit_log` (mig 102) + `paliad.documents`
(existing table, first writer).
- Frontend SubmissionsPanel: stock TSX + client/.ts pattern, same shape
as the existing CardLayout / EventsList panels.
The only novel piece is the docx merge library integration — that's a
~200 LoC isolated module the coder can prototype on a sample .docx
before wiring into the project flow.
NOT cronus per project memory directive.
---
## §17 Acceptance criteria for Slice 1
The coder considers Slice 1 done when:
1. Pushing a `.docx` to `m/mWorkRepo/templates/HLC/de.inf.lg.erwidg.docx`
and visiting any project with `proceeding_type=de.inf.lg` surfaces
a `[Generieren]` Klageerwiderung button.
2. Clicking it downloads a `.docx` named per §7 with all §6.2
placeholders resolved (or `[KEIN WERT: …]` markers for genuinely
missing project fields).
3. Opening the downloaded .docx in Word renders cleanly (no run
fragmentation artefacts, no broken styles).
4. A row appears in `paliad.documents` with `doc_type='generated_submission'`,
`file_path=NULL`, and `ai_extracted` jsonb carrying the template
path + SHA.
5. A row appears in `paliad.system_audit_log` with `event_type='submission.generated'`.
6. A row appears in `paliad.project_events` with
`event_type='submission_generated'` and shows up in the project's
Verlauf / SmartTimeline.
7. Calling the endpoint without project visibility returns 404.
8. Calling the endpoint with no template anywhere in the fallback
chain returns 503 with a clear error.
9. Unit tests cover: placeholder rendering happy path, missing-var
marker, fallback chain (all 4 levels), file naming, slash
sanitization, legalSourcePretty for every prefix in §6.4.
10. `go build ./... && go vet ./... && go test ./... && bun run build`
all clean.
11. Manual test on the live database (test admin
`tester@hlc.de` per memory) against a project with a real
`de.inf.lg` proceeding succeeds end-to-end.
---
## §18 Approval gate
Per inventor SKILL.md and project CLAUDE.md: this design needs m's
go/no-go before any coder is hired. After m approves:
- The head decides whether to hire the same worker as `/mai-coder`
with this design as the brief, or a fresh coder.
- A coder shift takes this doc as the spec, ships Slice 1, opens a
PR (no self-merge — maria's gate).
- Phase 11 (AI-drafted body) is a SEPARATE task — not auto-spawned.
Inventor parks here.

View File

@@ -0,0 +1,52 @@
# t-paliad-207 follow-up scope — close-out assessment
**Author:** fermi (inventor)
**Date:** 2026-05-20
**Verdict:** **(A) DONE** — interactive session scope is shipped; remaining tail is filed-or-fileable as discrete issues, not a fresh fermi slice.
---
## 0. What shipped under t-paliad-207
Six substantive deliveries on `mai/fermi/interactive-session`, all merged to main as of 2026-05-20 morning:
1. **Verfahrensablauf + Fristenrechner polish** — jurisdiction prefix on the picked proceeding, trigger-event label derived from the root rule, flag rows lifted to `/tools/verfahrensablauf`, rule references rendered as `youpc.org/laws#…` links via new `BuildLegalSourceURL`, `Vorab-Einrede → Einspruch` rename (DE i18n).
2. **DE proceeding picker — sub-group headers** (`Verletzungsverfahren` / `Nichtigkeitsverfahren`) + parallel labels (`LG (1. Instanz)` / `OLG (Berufung)` / …).
3. **mig 099** — drop the `with_po` flag from the two RoP 19 rules (Einspruch is always-available, not flag-gated).
4. **mig 100**`upc.inf.cfi.ccr` visible rule (`Nichtigkeitswiderklage`) so the CCR filing event surfaces when `with_ccr` is set; later corrected to `priority='optional'` via mig 101.
5. **mig 101** — strip rule-cite brackets from the two Einspruch names + flip the CCR priority `informational → optional`.
6. **mig 102** — track-aware sequence reshuffle on `upc.inf.cfi` so at any tied date the order is infringement (Replik) → revocation (Erwiderung Nichtigkeitswiderklage) → amendment.
7. **Notes toggle**`Hinweise anzeigen` checkbox in the view-toggle bar; compact ⓘ hover hint when off (default), inline `timeline-notes` block when on. `localStorage` shared across both tool pages.
Filed two follow-up issues during the session:
- **m/paliad#39** — link DE + EPA + EU rule references to `youpc.org/laws` (depends on youpc.org ingesting the corpus).
- **m/paliad#41** — DE proceedings as one combined timeline per type (LG→OLG→BGH, BPatG→BGH) — corpus + spawn + de-duplication + multi-instance UI.
## 1. Why (A) DONE
Every concrete thing m surfaced in the session was addressed and merged. The two larger unaddressed asks — combined-timeline behaviour for DE proceedings, and DE/EPA rule-link coverage — are already captured in #39 and #41 with concrete scope notes. Neither belongs as a fermi "next slice" because:
- **#41** is a corpus + UI design pass of its own (3 new spawn rules, de-duplication of the existing `de.inf.lg.berufung ↔ de.inf.olg.berufung` pair, multi-court picker shape, instance markers in the timeline body). That's its own design ticket, not a fermi follow-up.
- **#39** is primarily a youpc.org-side ingest task; the paliad-side change is a 5-line `switch` extension once youpc serves the URLs. Wait for the dependency, then small.
Everything else I surfaced in the read-only audit is either pre-existing (not introduced by this session) or speculative (no user complaint behind it).
## 2. Optional tail — would file as discrete issues, not a fermi slice
Surfacing these for completeness; none are blocking, and most would be small enough to either roll into the existing tickets or land as one-off polish:
| # | Candidate | Size | Already covered? |
|---|---|---|---|
| 1 | **`legal_source` backfill on 47 unsourced active rules** — query: 4 of `upc.inf.cfi`, 4 of `upc.pi.cfi` (100% gap), 6 of `upc.rev.cfi`, others. Pre-condition for #39's links to bite. | Medium — corpus research per rule | Partially: huygens did the broader citation backfill in t-paliad-208 / mig 097. This is the remaining tail. |
| 2 | **`upc.pi.cfi` corpus completeness audit** — all 4 of its rules lack `legal_source`; likely also missing the analogous track-of-decision spawn rules to `upc.apl.merits`. | Small audit, medium fix | No — would be a fresh task. |
| 3 | **Touch-device fallback for the ⓘ hover hint**`title=` attribute degrades poorly on phones (no hover, no tap-to-show). Either a click-to-popover variant, or accept the gap. | Tiny | No, but no user complaint yet. |
| 4 | **R.46 mutatis-mutandis distinction in `upc.rev.cfi.prelim` description** — when mig 101 stripped the `(R. 19 i.V.m. R. 46)` cite, the legal nuance dropped from the user-visible name. Could be surfaced in the description text where it doesn't crowd the timeline cell. | Tiny (one row update) | No. |
| 5 | **Save-modal warning on SoD + CCR double-check** — with mig 100's new `upc.inf.cfi.ccr` rule, a user can save both `sod` and `ccr` from the same modal and get two `paliad.deadlines` rows on the same date. Today's pre-uncheck behaviour for optional priority mitigates accidental double-write but doesn't surface the duplication actively. | Small | No. |
| 6 | **Deferred slices from earlier design docs that touch this surface**: t-paliad-179 Slice 2-4 (variant chips, lane view, side-by-side compare on `/tools/verfahrensablauf`); t-paliad-169 "+ Eintrag" CTA on the SmartTimeline (project-bound) path. | Each a separate slice. | Yes — parked from their original tasks; would be revisited when m prioritises. |
None of these warrant a "next fermi slice" right now. They're polish + corpus tail, and best handled as individual issues that m can pick from.
## 3. Recommendation
Close t-paliad-207. Fire fermi. The remaining tail (items 16 above) is appropriate as a small "polish backlog" m can dip into when relevant, but not a coherent unit of work that needs a parked inventor.

Binary file not shown.

View File

@@ -0,0 +1,126 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>HL Patents Style</title>
<style>
:root {
--bg: #002236;
--fg: #e8e8ed;
--muted: #8a9aa6;
--accent: #bff355;
--rule: #0f3a55;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; background: var(--bg); color: var(--fg); }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Inter, sans-serif;
line-height: 1.55;
font-size: 17px;
}
main {
max-width: 720px;
margin: 0 auto;
padding: 4rem 1.5rem 6rem;
}
h1 {
font-size: 2.25rem;
margin: 0 0 0.25rem;
letter-spacing: -0.02em;
}
h1 .accent { color: var(--accent); }
.lead {
color: var(--muted);
margin: 0 0 3rem;
font-size: 1.05rem;
}
h2 {
font-size: 1.1rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--accent);
margin: 2.5rem 0 0.75rem;
border-bottom: 1px solid var(--rule);
padding-bottom: 0.5rem;
}
ul { padding-left: 1.25rem; margin: 0.5rem 0 1rem; }
li { margin: 0.35rem 0; }
p { margin: 0.6rem 0; }
a { color: var(--accent); text-decoration: none; border-bottom: 1px solid transparent; }
a:hover { border-bottom-color: var(--accent); }
code, kbd {
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
font-size: 0.9em;
background: #0a2d44;
padding: 0.1em 0.35em;
border-radius: 3px;
color: var(--accent);
}
.download {
display: inline-block;
margin-top: 0.5rem;
padding: 0.7rem 1.2rem;
background: var(--accent);
color: var(--bg);
font-weight: 600;
border-radius: 4px;
border: 0;
}
.download:hover { border-bottom: 0; filter: brightness(1.05); }
footer {
margin-top: 4rem;
padding-top: 1.5rem;
border-top: 1px solid var(--rule);
color: var(--muted);
font-size: 0.85rem;
}
footer code { color: var(--muted); background: transparent; padding: 0; }
</style>
</head>
<body>
<main>
<h1>HL <span class="accent">Patents Style</span></h1>
<p class="lead">Das Word-Template fuer Patentschriftsaetze bei Hogan Lovells.</p>
<h2>Was es kann</h2>
<ul>
<li>Vorlagen-Stile fuer alle gaengigen Schriftsatz-Bausteine (Headings, Randnummern, Antraege, Exhibits)</li>
<li>BuildingBlocks: ueber das Ribbon vorgefertigte Abschnitte einfuegen</li>
<li>Sprachumschaltung DE / EN per Ribbon-Toggle</li>
<li>Scaffolding: kompletter Schriftsatz-Aufbau mit einem Klick</li>
<li>Margin Numbers, Exhibit-Nummerierung, SEQ-Felder</li>
<li>Auto-Update ueber das Ribbon (siehe unten)</li>
</ul>
<h2>Aktualisierungen</h2>
<p>Im Ribbon-Tab <em>HL Patent</em> &rarr; Gruppe <em>Manage</em> &rarr; <kbd>Check for Updates</kbd>. Holt das aktuelle Manifest von diesem Server, prueft die Version, laedt die neue <code>.dotm</code> nur bei Bedarf, verifiziert per SHA256, installiert. Nach dem Update Word neu starten.</p>
<h2>Frische Installation</h2>
<p>Wer das Template noch nicht installiert hat, laedt einmal manuell die aktuelle Version und kopiert sie in den Word-Startup-Ordner. Den Rest macht die <code>InstallTemplate</code>-Routine im Template selbst.</p>
<p><a class="download" href="HL-Patents-Style.dotm" download>HL Patents Style.dotm herunterladen</a></p>
<h2>Hilfe &amp; Feedback</h2>
<p>Fehler, Wuensche, Stilfragen, Build-Probleme: <a href="mailto:matthias.siebels@hoganlovells.com?subject=HL%20Patents%20Style">matthias.siebels@hoganlovells.com</a></p>
<footer>
<p>Update-Endpoint: <code>paliad.msbls.de/patentstyle/</code> &middot; Mirror: <code>hihlc.msbls.de/patentstyle/</code></p>
<p id="ver"></p>
</footer>
<script>
// Best-effort: show the currently-served version
fetch('version.json', { cache: 'no-cache' })
.then(r => r.ok ? r.json() : null)
.then(j => {
if (j && j.version) {
document.getElementById('ver').textContent = 'Aktuell ausgeliefert: ' + j.version;
}
})
.catch(() => {});
</script>
</main>
</body>
</html>

View File

@@ -0,0 +1,5 @@
{
"version": "v0.260518",
"dotm_url": "https://paliad.msbls.de/patentstyle/HL-Patents-Style.dotm",
"sha256": "5CEA98A29D2FD6D9970B9A2499054DF52685A1116459E07F9290B0D0ADD521F4"
}

View File

@@ -71,16 +71,16 @@ export function renderAdminRulesEdit(): string {
</div>
<div className="admin-rules-edit-row">
<div className="form-field">
<label htmlFor="f-code" data-i18n="admin.rules.edit.field.code">Code</label>
<input type="text" id="f-code" className="admin-rules-input" />
<label htmlFor="f-submission-code" data-i18n="admin.rules.edit.field.submission_code">Submission Code / Einreichung-Kennung</label>
<input type="text" id="f-submission-code" className="admin-rules-input" readonly placeholder="z. B. upc.inf.cfi.soc" />
</div>
<div className="form-field">
<label htmlFor="f-rule-code" data-i18n="admin.rules.edit.field.rule_code">Rule-Code (zit.)</label>
<label htmlFor="f-rule-code" data-i18n="admin.rules.edit.field.rule_code">Rechtsgrundlage (Kurzform)</label>
<input type="text" id="f-rule-code" className="admin-rules-input" placeholder="z. B. RoP.151" />
</div>
<div className="form-field">
<label htmlFor="f-legal-source" data-i18n="admin.rules.edit.field.legal_source">Rechtsgrundlage</label>
<input type="text" id="f-legal-source" className="admin-rules-input" />
<label htmlFor="f-legal-source" data-i18n="admin.rules.edit.field.legal_source">Rechtsgrundlage (Langform)</label>
<input type="text" id="f-legal-source" className="admin-rules-input" placeholder="z. B. UPC.RoP.151" />
</div>
</div>
</fieldset>

View File

@@ -93,7 +93,7 @@ export function renderAdminRulesList(): string {
type="text"
id="rules-filter-search"
className="admin-rules-input"
placeholder="Name, Code, rule_code..."
placeholder="Name, Submission Code, Rechtsgrundlage..."
data-i18n-placeholder="admin.rules.filter.search.placeholder"
autocomplete="off"
/>
@@ -104,7 +104,8 @@ export function renderAdminRulesList(): string {
<table className="entity-table admin-rules-table">
<thead>
<tr>
<th data-i18n="admin.rules.col.code">Code</th>
<th data-i18n="admin.rules.col.submission_code">Submission Code</th>
<th data-i18n="admin.rules.col.legal_citation">Rechtsgrundlage</th>
<th data-i18n="admin.rules.col.name">Name</th>
<th data-i18n="admin.rules.col.proceeding">Verfahrenstyp</th>
<th data-i18n="admin.rules.col.priority">Priorit&auml;t</th>
@@ -113,7 +114,7 @@ export function renderAdminRulesList(): string {
</tr>
</thead>
<tbody id="rules-tbody">
<tr><td colspan={6} className="admin-rules-loading" data-i18n="admin.rules.loading">Lade...</td></tr>
<tr><td colspan={7} className="admin-rules-loading" data-i18n="admin.rules.loading">Lade...</td></tr>
</tbody>
</table>
</div>

View File

@@ -11,7 +11,10 @@ interface Rule {
id: string;
proceeding_type_id?: number | null;
parent_id?: string | null;
code?: string | null;
// submission_code is the proceeding-prefixed identifier of this rule
// within its proceeding (e.g. `upc.inf.cfi.soc`), distinct from
// rule_code (legal citation, e.g. `RoP.013.1`).
submission_code?: string | null;
rule_code?: string | null;
name: string;
name_en: string;
@@ -255,7 +258,7 @@ function populateForm() {
setInput("f-name", rule.name);
setInput("f-name-en", rule.name_en);
setInput("f-description", rule.description ?? "");
setInput("f-code", rule.code ?? "");
setInput("f-submission-code", rule.submission_code ?? "");
setInput("f-rule-code", rule.rule_code ?? "");
setInput("f-legal-source", rule.legal_source ?? "");
setInput("f-proceeding", rule.proceeding_type_id ?? "");

View File

@@ -11,7 +11,10 @@ import { initSidebar } from "./sidebar";
interface Rule {
id: string;
proceeding_type_id?: number | null;
code?: string | null;
// submission_code is the proceeding-prefixed identifier of this rule
// within its proceeding (e.g. `upc.inf.cfi.soc`), distinct from
// rule_code (the legal citation, e.g. `RoP.013.1`).
submission_code?: string | null;
rule_code?: string | null;
name: string;
name_en: string;
@@ -219,7 +222,8 @@ function renderRulesTable() {
const name = (r: Rule) => (getLang() === "en" ? r.name_en : r.name) || r.name;
tbody.innerHTML = rules.map((r) => `
<tr data-row-id="${esc(r.id)}" class="admin-rules-row">
<td class="admin-rules-col-code"><code>${esc(r.rule_code || r.code || "")}</code></td>
<td class="admin-rules-col-code"><code>${esc(r.submission_code || "")}</code></td>
<td class="admin-rules-col-legal"><code>${esc(r.rule_code || "")}</code></td>
<td>${esc(name(r))}</td>
<td>${esc(proceedingLabel(r.proceeding_type_id ?? null))}</td>
<td><span class="admin-rules-priority admin-rules-priority-${esc(r.priority)}">${esc(priorityLabel(r.priority))}</span></td>

View File

@@ -1,16 +1,23 @@
// broadcast.ts — bulk team-email compose modal (t-paliad-147 / issue #7).
// broadcast.ts — bulk team-email compose modal (t-paliad-147 / issue #7,
// retrofitted onto the unified modal primitive in t-paliad-217 Slice D).
//
// Exposes openBroadcastModal({ recipients, projectIDs }) which the /team
// page calls when the "E-Mail an Auswahl" button is clicked. The modal
// collects subject + body + (optional) template and posts to
// /api/team/broadcast. On success it shows a per-recipient send report
// and closes.
// and closes after a short delay.
//
// Per-recipient privacy: each member receives their own envelope. The
// modal lists every addressee so the sender knows exactly who will be
// mailed; there is no surprise to-line.
//
// Migration notes (t-paliad-217 Slice D): the shell, ESC, backdrop,
// close button, and browser back-button are now owned by openModal().
// The body is built imperatively so the submit handler can read form
// state from the modal-body element it constructed.
import { t } from "./i18n";
import { openModal } from "./components/modal";
export interface BroadcastRecipient {
user_id: string;
@@ -35,6 +42,12 @@ interface EmailTemplateOption {
is_default: boolean;
}
interface BroadcastResult {
sent: number;
failed: number;
total: number;
}
const RECIPIENT_CAP = 100;
function esc(s: string): string {
@@ -78,69 +91,32 @@ export function openBroadcastModal(args: OpenBroadcastModalArgs): void {
return;
}
// Existing modal? Remove. Avoids stacking on rapid double-click.
document.getElementById("broadcast-modal")?.remove();
const body = renderBody(args);
wireBody(body);
const overlay = document.createElement("div");
overlay.id = "broadcast-modal";
overlay.className = "modal-overlay";
overlay.innerHTML = renderShell(args);
document.body.appendChild(overlay);
// Close handlers
overlay.querySelector("[data-broadcast-close]")?.addEventListener("click", () => overlay.remove());
overlay.addEventListener("click", (e) => {
if (e.target === overlay) overlay.remove();
});
document.addEventListener("keydown", function escClose(e) {
if (e.key === "Escape") {
overlay.remove();
document.removeEventListener("keydown", escClose);
}
});
// Recipient toggle
overlay.querySelector("[data-broadcast-toggle-recipients]")?.addEventListener("click", () => {
const list = overlay.querySelector<HTMLDivElement>("[data-broadcast-recipient-list]");
if (!list) return;
list.classList.toggle("hidden");
});
// Template dropdown
const templateSelect = overlay.querySelector<HTMLSelectElement>("[data-broadcast-template]");
templateSelect?.addEventListener("change", async () => {
const key = templateSelect.value;
if (!key) return;
const lang = (document.documentElement.lang || "de") as "de" | "en";
try {
const res = await fetch(`/api/admin/email-templates/${encodeURIComponent(key)}/${lang}`);
if (!res.ok) return;
const tpl = (await res.json()) as EmailTemplateOption;
const subjectInput = overlay.querySelector<HTMLInputElement>("[data-broadcast-subject]");
const bodyInput = overlay.querySelector<HTMLTextAreaElement>("[data-broadcast-body]");
if (subjectInput) subjectInput.value = stripGoTemplate(tpl.subject);
if (bodyInput) bodyInput.value = stripGoTemplate(tpl.body);
} catch {
/* template load failure is non-fatal — sender keeps freeform mode. */
}
});
// Submit
const form = overlay.querySelector<HTMLFormElement>("[data-broadcast-form]");
form?.addEventListener("submit", async (e) => {
e.preventDefault();
await onSubmit(form, overlay, args);
void openModal<BroadcastResult>({
title: t("team.broadcast.title") || "E-Mail an Auswahl",
body,
size: "lg",
primary: {
label: `${t("team.broadcast.send") || "Senden"} (${args.recipients.length})`,
handler: async (close) => {
await onSubmit(body, args, close);
},
},
secondary: { label: t("common.cancel") || "Abbrechen" },
});
}
function renderShell(args: OpenBroadcastModalArgs): string {
function renderBody(args: OpenBroadcastModalArgs): HTMLElement {
const root = document.createElement("div");
root.className = "broadcast-body";
const count = args.recipients.length;
const previewItems = args.recipients
.slice(0, 5)
.map((r) => esc(r.display_name) + " &lt;" + esc(r.email) + "&gt;")
.join(", ");
const more = count > 5 ? ` +${count - 5}` : "";
const fullList = args.recipients
.map(
(r) =>
@@ -150,65 +126,89 @@ function renderShell(args: OpenBroadcastModalArgs): string {
)
.join("");
return `
<div class="modal modal-broadcast" role="dialog" aria-modal="true" aria-labelledby="broadcast-title">
<header class="modal-header">
<h2 id="broadcast-title">${esc(t("team.broadcast.title") || "E-Mail an Auswahl")}</h2>
<button type="button" class="modal-close" data-broadcast-close aria-label="${esc(t("common.close") || "Schließen")}">&times;</button>
</header>
<form data-broadcast-form>
<div class="modal-body">
<div class="broadcast-recipient-summary">
<strong>${esc(t("team.broadcast.recipients") || "Empfänger")}: ${count}</strong>
<button type="button" class="link-button" data-broadcast-toggle-recipients>${esc(t("team.broadcast.show_all") || "Alle anzeigen")}</button>
<a class="link-button broadcast-mailto" href="${buildMailtoHref(args.recipients)}" data-broadcast-mailto title="${esc(t("team.broadcast.mailto.tooltip") || "Im lokalen Mail-Client öffnen")}">
${esc(t("team.broadcast.mailto.label") || "Im Mail-Client öffnen")}
</a>
<div class="broadcast-recipient-preview">${previewItems}${more}</div>
<div class="broadcast-recipient-list hidden" data-broadcast-recipient-list>
<ul>${fullList}</ul>
</div>
</div>
<label for="broadcast-template-select">${esc(t("team.broadcast.template") || "Vorlage")} <span class="muted">(${esc(t("team.broadcast.template_optional") || "optional")})</span></label>
<select id="broadcast-template-select" data-broadcast-template>
<option value="">${esc(t("team.broadcast.template_freeform") || "Freitext")}</option>
<option value="invitation">${esc(t("team.broadcast.template.invitation") || "Einladung")}</option>
<option value="deadline_digest">${esc(t("team.broadcast.template.deadline_digest") || "Frist-Digest")}</option>
</select>
<label for="broadcast-subject">${esc(t("team.broadcast.subject") || "Betreff")}</label>
<input type="text" id="broadcast-subject" data-broadcast-subject required maxlength="200" />
<label for="broadcast-body">${esc(t("team.broadcast.body") || "Nachricht")}</label>
<textarea id="broadcast-body" data-broadcast-body required rows="12" placeholder="${esc(t("team.broadcast.body_placeholder") || "Hallo {{first_name}}, …")}"></textarea>
<p class="broadcast-hint muted">
${esc(t("team.broadcast.placeholders_hint") || "Platzhalter: {{name}}, {{first_name}}, {{role_on_project}}")}
</p>
<p class="broadcast-hint muted">
${esc(t("team.broadcast.markdown_hint") || "Markdown unterstützt: **fett**, *kursiv*, [Link](https://...), - Aufzählung.")}
</p>
<div class="broadcast-error hidden" data-broadcast-error></div>
<div class="broadcast-success hidden" data-broadcast-success></div>
</div>
<footer class="modal-footer">
<button type="button" class="btn btn-ghost" data-broadcast-close>${esc(t("common.cancel") || "Abbrechen")}</button>
<button type="submit" class="btn btn-primary" data-broadcast-submit>${esc(t("team.broadcast.send") || "Senden")} (${count})</button>
</footer>
</form>
root.innerHTML = `
<div class="broadcast-recipient-summary">
<strong>${esc(t("team.broadcast.recipients") || "Empfänger")}: ${count}</strong>
<button type="button" class="link-button" data-broadcast-toggle-recipients>${esc(t("team.broadcast.show_all") || "Alle anzeigen")}</button>
<a class="link-button broadcast-mailto" href="${buildMailtoHref(args.recipients)}" data-broadcast-mailto title="${esc(t("team.broadcast.mailto.tooltip") || "Im lokalen Mail-Client öffnen")}">
${esc(t("team.broadcast.mailto.label") || "Im Mail-Client öffnen")}
</a>
<div class="broadcast-recipient-preview">${previewItems}${more}</div>
<div class="broadcast-recipient-list hidden" data-broadcast-recipient-list>
<ul>${fullList}</ul>
</div>
</div>
<div class="form-field">
<label for="broadcast-template-select">${esc(t("team.broadcast.template") || "Vorlage")} <span class="muted">(${esc(t("team.broadcast.template_optional") || "optional")})</span></label>
<select id="broadcast-template-select" data-broadcast-template>
<option value="">${esc(t("team.broadcast.template_freeform") || "Freitext")}</option>
<option value="invitation">${esc(t("team.broadcast.template.invitation") || "Einladung")}</option>
<option value="deadline_digest">${esc(t("team.broadcast.template.deadline_digest") || "Frist-Digest")}</option>
</select>
</div>
<div class="form-field">
<label for="broadcast-subject">${esc(t("team.broadcast.subject") || "Betreff")}</label>
<input type="text" id="broadcast-subject" data-broadcast-subject required maxlength="200" />
</div>
<div class="form-field">
<label for="broadcast-body">${esc(t("team.broadcast.body") || "Nachricht")}</label>
<textarea id="broadcast-body" data-broadcast-body required rows="12" placeholder="${esc(t("team.broadcast.body_placeholder") || "Hallo {{first_name}}, …")}"></textarea>
</div>
<p class="broadcast-hint muted">
${esc(t("team.broadcast.placeholders_hint") || "Platzhalter: {{name}}, {{first_name}}, {{role_on_project}}")}
</p>
<p class="broadcast-hint muted">
${esc(t("team.broadcast.markdown_hint") || "Markdown unterstützt: **fett**, *kursiv*, [Link](https://...), - Aufzählung.")}
</p>
<div class="broadcast-error hidden" data-broadcast-error></div>
<div class="broadcast-success hidden" data-broadcast-success></div>
`;
return root;
}
async function onSubmit(form: HTMLFormElement, overlay: HTMLElement, args: OpenBroadcastModalArgs): Promise<void> {
const subject = (form.querySelector<HTMLInputElement>("[data-broadcast-subject]")?.value ?? "").trim();
const body = (form.querySelector<HTMLTextAreaElement>("[data-broadcast-body]")?.value ?? "").trim();
const templateKey = form.querySelector<HTMLSelectElement>("[data-broadcast-template]")?.value ?? "";
const errEl = overlay.querySelector<HTMLDivElement>("[data-broadcast-error]");
const okEl = overlay.querySelector<HTMLDivElement>("[data-broadcast-success]");
function wireBody(body: HTMLElement): void {
// Recipient list toggle.
body.querySelector("[data-broadcast-toggle-recipients]")?.addEventListener("click", () => {
const list = body.querySelector<HTMLDivElement>("[data-broadcast-recipient-list]");
if (!list) return;
list.classList.toggle("hidden");
});
// Template dropdown — populates subject/body from the selected template.
const templateSelect = body.querySelector<HTMLSelectElement>("[data-broadcast-template]");
templateSelect?.addEventListener("change", async () => {
const key = templateSelect.value;
if (!key) return;
const lang = (document.documentElement.lang || "de") as "de" | "en";
try {
const res = await fetch(`/api/admin/email-templates/${encodeURIComponent(key)}/${lang}`);
if (!res.ok) return;
const tpl = (await res.json()) as EmailTemplateOption;
const subjectInput = body.querySelector<HTMLInputElement>("[data-broadcast-subject]");
const bodyInput = body.querySelector<HTMLTextAreaElement>("[data-broadcast-body]");
if (subjectInput) subjectInput.value = stripGoTemplate(tpl.subject);
if (bodyInput) bodyInput.value = stripGoTemplate(tpl.body);
} catch {
/* template load failure is non-fatal — sender keeps freeform mode. */
}
});
}
async function onSubmit(
body: HTMLElement,
args: OpenBroadcastModalArgs,
close: (result: BroadcastResult) => void,
): Promise<void> {
const subject = (body.querySelector<HTMLInputElement>("[data-broadcast-subject]")?.value ?? "").trim();
const bodyText = (body.querySelector<HTMLTextAreaElement>("[data-broadcast-body]")?.value ?? "").trim();
const templateKey = body.querySelector<HTMLSelectElement>("[data-broadcast-template]")?.value ?? "";
const errEl = body.querySelector<HTMLDivElement>("[data-broadcast-error]");
const okEl = body.querySelector<HTMLDivElement>("[data-broadcast-success]");
errEl?.classList.add("hidden");
okEl?.classList.add("hidden");
@@ -216,17 +216,15 @@ async function onSubmit(form: HTMLFormElement, overlay: HTMLElement, args: OpenB
showError(errEl, t("team.broadcast.error.subject_required") || "Betreff ist erforderlich.");
return;
}
if (!body) {
if (!bodyText) {
showError(errEl, t("team.broadcast.error.body_required") || "Nachricht ist erforderlich.");
return;
}
const submitBtn = form.querySelector<HTMLButtonElement>("[data-broadcast-submit]");
if (submitBtn) {
submitBtn.disabled = true;
submitBtn.textContent = t("team.broadcast.sending") || "Sende…";
}
// The modal primary button lives in the footer (owned by openModal),
// not in the body. We surface "sending..." feedback via the in-body
// success/error areas; the primary button stays clickable but the
// server-side idempotency + RECIPIENT_CAP make double-clicks safe.
const recipientFilter: Record<string, unknown> = {};
if (args.projectIDs?.length) recipientFilter.project_ids = args.projectIDs;
if (args.projectID) recipientFilter.project_id = args.projectID;
@@ -242,7 +240,7 @@ async function onSubmit(form: HTMLFormElement, overlay: HTMLElement, args: OpenB
body: JSON.stringify({
project_id: args.projectID ?? null,
subject,
body,
body: bodyText,
template_key: templateKey || undefined,
lang,
recipient_filter: recipientFilter,
@@ -252,13 +250,9 @@ async function onSubmit(form: HTMLFormElement, overlay: HTMLElement, args: OpenB
if (!res.ok) {
const errBody = await res.json().catch(() => ({ error: "Send failed" }));
showError(errEl, (errBody as { error?: string }).error || "Send failed");
if (submitBtn) {
submitBtn.disabled = false;
submitBtn.textContent = (t("team.broadcast.send") || "Senden") + ` (${args.recipients.length})`;
}
return;
}
const report = (await res.json()) as { sent: number; failed: number; total: number };
const report = (await res.json()) as BroadcastResult;
if (okEl) {
okEl.classList.remove("hidden");
const tpl = t("team.broadcast.success") || "{sent} von {total} Mails versandt ({failed} fehlgeschlagen).";
@@ -267,17 +261,10 @@ async function onSubmit(form: HTMLFormElement, overlay: HTMLElement, args: OpenB
.replace("{total}", String(report.total))
.replace("{failed}", String(report.failed));
}
if (submitBtn) {
submitBtn.disabled = true;
submitBtn.textContent = t("team.broadcast.sent") || "Versandt";
}
setTimeout(() => overlay.remove(), 2500);
// Give the sender a moment to see the report, then close.
setTimeout(() => close(report), 2500);
} catch (e) {
showError(errEl, String(e));
if (submitBtn) {
submitBtn.disabled = false;
submitBtn.textContent = (t("team.broadcast.send") || "Senden") + ` (${args.recipients.length})`;
}
}
}

View File

@@ -0,0 +1,405 @@
// t-paliad-216 Slice B (initial) + t-paliad-217 Slice C (rewrite) —
// modal for the "Suggest changes" approval action.
//
// The approver authors a counter-proposal: edits any field on the
// underlying deadline / appointment AND/OR leaves a free-text note. On
// submit the caller POSTs to /api/approval-requests/{id}/suggest-changes,
// which closes the OLD row as `changes_requested` and spawns a NEW pending
// row authored by the approver carrying counter_payload as its payload.
//
// Scope (t-paliad-217 m's Q1 Reading A — 2026-05-20):
// - Every editable field on the entity is in the form, not just the
// date allowlist that triggers approval (t-paliad-138 §Q4). The
// backend's counter-allowlist (buildCounterSetClauses in
// approval_service.go) accepts the wider set:
// deadline: title, due_date, original_due_date, warning_date,
// description, notes, rule_code, event_type_ids
// appointment: title, start_at, end_at, description, location,
// appointment_type
// - Lifecycle restriction: update-only. shape-list.ts hides the
// suggest_changes button for create / complete / delete; this modal
// refuses to open on them as defence-in-depth.
//
// Built on the unified openModal() primitive (t-paliad-217 Slice A) —
// the primitive owns ESC, focus, backdrop, close button, browser
// back-button, mobile takeover. This module only constructs the body.
//
// API:
// const result = await openApprovalEditModal({
// entityType: "deadline",
// lifecycleEvent: "update",
// payload: {...}, // requester's proposed values (= current entity row)
// preImage: {...}, // pre-mutation values (for "vorher" diff hints)
// });
// if (result) {
// // result.counterPayload + result.note ready to POST
// } else {
// // user cancelled
// }
import { t } from "../i18n";
import {
attachEventTypePicker,
fetchEventTypes,
type PickerHandle,
} from "../event-types";
import { openModal } from "./modal";
export interface ApprovalEditModalArgs {
entityType: "deadline" | "appointment";
lifecycleEvent: string;
payload: Record<string, unknown> | null;
preImage: Record<string, unknown> | null;
// Optional context for the read-only context section. The caller can
// hydrate these from the row's API response (project_title,
// requester_name, requested_at) when available; the modal degrades
// gracefully when they're missing.
projectTitle?: string;
requesterName?: string;
requestedAt?: string;
}
export interface ApprovalEditModalResult {
counterPayload: Record<string, unknown>;
note: string;
}
// FieldSpec — one editable input row. The type determines the <input>
// (or <textarea>) shape; getValue / setValue normalise the form-element
// value to the server-friendly counter_payload shape.
interface FieldSpec {
key: string;
labelKey: string; // i18n key
inputType: "text" | "date" | "datetime-local" | "textarea";
// Required = title (NOT NULL on the column). Other fields are nullable;
// empty string clears (server's addText helper handles this).
required?: boolean;
}
const DEADLINE_FIELDS: ReadonlyArray<FieldSpec> = [
{ key: "title", labelKey: "deadlines.field.title", inputType: "text", required: true },
{ key: "due_date", labelKey: "deadlines.field.due", inputType: "date" },
{ key: "original_due_date", labelKey: "approvals.suggest.field.original_due_date", inputType: "date" },
{ key: "warning_date", labelKey: "approvals.suggest.field.warning_date", inputType: "date" },
{ key: "rule_code", labelKey: "approvals.suggest.field.rule_code", inputType: "text" },
{ key: "description", labelKey: "approvals.suggest.field.description", inputType: "textarea" },
{ key: "notes", labelKey: "deadlines.field.notes", inputType: "textarea" },
];
const APPOINTMENT_FIELDS: ReadonlyArray<FieldSpec> = [
{ key: "title", labelKey: "appointments.field.title", inputType: "text", required: true },
{ key: "start_at", labelKey: "appointments.field.start", inputType: "datetime-local" },
{ key: "end_at", labelKey: "appointments.field.end", inputType: "datetime-local" },
{ key: "location", labelKey: "appointments.field.location", inputType: "text" },
{ key: "appointment_type", labelKey: "appointments.field.type", inputType: "text" },
{ key: "description", labelKey: "appointments.field.description", inputType: "textarea" },
];
export async function openApprovalEditModal(
args: ApprovalEditModalArgs,
): Promise<ApprovalEditModalResult | null> {
if (args.lifecycleEvent !== "update") {
window.alert(t("approvals.suggest.unsupported_lifecycle"));
return null;
}
const fields = args.entityType === "deadline" ? DEADLINE_FIELDS : APPOINTMENT_FIELDS;
const original = (args.payload ?? {}) as Record<string, unknown>;
const preImage = (args.preImage ?? {}) as Record<string, unknown>;
// Build the body element imperatively so we can wire input handlers
// before openModal mounts the dialog.
const body = document.createElement("div");
body.className = "approval-suggest-body";
body.appendChild(renderIntro());
body.appendChild(renderFieldsSection(fields, original, preImage));
// event_type_ids picker (deadline-only) — async because the picker
// needs to fetch the firm's event-type catalogue. We attach a host
// element synchronously and populate it once the fetch returns.
let eventTypePicker: PickerHandle | null = null;
let eventTypePickerLoaded = false;
if (args.entityType === "deadline") {
const pickerSection = renderEventTypePickerSection();
body.appendChild(pickerSection.section);
void (async () => {
try {
await fetchEventTypes();
eventTypePicker = attachEventTypePicker(pickerSection.host, {
initialIDs: (original.event_type_ids as string[] | undefined) ?? [],
});
eventTypePickerLoaded = true;
} catch (_e) {
// Fail-soft: leave the section empty; counter still works
// without event_type_ids in the payload.
pickerSection.host.textContent = t("approvals.suggest.event_type_picker_unavailable");
}
})();
}
body.appendChild(renderContextSection(args, original));
const noteEl = renderNoteSection();
body.appendChild(noteEl.section);
// Read inputs back at submit time. The same list is what we listen to
// for the dirty-state gate.
const fieldInputs = Array.from(
body.querySelectorAll<HTMLInputElement | HTMLTextAreaElement>("[data-suggest-field]"),
);
return openModal<ApprovalEditModalResult>({
title: `${t("approvals.suggest.modal_title")}${t(("approvals.entity." + args.entityType) as never)}`,
body,
size: "lg",
primary: {
label: t("approvals.suggest.submit"),
handler: (close) => {
const result = buildResult(fieldInputs, noteEl.textarea, original, eventTypePicker, eventTypePickerLoaded);
if (!result.dirty && !result.note) {
// Server enforces too. Client-side guard avoids the 400 round-trip.
window.alert(t("approvals.suggest.submit_disabled_hint"));
return;
}
close({
counterPayload: result.counterPayload,
note: result.note,
});
},
},
secondary: { label: t("approvals.suggest.cancel") },
});
}
function renderIntro(): HTMLElement {
const p = document.createElement("p");
p.className = "approval-suggest-intro muted";
p.textContent = t("approvals.suggest.intro");
return p;
}
function renderFieldsSection(
fields: ReadonlyArray<FieldSpec>,
original: Record<string, unknown>,
preImage: Record<string, unknown>,
): HTMLElement {
const section = document.createElement("section");
section.className = "approval-suggest-section approval-suggest-section--editable";
const h = document.createElement("h3");
h.className = "approval-suggest-section-title";
h.textContent = t("approvals.suggest.section.editable");
section.appendChild(h);
for (const f of fields) {
const wrap = document.createElement("div");
wrap.className = "form-field approval-suggest-field";
const label = document.createElement("label");
label.textContent = t(f.labelKey as never);
wrap.appendChild(label);
const value = formatFieldForInput(original[f.key], f.inputType);
let input: HTMLInputElement | HTMLTextAreaElement;
if (f.inputType === "textarea") {
input = document.createElement("textarea");
input.rows = 3;
(input as HTMLTextAreaElement).value = value;
} else {
input = document.createElement("input");
(input as HTMLInputElement).type = f.inputType;
(input as HTMLInputElement).value = value;
}
input.dataset.suggestField = f.key;
input.dataset.suggestOriginal = value;
input.dataset.suggestInputType = f.inputType;
if (f.required) input.required = true;
// Wire the <label> to focus the <input> on click.
const inputID = `suggest-field-${f.key}`;
input.id = inputID;
label.setAttribute("for", inputID);
wrap.appendChild(input);
// "Vorher" hint when pre_image carries a distinct value for this field.
const preVal = formatFieldForInput(preImage[f.key], f.inputType);
if (preVal && preVal !== value) {
const hint = document.createElement("span");
hint.className = "approval-suggest-prehint";
hint.textContent = `${t("approvals.diff.before")}: ${preVal}`;
wrap.appendChild(hint);
}
section.appendChild(wrap);
}
return section;
}
function renderEventTypePickerSection(): { section: HTMLElement; host: HTMLElement } {
const section = document.createElement("section");
section.className = "approval-suggest-section approval-suggest-section--editable";
const h = document.createElement("h3");
h.className = "approval-suggest-section-title";
h.textContent = t("deadlines.field.event_type");
section.appendChild(h);
const host = document.createElement("div");
host.className = "approval-suggest-event-type-picker";
section.appendChild(host);
return { section, host };
}
function renderContextSection(
args: ApprovalEditModalArgs,
original: Record<string, unknown>,
): HTMLElement {
const section = document.createElement("section");
section.className = "approval-suggest-section approval-suggest-section--context";
const h = document.createElement("h3");
h.className = "approval-suggest-section-title";
h.textContent = t("approvals.suggest.section.context");
section.appendChild(h);
const rows: Array<[string, string]> = [];
if (args.projectTitle) {
rows.push([t("approvals.suggest.context.project"), args.projectTitle]);
}
if (args.requesterName) {
rows.push([t("approvals.suggest.context.requester"), args.requesterName]);
}
if (args.requestedAt) {
rows.push([t("approvals.suggest.context.requested_at"), formatDateForDisplay(args.requestedAt)]);
}
// Approval status — entity row's current approval_status (typically
// "pending" while the modal is open, but display the requester's
// perspective for completeness).
const approvalStatus = original.approval_status as string | undefined;
if (approvalStatus) {
rows.push([
t("approvals.suggest.context.approval_status"),
t(("approvals.status." + approvalStatus) as never) || approvalStatus,
]);
}
if (rows.length === 0) {
section.style.display = "none";
return section;
}
const dl = document.createElement("dl");
dl.className = "approval-suggest-context-grid";
for (const [label, value] of rows) {
const dt = document.createElement("dt");
dt.textContent = label;
const dd = document.createElement("dd");
dd.textContent = value;
dl.appendChild(dt);
dl.appendChild(dd);
}
section.appendChild(dl);
return section;
}
function renderNoteSection(): { section: HTMLElement; textarea: HTMLTextAreaElement } {
const section = document.createElement("section");
section.className = "approval-suggest-section approval-suggest-section--note";
const wrap = document.createElement("div");
wrap.className = "form-field approval-suggest-note";
const label = document.createElement("label");
label.textContent = t("approvals.suggest.note_label");
label.setAttribute("for", "suggest-note");
wrap.appendChild(label);
const textarea = document.createElement("textarea");
textarea.id = "suggest-note";
textarea.rows = 3;
textarea.placeholder = t("approvals.suggest.note_placeholder");
textarea.dataset.suggestNote = "true";
wrap.appendChild(textarea);
section.appendChild(wrap);
return { section, textarea };
}
interface BuildResult {
counterPayload: Record<string, unknown>;
note: string;
dirty: boolean;
}
function buildResult(
fieldInputs: ReadonlyArray<HTMLInputElement | HTMLTextAreaElement>,
noteEl: HTMLTextAreaElement,
original: Record<string, unknown>,
eventTypePicker: PickerHandle | null,
eventTypePickerLoaded: boolean,
): BuildResult {
const counterPayload: Record<string, unknown> = {};
let dirty = false;
for (const el of fieldInputs) {
const key = el.dataset.suggestField || "";
const orig = el.dataset.suggestOriginal || "";
const inputType = el.dataset.suggestInputType || "text";
if (el.value === orig) continue;
counterPayload[key] = formatFieldForServer(el.value, inputType);
dirty = true;
}
if (eventTypePicker && eventTypePickerLoaded) {
const currentIDs = eventTypePicker.getIDs().slice().sort();
const originalIDs = ((original.event_type_ids as string[] | undefined) ?? []).slice().sort();
if (currentIDs.length !== originalIDs.length
|| currentIDs.some((id, i) => id !== originalIDs[i])) {
counterPayload.event_type_ids = currentIDs;
dirty = true;
}
}
return {
counterPayload,
note: noteEl.value.trim(),
dirty,
};
}
// formatFieldForInput — convert a server-side payload value to the format
// the <input> wants. Dates round-trip as YYYY-MM-DD; datetime-local wants
// YYYY-MM-DDTHH:MM. Server returns ISO 8601 / RFC 3339 timestamps; we
// trim to the local-input shape. Text passes through verbatim.
function formatFieldForInput(v: unknown, inputType: string): string {
if (v == null) return "";
const s = String(v);
if (inputType === "date") {
if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return s;
const m = s.match(/^(\d{4}-\d{2}-\d{2})/);
return m ? m[1] : s;
}
if (inputType === "datetime-local") {
const m = s.match(/^(\d{4}-\d{2}-\d{2})[T\s](\d{2}:\d{2})/);
return m ? `${m[1]}T${m[2]}` : s;
}
return s;
}
// formatFieldForServer — convert input value back to server-friendly
// shape. Empty string means "clear this nullable field"; the server's
// addText helper writes NULL for "". Required fields (title) reach the
// server's non-empty CHECK on the column, which surfaces as a 400.
function formatFieldForServer(value: string, inputType: string): unknown {
if (inputType === "date" || inputType === "datetime-local") {
return value || null;
}
return value;
}
function formatDateForDisplay(iso: string): string {
const d = Date.parse(iso);
if (isNaN(d)) return iso;
return new Date(d).toLocaleString();
}

View File

@@ -0,0 +1,200 @@
// Unified modal primitive — t-paliad-217.
//
// Native <dialog>-backed. The browser handles top-layer stacking, ESC,
// ARIA, and focus trap. We layer back-button integration and focus
// restoration on top so the modal behaves consistently on desktop and on
// the iPhone PWA (m's checking surface).
//
// API:
// const result = await openModal<MyResult>({
// title: "…",
// body: htmlStringOrElement,
// primary: { label: "Speichern", handler: (close) => { close(result); } },
// secondary: { label: "Abbrechen" }, // optional, defaults to "Abbrechen"
// size: "sm" | "md" | "lg" | "full", // optional, defaults to "md"
// onClose: () => { /* … */ },
// classNames: "extra css classes on the <dialog>",
// });
// // result is the value passed to close(), or null if the user
// // dismissed via ESC / backdrop / secondary / browser back-button.
//
// All dismiss paths are unified: ESC, backdrop click, secondary button,
// the always-rendered close (×) button, and the browser back-button all
// resolve the promise with null. Programmatic close from the primary
// handler resolves with whatever was passed.
//
// Migration target: call sites that currently roll their own
// modal-overlay + ESC handler + focus management replace all of it with
// one openModal() call. broadcast.ts and approval-edit-modal.ts are the
// first two call sites (t-paliad-217 Slices C + D); the other ~5 legacy
// modals migrate in follow-up PRs.
import { t } from "../i18n";
export interface ModalConfig<T> {
title: string;
// body can be either a pre-built HTMLElement (the caller assembled the
// DOM and may have local references for read-back) or an HTML string
// (caller is responsible for escaping). Element is preferred when the
// caller needs to read form state on submit.
body: HTMLElement | string;
primary: {
label: string;
handler: (close: (result: T) => void) => void | Promise<void>;
};
// secondary defaults to a Cancel button that just dismisses. Pass null
// explicitly to suppress (rare — primary-only modals like a confirmation
// toast).
secondary?: { label: string } | null;
size?: "sm" | "md" | "lg" | "full";
// onClose fires on EVERY dismiss path (including primary handler
// resolution). Use for analytics / dirty-state warnings.
onClose?: () => void;
classNames?: string;
}
// openModal returns a promise that resolves with the value passed to
// close() inside the primary handler, or null if the user dismissed via
// any other path. Always non-throwing — the primary handler decides
// whether to surface errors via its own UI (e.g. inline form errors)
// rather than rejecting the promise.
export function openModal<T = void>(config: ModalConfig<T>): Promise<T | null> {
return new Promise((resolve) => {
// Record + restore focus to whatever was focused before the modal
// opened. Native <dialog> does NOT do this automatically.
const previouslyFocused = document.activeElement as HTMLElement | null;
const dialog = document.createElement("dialog");
dialog.className = ["modal", config.classNames].filter(Boolean).join(" ");
dialog.dataset.size = config.size ?? "md";
const header = document.createElement("header");
header.className = "modal__header";
const titleEl = document.createElement("h2");
titleEl.className = "modal__title";
titleEl.textContent = config.title;
header.appendChild(titleEl);
const closeBtn = document.createElement("button");
closeBtn.type = "button";
closeBtn.className = "modal__close";
closeBtn.setAttribute("aria-label", t("modal.close.label"));
closeBtn.textContent = "×"; // ×
header.appendChild(closeBtn);
dialog.appendChild(header);
const body = document.createElement("div");
body.className = "modal__body";
if (typeof config.body === "string") {
body.innerHTML = config.body;
} else {
body.appendChild(config.body);
}
dialog.appendChild(body);
const footer = document.createElement("footer");
footer.className = "modal__footer";
const secondaryCfg = config.secondary === null
? null
: config.secondary ?? { label: t("common.cancel") };
let secondaryBtn: HTMLButtonElement | null = null;
if (secondaryCfg) {
secondaryBtn = document.createElement("button");
secondaryBtn.type = "button";
secondaryBtn.className = "btn btn-ghost modal__secondary";
secondaryBtn.textContent = secondaryCfg.label;
footer.appendChild(secondaryBtn);
}
const primaryBtn = document.createElement("button");
primaryBtn.type = "button";
primaryBtn.className = "btn btn-primary modal__primary";
primaryBtn.textContent = config.primary.label;
footer.appendChild(primaryBtn);
dialog.appendChild(footer);
document.body.appendChild(dialog);
// History integration (Q5): push a synthetic history state so the
// browser back-button closes the modal instead of leaving the page.
// We pop the state in finish() unless popstate already fired it.
let historyEntryActive = false;
try {
history.pushState({ paliadModalOpen: true }, "");
historyEntryActive = true;
} catch (_e) {
// pushState may throw in obscure embedded contexts; degrade gracefully.
}
// resolved guards against double-resolution (e.g. ESC fires + then a
// microtask-deferred primary handler also calls close).
let resolved = false;
const finish = (value: T | null) => {
if (resolved) return;
resolved = true;
window.removeEventListener("popstate", onPopState);
// Pop our history entry if it's still on the stack. Skip when the
// popstate listener already fired (otherwise we'd go back twice).
if (historyEntryActive) {
historyEntryActive = false;
try { history.back(); } catch (_e) { /* same fallback as pushState */ }
}
// Native dialog close. Use the close event's default rather than
// the cancel event so we don't fight the browser's own dismissal.
if (dialog.open) dialog.close();
dialog.remove();
// Restore focus to whatever the user was on before. The dialog
// teardown happens synchronously so the focus call lands on a
// live element.
if (previouslyFocused && document.body.contains(previouslyFocused)) {
previouslyFocused.focus();
}
config.onClose?.();
resolve(value);
};
const close = (result: T) => finish(result);
// Dismiss paths.
closeBtn.addEventListener("click", () => finish(null));
secondaryBtn?.addEventListener("click", () => finish(null));
dialog.addEventListener("click", (e) => {
// Backdrop click — only when the click landed on the dialog element
// itself (not on a child). Browsers report dialog.click events
// through the backdrop too because the backdrop is conceptually
// part of the dialog's box.
if (e.target === dialog) finish(null);
});
// <dialog>'s cancel event fires on ESC. preventDefault stops the
// browser's default close so we can run our finish() (history pop,
// focus restore, onClose, resolve).
dialog.addEventListener("cancel", (e) => {
e.preventDefault();
finish(null);
});
const onPopState = () => {
// Browser back-button. Our history entry is gone by the time this
// fires, so skip the history.back() in finish().
historyEntryActive = false;
finish(null);
};
window.addEventListener("popstate", onPopState);
// Primary action.
primaryBtn.addEventListener("click", () => {
const result = config.primary.handler(close);
// Allow async primary handlers (handler returns a promise) — we
// don't wait for it explicitly; the handler is responsible for
// calling close() when ready.
void result;
});
// Open the dialog in the top layer. showModal activates ARIA
// role="dialog" + aria-modal=true + focus trap + backdrop.
dialog.showModal();
});
}

View File

@@ -65,14 +65,60 @@ interface DashboardData {
upcoming_deadlines: UpcomingDeadline[];
upcoming_appointments: UpcomingAppointment[];
recent_activity: ActivityEntry[];
inbox_summary?: InboxSummary;
}
interface InboxEntry {
id: string;
entity_type: string;
entity_title?: string | null;
project_id: string;
project_title: string;
requested_at: string;
requester_id: string;
requester_name: string;
}
interface InboxSummary {
pending_count: number;
top: InboxEntry[];
}
// DashboardLayoutSpec mirrors the Go shape in
// internal/services/dashboard_layout_spec.go. The client treats the spec
// as advice: unknown widget keys are dropped silently (server is the
// source of truth for the catalog).
interface DashboardWidgetRef {
key: string;
visible: boolean;
settings?: { count?: number; horizon_days?: number };
}
interface DashboardLayoutSpec {
v: number;
widgets: DashboardWidgetRef[];
}
declare global {
interface Window {
__PALIAD_DASHBOARD__?: DashboardData | null;
__PALIAD_DASHBOARD_LAYOUT__?: DashboardLayoutSpec | null;
__PALIAD_DASHBOARD_CATALOG__?: unknown;
}
}
let currentLayout: DashboardLayoutSpec | null = null;
// settingsFor returns the (possibly-empty) settings blob for a given
// widget key in the active layout. Falls back to an empty object so
// renderers can read `.count ?? defaultN` without null checks.
function settingsFor(key: string): { count?: number; horizon_days?: number } {
if (!currentLayout) return {};
for (const w of currentLayout.widgets) {
if (w.key === key) return w.settings ?? {};
}
return {};
}
const POLL_INTERVAL_MS = 60_000;
// 30-day look-ahead matches the agenda.tsx default chip and the server's
// default `to=today+30d` window — keeps the inline agenda visually
@@ -110,7 +156,13 @@ function render(): void {
renderAppointments(data.upcoming_appointments);
renderAgenda();
renderActivity(data.recent_activity);
renderInbox(data.inbox_summary ?? { pending_count: 0, top: [] });
toggleOnboardingHint(data.user);
// Apply the saved layout AFTER renderers so the per-widget settings
// applied above (count truncation, horizon filtering) are stable
// before we toggle visibility + reorder. Failing to find the layout
// is non-fatal — the factory default markup order takes over.
applyLayout();
}
function renderGreeting(user: DashboardUser | null): void {
@@ -162,6 +214,13 @@ function renderDeadlines(items: UpcomingDeadline[]): void {
const list = document.getElementById("dashboard-deadlines-list")!;
const empty = document.getElementById("dashboard-deadlines-empty")!;
// Per-widget settings: truncate by count + filter by horizon. Backend
// returns 40 rows / 60d; the widget settings narrow it. Defaults match
// the catalog (10 rows, 30 days).
const s = settingsFor("upcoming-deadlines");
items = filterByHorizonDays(items, s.horizon_days ?? 30, (d) => d.due_date);
items = items.slice(0, s.count ?? 10);
if (!items.length) {
list.innerHTML = "";
list.style.display = "none";
@@ -191,6 +250,10 @@ function renderAppointments(items: UpcomingAppointment[]): void {
const list = document.getElementById("dashboard-appointments-list")!;
const empty = document.getElementById("dashboard-appointments-empty")!;
const s = settingsFor("upcoming-appointments");
items = filterByHorizonDays(items, s.horizon_days ?? 30, (a) => a.start_at);
items = items.slice(0, s.count ?? 10);
if (!items.length) {
list.innerHTML = "";
list.style.display = "none";
@@ -226,6 +289,9 @@ function renderActivity(items: ActivityEntry[]): void {
const list = document.getElementById("dashboard-activity-list")!;
const empty = document.getElementById("dashboard-activity-empty")!;
const s = settingsFor("recent-activity");
items = items.slice(0, s.count ?? 10);
if (!items.length) {
list.innerHTML = "";
list.style.display = "none";
@@ -344,8 +410,10 @@ function renderAgenda(): void {
}
async function loadAgenda(): Promise<void> {
const s = settingsFor("inline-agenda");
const horizon = s.horizon_days ?? AGENDA_LOOKAHEAD_DAYS;
const from = toAgendaDate(startOfToday());
const to = toAgendaDate(addDays(startOfToday(), AGENDA_LOOKAHEAD_DAYS - 1));
const to = toAgendaDate(addDays(startOfToday(), horizon - 1));
try {
const resp = await fetch(`/api/agenda?from=${from}&to=${to}&types=deadlines,appointments`);
if (!resp.ok) {
@@ -439,6 +507,125 @@ function syncCollapseAriaLabels(): void {
});
}
function renderInbox(s: InboxSummary): void {
const summary = document.getElementById("dashboard-inbox-summary");
const list = document.getElementById("dashboard-inbox-list");
const empty = document.getElementById("dashboard-inbox-empty");
if (!summary || !list || !empty) return;
const settings = settingsFor("inbox-approvals");
const cap = settings.count ?? 3;
const top = s.top.slice(0, cap);
if (s.pending_count === 0) {
summary.style.display = "none";
list.innerHTML = "";
list.style.display = "none";
empty.style.display = "block";
return;
}
empty.style.display = "none";
summary.style.display = "block";
summary.textContent = getLang() === "de"
? `${s.pending_count} offene Freigaben warten auf dich.`
: `${s.pending_count} open approvals are waiting for you.`;
list.style.display = "";
list.innerHTML = top.map((e) => {
const entityLabel = e.entity_type === "deadline"
? tDyn("dashboard.inbox.entity.deadline")
: (e.entity_type === "appointment"
? tDyn("dashboard.inbox.entity.appointment")
: e.entity_type);
const title = e.entity_title || entityLabel;
return `<li class="dashboard-list-item">
<a href="/inbox" class="dashboard-list-link">
<div class="dashboard-list-main">
<span class="dashboard-list-title">${esc(title)}</span>
<span class="dashboard-list-ref" title="${escAttr(`${e.project_title} · ${e.requester_name}`)}">${esc(e.project_title)} &middot; ${esc(e.requester_name)}</span>
</div>
<div class="dashboard-list-meta">
<span class="dashboard-appt-time">${esc(formatDateTime(e.requested_at))}</span>
</div>
</a>
</li>`;
}).join("");
}
// applyLayout walks the saved DashboardLayoutSpec and hides widgets whose
// keys are `visible: false`, then reorders the visible ones to match the
// layout's order. Widgets in the layout but missing from the DOM are
// ignored (the catalog must define the markup for them — Slice A has
// every catalog widget pre-rendered in dashboard.tsx). Widgets in the
// DOM but missing from the layout (e.g. a deploy added markup ahead of a
// migration) stay in their authored position so nothing disappears
// silently.
//
// Reordering target: the visible widgets live in two parents — the
// outer .container and the .dashboard-columns 2-up grid. We respect
// that boundary: widgets inside .dashboard-columns are reordered within
// it; widgets outside are reordered relative to each other inside
// .container. This keeps the existing 2-up behaviour for the
// deadlines+appointments pair without forcing a full container flatten.
function applyLayout(): void {
if (!currentLayout || !Array.isArray(currentLayout.widgets)) return;
// Discover widget elements once. data-widget-key set in dashboard.tsx.
const allWidgets = Array.from(
document.querySelectorAll<HTMLElement>("[data-widget-key]"),
);
if (!allWidgets.length) return;
const byKey = new Map<string, HTMLElement>();
allWidgets.forEach((el) => {
const k = el.dataset.widgetKey;
if (k) byKey.set(k, el);
});
// Hide widgets whose layout entry says visible:false. Anything not in
// the layout at all stays untouched.
const seenInLayout = new Set<string>();
for (const w of currentLayout.widgets) {
seenInLayout.add(w.key);
const el = byKey.get(w.key);
if (!el) continue;
el.style.display = w.visible ? "" : "none";
}
// Reorder visible widgets inside each parent. We group widgets by their
// current parent element so we don't move them out of .dashboard-columns
// and lose the 2-up grid layout.
const groups = new Map<HTMLElement, HTMLElement[]>();
for (const w of currentLayout.widgets) {
if (!w.visible) continue;
const el = byKey.get(w.key);
if (!el || !el.parentElement) continue;
const arr = groups.get(el.parentElement) ?? [];
arr.push(el);
groups.set(el.parentElement, arr);
}
groups.forEach((widgets, parent) => {
widgets.forEach((el) => parent.appendChild(el));
});
}
// filterByHorizonDays drops items whose key date is more than `days`
// days from today. Items without a parseable date stay in (we don't
// want to silently hide rows on bad data). today is inclusive.
function filterByHorizonDays<T>(items: T[], days: number, key: (t: T) => string): T[] {
if (!Number.isFinite(days) || days <= 0) return items;
const cutoff = new Date();
cutoff.setHours(0, 0, 0, 0);
cutoff.setDate(cutoff.getDate() + days);
return items.filter((t) => {
const raw = key(t);
if (!raw) return true;
// due_date is "YYYY-MM-DD"; start_at is RFC 3339. Both parseable
// by Date.
const d = new Date(raw.length === 10 ? raw + "T00:00:00" : raw);
if (isNaN(d.getTime())) return true;
return d.getTime() <= cutoff.getTime();
});
}
function toggleOnboardingHint(user: DashboardUser | null): void {
// Belt-and-braces: the server-side gate (gateOnboarded in handlers.go)
// already redirects users without a paliad.users row to /onboarding before
@@ -518,6 +705,23 @@ document.addEventListener("DOMContentLoaded", () => {
syncCollapseAriaLabels();
});
// Configurable layout (t-paliad-219). The Go shell handler splices
// the user's saved layout into __PALIAD_DASHBOARD_LAYOUT__. If it's
// missing (knowledge-platform-only deploy, hydration failure), the
// dashboard renders the factory order baked into dashboard.tsx; the
// client also kicks off a best-effort fetch so a slow-hydrating user
// still gets their saved layout on the next render pass.
const layoutInline = window.__PALIAD_DASHBOARD_LAYOUT__;
if (layoutInline) {
currentLayout = layoutInline;
} else if (layoutInline === undefined) {
void fetch("/api/me/dashboard-layout").then(async (r) => {
if (!r.ok) return;
currentLayout = (await r.json()) as DashboardLayoutSpec;
if (data) render();
}).catch(() => { /* silent — factory order is the fallback */ });
}
// Inline agenda fetch is independent of the main dashboard payload.
// Kicked off in parallel so the agenda section paints as soon as the
// /api/agenda response lands instead of waiting on the dashboard

View File

@@ -126,11 +126,12 @@ const STATUS_OPTIONS_DEADLINE: StatusOption[] = [
];
const STATUS_OPTIONS_APPOINTMENT: StatusOption[] = [
{ value: "all", key: "events.filter.status.all" },
{ value: "upcoming", key: "events.filter.status.upcoming" },
{ value: "today", key: "deadlines.filter.today" },
{ value: "this_week", key: "deadlines.filter.thisweek" },
{ value: "next_week", key: "deadlines.filter.nextweek" },
{ value: "later", key: "deadlines.filter.later" },
{ value: "all", key: "events.filter.status.all" },
];
function statusOptionsFor(type: EventTypeChoice): StatusOption[] {
@@ -139,7 +140,7 @@ function statusOptionsFor(type: EventTypeChoice): StatusOption[] {
}
function defaultStatusFor(type: EventTypeChoice): string {
return type === "appointment" ? "all" : "pending";
return type === "appointment" ? "upcoming" : "pending";
}
let currentType: EventTypeChoice = "deadline";

View File

@@ -162,10 +162,11 @@ function renderApprovalRoleAxis(ctx: AxisCtx): HTMLElement {
// ----------------------------------------------------------------------
const APPROVAL_STATUSES: Array<{ value: string; key: I18nKey }> = [
{ value: "pending", key: "views.bar.approval_status.pending" },
{ value: "approved", key: "views.bar.approval_status.approved" },
{ value: "rejected", key: "views.bar.approval_status.rejected" },
{ value: "revoked", key: "views.bar.approval_status.revoked" },
{ value: "pending", key: "views.bar.approval_status.pending" },
{ value: "approved", key: "views.bar.approval_status.approved" },
{ value: "rejected", key: "views.bar.approval_status.rejected" },
{ value: "revoked", key: "views.bar.approval_status.revoked" },
{ value: "changes_requested", key: "views.bar.approval_status.changes_requested" },
];
function renderApprovalStatusAxis(ctx: AxisCtx): HTMLElement {

View File

@@ -2,11 +2,11 @@
// 3-step wizard: select proceeding -> enter date -> view timeline
//
// Rendering primitives (renderTimelineBody / renderColumnsBody /
// deadlineCardHtml / formatDate / partyBadge / court picker) live in
// `./views/verfahrensablauf-core` and are shared with the
// /tools/verfahrensablauf page (t-paliad-179 Slice 1). This module owns
// the Step1/2/3a wizard, Pathway A/B, Akte save flow, anchor-override
// click-to-edit — none of which Verfahrensablauf wants.
// deadlineCardHtml / formatDate / partyBadge / court picker / inline
// date editor) live in `./views/verfahrensablauf-core` and are shared
// with /tools/verfahrensablauf. This module owns the Step1/2/3a
// wizard, Pathway A/B, Akte save flow — none of which Verfahrensablauf
// wants.
import { initI18n, t, tDyn, getLang, onLangChange } from "./i18n";
import { initSidebar } from "./sidebar";
@@ -22,6 +22,7 @@ import {
priorityRendering,
renderColumnsBody,
renderTimelineBody,
wireDateEditClicks,
} from "./views/verfahrensablauf-core";
let lastResponse: DeadlineResponse | null = null;
@@ -57,6 +58,19 @@ type ProcedureView = "timeline" | "columns";
// HLC team than the single vertical line.
let procedureView: ProcedureView = "columns";
// Notes toggle — off by default; per-rule notes render as a compact
// ⓘ hover icon. Flipped on, they expand under each card. Choice is
// localStorage-persisted (paliad.fristen.notes-show key shared with
// /tools/verfahrensablauf so the preference carries across both).
const NOTES_PREF_KEY = "paliad.fristen.notes-show";
function readNotesPref(): boolean {
try { return localStorage.getItem(NOTES_PREF_KEY) === "1"; } catch { return false; }
}
function writeNotesPref(on: boolean): void {
try { localStorage.setItem(NOTES_PREF_KEY, on ? "1" : "0"); } catch { /* no-op */ }
}
let showNotes = readNotesPref();
onLangChange(() => {
if (lastResponse) renderProcedureResults(lastResponse);
// Update trigger event name if a proceeding is selected
@@ -112,11 +126,14 @@ async function calculate() {
const priorityInput = document.getElementById("priority-date") as HTMLInputElement | null;
const priorityDate = selectedType === "epa.grant.exa" && priorityInput?.value ? priorityInput.value : "";
// Flags — three proceeding-specific checkboxes:
// Flags — proceeding-specific checkboxes:
// upc.inf.cfi: with_ccr (always available); with_amend (nested under
// with_ccr — R.30 application is only available with a CCR).
// upc.rev.cfi: with_amend (R.49.2.a) and with_cci (R.49.2.b) as two
// independent gates; both can be on simultaneously.
// R.19 Einspruch is NOT flag-gated (mig 098, m's 2026-05-18 call): it's
// an always-available optional submission, surfaced as priority='optional'
// without a separate checkbox.
const ccrFlag = document.getElementById("ccr-flag") as HTMLInputElement | null;
const infAmendFlag = document.getElementById("inf-amend-flag") as HTMLInputElement | null;
const revAmendFlag = document.getElementById("rev-amend-flag") as HTMLInputElement | null;
@@ -388,8 +405,8 @@ function renderProcedureResults(data: DeadlineResponse) {
</div>`;
const bodyHtml = procedureView === "columns"
? renderColumnsBody(data, { editable: true })
: renderTimelineBody(data, { showParty: true, editable: true });
? renderColumnsBody(data, { editable: true, showNotes })
: renderTimelineBody(data, { showParty: true, editable: true, showNotes });
container.innerHTML = headerHtml + bodyHtml;
printBtn.style.display = "block";
@@ -414,54 +431,21 @@ function renderProcedureResults(data: DeadlineResponse) {
applyPendingFocus();
}
// openInlineDateEditor swaps the date span for a date input. On commit
// (blur or Enter), the override is recorded and the timeline re-fetched.
// On Escape, the editor closes without changing anything. An empty
// commit clears the override (lets the user revert to the calculated
// date or to the IsCourtSet placeholder).
function openInlineDateEditor(span: HTMLElement) {
const ruleCode = span.dataset.ruleCode!;
const current = span.dataset.currentDate || anchorOverrides.get(ruleCode) || "";
const editor = document.createElement("input");
editor.type = "date";
editor.className = "frist-date-edit-input";
editor.value = current;
const commit = (newValue: string) => {
if (newValue === "") {
anchorOverrides.delete(ruleCode);
} else {
anchorOverrides.set(ruleCode, newValue);
}
void calculate();
};
const cancel = () => {
editor.replaceWith(span);
};
editor.addEventListener("blur", () => {
if (editor.value !== current) commit(editor.value);
else cancel();
});
editor.addEventListener("keydown", (e) => {
const ke = e as KeyboardEvent;
if (ke.key === "Enter") {
e.preventDefault();
editor.blur();
} else if (ke.key === "Escape") {
e.preventDefault();
cancel();
}
});
span.replaceWith(editor);
editor.focus();
if (editor.value) editor.select();
// onDateEditCommit is the click-to-edit callback handed to the shared
// wireDateEditClicks() helper: persist the per-rule override (empty value
// clears it) then recompute so downstream rules re-anchor.
function onDateEditCommit(ruleCode: string, newValue: string) {
if (newValue === "") {
anchorOverrides.delete(ruleCode);
} else {
anchorOverrides.set(ruleCode, newValue);
}
void calculate();
}
// deadlineCardHtml / renderTimelineBody / renderColumnsBody moved to
// ./views/verfahrensablauf-core (t-paliad-179 Slice 1).
// deadlineCardHtml / renderTimelineBody / renderColumnsBody /
// openInlineDateEditor / wireDateEditClicks moved to
// ./views/verfahrensablauf-core.
function reset() {
selectedType = "";
@@ -632,21 +616,7 @@ document.addEventListener("DOMContentLoaded", () => {
// rules re-anchor on the user's date. Delegated on the container so
// it survives renderProcedureResults() innerHTML rewrites.
const timelineContainer = document.getElementById("timeline-container");
if (timelineContainer) {
timelineContainer.addEventListener("click", (e) => {
const target = (e.target as HTMLElement).closest<HTMLElement>(".frist-date-edit");
if (!target || !target.dataset.ruleCode) return;
openInlineDateEditor(target);
});
timelineContainer.addEventListener("keydown", (e) => {
const ke = e as KeyboardEvent;
if (ke.key !== "Enter" && ke.key !== " ") return;
const target = (e.target as HTMLElement).closest<HTMLElement>(".frist-date-edit");
if (!target || !target.dataset.ruleCode) return;
e.preventDefault();
openInlineDateEditor(target);
});
}
if (timelineContainer) wireDateEditClicks(timelineContainer, onDateEditCommit);
// Reset button
document.getElementById("reset-btn")!.addEventListener("click", reset);
@@ -658,6 +628,18 @@ document.addEventListener("DOMContentLoaded", () => {
const saveBtn = document.getElementById("fristen-save-cta");
if (saveBtn) saveBtn.addEventListener("click", openSaveModal);
// Notes toggle — restores last preference on load + re-renders when
// the user flips it. Lives in the same toggle bar as the view picker.
const notesShowCb = document.getElementById("fristen-notes-show") as HTMLInputElement | null;
if (notesShowCb) {
notesShowCb.checked = showNotes;
notesShowCb.addEventListener("change", () => {
showNotes = notesShowCb.checked;
writeNotesPref(showNotes);
if (lastResponse) renderProcedureResults(lastResponse);
});
}
// View toggle (timeline vs. columns layout) for procedure mode.
initViewToggle();

View File

@@ -235,11 +235,13 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.upc.disc.cfi": "Bucheinsicht",
"deadlines.upc.apl.cost": "Berufung Kosten",
"deadlines.upc.apl.order": "Berufung Anordnungen",
"deadlines.de.inf.lg": "Verletzungsklage (LG)",
"deadlines.de.inf.olg": "Berufung OLG",
"deadlines.de.inf.bgh": "Revision/NZB BGH",
"deadlines.de.null.bpatg": "Nichtigkeitsverfahren",
"deadlines.de.null.bgh": "Berufung BGH (Nichtigk.)",
"deadlines.de.group.inf": "Verletzungsverfahren",
"deadlines.de.group.null": "Nichtigkeitsverfahren",
"deadlines.de.inf.lg": "LG (1. Instanz)",
"deadlines.de.inf.olg": "OLG (Berufung)",
"deadlines.de.inf.bgh": "BGH (Revision / NZB)",
"deadlines.de.null.bpatg": "BPatG (1. Instanz)",
"deadlines.de.null.bgh": "BGH (Berufung)",
"deadlines.epa.opp.opd": "Einspruchsverfahren",
"deadlines.epa.opp.boa": "Beschwerdeverfahren",
"deadlines.epa.grant.exa": "EP-Erteilungsverfahren",
@@ -298,6 +300,7 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.view.label": "Ansicht:",
"deadlines.view.timeline": "Zeitstrahl",
"deadlines.view.columns": "Spalten",
"deadlines.notes.show": "Hinweise anzeigen",
"deadlines.col.proactive": "Proaktiv",
"deadlines.col.court": "Gericht",
"deadlines.col.reactive": "Reaktiv",
@@ -837,6 +840,18 @@ const translations: Record<Lang, Record<string, string>> = {
"cal.month.9": "Oktober",
"cal.month.10": "November",
"cal.month.11": "Dezember",
"cal.view.month": "Monat",
"cal.view.week": "Woche",
"cal.view.day": "Tag",
"cal.month.prev": "Vorheriger Monat",
"cal.month.next": "Nächster Monat",
"cal.week.prev": "Vorherige Woche",
"cal.week.next": "Nächste Woche",
"cal.day.prev": "Vorheriger Tag",
"cal.day.next": "Nächster Tag",
"cal.day.back_to_month": "Zurück zum Monat",
"cal.day.open_day": "Tagesansicht öffnen",
"cal.day.no_entries": "Keine Einträge an diesem Tag.",
// Akten detail — Fristen tab (Phase E)
@@ -896,6 +911,12 @@ const translations: Record<Lang, Record<string, string>> = {
"dashboard.agenda.heading": "Agenda",
"dashboard.agenda.empty": "Keine F\u00e4lligkeiten in den n\u00e4chsten 30 Tagen.",
"dashboard.agenda.full_link": "Vollst\u00e4ndige Agenda \u00f6ffnen \u2192",
// Inbox-approvals widget (t-paliad-219).
"dashboard.inbox.heading": "Offene Freigaben",
"dashboard.inbox.empty": "Keine offenen Freigaben.",
"dashboard.inbox.full_link": "Vollst\u00e4ndigen Posteingang \u00f6ffnen \u2192",
"dashboard.inbox.entity.deadline": "Frist",
"dashboard.inbox.entity.appointment": "Termin",
// 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",
@@ -957,18 +978,22 @@ const translations: Record<Lang, Record<string, string>> = {
"event.title.deadline_approval_approved": "Genehmigung erteilt",
"event.title.deadline_approval_rejected": "Genehmigung abgelehnt",
"event.title.deadline_approval_revoked": "Anfrage zurückgezogen",
"event.title.deadline_approval_changes_suggested": "Änderungen vorgeschlagen",
"event.title.appointment_approval_requested": "Genehmigung beantragt",
"event.title.appointment_approval_approved": "Genehmigung erteilt",
"event.title.appointment_approval_rejected": "Genehmigung abgelehnt",
"event.title.appointment_approval_revoked": "Anfrage zurückgezogen",
"event.title.appointment_approval_changes_suggested": "Änderungen vorgeschlagen",
"event.description.deadline_approval_requested": "4-Augen-Genehmigung für Frist beantragt",
"event.description.deadline_approval_approved": "Genehmigung für Frist erteilt",
"event.description.deadline_approval_rejected": "Genehmigung für Frist abgelehnt",
"event.description.deadline_approval_revoked": "Genehmigungsanfrage für Frist zurückgezogen",
"event.description.deadline_approval_changes_suggested": "Frist abgelehnt mit Gegenvorschlag",
"event.description.appointment_approval_requested": "4-Augen-Genehmigung für Termin beantragt",
"event.description.appointment_approval_approved": "Genehmigung für Termin erteilt",
"event.description.appointment_approval_rejected": "Genehmigung für Termin abgelehnt",
"event.description.appointment_approval_revoked": "Genehmigungsanfrage für Termin zurückgezogen",
"event.description.appointment_approval_changes_suggested": "Termin abgelehnt mit Gegenvorschlag",
"dashboard.action.short.deadline_approval_requested": "beantragte Genehmigung",
"dashboard.action.short.deadline_approval_approved": "genehmigte Frist",
"dashboard.action.short.deadline_approval_rejected": "lehnte Frist ab",
@@ -1112,6 +1137,17 @@ const translations: Record<Lang, Record<string, string>> = {
"einstellungen.tab.profil": "Profil",
"einstellungen.tab.benachrichtigungen": "Benachrichtigungen",
"einstellungen.tab.caldav": "CalDAV",
"einstellungen.tab.export": "Datenexport",
"einstellungen.export.subtitle": "Laden Sie Ihre pers\u00f6nlichen Paliad-Daten als Excel- + JSON- + CSV-Paket herunter. Enthalten ist alles, was Sie aktuell sehen k\u00f6nnen \u2014 Ihre Projekte, Fristen, Termine, Notizen, Genehmigungen und Einstellungen.",
"einstellungen.export.heading": "Pers\u00f6nlicher Datenexport",
"einstellungen.export.what": "Das Paket enth\u00e4lt Ihre sichtbaren Daten in drei Formaten in einem .zip:",
"einstellungen.export.bullet.xlsx": "paliad-export.xlsx \u2014 eine Excel-Mappe pro Entit\u00e4t.",
"einstellungen.export.bullet.json": "paliad-export.json \u2014 maschinenlesbare Kopie f\u00fcr Skripte und Tools.",
"einstellungen.export.bullet.csv": "csv/<sheet>.csv \u2014 Tabellen einzeln als CSV (UTF-8 mit BOM).",
"einstellungen.export.scope": "Umfang: alles, was Sie aktuell in Paliad sehen k\u00f6nnen (Sichtbarkeit zum Zeitpunkt des Exports). Passw\u00f6rter, CalDAV-Zugangsdaten und andere Geheimnisse werden nie exportiert.",
"einstellungen.export.audit": "Jeder Export wird im Audit-Log protokolliert.",
"einstellungen.export.button": "Daten exportieren",
"einstellungen.export.started": "Download gestartet. Falls nichts passiert, pr\u00fcfen Sie Ihren Browser-Downloadordner.",
"projects.title": "Projekte \u2014 Paliad",
"projects.heading": "Projekte",
"projects.subtitle": "Mandanten, Streitsachen, Patente und Verfahren \u2014 hierarchisch organisiert.",
@@ -1231,6 +1267,18 @@ const translations: Record<Lang, Record<string, string>> = {
"projects.detail.tab.termine": "Termine",
"projects.detail.tab.notizen": "Notizen",
"projects.detail.tab.checklisten": "Checklisten",
"projects.detail.tab.submissions": "Schriftsätze",
"projects.detail.export.button": "Daten exportieren",
"projects.detail.export.tooltip": "Daten dieses Projekts (mit Unter-Projekten) als Excel + JSON + CSV herunterladen.",
"projects.detail.submissions.empty": "Für dieses Verfahren sind keine Schriftsätze hinterlegt.",
"projects.detail.submissions.empty.no_proceeding": "Bitte zuerst einen Verfahrenstyp setzen.",
"projects.detail.submissions.col.name": "Schriftsatz",
"projects.detail.submissions.col.party": "Partei",
"projects.detail.submissions.col.source": "Rechtsgrundlage",
"projects.detail.submissions.col.action": "",
"projects.detail.submissions.action.generate": "Generieren",
"projects.detail.submissions.action.no_template": "Keine Vorlage",
"projects.detail.submissions.hint": "Schriftsätze werden direkt aus dem Projekt heraus als .docx generiert. Anpassen, drucken, einreichen.",
"projects.detail.verlauf.empty": "Noch keine Ereignisse aufgezeichnet.",
"projects.detail.verlauf.loadMore": "Mehr laden",
// SmartTimeline (t-paliad-171, Slice 1).
@@ -1591,7 +1639,8 @@ const translations: Record<Lang, Record<string, string>> = {
"events.toggle.deadline": "Fristen",
"events.toggle.appointment": "Termine",
"events.toggle.all": "Beides",
"events.filter.status.all": "Alle",
"events.filter.status.all": "Alle (auch vergangene)",
"events.filter.status.upcoming": "Ab heute",
"events.summary.later": "Sp\u00e4ter",
"events.col.date": "Datum",
"events.col.location": "Ort",
@@ -1642,6 +1691,45 @@ const translations: Record<Lang, Record<string, string>> = {
"caldav.log.col.error": "Fehler",
"caldav.log.empty": "Noch keine Synchronisationen aufgezeichnet.",
// CalDAV multi-calendar bindings (t-paliad-212 Slice 2b)
"caldav.bindings.heading": "Kalender",
"caldav.bindings.hint": "Verbinde mehrere Kalender mit Paliad — einen Master für alles oder eigene Kalender pro Projekt.",
"caldav.bindings.add": "+ Kalender hinzufügen",
"caldav.bindings.empty": "Noch keine Kalender konfiguriert.",
"caldav.bindings.scope.all_visible": "Alles",
"caldav.bindings.scope.personal_only": "Nur persönlich",
"caldav.bindings.scope.project": "Projekt",
"caldav.bindings.card.enabled": "Aktiv",
"caldav.bindings.card.edit": "Bearbeiten",
"caldav.bindings.card.remove": "Entfernen",
"caldav.bindings.modal.add_title": "Kalender hinzufügen",
"caldav.bindings.modal.edit_title": "Kalender bearbeiten",
"caldav.bindings.modal.source": "Kalender",
"caldav.bindings.modal.source.loading": "Lädt …",
"caldav.bindings.modal.source.existing": "Vorhandenen Kalender wählen",
"caldav.bindings.modal.source.create": "Neuen Kalender erstellen",
"caldav.bindings.modal.source.custom": "Eigene URL eingeben",
"caldav.bindings.modal.source.degrade": "Dieser Anbieter erlaubt das Erstellen neuer Kalender nicht via CalDAV. Erstelle den Kalender direkt in der Anbieter-Oberfläche und füge ihn hier per URL hinzu.",
"caldav.bindings.modal.source.discover_failed": "Kalender konnten nicht ermittelt werden — eigene URL eingeben.",
"caldav.bindings.modal.source.discover_empty": "Keine Kalender gefunden — eigene URL eingeben.",
"caldav.bindings.modal.display_name": "Anzeigename (optional)",
"caldav.bindings.modal.display_name.placeholder": "z.B. Projekt Acme v Bosch",
"caldav.bindings.modal.scope": "Inhalt",
"caldav.bindings.modal.scope.all_visible": "Alles, was ich sehe",
"caldav.bindings.modal.scope.personal_only": "Nur persönliche Termine",
"caldav.bindings.modal.scope.project": "Ein Projekt:",
"caldav.bindings.modal.scope.project.loading": "Lädt …",
"caldav.bindings.modal.submit_add": "Hinzufügen",
"caldav.bindings.modal.submit_edit": "Speichern",
"caldav.bindings.delete.confirm": "Diesen Kalender wirklich entfernen? Die zugehörigen Termine werden im externen Kalender gelöscht.",
"caldav.bindings.delete.failed": "Entfernen fehlgeschlagen — bitte später erneut versuchen.",
"caldav.bindings.error.scope": "Bitte einen Inhaltsbereich wählen.",
"caldav.bindings.error.scope_project": "Bitte ein Projekt auswählen.",
"caldav.bindings.error.path": "Bitte einen Kalender wählen oder eine URL eingeben.",
"caldav.bindings.error.create_name_required": "Bitte einen Anzeigenamen eingeben.",
"caldav.bindings.error.create_name_taken": "Name bereits vergeben — bitte einen anderen Anzeigenamen wählen.",
"caldav.bindings.error.create_unsupported": "Dein Anbieter unterstützt das Erstellen neuer Kalender nicht. Bitte 'Eigene URL eingeben' verwenden.",
// Notizen (polymorphic notes — Phase I)
"notes.section.title": "Notizen",
"notes.placeholder": "Notiz hinzuf\u00fcgen\u2026",
@@ -2072,6 +2160,7 @@ const translations: Record<Lang, Record<string, string>> = {
// t-paliad-088: Event Types — picker, multi-select filter, add modal.
"common.cancel": "Abbrechen",
"modal.close.label": "Schließen",
"event_types.cat.submission": "Eingaben",
"event_types.cat.decision": "Entscheidungen",
"event_types.cat.order": "Anordnungen",
@@ -2188,10 +2277,32 @@ const translations: Record<Lang, Record<string, string>> = {
"approvals.status.rejected": "Abgelehnt",
"approvals.status.revoked": "Zurückgezogen",
"approvals.status.superseded": "Ersetzt",
"approvals.status.changes_requested": "Abgelehnt mit Vorschlag",
"approvals.action.approve": "Genehmigen",
"approvals.action.reject": "Ablehnen",
"approvals.action.revoke": "Zurückziehen",
"approvals.action.suggest_changes": "Änderungen vorschlagen",
"approvals.note.placeholder": "Optionale Begründung...",
"approvals.suggest.modal_title": "Änderungen vorschlagen",
"approvals.suggest.intro": "Bearbeite die vorgeschlagenen Werte und/oder hinterlasse einen Kommentar. Dein Vorschlag wird als neue Genehmigungsanfrage eingestellt und kann vom ursprünglichen Antragsteller (oder einer anderen berechtigten Person) genehmigt werden.",
"approvals.suggest.note_label": "Kommentar zum Vorschlag",
"approvals.suggest.note_placeholder": "Warum sollen die Werte angepasst werden?",
"approvals.suggest.submit": "Vorschlag einreichen",
"approvals.suggest.cancel": "Abbrechen",
"approvals.suggest.submit_disabled_hint": "Bitte mindestens ein Feld ändern oder einen Kommentar hinterlassen.",
"approvals.suggest.next_request_link": "→ Neuer Vorschlag von {name}",
"approvals.suggest.unsupported_lifecycle": "Änderungen vorschlagen ist nur für Update-Anfragen möglich.",
"approvals.suggest.section.editable": "Felder",
"approvals.suggest.section.context": "Kontext",
"approvals.suggest.context.project": "Projekt",
"approvals.suggest.context.requester": "Eingereicht von",
"approvals.suggest.context.requested_at": "Eingereicht am",
"approvals.suggest.context.approval_status": "Genehmigungsstatus",
"approvals.suggest.event_type_picker_unavailable": "Ereignistypen konnten nicht geladen werden.",
"approvals.suggest.field.original_due_date": "Ursprüngliches Fälligkeitsdatum",
"approvals.suggest.field.warning_date": "Warndatum",
"approvals.suggest.field.rule_code": "Regel-Zitat",
"approvals.suggest.field.description": "Beschreibung",
"approvals.requested_by": "Eingereicht von",
"approvals.decided_by": "Entschieden von",
"approvals.decision_kind.peer": "Genehmigt durch Teammitglied",
@@ -2203,9 +2314,12 @@ const translations: Record<Lang, Record<string, string>> = {
"approvals.error.concurrent_pending": "Es liegt bereits eine Genehmigungsanfrage auf diesem Eintrag vor.",
"approvals.error.awaiting_approval": "Diese Anforderung wartet auf Genehmigung.",
"approvals.error.request_not_pending": "Diese Anfrage ist nicht mehr offen.",
"approvals.error.suggestion_requires_change": "Ein Vorschlag braucht entweder geänderte Werte oder einen Kommentar.",
"approvals.error.suggestion_lifecycle_invalid": "Änderungen vorschlagen ist nur für Update-Anfragen möglich.",
"approvals.disabled.self_approval": "Du kannst eigene Anträge nicht genehmigen",
"approvals.disabled.not_authorized": "Du hast keine Genehmigungsberechtigung für diesen Antrag",
"approvals.disabled.revoke_not_requester": "Nur der Antragsteller kann zurückziehen",
"approvals.disabled.suggest_lifecycle": "Änderungen vorschlagen ist nur für Update-Anfragen möglich",
"approvals.pending.badge": "Wartet auf Genehmigung",
"approvals.withdraw.cta": "Genehmigungsanfrage zurückziehen",
"approvals.withdraw.confirm": "Genehmigungsanfrage wirklich zurückziehen?",
@@ -2237,6 +2351,12 @@ const translations: Record<Lang, Record<string, string>> = {
"views.shape.calendar": "Kalender",
"views.shape.timeline": "Timeline",
"views.timeline.caveat.body": "Custom Views zeigen nur eingetretene Ereignisse. Für prognostizierte Fristen das Projekt-Chart öffnen.",
"views.timeline.zoom.label": "Zoom",
"views.timeline.zoom.in": "Heranzoomen",
"views.timeline.zoom.out": "Herauszoomen",
"views.timeline.zoom.1y": "±1 J.",
"views.timeline.zoom.2y": "±2 J.",
"views.timeline.zoom.all": "Alles",
"views.save_as": "Als Ansicht speichern",
"views.action.edit": "Bearbeiten",
"views.empty.title": "Keine Einträge gefunden.",
@@ -2367,6 +2487,7 @@ const translations: Record<Lang, Record<string, string>> = {
"views.bar.approval_status.approved": "Genehmigt",
"views.bar.approval_status.rejected": "Abgelehnt",
"views.bar.approval_status.revoked": "Zurückgezogen",
"views.bar.approval_status.changes_requested": "Mit Vorschlag",
"views.bar.approval_entity.deadline": "Frist",
"views.bar.approval_entity.appointment": "Termin",
"views.bar.deadline_status.pending": "Offen",
@@ -2420,9 +2541,10 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.rules.filter.lifecycle": "Lifecycle",
"admin.rules.filter.lifecycle.any": "Alle",
"admin.rules.filter.search": "Suche",
"admin.rules.filter.search.placeholder": "Name, Code, rule_code…",
"admin.rules.filter.search.placeholder": "Name, Submission Code, Rechtsgrundlage…",
"admin.rules.col.code": "Code",
"admin.rules.col.submission_code": "Submission Code / Einreichung-Kennung",
"admin.rules.col.legal_citation": "Rechtsgrundlage",
"admin.rules.col.name": "Name",
"admin.rules.col.proceeding": "Verfahrenstyp",
"admin.rules.col.priority": "Priorität",
@@ -2485,9 +2607,9 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.rules.edit.field.name": "Name (DE)",
"admin.rules.edit.field.name_en": "Name (EN)",
"admin.rules.edit.field.description": "Beschreibung",
"admin.rules.edit.field.code": "Code",
"admin.rules.edit.field.rule_code": "Rule-Code (zit.)",
"admin.rules.edit.field.legal_source": "Rechtsgrundlage",
"admin.rules.edit.field.submission_code": "Submission Code / Einreichung-Kennung",
"admin.rules.edit.field.rule_code": "Rechtsgrundlage (Kurzform)",
"admin.rules.edit.field.legal_source": "Rechtsgrundlage (Langform)",
"admin.rules.edit.field.proceeding": "Verfahrenstyp",
"admin.rules.edit.field.proceeding.none": "—",
"admin.rules.edit.field.trigger": "Trigger-Ereignis",
@@ -2801,11 +2923,13 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.upc.disc.cfi": "Lay-open Books",
"deadlines.upc.apl.cost": "Cost-Decision Appeal",
"deadlines.upc.apl.order": "Order Appeal (15-day)",
"deadlines.de.inf.lg": "Infringement (Regional Court)",
"deadlines.de.inf.olg": "Appeal OLG",
"deadlines.de.inf.bgh": "Revision / NZB BGH",
"deadlines.de.null.bpatg": "Nullity",
"deadlines.de.null.bgh": "Appeal BGH (Nullity)",
"deadlines.de.group.inf": "Infringement proceedings",
"deadlines.de.group.null": "Nullity proceedings",
"deadlines.de.inf.lg": "LG (1st instance)",
"deadlines.de.inf.olg": "OLG (Appeal)",
"deadlines.de.inf.bgh": "BGH (Revision / NZB)",
"deadlines.de.null.bpatg": "BPatG (1st instance)",
"deadlines.de.null.bgh": "BGH (Appeal)",
"deadlines.epa.opp.opd": "Opposition",
"deadlines.epa.opp.boa": "Appeal",
"deadlines.epa.grant.exa": "Grant Procedure",
@@ -2864,6 +2988,7 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.view.label": "View:",
"deadlines.view.timeline": "Timeline",
"deadlines.view.columns": "Columns",
"deadlines.notes.show": "Show details",
"deadlines.col.proactive": "Proactive",
"deadlines.col.court": "Court",
"deadlines.col.reactive": "Reactive",
@@ -3403,6 +3528,18 @@ const translations: Record<Lang, Record<string, string>> = {
"cal.month.9": "October",
"cal.month.10": "November",
"cal.month.11": "December",
"cal.view.month": "Month",
"cal.view.week": "Week",
"cal.view.day": "Day",
"cal.month.prev": "Previous month",
"cal.month.next": "Next month",
"cal.week.prev": "Previous week",
"cal.week.next": "Next week",
"cal.day.prev": "Previous day",
"cal.day.next": "Next day",
"cal.day.back_to_month": "Back to month",
"cal.day.open_day": "Open day view",
"cal.day.no_entries": "Nothing scheduled this day.",
// Akten detail — Fristen tab (Phase E)
@@ -3458,6 +3595,11 @@ const translations: Record<Lang, Record<string, string>> = {
"dashboard.agenda.heading": "Agenda",
"dashboard.agenda.empty": "Nothing due in the next 30 days.",
"dashboard.agenda.full_link": "Open full agenda →",
"dashboard.inbox.heading": "Open approvals",
"dashboard.inbox.empty": "No open approvals.",
"dashboard.inbox.full_link": "Open full inbox →",
"dashboard.inbox.entity.deadline": "Deadline",
"dashboard.inbox.entity.appointment": "Appointment",
"dashboard.section.collapse": "Collapse section",
"dashboard.section.expand": "Expand section",
"dashboard.urgency.overdue": "Overdue",
@@ -3511,18 +3653,22 @@ const translations: Record<Lang, Record<string, string>> = {
"event.title.deadline_approval_approved": "Approval granted",
"event.title.deadline_approval_rejected": "Approval rejected",
"event.title.deadline_approval_revoked": "Request revoked",
"event.title.deadline_approval_changes_suggested": "Changes suggested",
"event.title.appointment_approval_requested": "Approval requested",
"event.title.appointment_approval_approved": "Approval granted",
"event.title.appointment_approval_rejected": "Approval rejected",
"event.title.appointment_approval_revoked": "Request revoked",
"event.title.appointment_approval_changes_suggested": "Changes suggested",
"event.description.deadline_approval_requested": "Four-eyes approval requested for deadline",
"event.description.deadline_approval_approved": "Deadline approval granted",
"event.description.deadline_approval_rejected": "Deadline approval rejected",
"event.description.deadline_approval_revoked": "Deadline approval request revoked",
"event.description.deadline_approval_changes_suggested": "Deadline declined with a counter-proposal",
"event.description.appointment_approval_requested": "Four-eyes approval requested for appointment",
"event.description.appointment_approval_approved": "Appointment approval granted",
"event.description.appointment_approval_rejected": "Appointment approval rejected",
"event.description.appointment_approval_revoked": "Appointment approval request revoked",
"event.description.appointment_approval_changes_suggested": "Appointment declined with a counter-proposal",
"dashboard.action.short.deadline_approval_requested": "requested approval",
"dashboard.action.short.deadline_approval_approved": "approved deadline",
"dashboard.action.short.deadline_approval_rejected": "rejected deadline",
@@ -3666,6 +3812,17 @@ const translations: Record<Lang, Record<string, string>> = {
"einstellungen.tab.profil": "Profile",
"einstellungen.tab.benachrichtigungen": "Notifications",
"einstellungen.tab.caldav": "CalDAV",
"einstellungen.tab.export": "Data export",
"einstellungen.export.subtitle": "Download your personal Paliad data as an Excel + JSON + CSV bundle. The package contains everything you can currently see \u2014 your projects, deadlines, appointments, notes, approvals and settings.",
"einstellungen.export.heading": "Personal data export",
"einstellungen.export.what": "The package contains your visible data in three formats in one .zip:",
"einstellungen.export.bullet.xlsx": "paliad-export.xlsx \u2014 one Excel sheet per entity.",
"einstellungen.export.bullet.json": "paliad-export.json \u2014 machine-readable copy for scripts and tools.",
"einstellungen.export.bullet.csv": "csv/<sheet>.csv \u2014 individual tables as CSV (UTF-8 with BOM).",
"einstellungen.export.scope": "Scope: everything you can currently see in Paliad (visibility at the moment of export). Passwords, CalDAV credentials and other secrets are never exported.",
"einstellungen.export.audit": "Every export is logged in the audit log.",
"einstellungen.export.button": "Export data",
"einstellungen.export.started": "Download started. If nothing happens, check your browser's downloads folder.",
"projects.title": "Projects \u2014 Paliad",
"projects.heading": "Projects",
"projects.subtitle": "Clients, litigations, patents and cases \u2014 organised hierarchically.",
@@ -3785,6 +3942,18 @@ const translations: Record<Lang, Record<string, string>> = {
"projects.detail.tab.termine": "Appointments",
"projects.detail.tab.notizen": "Notes",
"projects.detail.tab.checklisten": "Checklists",
"projects.detail.tab.submissions": "Submissions",
"projects.detail.export.button": "Export data",
"projects.detail.export.tooltip": "Download this project's data (including sub-projects) as Excel + JSON + CSV.",
"projects.detail.submissions.empty": "No submissions are configured for this proceeding.",
"projects.detail.submissions.empty.no_proceeding": "Please set a proceeding type first.",
"projects.detail.submissions.col.name": "Submission",
"projects.detail.submissions.col.party": "Party",
"projects.detail.submissions.col.source": "Legal basis",
"projects.detail.submissions.col.action": "",
"projects.detail.submissions.action.generate": "Generate",
"projects.detail.submissions.action.no_template": "No template",
"projects.detail.submissions.hint": "Submissions are generated as .docx directly from the project. Edit, print, file.",
"projects.detail.verlauf.empty": "No events recorded yet.",
"projects.detail.verlauf.loadMore": "Load more",
"projects.detail.smarttimeline.empty": "No events captured yet.",
@@ -4141,7 +4310,8 @@ const translations: Record<Lang, Record<string, string>> = {
"events.toggle.deadline": "Deadlines",
"events.toggle.appointment": "Appointments",
"events.toggle.all": "Both",
"events.filter.status.all": "All",
"events.filter.status.all": "All (incl. past)",
"events.filter.status.upcoming": "From today",
"events.summary.later": "Later",
"events.col.date": "Date",
"events.col.location": "Location",
@@ -4192,6 +4362,45 @@ const translations: Record<Lang, Record<string, string>> = {
"caldav.log.col.error": "Error",
"caldav.log.empty": "No sync attempts recorded yet.",
// CalDAV multi-calendar bindings (t-paliad-212 Slice 2b)
"caldav.bindings.heading": "Calendars",
"caldav.bindings.hint": "Connect multiple calendars to Paliad — one master for everything or separate calendars per project.",
"caldav.bindings.add": "+ Add calendar",
"caldav.bindings.empty": "No calendars configured yet.",
"caldav.bindings.scope.all_visible": "Everything",
"caldav.bindings.scope.personal_only": "Personal only",
"caldav.bindings.scope.project": "Project",
"caldav.bindings.card.enabled": "Enabled",
"caldav.bindings.card.edit": "Edit",
"caldav.bindings.card.remove": "Remove",
"caldav.bindings.modal.add_title": "Add calendar",
"caldav.bindings.modal.edit_title": "Edit calendar",
"caldav.bindings.modal.source": "Calendar",
"caldav.bindings.modal.source.loading": "Loading…",
"caldav.bindings.modal.source.existing": "Pick existing calendar",
"caldav.bindings.modal.source.create": "Create new calendar",
"caldav.bindings.modal.source.custom": "Enter custom URL",
"caldav.bindings.modal.source.degrade": "This provider doesn't allow creating calendars via CalDAV. Please create the calendar in your provider's UI and add it here by URL.",
"caldav.bindings.modal.source.discover_failed": "Couldn't discover calendars — enter URL manually.",
"caldav.bindings.modal.source.discover_empty": "No calendars found — enter URL manually.",
"caldav.bindings.modal.display_name": "Display name (optional)",
"caldav.bindings.modal.display_name.placeholder": "e.g. Project Acme v Bosch",
"caldav.bindings.modal.scope": "Contents",
"caldav.bindings.modal.scope.all_visible": "Everything I can see",
"caldav.bindings.modal.scope.personal_only": "Personal appointments only",
"caldav.bindings.modal.scope.project": "One project:",
"caldav.bindings.modal.scope.project.loading": "Loading…",
"caldav.bindings.modal.submit_add": "Add",
"caldav.bindings.modal.submit_edit": "Save",
"caldav.bindings.delete.confirm": "Remove this calendar? Its events will be deleted from the external calendar.",
"caldav.bindings.delete.failed": "Removal failed — please try again later.",
"caldav.bindings.error.scope": "Please pick a content scope.",
"caldav.bindings.error.scope_project": "Please pick a project.",
"caldav.bindings.error.path": "Please pick a calendar or enter a URL.",
"caldav.bindings.error.create_name_required": "Please enter a display name.",
"caldav.bindings.error.create_name_taken": "Name already in use — please pick a different display name.",
"caldav.bindings.error.create_unsupported": "Your provider doesn't support creating calendars. Please use 'Enter custom URL' instead.",
// Notizen (polymorphic notes — Phase I)
"notes.section.title": "Notes",
"notes.placeholder": "Add a note\u2026",
@@ -4622,6 +4831,7 @@ const translations: Record<Lang, Record<string, string>> = {
// t-paliad-088: Event Types — picker, multi-select filter, add modal.
"common.cancel": "Cancel",
"modal.close.label": "Close",
"event_types.cat.submission": "Submissions",
"event_types.cat.decision": "Decisions",
"event_types.cat.order": "Orders",
@@ -4738,10 +4948,32 @@ const translations: Record<Lang, Record<string, string>> = {
"approvals.status.rejected": "Rejected",
"approvals.status.revoked": "Revoked",
"approvals.status.superseded": "Superseded",
"approvals.status.changes_requested": "Declined with changes",
"approvals.action.approve": "Approve",
"approvals.action.reject": "Reject",
"approvals.action.revoke": "Revoke",
"approvals.action.suggest_changes": "Suggest changes",
"approvals.note.placeholder": "Optional note...",
"approvals.suggest.modal_title": "Suggest changes",
"approvals.suggest.intro": "Edit the proposed values and/or leave a note. Your suggestion will be filed as a new approval request and may be approved by the original requester (or anyone else eligible).",
"approvals.suggest.note_label": "Note about your suggestion",
"approvals.suggest.note_placeholder": "Why should these values change?",
"approvals.suggest.submit": "Submit suggestion",
"approvals.suggest.cancel": "Cancel",
"approvals.suggest.submit_disabled_hint": "Change at least one field or leave a note.",
"approvals.suggest.next_request_link": "→ New suggestion by {name}",
"approvals.suggest.unsupported_lifecycle": "Suggest changes is only available for update requests.",
"approvals.suggest.section.editable": "Fields",
"approvals.suggest.section.context": "Context",
"approvals.suggest.context.project": "Project",
"approvals.suggest.context.requester": "Submitted by",
"approvals.suggest.context.requested_at": "Submitted at",
"approvals.suggest.context.approval_status": "Approval status",
"approvals.suggest.event_type_picker_unavailable": "Event types could not be loaded.",
"approvals.suggest.field.original_due_date": "Original due date",
"approvals.suggest.field.warning_date": "Warning date",
"approvals.suggest.field.rule_code": "Rule citation",
"approvals.suggest.field.description": "Description",
"approvals.requested_by": "Submitted by",
"approvals.decided_by": "Decided by",
"approvals.decision_kind.peer": "Peer approval",
@@ -4753,9 +4985,12 @@ const translations: Record<Lang, Record<string, string>> = {
"approvals.error.concurrent_pending": "Another approval request is already in flight on this entity.",
"approvals.error.awaiting_approval": "This entity is awaiting approval.",
"approvals.error.request_not_pending": "This request is no longer open.",
"approvals.error.suggestion_requires_change": "A suggestion needs either changed values or a note.",
"approvals.error.suggestion_lifecycle_invalid": "Suggest changes is only available for update requests.",
"approvals.disabled.self_approval": "You cannot approve your own requests",
"approvals.disabled.not_authorized": "You are not authorized to approve this request",
"approvals.disabled.revoke_not_requester": "Only the requester can withdraw",
"approvals.disabled.suggest_lifecycle": "Suggest changes is only available for update requests",
"approvals.pending.badge": "Awaiting approval",
"approvals.withdraw.cta": "Withdraw approval request",
"approvals.withdraw.confirm": "Withdraw the approval request?",
@@ -4787,6 +5022,12 @@ const translations: Record<Lang, Record<string, string>> = {
"views.shape.calendar": "Calendar",
"views.shape.timeline": "Timeline",
"views.timeline.caveat.body": "Custom Views show actual events only. Open the project's chart for projected rules.",
"views.timeline.zoom.label": "Zoom",
"views.timeline.zoom.in": "Zoom in",
"views.timeline.zoom.out": "Zoom out",
"views.timeline.zoom.1y": "±1 yr",
"views.timeline.zoom.2y": "±2 yr",
"views.timeline.zoom.all": "All",
"views.save_as": "Save as view",
"views.action.edit": "Edit",
"views.empty.title": "No matches found.",
@@ -4916,6 +5157,7 @@ const translations: Record<Lang, Record<string, string>> = {
"views.bar.approval_status.approved": "Approved",
"views.bar.approval_status.rejected": "Rejected",
"views.bar.approval_status.revoked": "Revoked",
"views.bar.approval_status.changes_requested": "With suggestion",
"views.bar.approval_entity.deadline": "Deadline",
"views.bar.approval_entity.appointment": "Appointment",
"views.bar.deadline_status.pending": "Open",
@@ -4969,9 +5211,10 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.rules.filter.lifecycle": "Lifecycle",
"admin.rules.filter.lifecycle.any": "Any",
"admin.rules.filter.search": "Search",
"admin.rules.filter.search.placeholder": "Name, code, rule_code…",
"admin.rules.filter.search.placeholder": "Name, submission code, legal citation…",
"admin.rules.col.code": "Code",
"admin.rules.col.submission_code": "Submission code",
"admin.rules.col.legal_citation": "Legal citation",
"admin.rules.col.name": "Name",
"admin.rules.col.proceeding": "Proceeding type",
"admin.rules.col.priority": "Priority",
@@ -5034,9 +5277,9 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.rules.edit.field.name": "Name (DE)",
"admin.rules.edit.field.name_en": "Name (EN)",
"admin.rules.edit.field.description": "Description",
"admin.rules.edit.field.code": "Code",
"admin.rules.edit.field.rule_code": "Rule code (cit.)",
"admin.rules.edit.field.legal_source": "Legal source",
"admin.rules.edit.field.submission_code": "Submission code",
"admin.rules.edit.field.rule_code": "Legal citation (short form)",
"admin.rules.edit.field.legal_source": "Legal citation (long form)",
"admin.rules.edit.field.proceeding": "Proceeding type",
"admin.rules.edit.field.proceeding.none": "—",
"admin.rules.edit.field.trigger": "Trigger event",

View File

@@ -4,6 +4,7 @@ import { mountFilterBar, type BarHandle } from "./filter-bar";
import type { AxisKey } from "./filter-bar";
import type { FilterSpec, RenderSpec, SystemView, ViewRunResult } from "./views/types";
import { renderListShape } from "./views/shape-list";
import { openApprovalEditModal } from "./components/approval-edit-modal";
// /inbox client — t-paliad-163 universal-filter migration.
//
@@ -123,11 +124,20 @@ function paint(
function wireApprovalActions(host: HTMLElement): void {
host.querySelectorAll<HTMLButtonElement>(".views-approval-action").forEach((btn) => {
const action = btn.dataset.action as "approve" | "reject" | "revoke" | undefined;
const action = btn.dataset.action as
| "approve"
| "reject"
| "revoke"
| "suggest_changes"
| undefined;
const li = btn.closest<HTMLLIElement>(".views-approval-row");
const id = li?.dataset.requestId;
if (!action || !id) return;
btn.addEventListener("click", async () => {
if (action === "suggest_changes") {
await handleSuggestChanges(btn, id, li!);
return;
}
let note = "";
if (action === "reject") {
note = window.prompt(t("approvals.note.placeholder")) || "";
@@ -141,8 +151,8 @@ function wireApprovalActions(host: HTMLElement): void {
body: JSON.stringify({ note }),
});
if (!r.ok) {
const body = await r.json().catch(() => ({} as { error?: string }));
alert(mapApprovalError(body.error || "internal"));
const body = await r.json().catch(() => ({} as { error?: string; code?: string }));
alert(mapApprovalError(body.code || body.error || "internal"));
btn.disabled = false;
return;
}
@@ -156,14 +166,109 @@ function wireApprovalActions(host: HTMLElement): void {
});
}
// handleSuggestChanges — t-paliad-216. Open the edit modal with the
// requester's original payload + pre_image pre-populated. If the user
// submits non-empty changes / note, POST to
// /api/approval-requests/{id}/suggest-changes; refresh the bar on success
// so the OLD row flips to changes_requested and the NEW pending row
// appears.
async function handleSuggestChanges(
btn: HTMLButtonElement,
requestID: string,
li: HTMLLIElement,
): Promise<void> {
// Read the row's detail blob off the data-attrs the shape-list stamped.
// shape-list serialises payload/pre_image inline; we fetch fresh via
// the per-row API to avoid relying on stale list data.
let payload: Record<string, unknown> | null = null;
let preImage: Record<string, unknown> | null = null;
let entityType: "deadline" | "appointment" = "deadline";
let lifecycleEvent = "update";
let projectTitle: string | undefined;
let requesterName: string | undefined;
let requestedAt: string | undefined;
try {
const r = await fetch(`/api/approval-requests/${requestID}`, { credentials: "include" });
if (r.ok) {
const body = (await r.json()) as {
entity_type?: "deadline" | "appointment";
lifecycle_event?: string;
payload?: Record<string, unknown> | null;
pre_image?: Record<string, unknown> | null;
project_title?: string;
requester_name?: string;
requested_at?: string;
};
payload = body.payload ?? null;
preImage = body.pre_image ?? null;
if (body.entity_type === "appointment") entityType = "appointment";
if (body.lifecycle_event) lifecycleEvent = body.lifecycle_event;
projectTitle = body.project_title;
requesterName = body.requester_name;
requestedAt = body.requested_at;
}
} catch (_e) {
// Modal still opens with empty defaults if the fetch fails; the
// server-side schema validation catches a misshapen counter.
}
const result = await openApprovalEditModal({
entityType,
lifecycleEvent,
payload,
preImage,
projectTitle,
requesterName,
requestedAt,
});
if (!result) return; // cancel
btn.disabled = true;
try {
const r = await fetch(`/api/approval-requests/${requestID}/suggest-changes`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
counter_payload: result.counterPayload,
note: result.note,
}),
});
const body = (await r.json().catch(() => ({}))) as {
error?: string;
code?: string;
new_request_id?: string;
};
if (!r.ok) {
alert(mapApprovalError(body.code || body.error || "internal"));
btn.disabled = false;
return;
}
await bar?.refresh();
await refreshInboxBadge();
btn.disabled = false;
// Surface the new row's id on the OLD row's <li> so callers (e.g.
// tests, future inspection) can find it without re-querying.
if (body.new_request_id) {
li.dataset.spawnedRequestId = body.new_request_id;
}
} catch (_e) {
alert("Network error");
btn.disabled = false;
}
}
function mapApprovalError(key: string): string {
switch (key) {
case "self_approval_blocked": return t("approvals.error.self_approval");
case "no_qualified_approver": return t("approvals.error.no_qualified_approver");
case "concurrent_pending": return t("approvals.error.concurrent_pending");
case "not_authorized": return t("approvals.error.not_authorized");
case "request_not_pending": return t("approvals.error.request_not_pending");
default: return key;
case "self_approval_blocked": return t("approvals.error.self_approval");
case "no_qualified_approver": return t("approvals.error.no_qualified_approver");
case "concurrent_pending": return t("approvals.error.concurrent_pending");
case "not_authorized": return t("approvals.error.not_authorized");
case "request_not_pending": return t("approvals.error.request_not_pending");
case "suggestion_requires_change": return t("approvals.error.suggestion_requires_change");
case "suggestion_lifecycle_invalid": return t("approvals.error.suggestion_lifecycle_invalid");
default: return key;
}
}

View File

@@ -12,6 +12,7 @@ import {
import { mountFilterBar, type BarHandle } from "./filter-bar";
import type { FilterSpec, RenderSpec } from "./views/types";
import { renderSmartTimeline, type TimelineEvent as SmartTimelineEvent, type LaneInfo as SmartTimelineLane } from "./views/shape-timeline";
import { loadAndRenderSubmissions } from "./submissions";
interface Project {
id: string;
@@ -158,7 +159,8 @@ type TabId =
| "deadlines"
| "appointments"
| "notes"
| "checklists";
| "checklists"
| "submissions";
const VALID_TABS: TabId[] = [
"history",
@@ -169,6 +171,7 @@ const VALID_TABS: TabId[] = [
"appointments",
"notes",
"checklists",
"submissions",
];
// Legacy German tab slugs that may appear in bookmarked URLs after the
@@ -1610,6 +1613,9 @@ function showTab(tab: TabId) {
if (tab === "checklists" && project) {
void loadAndRenderChecklistInstances(project.id);
}
if (tab === "submissions" && project) {
void loadAndRenderSubmissions(project.id);
}
}
let checklistInstancesInited = false;
@@ -2058,6 +2064,7 @@ async function main() {
initAttachUnitForm(id);
initNotesContainer(id);
mountVerlaufFilterBar(id);
wireExportButton(id);
showTab(parseTab());
}
@@ -2680,6 +2687,41 @@ function canManagePartnerUnits(): boolean {
);
}
// canExportProject mirrors the §4 server-side gate for /api/projects/{id}/export:
// global_admin OR direct team responsibility ∈ {lead, member}. Used to
// reveal the export button — server still re-enforces on the request.
function canExportProject(): boolean {
if (!me || !project) return false;
if (me.global_role === "global_admin") return true;
return teamMembers.some(
(m) =>
m.user_id === me!.id &&
m.project_id === project!.id &&
(m.responsibility === "lead" || m.responsibility === "member"),
);
}
// wireExportButton reveals + hooks up the project-export button on the
// tabs nav. Triggers a download via a transient <a download> — same
// pattern as the personal export in client/settings.ts.
function wireExportButton(projectID: string): void {
const btn = document.getElementById("project-export-btn") as HTMLButtonElement | null;
if (!btn) return;
if (!canExportProject()) {
btn.style.display = "none";
return;
}
btn.style.display = "";
btn.addEventListener("click", () => {
const a = document.createElement("a");
a.href = `/api/projects/${encodeURIComponent(projectID)}/export`;
a.download = "";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
});
}
function canRemoveTeamMember(m: ProjectTeamMember): boolean {
if (!me) return false;
if (m.user_id === me.id) return true;

View File

@@ -51,8 +51,8 @@ interface SyncLogEntry {
duration_ms?: number;
}
type TabName = "profil" | "benachrichtigungen" | "caldav";
const TABS: TabName[] = ["profil", "benachrichtigungen", "caldav"];
type TabName = "profil" | "benachrichtigungen" | "caldav" | "export";
const TABS: TabName[] = ["profil", "benachrichtigungen", "caldav", "export"];
const DEFAULT_TAB: TabName = "profil";
let me: Me | null = null;
@@ -115,6 +115,7 @@ function showTab(tab: TabName, pushHistory: boolean) {
if (tab === "profil") void loadProfilTab();
else if (tab === "benachrichtigungen") void loadPrefsTab();
else if (tab === "caldav") void loadCalDAVTab();
else if (tab === "export") void loadExportTab();
}
}
@@ -411,6 +412,11 @@ async function loadCalDAVTab() {
fillCalDAVForm();
renderCalDAVStatus();
await loadCalDAVLog();
// Slice 2b — multi-calendar bindings. loadBindingProjects feeds the
// project picker for scope=project; runs in parallel with the binding
// list fetch.
void loadBindingProjects();
await loadBindings();
}
async function loadCalDAVConfig(): Promise<boolean> {
@@ -596,6 +602,415 @@ async function deleteCalDAVConfig() {
}
}
// --- CalDAV bindings (Slice 2b multi-calendar picker) ---------------------
interface UserCalendarBinding {
id: string;
user_id: string;
calendar_path: string;
display_name: string;
scope_kind: "all_visible" | "personal_only" | "project" | "client" | "litigation" | "patent" | "case";
scope_id?: string | null;
include_personal: boolean;
enabled: boolean;
last_sync_at?: string | null;
last_sync_error?: string | null;
}
interface DiscoveredCalendar {
href: string;
display_name: string;
supported_components?: string[];
}
interface ProjectListItem {
id: string;
reference?: string;
title?: string;
type?: string;
}
let bindings: UserCalendarBinding[] = [];
let discoveredCalendars: DiscoveredCalendar[] = [];
let bindingProjects: ProjectListItem[] = [];
let editingBindingID: string | null = null;
// Slice 2c — capability cached from /api/caldav-discover. null = unprobed,
// true = MKCALENDAR supported (show "Create new calendar" radio),
// false = degrade UX (hide radio, surface bilingual notice).
let supportsMKCalendar: boolean | null = null;
async function loadBindings(): Promise<void> {
const section = document.getElementById("caldav-bindings-section");
if (!section) return;
try {
const resp = await fetch("/api/caldav-bindings");
if (resp.status === 501) return; // CalDAV unavailable; leave hidden
if (!resp.ok) return;
bindings = (await resp.json()) as UserCalendarBinding[];
section.style.display = "";
renderBindingsList();
} catch {
/* non-fatal */
}
}
function renderBindingsList(): void {
const list = document.getElementById("caldav-bindings-list")!;
const empty = document.getElementById("caldav-bindings-empty")!;
if (!bindings.length) {
list.innerHTML = "";
empty.style.display = "block";
return;
}
empty.style.display = "none";
list.innerHTML = bindings.map(renderBindingCard).join("");
// Wire per-card buttons.
for (const b of bindings) {
const card = document.getElementById(`caldav-binding-card-${b.id}`);
if (!card) continue;
card.querySelector(".caldav-binding-edit-btn")?.addEventListener("click", () => openBindingModal(b));
card.querySelector(".caldav-binding-delete-btn")?.addEventListener("click", () => deleteBinding(b));
const toggle = card.querySelector(".caldav-binding-enabled-toggle") as HTMLInputElement | null;
toggle?.addEventListener("change", () => toggleBindingEnabled(b, toggle.checked));
}
}
function renderBindingCard(b: UserCalendarBinding): string {
const label = b.display_name || b.calendar_path;
const scope = scopeLabel(b);
const last = b.last_sync_at ? fmtDateTime(b.last_sync_at) : t("caldav.never");
const err = b.last_sync_error ? `<span class="caldav-status-error">${esc(b.last_sync_error)}</span>` : "";
return `<div class="caldav-binding-card" id="caldav-binding-card-${esc(b.id)}">
<div class="caldav-binding-card-row">
<div class="caldav-binding-card-title">
<strong>${esc(label)}</strong>
<span class="caldav-binding-scope-chip">${esc(scope)}</span>
</div>
<label class="caldav-toggle-label">
<input type="checkbox" class="caldav-binding-enabled-toggle" ${b.enabled ? "checked" : ""} />
<span data-i18n="caldav.bindings.card.enabled">Aktiv</span>
</label>
</div>
<div class="caldav-binding-card-row caldav-binding-card-meta">
<span class="caldav-binding-path">${esc(b.calendar_path)}</span>
<span class="caldav-binding-last-sync">${esc(t("caldav.status.last_sync"))} ${esc(last)} ${err}</span>
</div>
<div class="caldav-binding-card-actions">
<button type="button" class="btn-secondary caldav-binding-edit-btn" data-i18n="caldav.bindings.card.edit">Bearbeiten</button>
<button type="button" class="btn-danger caldav-binding-delete-btn" data-i18n="caldav.bindings.card.remove">Entfernen</button>
</div>
</div>`;
}
function scopeLabel(b: UserCalendarBinding): string {
switch (b.scope_kind) {
case "all_visible":
return t("caldav.bindings.scope.all_visible");
case "personal_only":
return t("caldav.bindings.scope.personal_only");
case "project": {
const p = bindingProjects.find((p) => p.id === b.scope_id);
const name = p ? p.title || p.reference || p.id.slice(0, 8) : "?";
return `${t("caldav.bindings.scope.project")}: ${name}`;
}
default:
return b.scope_kind;
}
}
async function loadBindingProjects(): Promise<void> {
if (bindingProjects.length) return;
try {
const resp = await fetch("/api/projects");
if (resp.ok) bindingProjects = (await resp.json()) as ProjectListItem[];
} catch {
/* ignore */
}
}
async function loadDiscoveredCalendars(): Promise<void> {
const sel = document.getElementById("caldav-binding-discover-select") as HTMLSelectElement;
sel.innerHTML = `<option value="">${esc(t("caldav.bindings.modal.source.loading"))}</option>`;
try {
const resp = await fetch("/api/caldav-discover");
if (!resp.ok) {
sel.innerHTML = `<option value="">${esc(t("caldav.bindings.modal.source.discover_failed"))}</option>`;
supportsMKCalendar = null;
syncBindingSourceModeUI();
return;
}
const data = (await resp.json()) as {
calendars: DiscoveredCalendar[];
supports_mkcalendar?: boolean | null;
};
discoveredCalendars = data.calendars || [];
supportsMKCalendar = data.supports_mkcalendar ?? null;
if (!discoveredCalendars.length) {
sel.innerHTML = `<option value="">${esc(t("caldav.bindings.modal.source.discover_empty"))}</option>`;
} else {
sel.innerHTML = discoveredCalendars
.map((c) => `<option value="${esc(c.href)}">${esc(c.display_name || c.href)}</option>`)
.join("");
}
syncBindingSourceModeUI();
} catch {
sel.innerHTML = `<option value="">${esc(t("caldav.bindings.modal.source.discover_failed"))}</option>`;
supportsMKCalendar = null;
syncBindingSourceModeUI();
}
}
// syncBindingSourceModeUI shows / hides the "Neuen Kalender erstellen"
// radio + the Google-degrade notice based on the cached
// supports_mkcalendar capability. Also flips the visible input
// (dropdown vs URL text box) to match the currently selected mode.
function syncBindingSourceModeUI(): void {
const createRow = document.getElementById("caldav-binding-source-mode-create-row");
const degrade = document.getElementById("caldav-binding-degrade-notice");
if (createRow) createRow.style.display = supportsMKCalendar === true ? "" : "none";
if (degrade) degrade.style.display = supportsMKCalendar === false ? "" : "none";
// If supports_mkcalendar flipped to false while "create" was selected,
// fall back to "existing" so the user isn't staring at a hidden radio.
if (supportsMKCalendar !== true) {
const createRadio = document.querySelector(
'input[name="caldav-binding-source-mode"][value="create"]',
) as HTMLInputElement | null;
if (createRadio?.checked) {
const existing = document.querySelector(
'input[name="caldav-binding-source-mode"][value="existing"]',
) as HTMLInputElement | null;
if (existing) existing.checked = true;
}
}
const mode = currentBindingSourceMode();
const sel = document.getElementById("caldav-binding-discover-select") as HTMLSelectElement;
const customInput = document.getElementById("caldav-binding-custom-path") as HTMLInputElement;
sel.style.display = mode === "existing" ? "" : "none";
customInput.style.display = mode === "custom" ? "" : "none";
}
function currentBindingSourceMode(): "existing" | "create" | "custom" {
const checked = document.querySelector(
'input[name="caldav-binding-source-mode"]:checked',
) as HTMLInputElement | null;
return (checked?.value as "existing" | "create" | "custom") ?? "existing";
}
function openBindingModal(b: UserCalendarBinding | null) {
editingBindingID = b ? b.id : null;
const modal = document.getElementById("caldav-binding-modal")!;
const title = document.getElementById("caldav-binding-modal-title")!;
const submitBtn = document.getElementById("caldav-binding-submit-btn")!;
const sourceField = document.getElementById("caldav-binding-source-field")!;
const customInput = document.getElementById("caldav-binding-custom-path") as HTMLInputElement;
const nameInput = document.getElementById("caldav-binding-display-name") as HTMLInputElement;
const projectSel = document.getElementById("caldav-binding-project-select") as HTMLSelectElement;
const msg = document.getElementById("caldav-binding-msg")!;
msg.textContent = "";
if (b) {
title.textContent = t("caldav.bindings.modal.edit_title");
submitBtn.textContent = t("caldav.bindings.modal.submit_edit");
sourceField.style.display = "none";
nameInput.value = b.display_name;
const radio = document.querySelector(`input[name="caldav-binding-scope"][value="${b.scope_kind}"]`) as HTMLInputElement | null;
if (radio) radio.checked = true;
} else {
title.textContent = t("caldav.bindings.modal.add_title");
submitBtn.textContent = t("caldav.bindings.modal.submit_add");
sourceField.style.display = "";
// Reset the 3-way source-mode radio to "existing" (most common path).
const existingRadio = document.querySelector(
'input[name="caldav-binding-source-mode"][value="existing"]',
) as HTMLInputElement | null;
if (existingRadio) existingRadio.checked = true;
customInput.value = "";
nameInput.value = "";
const radio = document.querySelector(`input[name="caldav-binding-scope"][value="all_visible"]`) as HTMLInputElement;
radio.checked = true;
void loadDiscoveredCalendars();
}
// Project picker — populate options when project scope is picked.
projectSel.innerHTML = bindingProjects
.map((p) => `<option value="${esc(p.id)}">${esc((p.title || p.reference || p.id.slice(0, 8)))}</option>`)
.join("");
if (b && b.scope_kind === "project" && b.scope_id) {
projectSel.value = b.scope_id;
projectSel.disabled = false;
}
syncBindingScopeUI();
syncBindingSourceModeUI();
modal.style.display = "flex";
}
function closeBindingModal() {
document.getElementById("caldav-binding-modal")!.style.display = "none";
editingBindingID = null;
}
function syncBindingScopeUI(): void {
const scope = (document.querySelector('input[name="caldav-binding-scope"]:checked') as HTMLInputElement | null)?.value;
const projectSel = document.getElementById("caldav-binding-project-select") as HTMLSelectElement;
projectSel.disabled = scope !== "project";
}
async function submitBindingModal(ev: Event): Promise<void> {
ev.preventDefault();
const msg = document.getElementById("caldav-binding-msg")!;
msg.textContent = "";
const customInput = document.getElementById("caldav-binding-custom-path") as HTMLInputElement;
const sel = document.getElementById("caldav-binding-discover-select") as HTMLSelectElement;
const nameInput = document.getElementById("caldav-binding-display-name") as HTMLInputElement;
const projectSel = document.getElementById("caldav-binding-project-select") as HTMLSelectElement;
const submitBtn = document.getElementById("caldav-binding-submit-btn") as HTMLButtonElement;
const scope = (document.querySelector('input[name="caldav-binding-scope"]:checked') as HTMLInputElement | null)?.value;
if (!scope) {
msg.textContent = t("caldav.bindings.error.scope");
msg.className = "form-msg form-msg-error";
return;
}
if (scope === "project" && !projectSel.value) {
msg.textContent = t("caldav.bindings.error.scope_project");
msg.className = "form-msg form-msg-error";
return;
}
submitBtn.disabled = true;
try {
if (editingBindingID) {
const patchPayload: Record<string, unknown> = {
display_name: nameInput.value.trim(),
scope_kind: scope,
enabled: true,
};
if (scope === "project") patchPayload.scope_id = projectSel.value;
const resp = await fetch(`/api/caldav-bindings/${editingBindingID}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(patchPayload),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}) as { error?: string });
msg.textContent = err.error || t("caldav.error.generic");
msg.className = "form-msg form-msg-error";
return;
}
} else {
const mode = currentBindingSourceMode();
if (mode === "create") {
// Slice 2c MKCALENDAR path.
const displayName = nameInput.value.trim();
if (!displayName) {
msg.textContent = t("caldav.bindings.error.create_name_required");
msg.className = "form-msg form-msg-error";
return;
}
const createPayload: Record<string, unknown> = {
display_name: displayName,
scope_kind: scope,
};
if (scope === "project") createPayload.scope_id = projectSel.value;
const resp = await fetch("/api/caldav-mkcalendar", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(createPayload),
});
if (resp.status === 501) {
// Race: probe flipped to false between modal-open and submit.
// Re-sync the UI and surface a helpful message.
supportsMKCalendar = false;
syncBindingSourceModeUI();
msg.textContent = t("caldav.bindings.error.create_unsupported");
msg.className = "form-msg form-msg-error";
return;
}
if (resp.status === 409) {
msg.textContent = t("caldav.bindings.error.create_name_taken");
msg.className = "form-msg form-msg-error";
return;
}
if (!resp.ok) {
const err = await resp.json().catch(() => ({}) as { error?: string });
msg.textContent = err.error || t("caldav.error.generic");
msg.className = "form-msg form-msg-error";
return;
}
} else {
// existing | custom — POST /api/caldav-bindings with the path.
const path = mode === "custom" ? customInput.value.trim() : sel.value;
if (!path) {
msg.textContent = t("caldav.bindings.error.path");
msg.className = "form-msg form-msg-error";
return;
}
const postPayload: Record<string, unknown> = {
calendar_path: path,
display_name: nameInput.value.trim(),
scope_kind: scope,
enabled: true,
};
if (scope === "project") postPayload.scope_id = projectSel.value;
if (!postPayload.display_name && mode === "existing") {
const opt = sel.options[sel.selectedIndex];
postPayload.display_name = opt ? opt.text : "";
}
const resp = await fetch("/api/caldav-bindings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(postPayload),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}) as { error?: string });
msg.textContent = err.error || t("caldav.error.generic");
msg.className = "form-msg form-msg-error";
return;
}
}
}
closeBindingModal();
await loadBindings();
} catch {
msg.textContent = t("caldav.error.generic");
msg.className = "form-msg form-msg-error";
} finally {
submitBtn.disabled = false;
}
}
async function deleteBinding(b: UserCalendarBinding): Promise<void> {
if (!confirm(t("caldav.bindings.delete.confirm"))) return;
try {
const resp = await fetch(`/api/caldav-bindings/${b.id}`, { method: "DELETE" });
if (!resp.ok && resp.status !== 204 && resp.status !== 202) {
alert(t("caldav.bindings.delete.failed"));
return;
}
await loadBindings();
} catch {
alert(t("caldav.bindings.delete.failed"));
}
}
async function toggleBindingEnabled(b: UserCalendarBinding, enabled: boolean): Promise<void> {
try {
const resp = await fetch(`/api/caldav-bindings/${b.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled }),
});
if (resp.ok) {
b.enabled = enabled;
}
} catch {
/* non-fatal */
}
}
// --- "Meine Partner Units" card on the profile tab -------------------------
//
// Read-only summary of the current user's structural memberships. Membership
@@ -662,6 +1077,48 @@ async function renderMyPartnerUnits(): Promise<void> {
}
}
// --- Export tab (t-paliad-214 Slice 1) -------------------------------------
// Personal data export. One button; on click hits GET /api/me/export and the
// browser handles the download via Content-Disposition. We use an anchor +
// hidden iframe pattern so any non-200 response can surface inline instead
// of silently triggering a save dialog with an error-html body.
async function loadExportTab(): Promise<void> {
// Nothing to fetch on render; the tab is static text + button. Wired in
// the DOMContentLoaded handler.
}
function runExport(): void {
const msg = document.getElementById("export-msg");
const btn = document.getElementById("export-btn") as HTMLButtonElement | null;
if (msg) msg.textContent = "";
if (btn) btn.disabled = true;
// Trigger a navigation to the endpoint. The server sets
// Content-Disposition: attachment which the browser respects.
// We use a transient <a download> so the click goes through the
// normal download path even on browsers that try to render text/json.
const a = document.createElement("a");
a.href = "/api/me/export";
// download="" tells the browser to keep the server-provided filename
// when one is set via Content-Disposition.
a.download = "";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
// Re-enable after a short timeout so users can re-trigger if needed.
// We don't try to detect download completion — there's no portable
// browser API for it.
if (btn) {
setTimeout(() => {
btn.disabled = false;
if (msg)
msg.textContent =
t("einstellungen.export.started") ||
"Download gestartet. Falls nichts passiert, prüfen Sie Ihren Browser-Downloadordner.";
}, 500);
}
}
// --- Init -------------------------------------------------------------------
document.addEventListener("DOMContentLoaded", () => {
@@ -675,6 +1132,20 @@ document.addEventListener("DOMContentLoaded", () => {
document.getElementById("caldav-test-btn")!.addEventListener("click", testCalDAVConnection);
document.getElementById("caldav-delete-btn")!.addEventListener("click", deleteCalDAVConfig);
// CalDAV bindings (Slice 2b + 2c) — add/edit modal wiring.
document.getElementById("caldav-bindings-add-btn")?.addEventListener("click", () => openBindingModal(null));
document.getElementById("caldav-binding-modal-close")?.addEventListener("click", closeBindingModal);
document.getElementById("caldav-binding-cancel-btn")?.addEventListener("click", closeBindingModal);
document.getElementById("caldav-binding-form")?.addEventListener("submit", submitBindingModal);
document.querySelectorAll('input[name="caldav-binding-source-mode"]').forEach((el) => {
el.addEventListener("change", syncBindingSourceModeUI);
});
document.querySelectorAll('input[name="caldav-binding-scope"]').forEach((el) => {
el.addEventListener("change", syncBindingScopeUI);
});
const exportBtn = document.getElementById("export-btn");
if (exportBtn) exportBtn.addEventListener("click", runExport);
onLangChange(() => {
if (loadedTabs.has("profil")) renderOfficeOptions();
if (loadedTabs.has("caldav")) {

View File

@@ -0,0 +1,208 @@
// Submissions panel — fetches the project's submission catalog and
// renders one row per filing-type rule, with a [Generieren] action
// when a .docx template resolves server-side.
//
// t-paliad-215 Slice 1. Loaded lazily by the projects-detail tab
// switcher so projects without the Schriftsätze tab open don't pay
// for the per-row template-availability probes.
function escapeHtml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
interface SubmissionEntry {
submission_code: string;
name: string;
name_en: string;
event_type?: string;
primary_party?: string;
legal_source?: string;
has_template: boolean;
}
interface SubmissionListResponse {
project_id: string;
proceeding_type_id?: number;
entries: SubmissionEntry[];
}
// Module state — set once per page load when the user first opens the
// tab. Subsequent activations re-use the cached result so the lawyer
// doesn't pay for repeat list calls flipping between tabs.
let cached: { projectID: string; data: SubmissionListResponse } | null = null;
let loading = false;
/**
* Load + render the submissions panel for the given project.
*
* Idempotent: safe to call on every tab activation. The second call
* paints from cache instantly; the first call shows a loading state
* until the list response arrives.
*/
export async function loadAndRenderSubmissions(projectID: string): Promise<void> {
if (loading) return;
if (cached && cached.projectID === projectID) {
render(cached.data);
return;
}
loading = true;
try {
const resp = await fetch(`/api/projects/${projectID}/submissions`);
if (!resp.ok) {
renderError();
return;
}
const data = (await resp.json()) as SubmissionListResponse;
cached = { projectID, data };
render(data);
} catch {
renderError();
} finally {
loading = false;
}
}
function render(data: SubmissionListResponse): void {
const empty = document.getElementById("project-submissions-empty");
const noProc = document.getElementById("project-submissions-no-proceeding");
const wrap = document.getElementById("project-submissions-tablewrap");
const body = document.getElementById("project-submissions-body");
if (!empty || !noProc || !wrap || !body) return;
if (data.proceeding_type_id == null || data.proceeding_type_id === 0) {
noProc.style.display = "";
empty.style.display = "none";
wrap.style.display = "none";
return;
}
noProc.style.display = "none";
if (data.entries.length === 0) {
empty.style.display = "";
wrap.style.display = "none";
return;
}
empty.style.display = "none";
wrap.style.display = "";
const isEN = document.documentElement.lang === "en";
body.innerHTML = data.entries.map((entry) => {
const name = isEN && entry.name_en ? entry.name_en : entry.name;
const party = formatParty(entry.primary_party, isEN);
const source = entry.legal_source ?? "";
const action = entry.has_template
? `<button type="button" class="btn-primary btn-cta-lime btn-small submission-generate-btn"
data-code="${escapeHtml(entry.submission_code)}"
data-project="${escapeHtml(data.project_id)}"
data-i18n="projects.detail.submissions.action.generate">${isEN ? "Generate" : "Generieren"}</button>`
: `<span class="submission-no-template" data-i18n="projects.detail.submissions.action.no_template">${isEN ? "No template" : "Keine Vorlage"}</span>`;
return `<tr class="submission-row">
<td>
<span class="submission-name">${escapeHtml(name)}</span>
<span class="submission-code">${escapeHtml(entry.submission_code)}</span>
</td>
<td>${escapeHtml(party)}</td>
<td>${escapeHtml(source)}</td>
<td class="submission-action-cell">${action}</td>
</tr>`;
}).join("");
// Wire button clicks. One click handler per render to avoid stale
// closures from the previous render's data.
body.querySelectorAll<HTMLButtonElement>(".submission-generate-btn").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
void onGenerateClick(btn);
});
});
}
function renderError(): void {
const empty = document.getElementById("project-submissions-empty");
const noProc = document.getElementById("project-submissions-no-proceeding");
const wrap = document.getElementById("project-submissions-tablewrap");
if (!empty || !noProc || !wrap) return;
noProc.style.display = "none";
wrap.style.display = "none";
empty.style.display = "";
empty.textContent = document.documentElement.lang === "en"
? "Failed to load submissions list."
: "Schriftsatzliste konnte nicht geladen werden.";
}
function formatParty(role: string | undefined, isEN: boolean): string {
switch ((role ?? "").toLowerCase()) {
case "claimant": return isEN ? "Claimant" : "Klägerin";
case "defendant": return isEN ? "Defendant" : "Beklagte";
case "both": return isEN ? "Both" : "Beide";
case "court": return isEN ? "Court" : "Gericht";
default: return "";
}
}
// onGenerateClick triggers a download. Disables the button while the
// request is in flight to prevent double-submits and surfaces an
// inline error on failure.
async function onGenerateClick(btn: HTMLButtonElement): Promise<void> {
const code = btn.dataset.code;
const projectID = btn.dataset.project;
if (!code || !projectID) return;
const originalLabel = btn.textContent ?? "";
btn.disabled = true;
btn.textContent = document.documentElement.lang === "en" ? "Generating…" : "Wird generiert…";
try {
const url = `/api/projects/${projectID}/submissions/${encodeURIComponent(code)}/generate`;
const resp = await fetch(url, { method: "GET" });
if (!resp.ok) {
let detail = "";
try {
const data = await resp.json() as { error?: string };
detail = data.error ?? "";
} catch {
// fallthrough
}
alert(
(document.documentElement.lang === "en"
? "Generation failed."
: "Generieren fehlgeschlagen.") + (detail ? `\n\n${detail}` : ""),
);
return;
}
const blob = await resp.blob();
const filename = parseFilename(resp.headers.get("Content-Disposition") ?? "")
?? `${code}.docx`;
triggerDownload(blob, filename);
} finally {
btn.disabled = false;
btn.textContent = originalLabel;
}
}
// parseFilename pulls the filename out of a Content-Disposition
// header. Supports both unquoted and quoted forms.
function parseFilename(header: string): string | null {
const m = /filename\s*=\s*"?([^";]+)"?/i.exec(header);
return m ? m[1] : null;
}
// triggerDownload creates an <a> with an object URL, clicks it, and
// revokes the URL. Standard browser-side download pattern.
function triggerDownload(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
// Revoke on next tick so the click actually triggers the download
// before the URL is gone.
setTimeout(() => URL.revokeObjectURL(url), 0);
}

View File

@@ -17,14 +17,66 @@ import {
populateCourtPicker,
renderColumnsBody,
renderTimelineBody,
wireDateEditClicks,
} from "./views/verfahrensablauf-core";
let selectedType = "";
let lastResponse: DeadlineResponse | null = null;
// Per-rule anchor overrides set by the click-to-edit affordance on
// timeline / column date cells. Posted as `anchorOverrides` to the
// /api/tools/fristenrechner calc so downstream rules re-anchor off the
// user's chosen date. Cleared whenever the trigger changes (proceeding,
// trigger date, flag toggle) so a fresh calc starts unanchored — same
// semantic as /tools/fristenrechner.
const anchorOverrides = new Map<string, string>();
function clearAnchorOverrides() { anchorOverrides.clear(); }
type ProcedureView = "timeline" | "columns";
let procedureView: ProcedureView = "columns";
// Notes toggle — when off (default), per-rule descriptive notes render
// as a compact ⓘ icon next to the meta line (hover for full text). When
// on, the full notes block expands under each card. Choice persists in
// localStorage so a reload or recalc keeps the user's preference.
const NOTES_PREF_KEY = "paliad.fristen.notes-show";
function readNotesPref(): boolean {
try { return localStorage.getItem(NOTES_PREF_KEY) === "1"; } catch { return false; }
}
function writeNotesPref(on: boolean): void {
try { localStorage.setItem(NOTES_PREF_KEY, on ? "1" : "0"); } catch { /* no-op */ }
}
let showNotes = readNotesPref();
// Jurisdiction display prefix for the proceeding-summary chip + the
// trigger-event placeholder. Same forum slugs the .proceeding-group
// `data-forum` attribute carries in verfahrensablauf.tsx /
// fristenrechner.tsx (upc / de / epa / dpma). Disambiguates the
// 4 redundancies in the corpus (UPC Verletzungsverfahren vs DE
// Verletzungsklage etc.) once the picker collapses.
const FORUM_LABEL: Record<string, string> = {
upc: "UPC",
de: "DE",
epa: "EPA",
dpma: "DPMA",
};
function jurisdictionFor(btn: HTMLButtonElement): string {
const group = btn.closest<HTMLElement>(".proceeding-group");
const forum = group?.dataset.forum || "";
return FORUM_LABEL[forum] || "";
}
function proceedingDisplayName(btn: HTMLButtonElement): string {
const name = btn.querySelector("strong")?.textContent || "";
const jur = jurisdictionFor(btn);
return jur ? `${jur} ${name}` : name;
}
function activeProceedingButton(): HTMLButtonElement | null {
return document.querySelector<HTMLButtonElement>(".proceeding-btn.active");
}
// Auto-calc plumbing — sequence + debounce mirror /tools/fristenrechner
// so rapid input changes never let a stale response overwrite a fresh
// one.
@@ -46,6 +98,31 @@ function showStep(n: number) {
}
}
// Read the proceeding-specific flag checkboxes and assemble the
// payload the calculator expects. Mirrors fristenrechner.ts so the
// gating semantics stay identical: with_amend on upc.inf.cfi is
// nested under with_ccr (R.30 is only available with a CCR);
// upc.rev.cfi exposes with_amend + with_cci as two independent
// gates. R.19 Einspruch is NOT flag-gated (mig 098, m's 2026-05-18
// call): it's just an always-available optional submission, so it
// has no checkbox.
function readFlags(): string[] {
const ccr = document.getElementById("ccr-flag") as HTMLInputElement | null;
const infAmend = document.getElementById("inf-amend-flag") as HTMLInputElement | null;
const revAmend = document.getElementById("rev-amend-flag") as HTMLInputElement | null;
const revCci = document.getElementById("rev-cci-flag") as HTMLInputElement | null;
const flags: string[] = [];
if (selectedType === "upc.inf.cfi") {
if (ccr?.checked) flags.push("with_ccr");
if (ccr?.checked && infAmend?.checked) flags.push("with_amend");
}
if (selectedType === "upc.rev.cfi") {
if (revAmend?.checked) flags.push("with_amend");
if (revCci?.checked) flags.push("with_cci");
}
return flags;
}
async function doCalc() {
const seq = ++calcSeq;
const dateInput = document.getElementById("trigger-date") as HTMLInputElement | null;
@@ -58,9 +135,14 @@ async function doCalc() {
? courtPicker.value
: "";
const overrides: Record<string, string> = {};
for (const [code, date] of anchorOverrides) overrides[code] = date;
const data = await calculateDeadlines({
proceedingType: selectedType,
triggerDate,
flags: readFlags(),
anchorOverrides: overrides,
courtId,
});
if (seq !== calcSeq) return;
@@ -70,25 +152,56 @@ async function doCalc() {
showStep(3);
}
// triggerEventLabelFor picks the user-facing "Auslösendes Ereignis"
// label from the calc response. The root rule (isRootEvent=true) is
// 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).
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 || "";
}
function syncTriggerEventLabel() {
const triggerEventEl = document.getElementById("trigger-event");
if (!triggerEventEl) return;
if (lastResponse) {
triggerEventEl.textContent = triggerEventLabelFor(lastResponse);
} else {
triggerEventEl.textContent = "—";
}
}
function renderResults(data: DeadlineResponse) {
const container = document.getElementById("timeline-container");
if (!container) return;
const printBtn = document.getElementById("fristen-print-btn");
const toggle = document.getElementById("fristen-view-toggle");
const procName = tDyn(`deadlines.${data.proceedingType.toLowerCase()}`);
// Header shows the picked proceeding with its jurisdiction prefix
// so the user can tell UPC Verletzungsverfahren apart from DE
// Verletzungsklage once the picker collapses.
const activeBtn = activeProceedingButton();
const procName = activeBtn ? proceedingDisplayName(activeBtn)
: tDyn(`deadlines.${data.proceedingType.toLowerCase()}`);
const headerHtml = `<div class="timeline-header">
<strong>${procName}</strong>
<span class="timeline-trigger-date">${t("deadlines.trigger.label")}: ${formatDate(data.triggerDate)}</span>
</div>`;
const bodyHtml = procedureView === "columns"
? renderColumnsBody(data)
: renderTimelineBody(data);
? renderColumnsBody(data, { editable: true, showNotes })
: renderTimelineBody(data, { showParty: true, editable: true, showNotes });
container.innerHTML = headerHtml + bodyHtml;
if (printBtn) printBtn.style.display = "block";
if (toggle) toggle.style.display = "";
syncTriggerEventLabel();
}
function setProceedingPickerCollapsed(collapsed: boolean, displayName?: string) {
@@ -100,18 +213,52 @@ function setProceedingPickerCollapsed(collapsed: boolean, displayName?: string)
if (summaryName && displayName) summaryName.textContent = displayName;
}
// syncFlagRows shows/hides the proceeding-specific checkbox rows
// based on selectedType. Same disposition as fristenrechner.ts —
// the with_amend nested-under-ccr semantic is enforced via
// syncInfAmendEnabled().
function syncFlagRows() {
const show = (id: string, when: boolean) => {
const el = document.getElementById(id);
if (el) el.style.display = when ? "" : "none";
};
show("ccr-flag-row", selectedType === "upc.inf.cfi");
show("inf-amend-flag-row", selectedType === "upc.inf.cfi");
show("rev-amend-flag-row", selectedType === "upc.rev.cfi");
show("rev-cci-flag-row", selectedType === "upc.rev.cfi");
syncInfAmendEnabled();
}
// R.30 amendment-application is only available with a CCR — disable
// (and clear) the nested inf-amend checkbox while ccr is off so the
// calc payload stays coherent. Mirrors fristenrechner.ts.
function syncInfAmendEnabled() {
const ccr = document.getElementById("ccr-flag") as HTMLInputElement | null;
const infAmend = document.getElementById("inf-amend-flag") as HTMLInputElement | null;
if (!ccr || !infAmend) return;
infAmend.disabled = !ccr.checked;
if (!ccr.checked) infAmend.checked = false;
}
function selectProceeding(btn: HTMLButtonElement) {
document.querySelectorAll(".proceeding-btn").forEach((b) => b.classList.remove("active"));
btn.classList.add("active");
selectedType = btn.dataset.code || "";
const nextType = btn.dataset.code || "";
// Different proceeding tree → previously-set overrides reference
// rule codes that don't exist in the new tree. Clear before the
// next calc so the fresh proceeding starts unanchored.
if (selectedType !== nextType) clearAnchorOverrides();
selectedType = nextType;
const name = btn.querySelector("strong")?.textContent || "";
const triggerEventEl = document.getElementById("trigger-event");
if (triggerEventEl) triggerEventEl.textContent = name;
// Trigger-event label fires from the calc response (root rule).
// Until step 3 renders, fall back to an em-dash placeholder.
lastResponse = null;
syncTriggerEventLabel();
void populateCourtPicker("court-picker-row", "court-picker", selectedType);
syncFlagRows();
setProceedingPickerCollapsed(true, name);
setProceedingPickerCollapsed(true, proceedingDisplayName(btn));
showStep(2);
scheduleCalc(0);
@@ -169,18 +316,62 @@ document.addEventListener("DOMContentLoaded", () => {
const courtPicker = document.getElementById("court-picker") as HTMLSelectElement | null;
if (courtPicker) courtPicker.addEventListener("change", () => scheduleCalc(0));
// Flag-checkbox listeners — each flip triggers a fresh calc so the
// timeline re-projects with the new gating. ccr-flag additionally
// enables/disables the nested inf-amend row.
const ccrFlag = document.getElementById("ccr-flag") as HTMLInputElement | null;
if (ccrFlag) ccrFlag.addEventListener("change", () => {
syncInfAmendEnabled();
scheduleCalc(0);
});
(["inf-amend-flag", "rev-amend-flag", "rev-cci-flag"]).forEach((id) => {
const cb = document.getElementById(id) as HTMLInputElement | null;
if (cb) cb.addEventListener("change", () => scheduleCalc(0));
});
document.getElementById("fristen-print-btn")?.addEventListener("click", () => window.print());
// Click-to-edit on timeline / column date cells — same delegated
// pattern as /tools/fristenrechner. Survives renderResults()'s
// innerHTML rewrites because the listener lives on the container.
const timelineContainer = document.getElementById("timeline-container");
if (timelineContainer) {
wireDateEditClicks(timelineContainer, (ruleCode, newValue) => {
if (newValue === "") {
anchorOverrides.delete(ruleCode);
} else {
anchorOverrides.set(ruleCode, newValue);
}
scheduleCalc(0);
});
}
// Notes toggle — restores last preference on load + re-renders when
// the user flips it. Lives in the same toggle bar as the view picker.
const notesShowCb = document.getElementById("fristen-notes-show") as HTMLInputElement | null;
if (notesShowCb) {
notesShowCb.checked = showNotes;
notesShowCb.addEventListener("change", () => {
showNotes = notesShowCb.checked;
writeNotesPref(showNotes);
if (lastResponse) renderResults(lastResponse);
});
}
initViewToggle();
onLangChange(() => {
if (lastResponse) renderResults(lastResponse);
const activeBtn = document.querySelector<HTMLButtonElement>(".proceeding-btn.active");
// Active-button name updates with language change (the data-i18n
// pass swaps the inner <strong>'s text). Re-collapse the summary
// chip and re-derive the trigger event label from the lang-current
// calc response.
const activeBtn = activeProceedingButton();
if (activeBtn) {
const name = activeBtn.querySelector("strong")?.textContent || "";
const triggerEventEl = document.getElementById("trigger-event");
if (triggerEventEl) triggerEventEl.textContent = name;
const summary = document.getElementById("proceeding-summary-name");
if (summary) summary.textContent = proceedingDisplayName(activeBtn);
}
if (lastResponse) renderResults(lastResponse);
syncTriggerEventLabel();
});
// Pre-select the first proceeding tile so users see a timeline

View File

@@ -1,16 +1,25 @@
import { initI18n, t, type I18nKey } from "./i18n";
import { initSidebar } from "./sidebar";
import type { FilterSpec, RenderSpec, ViewRunResult, UserView, RenderShape } from "./views/types";
import type { FilterSpec, RenderSpec, ViewRunResult, UserView, RenderShape, DataSource } from "./views/types";
import { renderListShape } from "./views/shape-list";
import { renderCardsShape } from "./views/shape-cards";
import { renderCalendarShape } from "./views/shape-calendar";
import { renderTimelineShape } from "./views/shape-timeline-cv";
import type { ChartHandle } from "./views/shape-timeline-chart";
import { mountFilterBar, type BarHandle, type AxisKey } from "./filter-bar";
// /views and /views/{slug} client. Loads the saved or system view, runs
// it via /api/views/{slug}/run, and dispatches to the matching render-
// shape component. Shape-switcher chips toggle the live render without
// re-fetching (the rows are already in memory).
//
// t-paliad-211 — the per-view filter bar (`mountFilterBar`) lives between
// the shape chips and the render hosts. The saved view's filter_spec is
// the baseline; the bar overlays the user's per-session tweaks and POSTs
// `/api/views/{slug}/run` with the effective spec as override (the
// substrate accepts `{filter: ...}` per views.go:283). Axes are picked
// from the spec's data sources so a deadline-only view doesn't expose
// the appointment-type chip cluster and vice versa.
initI18n();
initSidebar();
@@ -30,6 +39,8 @@ interface ViewMeta {
let currentMeta: ViewMeta | null = null;
let currentRows: ViewRunResult | null = null;
let currentRender: RenderSpec | null = null;
let bar: BarHandle | null = null;
document.addEventListener("DOMContentLoaded", () => {
bindShapeChips();
@@ -54,9 +65,10 @@ async function hydrate(): Promise<void> {
return;
}
currentMeta = meta;
currentRender = meta.render;
document.title = `${meta.name} — Paliad`;
updateHeader(meta);
await runAndRender(meta);
mountBar(meta);
if (meta.user_view_id) {
fireAndForget(`/api/user-views/${meta.user_view_id}/touch`, "POST");
}
@@ -97,57 +109,97 @@ async function resolveMeta(slug: string): Promise<ViewMeta | null> {
return null;
}
async function runAndRender(meta: ViewMeta): Promise<void> {
// mountBar wires the filter-bar to the view's saved spec. The bar runs
// the spec through `/api/views/{slug}/run` whenever the user tweaks an
// axis, and the onResult callback re-paints into the active shape host.
function mountBar(meta: ViewMeta): void {
const host = document.getElementById("views-filter-bar");
const toolbar = document.getElementById("views-toolbar");
const loading = document.getElementById("views-loading");
if (loading) loading.hidden = false;
if (toolbar) toolbar.hidden = false;
if (host) host.hidden = false;
if (!host) return;
// Tear down any prior bar (re-mount on lang change isn't supported
// here, but a future Phase-2 axis switch may need this).
if (bar) {
bar.destroy();
bar = null;
}
const axes = axesForSources(meta.filter.sources);
// surfaceKey scoped per-view-slug so two views remember their own
// density/sort prefs independently.
const surfaceKey = `views.${meta.slug}`;
bar = mountFilterBar(host, {
baseFilter: meta.filter,
baseRender: meta.render,
axes,
surfaceKey,
systemViewSlug: meta.slug,
// The saved view IS the baseline; "Speichern als Sicht" remains
// available for users who want to fork.
showSaveAsView: !meta.is_system,
userViewId: meta.user_view_id,
onResult: (result, effective) => {
if (loading) loading.hidden = true;
currentRows = result;
currentRender = effective.render;
paintRows(result, effective.render);
},
});
}
// axesForSources picks the filter-bar axes a saved view's data sources
// support. Universal axes (time / personal_only / sort) always render;
// per-source predicates only render when the view's spec actually
// queries that source — otherwise the chip would be a no-op.
function axesForSources(sources: DataSource[]): AxisKey[] {
const set = new Set(sources);
const out: AxisKey[] = ["time"];
if (set.has("deadline")) out.push("deadline_status");
if (set.has("appointment")) out.push("appointment_type");
if (set.has("approval_request")) {
out.push("approval_viewer_role");
out.push("approval_status");
out.push("approval_entity_type");
}
if (set.has("project_event")) out.push("project_event_kind");
out.push("personal_only");
out.push("sort");
return out;
}
function paintRows(result: ViewRunResult, render: RenderSpec): void {
const empty = document.getElementById("views-empty");
const errorEl = document.getElementById("views-error");
const toolbar = document.getElementById("views-toolbar");
if (loading) loading.hidden = false;
if (empty) empty.hidden = true;
if (errorEl) errorEl.hidden = true;
if (toolbar) toolbar.hidden = false;
let result: ViewRunResult;
try {
const r = await fetch(`/api/views/${encodeURIComponent(meta.slug)}/run`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
if (!r.ok) {
showError(`${r.status}: ${r.statusText}`);
return;
}
result = (await r.json()) as ViewRunResult;
} catch (e) {
showError(t("views.error.network"));
return;
}
if (loading) loading.hidden = true;
currentRows = result;
if (result.inaccessible_project_ids && result.inaccessible_project_ids.length > 0) {
showInaccessibleToast(result.inaccessible_project_ids.length);
}
if (result.rows.length === 0) {
setActiveShape(null);
if (empty) {
empty.hidden = false;
const hint = document.getElementById("views-empty-hint");
if (hint) hint.textContent = filterSummary(meta.filter);
if (hint && currentMeta) hint.textContent = filterSummary(currentMeta.filter);
}
return;
}
if (empty) empty.hidden = true;
setActiveShape(meta.render.shape);
renderShape(meta.render.shape, meta.render, result.rows);
setActiveShape(render.shape);
renderShape(render.shape, render, result.rows);
}
function setActiveShape(shape: RenderShape): void {
function setActiveShape(shape: RenderShape | null): void {
for (const host of ["views-shape-list", "views-shape-cards", "views-shape-calendar", "views-shape-timeline"]) {
const el = document.getElementById(host);
if (el) el.hidden = !host.endsWith("-" + shape);
if (el) el.hidden = shape === null ? true : !host.endsWith("-" + shape);
}
document.querySelectorAll<HTMLButtonElement>("#views-shape-chips [data-shape]").forEach((btn) => {
btn.classList.toggle("active", btn.dataset.shape === shape);
@@ -223,9 +275,10 @@ function bindShapeChips(): void {
document.querySelectorAll<HTMLButtonElement>("#views-shape-chips [data-shape]").forEach((btn) => {
btn.addEventListener("click", () => {
const shape = (btn.dataset.shape ?? "list") as RenderShape;
if (!currentMeta || !currentRows) return;
if (!currentRows || !currentRender) return;
// Override the shape transiently — doesn't mutate the saved spec.
const overrideRender = { ...currentMeta.render, shape };
const overrideRender = { ...currentRender, shape };
currentRender = overrideRender;
setActiveShape(shape);
renderShape(shape, overrideRender, currentRows.rows);
});

View File

@@ -1,14 +1,21 @@
import { t, type I18nKey, getLang } from "../i18n";
import { t, tDyn, type I18nKey, getLang } from "../i18n";
import type { RenderSpec, ViewRow } from "./types";
// shape-calendar: month grid. Toggleable to week-view via per-shape
// config. Mirrors the look of /events?view=calendar but generic across
// sources.
// 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;
export function renderCalendarShape(host: HTMLElement, rows: ViewRow[], render: RenderSpec): void {
host.innerHTML = "";
const cfg = render.calendar ?? {};
const view = cfg.default_view ?? "month";
// Mobile fallback: viewport <600px collapses to cards (cleaner on narrow
// screens). Documented in design §9 trade-off 8.
@@ -19,15 +26,121 @@ export function renderCalendarShape(host: HTMLElement, rows: ViewRow[], render:
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));
}
const monthRef = pickMonthAnchor(rows);
wrap.appendChild(renderMonth(monthRef, rows));
host.appendChild(wrap);
}
function renderMonth(anchor: Date, rows: ViewRow[]): HTMLElement {
// --- 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";
@@ -37,20 +150,22 @@ function renderMonth(anchor: Date, rows: ViewRow[]): HTMLElement {
header.textContent = anchor.toLocaleDateString(lang, { month: "long", year: "numeric" });
wrap.appendChild(header);
// Weekday headers (Mon-Sun, ISO week).
const weekdayBar = document.createElement("div");
weekdayBar.className = "views-calendar-weekdays";
const weekdayKeys: I18nKey[] = ["cal.day.mon", "cal.day.tue", "cal.day.wed", "cal.day.thu", "cal.day.fri", "cal.day.sat", "cal.day.sun"];
// 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);
weekdayBar.appendChild(cell);
grid.appendChild(cell);
}
wrap.appendChild(weekdayBar);
const grid = document.createElement("div");
grid.className = "views-calendar-grid";
const monthStart = new Date(anchor.getFullYear(), anchor.getMonth(), 1);
const startWeekday = (monthStart.getDay() + 6) % 7; // Mon=0
@@ -63,47 +178,16 @@ function renderMonth(anchor: Date, rows: ViewRow[]): HTMLElement {
grid.appendChild(cell);
}
// Bucket rows by ISO date (yyyy-mm-dd).
const byDate = new Map<string, ViewRow[]>();
for (const row of rows) {
const d = new Date(row.event_date);
if (isNaN(d.getTime())) continue;
if (d.getMonth() !== anchor.getMonth() || d.getFullYear() !== anchor.getFullYear()) continue;
const key = isoDate(d);
const arr = byDate.get(key);
if (arr) arr.push(row);
else byDate.set(key, [row]);
}
// 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 cell = document.createElement("div");
cell.className = "views-calendar-cell";
const dayLabel = document.createElement("div");
dayLabel.className = "views-calendar-cell-day";
dayLabel.textContent = String(day);
cell.appendChild(dayLabel);
const dateKey = isoDate(new Date(anchor.getFullYear(), anchor.getMonth(), day));
const dayDate = new Date(anchor.getFullYear(), anchor.getMonth(), day);
const dateKey = isoDate(dayDate);
const dayRows = byDate.get(dateKey) ?? [];
if (dayRows.length > 0) {
const ul = document.createElement("ul");
ul.className = "views-calendar-pills";
const visible = dayRows.slice(0, 3);
for (const row of visible) {
const li = document.createElement("li");
li.className = `views-calendar-pill views-calendar-pill--${row.kind}`;
li.textContent = row.title;
li.title = row.title + (row.project_title ? `${row.project_title}` : "");
ul.appendChild(li);
}
if (dayRows.length > visible.length) {
const more = document.createElement("li");
more.className = "views-calendar-pill views-calendar-pill--more";
more.textContent = `+${dayRows.length - visible.length}`;
ul.appendChild(more);
}
cell.appendChild(ul);
}
const cell = renderMonthCell(dayDate, day, dayRows, onDayDrill);
grid.appendChild(cell);
}
@@ -111,14 +195,269 @@ function renderMonth(anchor: Date, rows: ViewRow[]): HTMLElement {
return wrap;
}
function pickMonthAnchor(rows: ViewRow[]): Date {
// Anchor on the first row's month, or "this month" if empty.
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())) return d;
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;
});
}
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 new Date(now.getFullYear(), now.getMonth(), 1);
return d.getFullYear() === now.getFullYear()
&& d.getMonth() === now.getMonth()
&& d.getDate() === now.getDate();
}
function isoDate(d: Date): string {
@@ -127,3 +466,60 @@ function isoDate(d: Date): string {
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());
}

View File

@@ -196,6 +196,12 @@ interface ApprovalDetail {
requester_kind?: "user" | "agent";
decider_name?: string;
decision_note?: string;
// counter_payload + next_request_id — populated on the OLD row of a
// suggest-changes pair (t-paliad-216). The new row's id lets us
// render a back-link "→ Neuer Vorschlag von {decider}". Both stay
// unset on any non-changes_requested status.
counter_payload?: Record<string, unknown> | null;
next_request_id?: string;
// Per-viewer eligibility flags resolved server-side against the caller
// (t-paliad-202). Used to grey out actions the server would reject.
// Optional so an older payload still renders — falsy means "treat as
@@ -204,6 +210,11 @@ interface ApprovalDetail {
viewer_is_requester?: boolean;
}
// Pending-row action set. suggest_changes was added in t-paliad-216 as
// the fourth action — the approver authors a counter-proposal which
// becomes a NEW pending row authored by them.
type ApprovalAction = "approve" | "reject" | "revoke" | "suggest_changes";
function renderApprovalList(rows: ViewRow[]): HTMLElement {
const ul = document.createElement("ul");
ul.className = "inbox-list views-approval-list";
@@ -262,13 +273,20 @@ function renderApprovalList(rows: ViewRow[]): HTMLElement {
actions.className = "inbox-row-actions";
if (detail.status === "pending") {
// All three actions are stamped on every pending row; the per-viewer
// All four actions are stamped on every pending row; the per-viewer
// viewer_can_approve / viewer_is_requester flags (resolved server-side)
// decide which are enabled vs. greyed out with a tooltip. m's ask
// (2026-05-17): show what's possible but disable what isn't, rather
// than alert-after-click. The server still enforces — disabled buttons
// are a UI hint, not a security gate.
//
// suggest_changes is hidden for non-update lifecycles (the backend
// returns ErrSuggestionLifecycleInvalid for create/complete/delete,
// so we don't even render the button for them).
actions.appendChild(approvalActionBtn("approve", detail));
if (detail.lifecycle_event === "update") {
actions.appendChild(approvalActionBtn("suggest_changes", detail));
}
actions.appendChild(approvalActionBtn("reject", detail));
actions.appendChild(approvalActionBtn("revoke", detail));
} else if (detail.status) {
@@ -285,6 +303,22 @@ function renderApprovalList(rows: ViewRow[]): HTMLElement {
}
li.appendChild(actions);
// Back-link from the OLD changes_requested row to the NEW pending
// counter row (t-paliad-216). Hydrated server-side as
// detail.next_request_id; the surface renders a link that scrolls
// / filters to the new row. Falsy next_request_id = no link (e.g.
// older rows pre-mig-103, or rows where the server hasn't joined the
// back-pointer).
if (detail.status === "changes_requested" && detail.next_request_id) {
const link = document.createElement("a");
link.className = "inbox-row-next-request";
link.href = `#request-${detail.next_request_id}`;
link.dataset.nextRequestId = detail.next_request_id;
const deciderName = detail.decider_name || "";
link.textContent = t("approvals.suggest.next_request_link").replace("{name}", deciderName);
li.appendChild(link);
}
ul.appendChild(li);
}
return ul;
@@ -321,17 +355,24 @@ function renderDiff(detail: ApprovalDetail): HTMLElement | null {
}
function approvalActionBtn(
action: "approve" | "reject" | "revoke",
action: ApprovalAction,
detail: ApprovalDetail,
): HTMLButtonElement {
const btn = document.createElement("button");
btn.type = "button";
btn.dataset.action = action;
const cls = action === "approve" ? "btn-primary" : action === "reject" ? "btn-danger" : "btn-secondary";
// suggest_changes shares the secondary style with revoke; reject is
// danger (terminal "no"); approve is primary.
const cls = action === "approve"
? "btn-primary"
: action === "reject"
? "btn-danger"
: "btn-secondary";
btn.className = `btn ${cls} inbox-row-action views-approval-action`;
btn.textContent = t(("approvals.action." + action) as I18nKey);
// approve / reject share the eligibility gate; revoke is requester-only.
// approve / reject / suggest_changes share the canApprove eligibility
// gate; revoke is requester-only.
const reason = disabledReasonFor(action, detail);
if (reason) {
btn.disabled = true;
@@ -341,13 +382,13 @@ function approvalActionBtn(
}
function disabledReasonFor(
action: "approve" | "reject" | "revoke",
action: ApprovalAction,
detail: ApprovalDetail,
): I18nKey | null {
if (action === "revoke") {
return detail.viewer_is_requester ? null : "approvals.disabled.revoke_not_requester";
}
// approve + reject — same gate as the server's canApprove.
// approve / reject / suggest_changes — same gate as the server's canApprove.
if (detail.viewer_can_approve) return null;
if (detail.viewer_is_requester) return "approvals.disabled.self_approval";
return "approvals.disabled.not_authorized";

View File

@@ -467,6 +467,11 @@ export function paint(
}
// Lane separators — horizontal lines between rows + labels in the gutter.
// Labels live inside <foreignObject> so HTML/CSS handles ellipsis +
// tooltip cleanly. SVG <text> has no auto-clipping and long titles
// would bleed into the chart canvas (t-paliad-211).
const labelPadding = 8;
const labelMaxWidth = Math.max(0, chart.viewport.laneLabelWidth - labelPadding * 2);
for (let i = 0; i < chart.laneRows.length; i++) {
const row = chart.laneRows[i];
if (i > 0) {
@@ -479,13 +484,19 @@ export function paint(
}));
}
if (row.label) {
const labelEl = svg("text", {
class: "chart-lane-label",
x: 8,
y: row.y + row.height / 2 + 4,
const fo = svg("foreignObject", {
class: "chart-lane-label-fo",
x: labelPadding,
y: row.y,
width: labelMaxWidth,
height: row.height,
});
labelEl.textContent = row.label;
gGrid.appendChild(labelEl);
const div = document.createElement("div");
div.className = "chart-lane-label";
div.textContent = row.label;
div.title = row.label;
fo.appendChild(div);
gGrid.appendChild(fo);
}
}

View File

@@ -7,6 +7,7 @@ import {
} from "./shape-timeline-chart";
import type { LaneInfo, TimelineEvent } from "./shape-timeline";
import type { RenderSpec, ViewRow } from "./types";
import { t } from "../i18n";
// shape-timeline-cv (t-paliad-177 Slice 4, faraday-Q7) — Custom Views
// host for the chart renderer.
@@ -23,6 +24,12 @@ import type { RenderSpec, ViewRow } from "./types";
//
// Design ref: docs/design-project-chart-2026-05-09.md §8.3 + §11.5 + §13.4.
// Zoom levels in ascending span (t-paliad-211). Width-only — the chart's
// existing range presets already provide three meaningful zoom levels.
// Stored in URL as ?tl_zoom=1y|2y|all.
const ZOOM_LEVELS: RangePreset[] = ["1y", "2y", "all"];
const ZOOM_PARAM = "tl_zoom";
export function renderTimelineShape(
host: HTMLElement,
rows: ReadonlyArray<ViewRow>,
@@ -35,21 +42,127 @@ export function renderTimelineShape(
const { events, lanes } = adapt(rows);
const cfg = render.timeline ?? {};
// Resolve the initial zoom: URL > render spec > "1y" default.
const initialZoom = resolveInitialZoom(cfg.range_preset);
// Toolbar lives above the chart in its own row so it doesn't compete
// with the date-axis / lane labels for space.
const toolbar = document.createElement("div");
toolbar.className = "views-timeline-toolbar";
host.appendChild(toolbar);
const chartHost = document.createElement("div");
chartHost.className = "views-timeline-chart-host-inner";
host.appendChild(chartHost);
// The CV adapter has no per-project "id" to fetch live timeline data
// for — we hand mount() a placeholder projectId and the staticData
// pre-loaded array so it skips the project endpoint entirely. If the
// user clicks a mark, the renderer's default click handler still
// resolves /deadlines/{id} / /appointments/{id} from the adapted
// event's id field, so deep-links land on the correct entity page.
return mount(host, {
const handle = mount(chartHost, {
projectId: "cv",
staticData: { events, lanes },
palette: (cfg.palette as Palette | undefined) ?? "default",
density: (cfg.density as Density | undefined) ?? "standard",
rangePreset: (cfg.range_preset as RangePreset | undefined) ?? "1y",
rangePreset: initialZoom,
rangeFrom: cfg.range_from,
rangeTo: cfg.range_to,
});
let currentZoom = initialZoom;
const setZoom = (next: RangePreset) => {
if (next === currentZoom) return;
currentZoom = next;
handle.setRange(next);
writeZoomURL(next);
paintToolbar();
};
const paintToolbar = () => {
toolbar.innerHTML = "";
const zoomGroup = document.createElement("div");
zoomGroup.className = "views-timeline-zoom-group";
const zoomLabel = document.createElement("span");
zoomLabel.className = "views-timeline-zoom-label";
zoomLabel.textContent = t("views.timeline.zoom.label");
zoomGroup.appendChild(zoomLabel);
const zoomOut = document.createElement("button");
zoomOut.type = "button";
zoomOut.className = "btn-secondary btn-small views-timeline-zoom-btn";
zoomOut.setAttribute("aria-label", t("views.timeline.zoom.out"));
zoomOut.title = t("views.timeline.zoom.out");
zoomOut.textContent = "";
zoomOut.disabled = currentZoom === ZOOM_LEVELS[ZOOM_LEVELS.length - 1];
zoomOut.addEventListener("click", () => {
const idx = ZOOM_LEVELS.indexOf(currentZoom);
if (idx < ZOOM_LEVELS.length - 1) setZoom(ZOOM_LEVELS[idx + 1]);
});
zoomGroup.appendChild(zoomOut);
// Active-level chips (1y / 2y / all). Clicking jumps directly.
const chips = document.createElement("div");
chips.className = "views-timeline-zoom-chips agenda-chip-row";
for (const level of ZOOM_LEVELS) {
const chip = document.createElement("button");
chip.type = "button";
chip.className = "agenda-chip views-timeline-zoom-chip"
+ (level === currentZoom ? " agenda-chip-active" : "");
chip.dataset.zoom = level;
chip.textContent = t(zoomLevelKey(level));
chip.addEventListener("click", () => setZoom(level));
chips.appendChild(chip);
}
zoomGroup.appendChild(chips);
const zoomIn = document.createElement("button");
zoomIn.type = "button";
zoomIn.className = "btn-secondary btn-small views-timeline-zoom-btn";
zoomIn.setAttribute("aria-label", t("views.timeline.zoom.in"));
zoomIn.title = t("views.timeline.zoom.in");
zoomIn.textContent = "+";
zoomIn.disabled = currentZoom === ZOOM_LEVELS[0];
zoomIn.addEventListener("click", () => {
const idx = ZOOM_LEVELS.indexOf(currentZoom);
if (idx > 0) setZoom(ZOOM_LEVELS[idx - 1]);
});
zoomGroup.appendChild(zoomIn);
toolbar.appendChild(zoomGroup);
};
paintToolbar();
// Apply the URL zoom if it differed from the spec — mount() already
// used initialZoom so this is a no-op when URL was empty. But when URL
// disagreed with the spec, mount() honoured the URL and the toolbar
// already reflects that, so nothing extra to do here.
return handle;
}
function zoomLevelKey(level: RangePreset): "views.timeline.zoom.1y" | "views.timeline.zoom.2y" | "views.timeline.zoom.all" {
if (level === "1y") return "views.timeline.zoom.1y";
if (level === "2y") return "views.timeline.zoom.2y";
return "views.timeline.zoom.all";
}
function resolveInitialZoom(spec: string | undefined): RangePreset {
const params = new URLSearchParams(window.location.search);
const raw = params.get(ZOOM_PARAM);
if (raw && (ZOOM_LEVELS as string[]).includes(raw)) return raw as RangePreset;
if (spec && (ZOOM_LEVELS as string[]).includes(spec)) return spec as RangePreset;
return "1y";
}
function writeZoomURL(zoom: RangePreset): void {
const url = new URL(window.location.href);
url.searchParams.set(ZOOM_PARAM, zoom);
history.replaceState(null, "", url.toString());
}
export interface AdapterResult {

View File

@@ -0,0 +1,67 @@
import { describe, expect, test } from "bun:test";
import {
type CalculatedDeadline,
deadlineCardHtml,
} from "./verfahrensablauf-core";
// Regression tests for the editable→click-to-edit wiring on timeline date
// cells (m/paliad#59). When CardOpts.editable=true the card renderer must
// emit `class="… frist-date-edit"` with `data-rule-code` + `data-current-
// date` on the date span. Pages then attach a delegated click handler that
// resolves that selector to swap in an inline `<input type="date">`. If a
// future refactor drops the attrs, /tools/verfahrensablauf and
// /tools/fristenrechner both silently lose click-to-edit (no script error,
// nothing happens on click). These tests pin the contract.
//
// Fixture leaves ruleRef/legalSource* empty so deadlineCardHtml stays
// inside its non-DOM code paths (escHtml is DOM-backed and bun test runs
// in plain Node without jsdom).
const dl = (overrides: Partial<CalculatedDeadline> = {}): CalculatedDeadline => ({
code: "upc-rop-12",
name: "Klageerwiderung",
nameEN: "Statement of Defence",
party: "defendant",
priority: "mandatory",
ruleRef: "",
dueDate: "2026-07-15",
originalDate: "2026-07-15",
wasAdjusted: false,
isRootEvent: false,
isCourtSet: false,
...overrides,
});
describe("deadlineCardHtml — editable=true emits click-to-edit attrs", () => {
test("date span carries frist-date-edit class + data-rule-code + data-current-date", () => {
const html = deadlineCardHtml(dl(), { showParty: true, editable: true });
expect(html).toContain('class="timeline-date frist-date-edit"');
expect(html).toContain('data-rule-code="upc-rop-12"');
expect(html).toContain('data-current-date="2026-07-15"');
expect(html).toContain('role="button"');
expect(html).toContain('tabindex="0"');
});
test("editable=false (default) emits the date span without click-to-edit attrs", () => {
const html = deadlineCardHtml(dl(), { showParty: true });
expect(html).toContain("timeline-date");
expect(html).not.toContain("data-rule-code=");
expect(html).not.toContain('role="button"');
});
test("root event suppresses editable even when editable=true (root has no override semantic)", () => {
const html = deadlineCardHtml(dl({ isRootEvent: true }), { showParty: true, editable: true });
expect(html).not.toContain("data-rule-code=");
});
test("isCourtSet renders the court-set placeholder with click-to-edit so users can pin a real date", () => {
const html = deadlineCardHtml(dl({ isCourtSet: true }), { showParty: true, editable: true });
expect(html).toContain("timeline-court-set frist-date-edit");
expect(html).toContain('data-rule-code="upc-rop-12"');
});
test("empty rule code with editable=true still suppresses click-to-edit (no anchor target)", () => {
const html = deadlineCardHtml(dl({ code: "" }), { showParty: true, editable: true });
expect(html).not.toContain("data-rule-code=");
});
});

View File

@@ -38,6 +38,14 @@ export interface CalculatedDeadline {
priority: "mandatory" | "recommended" | "optional" | "informational";
ruleRef: string;
legalSource?: string;
// legalSourceDisplay is the pretty form ("UPC RoP R.220(1)") produced
// by FormatLegalSourceDisplay on the backend. Renderer prefers this
// over ruleRef when set; falls back to ruleRef otherwise.
legalSourceDisplay?: string;
// legalSourceURL is the youpc.org/laws permalink when the cited body
// is hosted there (UPCRoP / UPCA / UPCS today). Empty for DE/EPA/EU
// bodies — the renderer shows display text without a link.
legalSourceURL?: string;
notes?: string;
notesEN?: string;
dueDate: string;
@@ -211,6 +219,13 @@ export interface CardOpts {
// verfahrensablauf abstract-browse surface keeps editable=false because
// there's no anchor-override state on that page in Slice 1.
editable?: boolean;
// showNotes controls how the per-rule descriptive notes render:
// true → expanded `<div class="timeline-notes">…</div>` below the card
// false → compact ⓘ icon next to the meta line, full text on hover
// (browser-native `title` attribute) and screen-reader-readable
// Page shells expose a toggle ("Hinweise anzeigen") that flips this and
// re-renders. Default false — notes are noisy on long timelines.
showNotes?: boolean;
}
export function deadlineCardHtml(dl: CalculatedDeadline, opts: CardOpts): string {
@@ -240,19 +255,35 @@ export function deadlineCardHtml(dl: CalculatedDeadline, opts: CardOpts): string
? `<div class="timeline-adjusted">⚠ ${formatAdjustedNote(dl)}</div>`
: "";
const ruleRef = dl.ruleRef
? `<span class="timeline-rule">${dl.ruleRef}</span>`
: "";
// Prefer the structured legalSource (pretty display + youpc.org link
// when hosted there) over the bare rule_code fallback. UPC.RoP rules
// link to /laws/UPCRoP/<n>; DE / EPA / EU bodies have no youpc home
// yet so we render display text plain.
const legalDisplay = dl.legalSourceDisplay || "";
const legalURL = dl.legalSourceURL || "";
let ruleRef = "";
if (legalDisplay && legalURL) {
ruleRef = `<a class="timeline-rule timeline-rule--link" href="${escAttr(legalURL)}" target="_blank" rel="noopener noreferrer">${escHtml(legalDisplay)}</a>`;
} else if (legalDisplay) {
ruleRef = `<span class="timeline-rule">${escHtml(legalDisplay)}</span>`;
} else if (dl.ruleRef) {
ruleRef = `<span class="timeline-rule">${escHtml(dl.ruleRef)}</span>`;
}
const noteText = getLang() === "en" ? (dl.notesEN || dl.notes) : dl.notes;
const notes = noteText
const showNotes = opts.showNotes === true;
const notesBlock = noteText && showNotes
? `<div class="timeline-notes">${noteText}</div>`
: "";
const noteHint = noteText && !showNotes
? `<span class="timeline-note-hint" tabindex="0" role="note" aria-label="${escAttr(noteText)}" title="${escAttr(noteText)}">ⓘ</span>`
: "";
const meta = (opts.showParty || ruleRef)
const meta = (opts.showParty || ruleRef || noteHint)
? `<div class="timeline-meta">
${opts.showParty ? partyBadge(dl.party) : ""}
${ruleRef}
${noteHint}
</div>`
: "";
@@ -265,7 +296,88 @@ export function deadlineCardHtml(dl: CalculatedDeadline, opts: CardOpts): string
</div>
${meta}
${adjustedNote}
${notes}`;
${notesBlock}`;
}
// ─── inline date editor (click-to-edit per-rule due date) ────────────────
//
// The renderer emits `<span class="frist-date-edit" data-rule-code="…"
// data-current-date="YYYY-MM-DD" role="button" tabindex="0">…</span>` when
// CardOpts.editable is true. Pages call wireDateEditClicks() on their
// result container once, and the delegated click/keydown handlers swap a
// clicked span for a `<input type="date">` editor via openInlineDateEditor.
// The caller's onCommit callback receives (ruleCode, newValue) — an empty
// newValue means "revert" (clear the anchor override and let the calculator
// re-project). The actual recompute is the caller's job — they own the
// anchor-overrides map + the calc dispatch.
export function openInlineDateEditor(
span: HTMLElement,
onCommit: (ruleCode: string, newValue: string) => void,
): void {
const ruleCode = span.dataset.ruleCode || "";
if (!ruleCode) return;
const current = span.dataset.currentDate || "";
const editor = document.createElement("input");
editor.type = "date";
editor.className = "frist-date-edit-input";
editor.value = current;
let done = false;
const cancel = () => {
if (done) return;
done = true;
editor.replaceWith(span);
};
const commit = (newValue: string) => {
if (done) return;
done = true;
onCommit(ruleCode, newValue);
};
editor.addEventListener("blur", () => {
if (editor.value !== current) commit(editor.value);
else cancel();
});
editor.addEventListener("keydown", (e) => {
const ke = e as KeyboardEvent;
if (ke.key === "Enter") {
e.preventDefault();
editor.blur();
} else if (ke.key === "Escape") {
e.preventDefault();
cancel();
}
});
span.replaceWith(editor);
editor.focus();
if (editor.value) editor.select();
}
// wireDateEditClicks attaches delegated click + keyboard handlers to the
// timeline result container so click-to-edit survives every innerHTML
// rewrite the page does on recalc. Idempotent — re-calling on the same
// container does nothing (the dataset flag short-circuits).
export function wireDateEditClicks(
container: HTMLElement,
onCommit: (ruleCode: string, newValue: string) => void,
): void {
if (container.dataset.dateEditWired === "1") return;
container.dataset.dateEditWired = "1";
container.addEventListener("click", (e) => {
const target = (e.target as HTMLElement).closest<HTMLElement>(".frist-date-edit");
if (!target || !target.dataset.ruleCode) return;
openInlineDateEditor(target, onCommit);
});
container.addEventListener("keydown", (e) => {
const ke = e as KeyboardEvent;
if (ke.key !== "Enter" && ke.key !== " ") return;
const target = (e.target as HTMLElement).closest<HTMLElement>(".frist-date-edit");
if (!target || !target.dataset.ruleCode) return;
e.preventDefault();
openInlineDateEditor(target, onCommit);
});
}
export function renderTimelineBody(data: DeadlineResponse, opts: CardOpts = { showParty: true }): string {
@@ -339,7 +451,7 @@ export function renderColumnsBody(data: DeadlineResponse, opts: Omit<CardOpts, "
unscheduledKeys.sort();
const keys = [...datedKeys, ...unscheduledKeys];
const cardOpts: CardOpts = { showParty: false, editable: opts.editable };
const cardOpts: CardOpts = { showParty: false, editable: opts.editable, showNotes: opts.showNotes };
const renderCell = (items: CalculatedDeadline[]): string => {
if (items.length === 0) {

View File

@@ -5,12 +5,14 @@ import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
// The /* __PALIAD_DASHBOARD_DATA__ */ token below is replaced at request time
// by the Go handler (internal/handlers/dashboard_shell.go) with a JSON blob
// assigned to window.__PALIAD_DASHBOARD__. Keep the token intact and exactly
// once in the output.
// The three /* __PALIAD_DASHBOARD_*__ */ tokens below are replaced at
// request time by the Go handler (internal/handlers/dashboard_shell.go)
// with JSON blobs assigned to window.__PALIAD_DASHBOARD__,
// window.__PALIAD_DASHBOARD_LAYOUT__, and window.__PALIAD_DASHBOARD_CATALOG__.
// Keep each token intact and exactly once in the output. The latter two
// power the per-user configurable layout (t-paliad-219).
const HYDRATION_SCRIPT =
"/*__PALIAD_DASHBOARD_DATA__*/";
"/*__PALIAD_DASHBOARD_DATA__*//*__PALIAD_DASHBOARD_LAYOUT__*//*__PALIAD_DASHBOARD_CATALOG__*/";
// Chevron used as the collapsible-section disclosure indicator. CSS rotates
// it 90deg clockwise when the section is open via the
@@ -23,12 +25,13 @@ const ICON_CHEVRON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"
// renders all sections expanded so unstyled fallback is sensible.
function CollapsibleSection(props: {
id: string;
widgetKey: string;
headingI18n: string;
headingDe: string;
children: any;
}): string {
return (
<section className="dashboard-section" data-collapse-key={props.id} aria-expanded="true">
<section className="dashboard-section" data-collapse-key={props.id} data-widget-key={props.widgetKey} aria-expanded="true">
<button type="button" className="dashboard-section-toggle" aria-expanded="true">
<h3 className="dashboard-section-heading" data-i18n={props.headingI18n}>{props.headingDe}</h3>
<span className="dashboard-section-chevron" aria-hidden="true"
@@ -88,7 +91,7 @@ export function renderDashboard(): string {
</div>
{/* Traffic-light deadline summary (4+1: Überfällig conditional + 4 universal — t-paliad-110) */}
<CollapsibleSection id="summary" headingI18n="dashboard.summary.heading" headingDe="Fristen auf einen Blick">
<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>
@@ -116,7 +119,7 @@ export function renderDashboard(): string {
{/* 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">
<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>
@@ -145,14 +148,14 @@ export function renderDashboard(): string {
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" headingI18n="dashboard.deadlines.heading" headingDe="Kommende Fristen">
<CollapsibleSection id="deadlines" widgetKey="upcoming-deadlines" headingI18n="dashboard.deadlines.heading" headingDe="Kommende Fristen">
<ul className="dashboard-list" id="dashboard-deadlines-list"></ul>
<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>
</CollapsibleSection>
<CollapsibleSection id="appointments" headingI18n="dashboard.appointments.heading" headingDe="Kommende Termine">
<CollapsibleSection id="appointments" widgetKey="upcoming-appointments" headingI18n="dashboard.appointments.heading" headingDe="Kommende Termine">
<ul className="dashboard-list" id="dashboard-appointments-list"></ul>
<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.
@@ -166,7 +169,7 @@ export function renderDashboard(): string {
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" headingI18n="dashboard.agenda.heading" headingDe="Agenda">
<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">
@@ -178,9 +181,26 @@ export function renderDashboard(): string {
</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" headingI18n="dashboard.activity.heading" headingDe="Letzte Aktivität">
<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.

View File

@@ -65,12 +65,21 @@ const UPC_TYPES: ProceedingDef[] = [
{ code: "upc.apl.order", i18nKey: "deadlines.upc.apl.order", name: "Berufung Anordnungen" },
];
const DE_TYPES: ProceedingDef[] = [
{ code: "de.inf.lg", i18nKey: "deadlines.de.inf.lg", name: "Verletzungsklage (LG)" },
{ code: "de.inf.olg", i18nKey: "deadlines.de.inf.olg", name: "Berufung OLG" },
{ code: "de.inf.bgh", i18nKey: "deadlines.de.inf.bgh", name: "Revision/NZB BGH" },
{ code: "de.null.bpatg", i18nKey: "deadlines.de.null.bpatg", name: "Nichtigkeitsverfahren" },
{ code: "de.null.bgh", i18nKey: "deadlines.de.null.bgh", name: "Berufung BGH (Nichtigk.)" },
// DE proceedings split by type (Verletzung / Nichtigkeit) per m's
// 2026-05-18 ask. Labels are parallel: <court> (<procedural role>),
// so a user scanning the picker sees the instance-and-role at a glance
// without one tile reading "Berufung OLG" and another "Nichtigkeits-
// verfahren". Sub-group headers convey the type grouping. Combined-
// timeline behaviour (LG→OLG→BGH as one calc) is filed as m/paliad#41.
const DE_INF_TYPES: ProceedingDef[] = [
{ code: "de.inf.lg", i18nKey: "deadlines.de.inf.lg", name: "LG (1. Instanz)" },
{ code: "de.inf.olg", i18nKey: "deadlines.de.inf.olg", name: "OLG (Berufung)" },
{ code: "de.inf.bgh", i18nKey: "deadlines.de.inf.bgh", name: "BGH (Revision / NZB)" },
];
const DE_NULL_TYPES: ProceedingDef[] = [
{ code: "de.null.bpatg", i18nKey: "deadlines.de.null.bpatg", name: "BPatG (1. Instanz)" },
{ code: "de.null.bgh", i18nKey: "deadlines.de.null.bgh", name: "BGH (Berufung)" },
];
const EPA_TYPES: ProceedingDef[] = [
@@ -425,8 +434,17 @@ export function renderFristenrechner(): string {
<div className="proceeding-group" data-forum="de">
<h4 data-i18n="deadlines.de">Deutsche Gerichte</h4>
<div className="proceeding-btns">
{DE_TYPES.map((p) => proceedingBtn(p))}
<div className="proceeding-subgroup">
<h5 className="proceeding-subgroup-heading" data-i18n="deadlines.de.group.inf">Verletzungsverfahren</h5>
<div className="proceeding-btns">
{DE_INF_TYPES.map((p) => proceedingBtn(p))}
</div>
</div>
<div className="proceeding-subgroup">
<h5 className="proceeding-subgroup-heading" data-i18n="deadlines.de.group.null">Nichtigkeitsverfahren</h5>
<div className="proceeding-btns">
{DE_NULL_TYPES.map((p) => proceedingBtn(p))}
</div>
</div>
</div>
@@ -528,6 +546,10 @@ export function renderFristenrechner(): string {
<input type="radio" name="fristen-view" value="timeline" />
<span data-i18n="deadlines.view.timeline">Zeitstrahl</span>
</label>
<label className="fristen-notes-option">
<input type="checkbox" id="fristen-notes-show" />
<span data-i18n="deadlines.notes.show">Hinweise anzeigen</span>
</label>
</div>
<div id="timeline-container">

View File

@@ -268,12 +268,13 @@ export type I18nKey =
| "admin.partner_units.new.heading"
| "admin.partner_units.subtitle"
| "admin.partner_units.title"
| "admin.rules.col.code"
| "admin.rules.col.legal_citation"
| "admin.rules.col.lifecycle"
| "admin.rules.col.modified"
| "admin.rules.col.name"
| "admin.rules.col.priority"
| "admin.rules.col.proceeding"
| "admin.rules.col.submission_code"
| "admin.rules.edit.action.archive"
| "admin.rules.edit.action.archive.error"
| "admin.rules.edit.action.archive.ok"
@@ -309,7 +310,6 @@ export type I18nKey =
| "admin.rules.edit.field.alt_duration_value"
| "admin.rules.edit.field.alt_rule_code"
| "admin.rules.edit.field.anchor_alt"
| "admin.rules.edit.field.code"
| "admin.rules.edit.field.combine_op"
| "admin.rules.edit.field.concept"
| "admin.rules.edit.field.condition.valid"
@@ -335,6 +335,7 @@ export type I18nKey =
| "admin.rules.edit.field.spawn_label"
| "admin.rules.edit.field.spawn_proceeding"
| "admin.rules.edit.field.spawn_proceeding.none"
| "admin.rules.edit.field.submission_code"
| "admin.rules.edit.field.timing"
| "admin.rules.edit.field.trigger"
| "admin.rules.edit.field.trigger.none"
@@ -582,6 +583,7 @@ export type I18nKey =
| "approvals.action.approve"
| "approvals.action.reject"
| "approvals.action.revoke"
| "approvals.action.suggest_changes"
| "approvals.agent.byline"
| "approvals.agent.label"
| "approvals.agent.suggestion_pending"
@@ -594,6 +596,7 @@ export type I18nKey =
| "approvals.disabled.not_authorized"
| "approvals.disabled.revoke_not_requester"
| "approvals.disabled.self_approval"
| "approvals.disabled.suggest_lifecycle"
| "approvals.empty.mine"
| "approvals.empty.pending_mine"
| "approvals.entity.appointment"
@@ -604,6 +607,8 @@ export type I18nKey =
| "approvals.error.not_authorized"
| "approvals.error.request_not_pending"
| "approvals.error.self_approval"
| "approvals.error.suggestion_lifecycle_invalid"
| "approvals.error.suggestion_requires_change"
| "approvals.heading"
| "approvals.lifecycle.complete"
| "approvals.lifecycle.create"
@@ -630,11 +635,32 @@ export type I18nKey =
| "approvals.required_role.pa"
| "approvals.required_role.senior_pa"
| "approvals.status.approved"
| "approvals.status.changes_requested"
| "approvals.status.pending"
| "approvals.status.rejected"
| "approvals.status.revoked"
| "approvals.status.superseded"
| "approvals.subtitle"
| "approvals.suggest.cancel"
| "approvals.suggest.context.approval_status"
| "approvals.suggest.context.project"
| "approvals.suggest.context.requested_at"
| "approvals.suggest.context.requester"
| "approvals.suggest.event_type_picker_unavailable"
| "approvals.suggest.field.description"
| "approvals.suggest.field.original_due_date"
| "approvals.suggest.field.rule_code"
| "approvals.suggest.field.warning_date"
| "approvals.suggest.intro"
| "approvals.suggest.modal_title"
| "approvals.suggest.next_request_link"
| "approvals.suggest.note_label"
| "approvals.suggest.note_placeholder"
| "approvals.suggest.section.context"
| "approvals.suggest.section.editable"
| "approvals.suggest.submit"
| "approvals.suggest.submit_disabled_hint"
| "approvals.suggest.unsupported_lifecycle"
| "approvals.tab.mine"
| "approvals.tab.pending_mine"
| "approvals.title"
@@ -652,8 +678,13 @@ export type I18nKey =
| "bottomnav.add.title"
| "bottomnav.badge.deadlines"
| "bottomnav.menu"
| "cal.day.back_to_month"
| "cal.day.fri"
| "cal.day.mon"
| "cal.day.next"
| "cal.day.no_entries"
| "cal.day.open_day"
| "cal.day.prev"
| "cal.day.sat"
| "cal.day.sun"
| "cal.day.thu"
@@ -671,6 +702,50 @@ export type I18nKey =
| "cal.month.7"
| "cal.month.8"
| "cal.month.9"
| "cal.month.next"
| "cal.month.prev"
| "cal.view.day"
| "cal.view.month"
| "cal.view.week"
| "cal.week.next"
| "cal.week.prev"
| "caldav.bindings.add"
| "caldav.bindings.card.edit"
| "caldav.bindings.card.enabled"
| "caldav.bindings.card.remove"
| "caldav.bindings.delete.confirm"
| "caldav.bindings.delete.failed"
| "caldav.bindings.empty"
| "caldav.bindings.error.create_name_required"
| "caldav.bindings.error.create_name_taken"
| "caldav.bindings.error.create_unsupported"
| "caldav.bindings.error.path"
| "caldav.bindings.error.scope"
| "caldav.bindings.error.scope_project"
| "caldav.bindings.heading"
| "caldav.bindings.hint"
| "caldav.bindings.modal.add_title"
| "caldav.bindings.modal.display_name"
| "caldav.bindings.modal.display_name.placeholder"
| "caldav.bindings.modal.edit_title"
| "caldav.bindings.modal.scope"
| "caldav.bindings.modal.scope.all_visible"
| "caldav.bindings.modal.scope.personal_only"
| "caldav.bindings.modal.scope.project"
| "caldav.bindings.modal.scope.project.loading"
| "caldav.bindings.modal.source"
| "caldav.bindings.modal.source.create"
| "caldav.bindings.modal.source.custom"
| "caldav.bindings.modal.source.degrade"
| "caldav.bindings.modal.source.discover_empty"
| "caldav.bindings.modal.source.discover_failed"
| "caldav.bindings.modal.source.existing"
| "caldav.bindings.modal.source.loading"
| "caldav.bindings.modal.submit_add"
| "caldav.bindings.modal.submit_edit"
| "caldav.bindings.scope.all_visible"
| "caldav.bindings.scope.personal_only"
| "caldav.bindings.scope.project"
| "caldav.delete"
| "caldav.delete.confirm"
| "caldav.delete.done"
@@ -852,6 +927,11 @@ export type I18nKey =
| "dashboard.deadlines.empty"
| "dashboard.deadlines.heading"
| "dashboard.greeting.prefix"
| "dashboard.inbox.empty"
| "dashboard.inbox.entity.appointment"
| "dashboard.inbox.entity.deadline"
| "dashboard.inbox.full_link"
| "dashboard.inbox.heading"
| "dashboard.matters.active"
| "dashboard.matters.archived"
| "dashboard.matters.heading"
@@ -918,6 +998,8 @@ export type I18nKey =
| "deadlines.court.set"
| "deadlines.date.edit.hint"
| "deadlines.de"
| "deadlines.de.group.inf"
| "deadlines.de.group.null"
| "deadlines.de.inf.bgh"
| "deadlines.de.inf.lg"
| "deadlines.de.inf.olg"
@@ -1054,6 +1136,7 @@ export type I18nKey =
| "deadlines.neu.submit"
| "deadlines.neu.subtitle"
| "deadlines.neu.title"
| "deadlines.notes.show"
| "deadlines.optional.badge"
| "deadlines.party.both"
| "deadlines.party.both.label"
@@ -1214,6 +1297,16 @@ export type I18nKey =
| "downloads.subtitle"
| "downloads.title"
| "einstellungen.error.generic"
| "einstellungen.export.audit"
| "einstellungen.export.bullet.csv"
| "einstellungen.export.bullet.json"
| "einstellungen.export.bullet.xlsx"
| "einstellungen.export.button"
| "einstellungen.export.heading"
| "einstellungen.export.scope"
| "einstellungen.export.started"
| "einstellungen.export.subtitle"
| "einstellungen.export.what"
| "einstellungen.heading"
| "einstellungen.loading"
| "einstellungen.optional"
@@ -1257,9 +1350,11 @@ export type I18nKey =
| "einstellungen.subtitle"
| "einstellungen.tab.benachrichtigungen"
| "einstellungen.tab.caldav"
| "einstellungen.tab.export"
| "einstellungen.tab.profil"
| "einstellungen.title"
| "event.description.appointment_approval_approved"
| "event.description.appointment_approval_changes_suggested"
| "event.description.appointment_approval_rejected"
| "event.description.appointment_approval_requested"
| "event.description.appointment_approval_revoked"
@@ -1268,6 +1363,7 @@ export type I18nKey =
| "event.description.appointment_project_changed"
| "event.description.appointment_updated"
| "event.description.deadline_approval_approved"
| "event.description.deadline_approval_changes_suggested"
| "event.description.deadline_approval_rejected"
| "event.description.deadline_approval_requested"
| "event.description.deadline_approval_revoked"
@@ -1283,6 +1379,7 @@ export type I18nKey =
| "event.note.parent.deadline"
| "event.note.parent.project"
| "event.title.appointment_approval_approved"
| "event.title.appointment_approval_changes_suggested"
| "event.title.appointment_approval_rejected"
| "event.title.appointment_approval_requested"
| "event.title.appointment_approval_revoked"
@@ -1297,6 +1394,7 @@ export type I18nKey =
| "event.title.checklist_reset"
| "event.title.checklist_unlinked"
| "event.title.deadline_approval_approved"
| "event.title.deadline_approval_changes_suggested"
| "event.title.deadline_approval_rejected"
| "event.title.deadline_approval_requested"
| "event.title.deadline_approval_revoked"
@@ -1361,6 +1459,7 @@ export type I18nKey =
| "events.empty.hint"
| "events.empty.title"
| "events.filter.status.all"
| "events.filter.status.upcoming"
| "events.row.type.appointment"
| "events.row.type.deadline"
| "events.summary.later"
@@ -1624,6 +1723,7 @@ export type I18nKey =
| "login.tab.login"
| "login.tab.register"
| "login.title"
| "modal.close.label"
| "nav.admin.audit"
| "nav.admin.bereich"
| "nav.admin.event_types"
@@ -1908,6 +2008,8 @@ export type I18nKey =
| "projects.detail.edit"
| "projects.detail.edit.modal.title"
| "projects.detail.edit.type_change_warning.title"
| "projects.detail.export.button"
| "projects.detail.export.tooltip"
| "projects.detail.firmwide.off"
| "projects.detail.firmwide.on"
| "projects.detail.kinder.add"
@@ -2003,11 +2105,21 @@ export type I18nKey =
| "projects.detail.smarttimeline.track.only.counterclaim"
| "projects.detail.smarttimeline.track.only.parent"
| "projects.detail.smarttimeline.track.only.parent_context"
| "projects.detail.submissions.action.generate"
| "projects.detail.submissions.action.no_template"
| "projects.detail.submissions.col.action"
| "projects.detail.submissions.col.name"
| "projects.detail.submissions.col.party"
| "projects.detail.submissions.col.source"
| "projects.detail.submissions.empty"
| "projects.detail.submissions.empty.no_proceeding"
| "projects.detail.submissions.hint"
| "projects.detail.tab.checklisten"
| "projects.detail.tab.fristen"
| "projects.detail.tab.kinder"
| "projects.detail.tab.notizen"
| "projects.detail.tab.parteien"
| "projects.detail.tab.submissions"
| "projects.detail.tab.team"
| "projects.detail.tab.termine"
| "projects.detail.tab.verlauf"
@@ -2272,6 +2384,7 @@ export type I18nKey =
| "views.bar.approval_role.approver_eligible"
| "views.bar.approval_role.self_requested"
| "views.bar.approval_status.approved"
| "views.bar.approval_status.changes_requested"
| "views.bar.approval_status.pending"
| "views.bar.approval_status.rejected"
| "views.bar.approval_status.revoked"
@@ -2420,6 +2533,12 @@ export type I18nKey =
| "views.source.project_event"
| "views.subtitle"
| "views.timeline.caveat.body"
| "views.timeline.zoom.1y"
| "views.timeline.zoom.2y"
| "views.timeline.zoom.all"
| "views.timeline.zoom.in"
| "views.timeline.zoom.label"
| "views.timeline.zoom.out"
| "views.title"
| "views.toast.inaccessible_n"
| "views.toast.inaccessible_one";

View File

@@ -9,7 +9,6 @@ const ICON_FILE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" st
const ICON_FOLDER = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>';
const ICON_CALC = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="2" width="16" height="20" rx="2"/><line x1="8" y1="6" x2="16" y2="6"/><line x1="8" y1="14" x2="8" y2="14.01"/><line x1="12" y1="14" x2="12" y2="14.01"/><line x1="16" y1="14" x2="16" y2="14.01"/><line x1="8" y1="18" x2="16" y2="18"/></svg>';
const ICON_CLOCK = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>';
const ICON_DOWNLOAD = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>';
const ICON_GLOSSAR = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>';
const ICON_TABLE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/><line x1="9" y1="3" x2="9" y2="21"/></svg>';
const ICON_CHECK = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>';
@@ -108,19 +107,6 @@ export function renderIndex(): string {
</div>
</section>
<section className="sections">
<div className="container">
<h3 className="section-heading" data-i18n="index.downloads">Downloads</h3>
<div className="grid grid-2">
<a href="/files/hl-patents-style.dotm" className="card card-link">
<div className="card-icon" dangerouslySetInnerHTML={{ __html: ICON_DOWNLOAD }} />
<h2 data-i18n="index.style.title">{`${FIRM} Patents Style`}</h2>
<p data-i18n="index.style.desc">{`Word-Vorlage im ${FIRM} Patents Style. Formatierung, Schriftarten und Makros für standardisierte Schriftsätze.`}</p>
</a>
</div>
</div>
</section>
<section className="offices">
<div className="container">
<h3 data-i18n="index.offices">Standorte</h3>

View File

@@ -80,6 +80,21 @@ export function renderProjectsDetail(): string {
<a className="entity-tab" data-tab="appointments" href="#" data-i18n="projects.detail.tab.termine">Termine</a>
<a className="entity-tab" data-tab="notes" href="#" data-i18n="projects.detail.tab.notizen">Notizen</a>
<a className="entity-tab" data-tab="checklists" href="#" data-i18n="projects.detail.tab.checklisten">Checklisten</a>
<a className="entity-tab" data-tab="submissions" href="#" data-i18n="projects.detail.tab.submissions">Schriftsätze</a>
{/* t-paliad-214 Slice 2 — project-subtree export button.
Sits at the end of the tab nav. Hidden by default; the
client unhides it after /api/me confirms the caller can
extract (responsibility ∈ {lead, member} OR global_admin). */}
<button
type="button"
id="project-export-btn"
className="entity-tab entity-tab-action"
style="display:none"
title=""
data-i18n-title="projects.detail.export.tooltip"
data-i18n="projects.detail.export.button">
Daten exportieren
</button>
</nav>
{/* History (Verlauf) — t-paliad-171 SmartTimeline Slice 1.
@@ -571,6 +586,38 @@ export function renderProjectsDetail(): string {
</p>
</section>
{/* Submissions (Schriftsätze) — t-paliad-215 Slice 1.
Lists the project's filing-type rules with a per-row
[Generieren] button when a .docx template resolves
in the registry's fallback chain (firm → base/code →
base/family → skeleton). Empty for projects with no
proceeding bound; otherwise enumerates every active
filing rule for the proceeding. */}
<section className="entity-tab-panel" id="tab-submissions" style="display:none">
<p id="project-submissions-no-proceeding" className="entity-events-empty" style="display:none" data-i18n="projects.detail.submissions.empty.no_proceeding">
Bitte zuerst einen Verfahrenstyp setzen.
</p>
<p id="project-submissions-empty" className="entity-events-empty" style="display:none" data-i18n="projects.detail.submissions.empty">
Für dieses Verfahren sind keine Schriftsätze hinterlegt.
</p>
<div className="entity-table-wrap" id="project-submissions-tablewrap" style="display:none">
<table className="entity-table entity-table--readonly">
<thead>
<tr>
<th data-i18n="projects.detail.submissions.col.name">Schriftsatz</th>
<th data-i18n="projects.detail.submissions.col.party">Partei</th>
<th data-i18n="projects.detail.submissions.col.source">Rechtsgrundlage</th>
<th data-i18n="projects.detail.submissions.col.action" />
</tr>
</thead>
<tbody id="project-submissions-body" />
</table>
</div>
<p className="tool-subtitle submissions-hint" data-i18n="projects.detail.submissions.hint">
Schriftsätze werden direkt aus dem Projekt heraus als .docx generiert. Anpassen, drucken, einreichen.
</p>
</section>
<div className="entity-detail-footer" id="project-delete-wrap" style="display:none">
<button id="project-delete-btn" className="btn-secondary" type="button" data-i18n="projects.detail.delete">
Projekt archivieren

View File

@@ -40,6 +40,7 @@ export function renderSettings(): string {
<a className="entity-tab" data-tab="profil" href="?tab=profil" data-i18n="einstellungen.tab.profil">Profil</a>
<a className="entity-tab" data-tab="benachrichtigungen" href="?tab=benachrichtigungen" data-i18n="einstellungen.tab.benachrichtigungen">Benachrichtigungen</a>
<a className="entity-tab" data-tab="caldav" href="?tab=caldav" data-i18n="einstellungen.tab.caldav">CalDAV</a>
<a className="entity-tab" data-tab="export" href="?tab=export" data-i18n="einstellungen.tab.export">Datenexport</a>
</nav>
{/* --- Profil tab ---------------------------------------- */}
@@ -322,6 +323,25 @@ export function renderSettings(): string {
</div>
</form>
{/* t-paliad-212 Slice 2b — multi-calendar bindings.
Each card is one (calendar, scope) binding layered on the
single CalDAV server connection above. */}
<div className="caldav-bindings-section" id="caldav-bindings-section" style="display:none">
<div className="caldav-bindings-header">
<h2 data-i18n="caldav.bindings.heading">Kalender</h2>
<button type="button" id="caldav-bindings-add-btn" className="btn-secondary" data-i18n="caldav.bindings.add">
+ Kalender hinzuf&uuml;gen
</button>
</div>
<p className="form-hint" data-i18n="caldav.bindings.hint">
Verbinde mehrere Kalender mit Paliad &mdash; einen Master f&uuml;r alles oder eigene Kalender pro Projekt.
</p>
<div id="caldav-bindings-list" className="caldav-bindings-list" />
<p className="entity-events-empty" id="caldav-bindings-empty" data-i18n="caldav.bindings.empty" style="display:none">
Noch keine Kalender konfiguriert.
</p>
</div>
<div className="caldav-log-card">
<h2 data-i18n="caldav.log.heading">Letzte Synchronisationen</h2>
<table className="entity-table entity-table--readonly caldav-log-table">
@@ -342,12 +362,138 @@ export function renderSettings(): string {
</div>
</section>
{/* --- Datenexport tab (t-paliad-214 Slice 1) ----------- */}
<section className="entity-tab-panel" id="tab-export" style="display:none">
<p className="tool-subtitle" data-i18n="einstellungen.export.subtitle">
Laden Sie Ihre pers&ouml;nlichen Paliad-Daten als Excel- + JSON- + CSV-Paket herunter.
Enthalten ist alles, was Sie aktuell sehen k&ouml;nnen &mdash; Ihre Projekte, Fristen, Termine, Notizen, Genehmigungen und Einstellungen.
</p>
<div className="caldav-info-card">
<h2 data-i18n="einstellungen.export.heading">Pers&ouml;nlicher Datenexport</h2>
<p data-i18n="einstellungen.export.what">
Das Paket enth&auml;lt Ihre sichtbaren Daten in drei Formaten in einem <code>.zip</code>:
</p>
<ul className="form-hint settings-export-list">
<li data-i18n="einstellungen.export.bullet.xlsx">
<strong>paliad-export.xlsx</strong> &mdash; eine Excel-Mappe pro Entit&auml;t.
</li>
<li data-i18n="einstellungen.export.bullet.json">
<strong>paliad-export.json</strong> &mdash; maschinenlesbare Kopie f&uuml;r Skripte und Tools.
</li>
<li data-i18n="einstellungen.export.bullet.csv">
<strong>csv/&lt;sheet&gt;.csv</strong> &mdash; Tabellen einzeln als CSV (UTF-8 mit BOM).
</li>
</ul>
<p className="form-hint" data-i18n="einstellungen.export.scope">
Umfang: alles, was Sie aktuell in Paliad sehen k&ouml;nnen (Sichtbarkeit zum Zeitpunkt des Exports).
Passw&ouml;rter, CalDAV-Zugangsdaten und andere Geheimnisse werden nie exportiert.
</p>
<p className="form-hint" data-i18n="einstellungen.export.audit">
Jeder Export wird im Audit-Log protokolliert.
</p>
<p className="form-msg" id="export-msg" />
<div className="form-actions">
<button type="button" id="export-btn" className="btn-primary btn-cta-lime" data-i18n="einstellungen.export.button">
Daten exportieren
</button>
</div>
</div>
</section>
</div>
</section>
</main>
<Footer />
<PaliadinWidget />
{/* t-paliad-212 Slice 2b — single-step Add/Edit modal for
calendar bindings. Source picker (existing dropdown or
custom URL) + scope radio + display name. Edit mode hides
the source picker (path is fixed). */}
<div id="caldav-binding-modal" className="modal-backdrop" style="display:none">
<div className="modal-dialog">
<div className="modal-header">
<h2 id="caldav-binding-modal-title" data-i18n="caldav.bindings.modal.add_title">Kalender hinzuf&uuml;gen</h2>
<button type="button" className="modal-close" id="caldav-binding-modal-close" aria-label="Schlie&szlig;en">&times;</button>
</div>
<form id="caldav-binding-form" className="entity-form modal-body" autocomplete="off">
<div className="form-field" id="caldav-binding-source-field">
<label data-i18n="caldav.bindings.modal.source">Kalender</label>
<div className="caldav-binding-source-modes" id="caldav-binding-source-modes">
<label className="caldav-toggle-label">
<input type="radio" name="caldav-binding-source-mode" value="existing" checked />
<span data-i18n="caldav.bindings.modal.source.existing">Vorhandenen Kalender w&auml;hlen</span>
</label>
<label className="caldav-toggle-label" id="caldav-binding-source-mode-create-row" style="display:none">
<input type="radio" name="caldav-binding-source-mode" value="create" />
<span data-i18n="caldav.bindings.modal.source.create">Neuen Kalender erstellen</span>
</label>
<label className="caldav-toggle-label">
<input type="radio" name="caldav-binding-source-mode" value="custom" />
<span data-i18n="caldav.bindings.modal.source.custom">Eigene URL eingeben</span>
</label>
</div>
<select id="caldav-binding-discover-select">
<option value="" data-i18n="caldav.bindings.modal.source.loading">L&auml;dt&hellip;</option>
</select>
<input
type="text"
id="caldav-binding-custom-path"
placeholder="https://..."
style="display:none"
/>
{/* Slice 2c — Google-degrade notice. Shown when
supports_mkcalendar=false; the create-new radio is
hidden in that state, so users are nudged to the
custom-URL path. */}
<p className="form-hint caldav-binding-degrade-notice" id="caldav-binding-degrade-notice" style="display:none" data-i18n="caldav.bindings.modal.source.degrade">
Dieser Anbieter erlaubt das Erstellen neuer Kalender nicht via CalDAV.
Erstelle den Kalender direkt in der Anbieter-Oberfl&auml;che und f&uuml;ge ihn hier per URL hinzu.
</p>
</div>
<div className="form-field">
<label htmlFor="caldav-binding-display-name" data-i18n="caldav.bindings.modal.display_name">Anzeigename (optional)</label>
<input type="text" id="caldav-binding-display-name" data-i18n-placeholder="caldav.bindings.modal.display_name.placeholder" placeholder="z.B. Projekt Acme v Bosch" />
</div>
<div className="form-field">
<label data-i18n="caldav.bindings.modal.scope">Inhalt</label>
<div className="caldav-binding-scope-radios">
<label className="caldav-toggle-label">
<input type="radio" name="caldav-binding-scope" value="all_visible" checked />
<span data-i18n="caldav.bindings.modal.scope.all_visible">Alles, was ich sehe</span>
</label>
<label className="caldav-toggle-label">
<input type="radio" name="caldav-binding-scope" value="personal_only" />
<span data-i18n="caldav.bindings.modal.scope.personal_only">Nur pers&ouml;nliche Termine</span>
</label>
<label className="caldav-toggle-label">
<input type="radio" name="caldav-binding-scope" value="project" />
<span data-i18n="caldav.bindings.modal.scope.project">Ein Projekt:</span>
<select id="caldav-binding-project-select" disabled>
<option value="" data-i18n="caldav.bindings.modal.scope.project.loading">L&auml;dt&hellip;</option>
</select>
</label>
</div>
</div>
<p className="form-msg" id="caldav-binding-msg" />
<div className="form-actions">
<button type="button" className="btn-secondary" id="caldav-binding-cancel-btn" data-i18n="common.cancel">Abbrechen</button>
<button type="submit" className="btn-primary btn-cta-lime" id="caldav-binding-submit-btn" data-i18n="caldav.bindings.modal.submit_add">Hinzuf&uuml;gen</button>
</div>
</form>
</div>
</div>
<script src="/assets/settings.js"></script>
</body>
</html>

View File

@@ -3075,6 +3075,25 @@ input[type="range"]::-moz-range-thumb {
margin-bottom: 0.5rem;
}
/* Sub-group inside a .proceeding-group — used today by the DE block
to split Verletzungsverfahren tiles from Nichtigkeitsverfahren tiles
under one "Deutsche Gerichte" h4. Heading is one tier below the h4
(mixed-case, no upper-tracking) so the two-level hierarchy reads at
a glance. */
.proceeding-subgroup {
margin-top: 0.6rem;
}
.proceeding-subgroup:first-child {
margin-top: 0;
}
.proceeding-subgroup-heading {
font-size: 0.78rem;
font-weight: 600;
color: var(--color-text);
margin: 0 0 0.35rem 0;
}
.proceeding-btns {
display: flex;
@@ -3422,6 +3441,49 @@ input[type="range"]::-moz-range-thumb {
cursor: pointer;
}
/* Notes toggle — checkbox affordance in the view-toggle bar that flips
per-card descriptive notes between compact (ⓘ tooltip icon) and
expanded (timeline-notes block). Sits with a leading separator so it
reads as a distinct control from the radio view picker. */
.fristen-notes-option {
display: inline-flex;
align-items: center;
gap: 0.35rem;
cursor: pointer;
color: var(--color-text);
margin-left: auto;
padding-left: 0.75rem;
border-left: 1px solid var(--color-border);
}
.fristen-notes-option input[type=checkbox] {
margin: 0;
cursor: pointer;
}
/* Compact note hint — sits in the timeline-meta line when the notes
toggle is off. Native browser tooltip via title= attribute carries
the full text on hover; tabindex=0 + aria-label make it
keyboard / screen-reader accessible. */
.timeline-note-hint {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.1rem;
height: 1.1rem;
border-radius: 50%;
font-size: 0.85rem;
color: var(--color-text-muted);
cursor: help;
user-select: none;
}
.timeline-note-hint:hover,
.timeline-note-hint:focus-visible {
color: var(--color-text);
outline: none;
}
/* Fristenrechner — three-column lane view (Proactive | Court | Reactive).
Each lane is independently date-ordered; party=both rows render below
as full-width spans because they apply to all sides. */
@@ -3820,7 +3882,177 @@ input[type="range"]::-moz-range-thumb {
font-size: 0.95rem;
}
/* --- Modal --- */
/* --- Unified modal primitive (t-paliad-217) ---
Native <dialog>-backed. Layered on top of the legacy .modal-overlay /
.modal-card / .modal-content / .modal classes below; those stay in
place until each call site migrates to openModal(). The new BEM-style
.modal__* selectors avoid colliding with the legacy class hierarchy. */
dialog.modal {
border: none;
border-radius: calc(var(--radius) * 1.5);
box-shadow: var(--shadow-xl);
padding: 0;
background: var(--color-surface);
color: var(--color-text);
width: 100%;
max-width: min(90vw, var(--modal-max-w, 480px));
max-height: min(90vh, 40rem);
overflow: hidden;
display: flex;
flex-direction: column;
}
dialog.modal[data-size="sm"] { --modal-max-w: 380px; }
dialog.modal[data-size="lg"] { --modal-max-w: 640px; }
dialog.modal[data-size="full"] {
--modal-max-w: 100vw;
max-height: 100vh;
border-radius: 0;
}
dialog.modal::backdrop {
background: var(--color-overlay-modal);
}
/* Phone breakpoint — full-screen takeover ABOVE the PWA bottom-nav.
m's 2026-05-20 lock-in: the modal must not cover the bottom-nav and
must close via the browser back-button (handled in modal.ts). */
@media (max-width: 32rem) {
dialog.modal {
--modal-max-w: 100vw;
border-radius: 0;
max-height: calc(100vh - var(--bottom-nav-height, 56px));
margin-bottom: var(--bottom-nav-height, 56px);
}
}
.modal__header {
flex-shrink: 0;
padding: 1.25rem 1.5rem 0.75rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
border-bottom: 1px solid var(--color-border);
}
.modal__title {
font-size: 1.15rem;
font-weight: 700;
margin: 0;
color: var(--color-text);
}
.modal__close {
background: none;
border: none;
cursor: pointer;
font-size: 1.5rem;
color: var(--color-text-muted);
padding: 0.25rem 0.5rem;
line-height: 1;
border-radius: var(--radius);
}
.modal__close:hover {
color: var(--color-text);
background: var(--color-surface-muted);
}
.modal__body {
flex: 1;
overflow-y: auto;
padding: 1.25rem 1.5rem;
font-size: 1rem;
color: var(--color-text);
}
.modal__footer {
flex-shrink: 0;
padding: 0.75rem 1.5rem 1.25rem;
display: flex;
gap: 0.75rem;
justify-content: flex-end;
border-top: 1px solid var(--color-border);
background: var(--color-surface);
}
/* --- approval-suggest modal body (t-paliad-217) ---
The body is laid out as three sections (editable / context /
comment), separated by light rules. Reuses the existing .form-field
shapes so input typography matches /deadlines/new + views editor. */
.approval-suggest-body {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.approval-suggest-intro {
margin: 0;
font-size: 0.9rem;
color: var(--color-text-muted);
line-height: 1.5;
}
.approval-suggest-section {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.approval-suggest-section-title {
font-size: 0.85rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-muted);
margin: 0;
}
.approval-suggest-section--context {
border-top: 1px dashed var(--color-border);
padding-top: 1rem;
}
.approval-suggest-context-grid {
display: grid;
grid-template-columns: max-content 1fr;
gap: 0.4rem 1rem;
margin: 0;
font-size: 0.88rem;
}
.approval-suggest-context-grid dt {
color: var(--color-text-muted);
font-weight: 600;
}
.approval-suggest-context-grid dd {
margin: 0;
color: var(--color-text);
}
.approval-suggest-prehint {
display: block;
margin-top: 0.25rem;
font-size: 0.78rem;
color: var(--color-text-muted);
font-style: italic;
}
.approval-suggest-section--note {
border-top: 1px solid var(--color-border);
padding-top: 1rem;
}
.approval-suggest-event-type-picker {
/* Picker styles its own internals (.event-type-picker). */
}
/* Legacy modal classes follow — kept until the other ~7 modals migrate. */
/* --- Modal (legacy) --- */
.modal-overlay {
position: fixed;
@@ -5185,6 +5417,40 @@ input[type="range"]::-moz-range-thumb {
text-decoration: underline;
}
/* Submissions panel — t-paliad-215 Slice 1. */
.submission-row td {
vertical-align: middle;
}
.submission-name {
color: var(--color-text);
font-weight: 500;
display: block;
}
.submission-code {
color: var(--color-text-muted);
font-size: 0.85em;
font-family: var(--font-mono, monospace);
display: block;
margin-top: 0.1rem;
}
.submission-action-cell {
text-align: right;
white-space: nowrap;
}
.submission-no-template {
color: var(--color-text-muted);
font-size: 0.9em;
font-style: italic;
}
.submissions-hint {
margin-top: 1rem;
}
.checklist-instance-actions {
display: flex;
gap: 0.35rem;
@@ -11773,16 +12039,58 @@ dialog.quick-add-sheet::backdrop {
color: var(--color-text-muted);
}
/* shape=calendar. */
/* shape=calendar. month / week / day views share .views-calendar wrapper;
the variant class .views-calendar--<view> drives any per-view tweaks. */
.views-calendar-toolbar {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.views-calendar-view-switcher {
display: inline-flex;
gap: 4px;
}
.views-calendar-nav {
display: inline-flex;
align-items: center;
gap: 8px;
}
.views-calendar-nav-btn {
min-width: 32px;
padding: 4px 8px;
}
.views-calendar-nav-label {
font-weight: 600;
min-width: 12ch;
text-align: center;
}
.views-calendar-back-to-month {
background: transparent;
border: none;
color: var(--color-link, var(--color-accent));
cursor: pointer;
padding: 4px 8px;
font-size: 13px;
text-decoration: underline;
}
.views-calendar-back-to-month:hover {
color: var(--color-link-hover, var(--color-accent));
}
.views-calendar-month-label {
font-size: 18px;
margin: 0 0 12px 0;
}
.views-calendar-weekdays {
/* Month view — one grid contains both the weekday header row and the day
cells, so they share the same column template (no drift). */
.views-calendar-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px;
margin-bottom: 4px;
}
.views-calendar-weekday {
font-size: 12px;
@@ -11790,11 +12098,7 @@ dialog.quick-add-sheet::backdrop {
letter-spacing: 0.05em;
color: var(--color-text-muted);
padding: 4px;
}
.views-calendar-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px;
text-align: center;
}
.views-calendar-cell {
min-height: 80px;
@@ -11803,15 +12107,32 @@ dialog.quick-add-sheet::backdrop {
border-radius: 4px;
background: var(--color-surface);
color: var(--color-text);
display: flex;
flex-direction: column;
gap: 4px;
}
.views-calendar-cell--out {
background: transparent;
border: 1px dashed var(--color-border);
}
.views-calendar-cell--today {
border-color: var(--color-accent);
box-shadow: inset 0 0 0 1px var(--color-accent);
}
.views-calendar-cell-day {
font-size: 12px;
color: var(--color-text-muted);
margin-bottom: 4px;
background: transparent;
border: none;
padding: 0;
cursor: pointer;
align-self: flex-start;
font-weight: 600;
}
.views-calendar-cell-day:hover,
.views-calendar-cell-day:focus-visible {
color: var(--color-text);
text-decoration: underline;
}
.views-calendar-pills {
list-style: none;
@@ -11830,12 +12151,147 @@ dialog.quick-add-sheet::backdrop {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: block;
text-decoration: none;
border: none;
text-align: left;
width: 100%;
cursor: pointer;
}
.views-calendar-pill:hover {
background: var(--color-surface-hover, var(--color-surface-muted));
}
.views-calendar-pill--more {
color: var(--color-text-muted);
text-align: center;
background: transparent;
}
/* Week view — 7 columns, scrollable per column when overflowing. */
.views-calendar-week-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px;
}
.views-calendar-week-column {
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-surface);
display: flex;
flex-direction: column;
min-height: 200px;
max-height: 70vh;
overflow: hidden;
}
.views-calendar-week-column--today {
border-color: var(--color-accent);
box-shadow: inset 0 0 0 1px var(--color-accent);
}
.views-calendar-week-head {
display: flex;
flex-direction: column;
gap: 2px;
padding: 6px 8px;
border-bottom: 1px solid var(--color-border);
background: var(--color-surface-muted);
font-size: 12px;
}
.views-calendar-week-dow {
text-transform: uppercase;
color: var(--color-text-muted);
letter-spacing: 0.05em;
}
.views-calendar-week-dnum {
font-weight: 600;
color: var(--color-text);
}
.views-calendar-week-list {
list-style: none;
margin: 0;
padding: 4px;
display: flex;
flex-direction: column;
gap: 4px;
overflow-y: auto;
flex: 1 1 auto;
}
.views-calendar-week-empty {
margin: 0;
padding: 12px 8px;
color: var(--color-text-muted);
font-size: 12px;
text-align: center;
}
/* Day view — single chronological list. */
.views-calendar-day-wrap {
display: flex;
flex-direction: column;
gap: 8px;
}
.views-calendar-day-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.views-calendar-day-empty {
margin: 0;
padding: 16px;
color: var(--color-text-muted);
font-style: italic;
text-align: center;
}
/* Row anchors used by both week and day views. */
.views-calendar-row {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 6px 8px;
border-radius: 4px;
border: 1px solid var(--color-border);
background: var(--color-surface);
text-decoration: none;
color: var(--color-text);
}
.views-calendar-row:hover {
background: var(--color-surface-hover, var(--color-surface-muted));
}
.views-calendar-row-dot {
width: 8px;
height: 8px;
border-radius: 50%;
margin-top: 6px;
flex: 0 0 8px;
background: var(--color-text-muted);
}
.views-calendar-row-dot--deadline { background: var(--color-accent); }
.views-calendar-row-dot--appointment { background: #3b82f6; }
.views-calendar-row-dot--project_event { background: #a855f7; }
.views-calendar-row-dot--approval_request { background: #f59e0b; }
.views-calendar-row-body {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.views-calendar-row-title {
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.views-calendar-row-meta {
font-size: 12px;
color: var(--color-text-muted);
}
.views-calendar-row--week .views-calendar-row-title {
white-space: normal;
}
.views-calendar-mobile-notice {
margin: 0 0 12px 0;
font-size: 12px;
@@ -11916,37 +12372,12 @@ dialog.quick-add-sheet::backdrop {
font-weight: 600;
}
/* Broadcast compose modal — extends .modal-overlay / .modal pattern. */
.modal-broadcast {
width: 720px;
max-width: 92vw;
max-height: 90vh;
display: flex;
flex-direction: column;
}
.modal-broadcast .modal-body {
overflow-y: auto;
flex: 1;
padding: 16px 20px;
}
.modal-broadcast label {
display: block;
margin-top: 12px;
margin-bottom: 4px;
font-weight: 500;
font-size: 14px;
}
.modal-broadcast input[type="text"],
.modal-broadcast textarea,
.modal-broadcast select {
width: 100%;
padding: 8px 10px;
border: 1px solid var(--color-border);
border-radius: 4px;
font-family: inherit;
font-size: 14px;
}
.modal-broadcast textarea {
/* Broadcast compose modal body styling. The shell (width, modal-body
padding, base form-field rules) is owned by the unified modal
primitive — these rules below cover only the broadcast-specific
content. Textarea gets a code-monospace face so the placeholder
syntax reads correctly. (Migrated onto openModal in t-paliad-217.) */
.broadcast-body [data-broadcast-body] {
resize: vertical;
min-height: 200px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
@@ -14603,7 +15034,14 @@ dialog.quick-add-sheet::backdrop {
.smart-timeline-chart .chart-lane-label {
font-size: 0.85rem;
font-weight: 500;
fill: var(--chart-lane-label);
color: var(--chart-lane-label);
height: 100%;
display: flex;
align-items: center;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
cursor: default;
}
.smart-timeline-chart .chart-today-rule {
stroke: var(--chart-today-rule);
@@ -14713,6 +15151,45 @@ dialog.quick-add-sheet::backdrop {
outline-offset: 2px;
}
/* Custom Views timeline toolbar (t-paliad-211) — zoom controls above the
chart canvas. Stays in flow so it doesn't overlap the SVG date axis. */
.views-timeline-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.views-timeline-zoom-group {
display: inline-flex;
align-items: center;
gap: 8px;
}
.views-timeline-zoom-label {
font-size: 12px;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.views-timeline-zoom-btn {
min-width: 32px;
padding: 4px 10px;
font-weight: 600;
}
.views-timeline-zoom-btn[disabled] {
opacity: 0.5;
cursor: not-allowed;
}
.views-timeline-zoom-chips {
display: inline-flex;
gap: 4px;
}
.views-timeline-chart-host-inner {
/* Reserve a min-height so the loading placeholder doesn't collapse
and the toolbar/chart stack stays predictable. */
min-height: 200px;
}
/* ---- Palette presets (t-paliad-177 Slice 2, design §5.1) ----
Each palette is a pure data-attribute swap of the --chart-* tokens.
Renderer code never reads palette state — it just emits classed SVG

View File

@@ -40,12 +40,21 @@ const UPC_TYPES: ProceedingDef[] = [
{ code: "upc.apl.order", i18nKey: "deadlines.upc.apl.order", name: "Berufung Anordnungen" },
];
const DE_TYPES: ProceedingDef[] = [
{ code: "de.inf.lg", i18nKey: "deadlines.de.inf.lg", name: "Verletzungsklage (LG)" },
{ code: "de.inf.olg", i18nKey: "deadlines.de.inf.olg", name: "Berufung OLG" },
{ code: "de.inf.bgh", i18nKey: "deadlines.de.inf.bgh", name: "Revision/NZB BGH" },
{ code: "de.null.bpatg", i18nKey: "deadlines.de.null.bpatg", name: "Nichtigkeitsverfahren" },
{ code: "de.null.bgh", i18nKey: "deadlines.de.null.bgh", name: "Berufung BGH (Nichtigk.)" },
// DE proceedings split by type (Verletzung / Nichtigkeit) per m's
// 2026-05-18 ask. Labels are parallel: <court> (<procedural role>),
// so a user scanning the picker sees the instance-and-role at a glance
// without one tile reading "Berufung OLG" and another "Nichtigkeits-
// verfahren". Sub-group headers convey the type grouping. Combined-
// timeline behaviour (LG→OLG→BGH as one calc) is filed as m/paliad#41.
const DE_INF_TYPES: ProceedingDef[] = [
{ code: "de.inf.lg", i18nKey: "deadlines.de.inf.lg", name: "LG (1. Instanz)" },
{ code: "de.inf.olg", i18nKey: "deadlines.de.inf.olg", name: "OLG (Berufung)" },
{ code: "de.inf.bgh", i18nKey: "deadlines.de.inf.bgh", name: "BGH (Revision / NZB)" },
];
const DE_NULL_TYPES: ProceedingDef[] = [
{ code: "de.null.bpatg", i18nKey: "deadlines.de.null.bpatg", name: "BPatG (1. Instanz)" },
{ code: "de.null.bgh", i18nKey: "deadlines.de.null.bgh", name: "BGH (Berufung)" },
];
const EPA_TYPES: ProceedingDef[] = [
@@ -108,8 +117,17 @@ export function renderVerfahrensablauf(): string {
<div className="proceeding-group" data-forum="de">
<h4 data-i18n="deadlines.de">Deutsche Gerichte</h4>
<div className="proceeding-btns">
{DE_TYPES.map((p) => proceedingBtn(p))}
<div className="proceeding-subgroup">
<h5 className="proceeding-subgroup-heading" data-i18n="deadlines.de.group.inf">Verletzungsverfahren</h5>
<div className="proceeding-btns">
{DE_INF_TYPES.map((p) => proceedingBtn(p))}
</div>
</div>
<div className="proceeding-subgroup">
<h5 className="proceeding-subgroup-heading" data-i18n="deadlines.de.group.null">Nichtigkeitsverfahren</h5>
<div className="proceeding-btns">
{DE_NULL_TYPES.map((p) => proceedingBtn(p))}
</div>
</div>
</div>
@@ -156,6 +174,35 @@ export function renderVerfahrensablauf(): string {
<label htmlFor="court-picker" className="date-label" data-i18n="deadlines.court.label">Gericht:</label>
<select id="court-picker" className="date-input"></select>
</div>
{/* Proceeding-specific flag rows — mirror /tools/fristenrechner
so an abstract-browse user can model the same variants
(CCR, Patentänderung, Verletzungswiderklage,
Vorab-Einrede). Show/hide driven by selectedType in
the client. */}
<div className="date-field-row" id="ccr-flag-row" style="display:none">
<label className="date-label">
<input type="checkbox" id="ccr-flag" />
<span data-i18n="deadlines.flag.ccr">Mit Widerklage auf Nichtigkeit</span>
</label>
</div>
<div className="date-field-row date-field-row--nested" id="inf-amend-flag-row" style="display:none">
<label className="date-label">
<input type="checkbox" id="inf-amend-flag" />
<span data-i18n="deadlines.flag.inf_amend">Mit Antrag auf Patent&auml;nderung (R.30)</span>
</label>
</div>
<div className="date-field-row" id="rev-amend-flag-row" style="display:none">
<label className="date-label">
<input type="checkbox" id="rev-amend-flag" />
<span data-i18n="deadlines.flag.rev_amend">Mit Antrag auf Patent&auml;nderung (R.49.2.a)</span>
</label>
</div>
<div className="date-field-row" id="rev-cci-flag-row" style="display:none">
<label className="date-label">
<input type="checkbox" id="rev-cci-flag" />
<span data-i18n="deadlines.flag.rev_cci">Mit Verletzungswiderklage (R.49.2.b)</span>
</label>
</div>
<button type="button" id="calculate-btn" className="calculate-btn" data-i18n="deadlines.calculate">
Fristen berechnen
</button>
@@ -178,6 +225,10 @@ export function renderVerfahrensablauf(): string {
<input type="radio" name="fristen-view" value="timeline" />
<span data-i18n="deadlines.view.timeline">Zeitstrahl</span>
</label>
<label className="fristen-notes-option">
<input type="checkbox" id="fristen-notes-show" />
<span data-i18n="deadlines.notes.show">Hinweise anzeigen</span>
</label>
</div>
<div id="timeline-container">

View File

@@ -60,6 +60,13 @@ export function renderViews(): string {
</a>
</div>
{/* Filter bar host — t-paliad-211. mountFilterBar appends its
own toolbar element here; the saved view's filter_spec
becomes the bar's baseline, axes are chosen client-side
per the view's data sources. */}
<div className="views-filter-bar" id="views-filter-bar" hidden />
{/* Empty / onboarding state — shown on bare /views with no saved views. */}
<div className="views-onboarding" id="views-onboarding" hidden>
<h2 data-i18n="views.onboarding.title">Eigene Ansichten &mdash; was ist das?</h2>

15
go.mod
View File

@@ -4,8 +4,21 @@ go 1.24.0
require (
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/golang-migrate/migrate/v4 v4.19.1
github.com/google/uuid v1.6.0
github.com/jmoiron/sqlx v1.4.0
github.com/lib/pq v1.12.3
github.com/xuri/excelize/v2 v2.10.1
)
require (
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/richardlehane/mscfb v1.0.6 // indirect
github.com/richardlehane/msoleps v1.0.6 // indirect
github.com/tiendc/go-deepcopy v1.7.2 // indirect
github.com/xuri/efp v0.0.1 // indirect
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/net v0.50.0 // indirect
golang.org/x/text v0.34.0 // indirect
)

76
go.sum
View File

@@ -1,39 +1,11 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4=
github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI=
github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
@@ -43,33 +15,29 @@ github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
github.com/richardlehane/mscfb v1.0.6 h1:eN3bvvZCp00bs7Zf52bxNwAx5lJDBK1tCuH19qq5aC8=
github.com/richardlehane/mscfb v1.0.6/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo=
github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg=
github.com/richardlehane/msoleps v1.0.6/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44=
github.com/tiendc/go-deepcopy v1.7.2/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ=
github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
github.com/xuri/excelize/v2 v2.10.1 h1:V62UlqopMqha3kOpnlHy2CcRVw1V8E63jFoWUmMzxN0=
github.com/xuri/excelize/v2 v2.10.1/go.mod h1:iG5tARpgaEeIhTqt3/fgXCGoBRt4hNXgCp3tfXKoOIc=
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE=
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ=
golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -1,46 +1,78 @@
// Package db owns the Paliad Postgres connection and embedded schema migrations.
//
// Migrations are golang-migrate format (NNN_description.up.sql / .down.sql) and
// live in the migrations/ subdirectory, embedded into the binary so a single
// artifact ships with its schema. The server applies pending migrations at
// startup before binding the HTTP listener.
// Migrations are NNN_description.up.sql / .down.sql files in the migrations/
// subdirectory, embedded into the binary so a single artifact ships with its
// schema. The server applies pending migrations at startup before binding
// the HTTP listener.
//
// The runner tracks applied state as a set, not a counter: every applied
// migration gets its own row in paliad.applied_migrations(version PK, name,
// applied_at, checksum). On every deploy, pending = on_disk \ applied, in
// ascending version order. Gaps in the version space are first-class — a
// version that's missing from applied_migrations runs on the next deploy,
// regardless of which higher versions are already applied.
//
// This is what closes the parallel-merge skip-hole that the single-counter
// tracker (golang-migrate) silently fell into on 2026-05-20 (m/paliad#44).
// Background and design: docs/design-migration-runner-applied-set-2026-05-20.md.
//
// .down.sql files ship in the embedded FS as reference material but are not
// auto-applied — there are no call sites for rolling back, and operator
// recovery (psql .down.sql + DELETE FROM paliad.applied_migrations WHERE
// version=N) is the documented path. If a real call site for auto-rollback
// materializes later, add it as a focused follow-up.
package db
import (
"crypto/sha256"
"database/sql"
"embed"
"errors"
"fmt"
"hash/fnv"
"sort"
"strconv"
"strings"
"github.com/golang-migrate/migrate/v4"
"github.com/golang-migrate/migrate/v4/database/postgres"
"github.com/golang-migrate/migrate/v4/source/iofs"
_ "github.com/lib/pq"
)
//go:embed migrations/*.sql
var migrationFS embed.FS
// migrationsTable is the name of the golang-migrate tracking table. We use a
// uniquely-named table (not the default "schema_migrations") because the
// production Supabase instance hosts multiple apps in the `public` schema,
// and a differently-shaped `public.schema_migrations` already exists there.
// Using "paliad_schema_migrations" prevents collision at startup.
// advisoryLockID is the Postgres advisory-lock id the runner takes around
// the apply loop. Derived once from the table name so the value is stable
// across processes — two concurrent deploys (rolling Dokploy update, dev
// laptop hitting the same scratch DB as CI) serialize on this id rather
// than racing on the pending set.
//
// The table lives in the `public` schema (golang-migrate's default) rather
// than `paliad`. Rationale: migration 001's down-step is
// DROP SCHEMA IF EXISTS paliad CASCADE
// which would take the tracking table with it — breaking any subsequent
// migrate.Up() call. Keeping the tracker in `public` makes the down-path
// safe and idempotent.
const migrationsTable = "paliad_schema_migrations"
// FNV-1a-64 is good enough: the id only has to be a stable int64, not
// cryptographically uniform. Process-wide constant.
var advisoryLockID = func() int64 {
h := fnv.New64a()
_, _ = h.Write([]byte("paliad.applied_migrations"))
return int64(h.Sum64())
}()
// ApplyMigrations runs all pending up-migrations against the given database
// URL. Returns nil if no migrations were pending. Safe to call repeatedly.
// migration is one *.up.sql file from the embedded FS.
type migration struct {
version int
name string
filename string
}
// ApplyMigrations applies every pending up-migration to the given database.
//
// Pre-creates the `paliad` schema before invoking golang-migrate because the
// first migration creates it and golang-migrate's tracking table would
// otherwise be created in whatever `current_schema()` happens to be.
// Safe to call repeatedly; a fully-applied tree is a no-op. Returns the
// first error encountered (with the offending migration filename wrapped
// in the message) and leaves the rest of pending unapplied — same fail-fast
// posture as the previous golang-migrate runner.
//
// On first deploy of this code path against a database that still has the
// legacy paliad.paliad_schema_migrations counter at version N, the runner
// seeds paliad.applied_migrations with rows 1..N (checksum NULL) before
// applying anything new. The first deploy is therefore effectively a
// no-op against the schema — the bootstrap just relabels existing state.
func ApplyMigrations(databaseURL string) error {
if databaseURL == "" {
return errors.New("database URL is empty")
@@ -51,39 +83,250 @@ func ApplyMigrations(databaseURL string) error {
return fmt.Errorf("open database: %w", err)
}
defer conn.Close()
if err := conn.Ping(); err != nil {
return fmt.Errorf("ping database: %w", err)
}
// Bootstrap the paliad schema so later migrations can target it cleanly.
// This duplicates migration 001, but is idempotent via IF NOT EXISTS and
// ensures the schema exists before golang-migrate touches the DB.
// Ensure the paliad schema exists. Mig 001 also creates it; the
// applied_migrations table lives in paliad.* and gets created before
// any migrations run, so the schema must exist first.
if _, err := conn.Exec(`CREATE SCHEMA IF NOT EXISTS paliad`); err != nil {
return fmt.Errorf("ensure paliad schema: %w", err)
}
source, err := iofs.New(migrationFS, "migrations")
if err != nil {
return fmt.Errorf("open migration source: %w", err)
if _, err := conn.Exec(`SELECT pg_advisory_lock($1)`, advisoryLockID); err != nil {
return fmt.Errorf("acquire advisory lock: %w", err)
}
defer func() {
_, _ = conn.Exec(`SELECT pg_advisory_unlock($1)`, advisoryLockID)
}()
if _, err := conn.Exec(`
CREATE TABLE IF NOT EXISTS paliad.applied_migrations (
version int NOT NULL PRIMARY KEY,
name text NOT NULL,
applied_at timestamptz NOT NULL DEFAULT now(),
checksum text NULL
)
`); err != nil {
return fmt.Errorf("create applied_migrations: %w", err)
}
driver, err := postgres.WithInstance(conn, &postgres.Config{
// Unique tracking-table name avoids collision with pre-existing
// public.schema_migrations owned by other apps on this Postgres.
MigrationsTable: migrationsTable,
})
onDisk, err := scanEmbeddedMigrations()
if err != nil {
return fmt.Errorf("create migration driver: %w", err)
return fmt.Errorf("scan embedded migrations: %w", err)
}
m, err := migrate.NewWithInstance("iofs", source, "postgres", driver)
if err != nil {
return fmt.Errorf("create migrator: %w", err)
if err := bootstrapFromLegacyTracker(conn, onDisk); err != nil {
return fmt.Errorf("bootstrap from legacy tracker: %w", err)
}
if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
return fmt.Errorf("apply migrations: %w", err)
applied, err := readAppliedMigrations(conn)
if err != nil {
return fmt.Errorf("read applied_migrations: %w", err)
}
if err := checkNameAgreement(onDisk, applied); err != nil {
return err
}
for _, m := range onDisk {
if _, ok := applied[m.version]; ok {
continue
}
if err := applyOne(conn, m); err != nil {
return fmt.Errorf("apply %s: %w", m.filename, err)
}
}
return nil
}
// scanEmbeddedMigrations returns every NNN_*.up.sql in the embedded FS,
// sorted by version ascending. Hard-fails on two files sharing the same
// version prefix — that's the failure mode the parallel-merge incident
// exposed, and the runner refuses to start rather than silently picking one.
func scanEmbeddedMigrations() ([]migration, error) {
entries, err := migrationFS.ReadDir("migrations")
if err != nil {
return nil, fmt.Errorf("read migrations dir: %w", err)
}
seen := map[int]string{}
var out []migration
for _, e := range entries {
name := e.Name()
if !strings.HasSuffix(name, ".up.sql") {
continue
}
v, n, ok := parseMigrationFilename(name)
if !ok {
return nil, fmt.Errorf("unparseable migration filename %q "+
"(expected NNN_description.up.sql)", name)
}
if prior, dup := seen[v]; dup {
return nil, fmt.Errorf("two migrations at version %d: %q and %q — "+
"rename one and redeploy", v, prior, name)
}
seen[v] = name
out = append(out, migration{version: v, name: n, filename: name})
}
sort.Slice(out, func(i, j int) bool { return out[i].version < out[j].version })
return out, nil
}
// parseMigrationFilename splits "NNN_description.up.sql" into (NNN, description).
// Returns ok=false on any deviation from that shape.
func parseMigrationFilename(filename string) (version int, name string, ok bool) {
base := strings.TrimSuffix(filename, ".up.sql")
if base == filename {
return 0, "", false
}
underscore := strings.IndexByte(base, '_')
if underscore <= 0 {
return 0, "", false
}
v, err := strconv.Atoi(base[:underscore])
if err != nil {
return 0, "", false
}
return v, base[underscore+1:], true
}
// readAppliedMigrations returns a map version → name from
// paliad.applied_migrations. Returns an empty map (no error) if the table
// is missing — that's the fresh-DB path before the CREATE TABLE in
// ApplyMigrations runs against it.
func readAppliedMigrations(conn *sql.DB) (map[int]string, error) {
rows, err := conn.Query(`SELECT version, name FROM paliad.applied_migrations`)
if err != nil {
if strings.Contains(err.Error(), "does not exist") {
return map[int]string{}, nil
}
return nil, err
}
defer rows.Close()
out := map[int]string{}
for rows.Next() {
var v int
var n string
if err := rows.Scan(&v, &n); err != nil {
return nil, err
}
out[v] = n
}
return out, rows.Err()
}
// bootstrapFromLegacyTracker seeds paliad.applied_migrations from
// paliad.paliad_schema_migrations on the first deploy of the new runner
// against a DB that previously ran golang-migrate.
//
// Behavior:
// - applied_migrations already has rows → no-op (idempotent).
// - applied_migrations empty AND legacy tracker missing → no-op
// (virgin DB; the apply loop will run everything from scratch).
// - applied_migrations empty AND legacy tracker present, clean, version N
// → INSERT rows for every on-disk version ≤ N with checksum NULL.
// - applied_migrations empty AND legacy tracker dirty → hard-fail.
// The operator must recover the legacy tracker first (it being dirty
// means a prior golang-migrate run crashed mid-flight); we will not
// paper over an unknown state by guessing what landed.
//
// Backfilled rows have checksum NULL because the legacy runner didn't hash
// anything — we can't fabricate a provenance hash today without falsely
// claiming we know the byte-identity of what shipped historically.
func bootstrapFromLegacyTracker(conn *sql.DB, onDisk []migration) error {
var count int
if err := conn.QueryRow(`SELECT count(*) FROM paliad.applied_migrations`).Scan(&count); err != nil {
return fmt.Errorf("count applied_migrations: %w", err)
}
if count > 0 {
return nil
}
var legacyVer int
var legacyDirty bool
err := conn.QueryRow(`SELECT version, dirty FROM paliad.paliad_schema_migrations LIMIT 1`).
Scan(&legacyVer, &legacyDirty)
if errors.Is(err, sql.ErrNoRows) {
return nil
}
if err != nil {
if strings.Contains(err.Error(), "does not exist") {
return nil
}
return fmt.Errorf("read legacy tracker: %w", err)
}
if legacyDirty {
return fmt.Errorf("legacy paliad.paliad_schema_migrations is dirty at version %d — "+
"recover manually before deploying", legacyVer)
}
for _, m := range onDisk {
if m.version > legacyVer {
continue
}
if _, err := conn.Exec(`
INSERT INTO paliad.applied_migrations(version, name, applied_at, checksum)
VALUES ($1, $2, now(), NULL)
ON CONFLICT (version) DO NOTHING
`, m.version, m.name); err != nil {
return fmt.Errorf("backfill version %d: %w", m.version, err)
}
}
return nil
}
// checkNameAgreement hard-fails if a version that's already applied has a
// different name on disk than in the DB. Catches the post-merge rename
// accident where someone renames `098_foo.up.sql` to `098_bar.up.sql` —
// the SQL has already run on prod with the old name, so the rename is a
// lie about history. Operator recovery: revert the rename, or update the
// DB row if the rename is intentional.
//
// Backfilled rows have a name pulled from the on-disk filename, so an
// out-of-the-box backfill never trips this check.
func checkNameAgreement(onDisk []migration, applied map[int]string) error {
for _, m := range onDisk {
dbName, ok := applied[m.version]
if !ok {
continue
}
if dbName != m.name {
return fmt.Errorf("migration %d: disk name %q != DB name %q "+
"(renamed after apply? revert the rename, or UPDATE paliad.applied_migrations "+
"SET name=%q WHERE version=%d if the rename is intentional)",
m.version, m.name, dbName, m.name, m.version)
}
}
return nil
}
// applyOne runs one migration's .up.sql plus its INSERT row in a single
// transaction. All-or-nothing per migration: if the SQL fails, the row
// isn't inserted and the next deploy re-tries from the same point. If
// the INSERT fails (e.g. PK violation because the lock wasn't held), the
// SQL rolls back too.
func applyOne(conn *sql.DB, m migration) error {
body, err := migrationFS.ReadFile("migrations/" + m.filename)
if err != nil {
return fmt.Errorf("read %s: %w", m.filename, err)
}
checksum := fmt.Sprintf("%x", sha256.Sum256(body))
tx, err := conn.Begin()
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.Exec(string(body)); err != nil {
return fmt.Errorf("exec sql: %w", err)
}
if _, err := tx.Exec(`
INSERT INTO paliad.applied_migrations(version, name, applied_at, checksum)
VALUES ($1, $2, now(), $3)
`, m.version, m.name, checksum); err != nil {
return fmt.Errorf("record applied: %w", err)
}
return tx.Commit()
}

145
internal/db/migrate_test.go Normal file
View File

@@ -0,0 +1,145 @@
// Package db tests — migration dry-run gate.
//
// This is the test that catches mig-N crash-loops before they reach prod.
// The new runner tracks applied state as a set in paliad.applied_migrations
// (one row per migration; see migrate.go). A migration that compiles cleanly
// but fails on apply (typo, missing column, wrong CHECK shape) crashes the
// Dokploy container loop before paliad.de finishes binding :8080, and the
// only way to learn about it today is to watch the deploy log.
//
// TestMigrations_DryRun closes that gap: for every *.up.sql in this
// directory whose version is NOT present in paliad.applied_migrations on
// the scratch DB, it opens a transaction, runs the SQL, and ROLLBACKs.
// Any error fails the test with the file name + Postgres error. Always
// non-destructive — the ROLLBACK runs even on success, so the scratch DB
// stays at its starting set.
//
// "Pending" means: a version that's on disk but not in applied_migrations.
// In CI against a fresh scratch DB (where applied_migrations either
// doesn't exist or is empty), every migration is pending and gets
// verified. On a developer laptop whose scratch DB is already at HEAD,
// no migrations are pending and the test logs and passes — the protection
// only kicks in the moment a new *.up.sql lands in the tree before the
// developer runs `db.ApplyMigrations` against the same scratch DB.
//
// Requires TEST_DATABASE_URL (same pattern as the rest of the live-DB
// tests). Skipped without it.
//
// Design: docs/design-paliad-test-strategy-2026-05-19.md §5 Slice 1 and
// docs/design-migration-runner-applied-set-2026-05-20.md §6.
package db
import (
"database/sql"
"fmt"
"os"
"strings"
"testing"
_ "github.com/lib/pq"
)
// TestMigrations_DryRun walks every pending *.up.sql in numeric order,
// applies each inside its own BEGIN/ROLLBACK against the scratch DB, and
// fails the test on the first SQL error. Reports per-file as a sub-test so
// `go test -v` shows which migration failed.
func TestMigrations_DryRun(t *testing.T) {
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
t.Skip("TEST_DATABASE_URL not set — skipping migration dry-run")
}
conn, err := sql.Open("postgres", url)
if err != nil {
t.Fatalf("open: %v", err)
}
defer conn.Close()
if err := conn.Ping(); err != nil {
t.Fatalf("ping: %v", err)
}
// The paliad schema must exist before migration 001 runs against it,
// mirroring the bootstrap step in ApplyMigrations. Without this, a
// fresh scratch DB would fail migration 001's CREATE TABLE paliad.*
// statements inside the BEGIN/ROLLBACK probe with "schema paliad does
// not exist" — a false negative that distracts from real errors.
if _, err := conn.Exec(`CREATE SCHEMA IF NOT EXISTS paliad`); err != nil {
t.Fatalf("ensure paliad schema: %v", err)
}
applied, err := readAppliedVersions(conn)
if err != nil {
t.Fatalf("read applied_migrations: %v", err)
}
onDisk, err := scanEmbeddedMigrations()
if err != nil {
t.Fatalf("scan embedded migrations: %v", err)
}
var pending []migration
for _, m := range onDisk {
if !applied[m.version] {
pending = append(pending, m)
}
}
if len(pending) == 0 {
t.Logf("no pending migrations — scratch DB applied set covers every on-disk version (%d total)",
len(onDisk))
return
}
t.Logf("scratch DB has %d/%d on-disk migrations applied; walking %d pending",
len(applied), len(onDisk), len(pending))
for _, m := range pending {
t.Run(fmt.Sprintf("%03d_%s", m.version, m.name), func(t *testing.T) {
body, err := migrationFS.ReadFile("migrations/" + m.filename)
if err != nil {
t.Fatalf("read %s: %v", m.filename, err)
}
tx, err := conn.Begin()
if err != nil {
t.Fatalf("begin: %v", err)
}
// Always rollback; the dry-run must not leave the scratch
// DB at a different applied set than where it started.
// Rollback is safe after a failed Exec — Postgres aborts
// the transaction internally on the first error.
defer func() { _ = tx.Rollback() }()
if _, err := tx.Exec(string(body)); err != nil {
t.Fatalf("migration %s failed dry-run: %v", m.filename, err)
}
})
}
}
// readAppliedVersions returns the set of versions present in
// paliad.applied_migrations on the scratch DB. Missing table → empty set
// (fresh-DB path; the table only exists after the runner has been called).
//
// We don't pre-create the table here because the dry-run is supposed to be
// a passive observer — it must not mutate the scratch DB outside of its
// own per-mig BEGIN/ROLLBACK probes. A "table doesn't exist" outcome is
// the right read against a virgin scratch DB.
func readAppliedVersions(conn *sql.DB) (map[int]bool, error) {
rows, err := conn.Query(`SELECT version FROM paliad.applied_migrations`)
if err != nil {
if strings.Contains(err.Error(), "does not exist") {
return map[int]bool{}, nil
}
return nil, err
}
defer rows.Close()
out := map[int]bool{}
for rows.Next() {
var v int
if err := rows.Scan(&v); err != nil {
return nil, err
}
out[v] = true
}
return out, rows.Err()
}

View File

@@ -0,0 +1,59 @@
-- Reverses mig 097. Restores rule_code + legal_source on every row
-- touched by the backfill (and the rev.defence normalization) from the
-- paliad.deadline_rules_pre_097 snapshot, refreshes the deadline_search
-- materialized view, then drops the snapshot.
--
-- audit_reason wrapper required by the mig 079 audit trigger.
SELECT set_config(
'paliad.audit_reason',
'mig 097 (down): revert t-paliad-210 legal-citation backfill — restore rule_code/legal_source from deadline_rules_pre_097 snapshot',
true);
-- =============================================================================
-- 1. Restore rule_code + legal_source from the pre_097 snapshot for every
-- row whose current values diverge from the snapshot. Symmetric across
-- the § 1 / § 2 / § 3 backfills and the § 5 rev.defence normalization
-- in one pass. If the snapshot table is missing (down run before up),
-- the restore is a no-op.
-- =============================================================================
DO $$
DECLARE
v_snap_exists boolean;
BEGIN
SELECT EXISTS (
SELECT 1
FROM information_schema.tables
WHERE table_schema = 'paliad'
AND table_name = 'deadline_rules_pre_097'
) INTO v_snap_exists;
IF NOT v_snap_exists THEN
RAISE NOTICE
'mig 097 (down): snapshot table paliad.deadline_rules_pre_097 missing — nothing to restore';
RETURN;
END IF;
UPDATE paliad.deadline_rules dr
SET rule_code = snap.rule_code,
legal_source = snap.legal_source
FROM paliad.deadline_rules_pre_097 snap
WHERE dr.id = snap.id
AND (dr.rule_code IS DISTINCT FROM snap.rule_code
OR dr.legal_source IS DISTINCT FROM snap.legal_source);
END $$;
-- =============================================================================
-- 2. Refresh deadline_search so the reverted rule_code / legal_source
-- values repopulate the materialized view.
-- =============================================================================
REFRESH MATERIALIZED VIEW paliad.deadline_search;
-- =============================================================================
-- 3. Drop the snapshot so a re-applied up migration captures a fresh
-- snapshot of the current state.
-- =============================================================================
DROP TABLE IF EXISTS paliad.deadline_rules_pre_097;

View File

@@ -0,0 +1,684 @@
-- t-paliad-210 / legal-citation backfill — apply huygens's HIGH/MED
-- proposals from docs/proposals/legal-citation-backfill-2026-05-18.md
-- (commit 391be09) PLUS m's 2026-05-18 FLAG walk-through (paliadin/head
-- instruction-msg 2002). Scope grew from the original brief: m approved
-- filling almost every category, with only 3 FLAG-J rows left NULL.
--
-- Touches (in 8 buckets, ~135 rows):
--
-- § 1 Easy wins — 6 rows. rule_code only. The 2
-- § 123 PatG twins (Wiedereinsetzung)
-- move into the FLAG-A dedup bucket
-- below; not filled here.
--
-- § 2 HIGH/MED proceeding-typed — 15 rows. rule_code + legal_source.
--
-- § 3 HIGH/MED orphans — 47 rows. rule_code + legal_source.
-- For UPC rows also rule_codes[]
-- normalized to ARRAY[rule_code].
-- Excludes 3 archive-dest dup rows
-- that are filled via the canonical
-- in § 4 instead (5c0508f4 /
-- 791fd0f7 / d886f46f).
--
-- § 4 FLAG-A dedup (clean only) — 3 canonical fills + 3 archive
-- flips. Only sets where the
-- duplicate rows share an existing
-- rule_codes[] value (or both are
-- NULL) are deduped:
-- * 2× "Wiedereinsetzungsantrag
-- § 123 PatG" — canonical
-- b588fa64 (lowest UUID),
-- archive c24d494c.
-- * 2× "Berufungsschrift R.220.1
-- (a)/(b)" — canonical 1dfba5b1
-- (filled in § 3.3), archive
-- 5c0508f4.
-- * 2× "Berufungsbegründung R.220.1
-- (a)/(b)" — canonical 573df3d1
-- (filled in § 3.3), archive
-- 791fd0f7.
--
-- DEFERRED (paliadin/head msg 2006,
-- pending m's call): 6× "Mängel-
-- beseitigung / Zahlung" and 2×
-- "Beginn des Hauptsacheverfahrens".
-- Each row in those sets carries a
-- DIFFERENT existing rule_codes[]
-- value (Mängelbeseitigung: RoP.207
-- .6.a, RoP.253.2, RoP.016.3.a,
-- RoP.027.2, RoP.089.2, RoP.229.2;
-- Beginn-Hauptsache: RoP.198 vs
-- RoP.213). These may be distinct
-- procedural-context rules masquer-
-- ading as duplicates; m owns the
-- collapse-or-preserve decision.
-- Mig 097 leaves all 8 rows
-- untouched (rule_code stays NULL,
-- rule_codes[] stays as-is, neither
-- archived nor filled).
--
-- § 5 FLAG-B court-scheduled — 26 rows. Per m: "try to find the
-- rules — they often exist." Cites
-- the framing norm authorising the
-- court to schedule the event (RoP.111
-- for UPC oral hearings, RoP.118 for
-- UPC decisions, § 285 ZPO / § 300
-- ZPO for DE Verhandlung / Urteil,
-- § 47 / 78 / 79 / 107 PatG for
-- DPMA/BPatG/BGH variants, etc.).
--
-- § 6 FLAG-C/D rubber-stamp — 5 rows. rev.reply/rev.rejoin/
-- app.response use canonical RoP.5x
-- regardless of duration-vs-norm
-- mismatch (m: "just go ahead").
-- de_inf.replik/de_inf.duplik cite
-- § 273 ZPO (court-set framing).
--
-- § 7 FLAG-E service triggers — 6 rows (DE/EPA). Service-trigger
-- citations on Zustellung events.
-- UPC initial-submission rows carry
-- the RoP.271.b 10-day deferral as a
-- secondary cite in rule_codes[]
-- (handled in § 9 below).
--
-- § 8 FLAG-F combined-pleading — 5 rows. Use rule_codes[] multi-cite
-- array (column already exists from
-- mig 095). Primary cite in
-- rule_code, full set in rule_codes[].
--
-- § 9 FLAG-G/H/I + RoP.271.b — 13 rows. G: 2 Patentänderung
-- orphans split by INF/REV context.
-- H: 8 sub-paragraph spot-checks
-- applied as-is per the doc. I: 3
-- negative-declaration rows cite
-- RoP.069 by analogy.
-- Plus: 5 UPC initial-submission rows
-- append RoP.271.b to rule_codes[]
-- as the 10-day service deferral.
-- m flagged this distinct from the
-- primary substantive cite.
--
-- § 10 R.19 label rename — 2 rows max. inf.prelim / rev.prelim:
-- set name to "Einspruch (R. 19 VerfO)"
-- / "Einspruch (R. 19 i.V.m. R. 46
-- VerfO)" + rule_code 'RoP.019.1'.
-- Originally drafted in fermi's
-- t-paliad-207 session; m applied the
-- rename live on prod and asked us to
-- consolidate the mig here per Path-A.
-- Guard `name LIKE 'Vorab-Einrede%'`
-- makes this a defensive no-op on the
-- prod DB (fermi already wrote there)
-- but applies cleanly on any future
-- deploy that hasn't seen the live
-- write.
--
-- § 11 Side-fix RoP.49.1 → .049.1 — 1 row. rev.defence carries an
-- un-padded rule_code; all other UPC
-- RoP rules under 100 use 3-digit
-- padding. legal_source stays
-- 'UPC.RoP.49.1' (structured locator
-- never pads).
--
-- FLAG-J kept NULL (3 rows: d124c95b — Aufhebung Entscheidung des
-- Amtes, 002c2ba7 — Folgemaßnahmen Validitätsentscheidung, 902cc5d5 —
-- Klärung Übersetzungsfragen). m will pick them up later via
-- /admin/rules. Existing rule_codes[] on these is left untouched.
--
-- Idempotent:
-- * Backfill UPDATEs guarded on `rule_code IS NULL` (the de-novo fill
-- bucket) — re-running is a no-op.
-- * Archive UPDATEs guarded on `is_active = true AND lifecycle_state
-- = 'published'` — re-running is a no-op.
-- * Normalization UPDATE guarded on `rule_code = 'RoP.49.1'` — no-op
-- after first apply.
-- * Prelim rename UPDATEs guarded on `name LIKE 'Vorab-Einrede%'` —
-- no-op after first apply or on prod (fermi already wrote).
-- * Snapshot table uses CREATE TABLE IF NOT EXISTS.
-- * Materialized-view refresh is safe to repeat.
--
-- audit_reason is set at the top via set_config(..., true) so the
-- mig-079 audit trigger on paliad.deadline_rules accepts the UPDATEs.
SELECT set_config(
'paliad.audit_reason',
'mig 097: t-paliad-210 legal-citation backfill — m''s FLAG walk-through 2026-05-18 (paliadin/head msg 2002). HIGH/MED proposals from docs/proposals/legal-citation-backfill-2026-05-18.md (commit 391be09) plus FLAG-A dedup + FLAG-B court-scheduled cites + FLAG-F rule_codes[] multi-cite + RoP.271.b on UPC initial submissions + RoP.49.1 padding normalization + R.19 prelim rename (fermi/t-paliad-207 consolidated)',
true);
-- =============================================================================
-- 0. Backup snapshot of paliad.deadline_rules BEFORE the backfill. Full
-- table snapshot for the complete pre-097 baseline. Matches the
-- mig 096 pattern (proceeding_types_pre_096).
-- =============================================================================
CREATE TABLE IF NOT EXISTS paliad.deadline_rules_pre_097 AS
SELECT *, now() AS snapshotted_at
FROM paliad.deadline_rules;
COMMENT ON TABLE paliad.deadline_rules_pre_097 IS
'Snapshot of paliad.deadline_rules taken before mig 097 backfilled '
'rule_code + legal_source + rule_codes[] across huygens''s HIGH/MED '
'proposals (t-paliad-208) and m''s expanded FLAG walk-through '
'(2026-05-18). Source-of-truth for the down migration; persists '
'post-backfill as the permanent audit anchor — also retains the '
'pre-dedup per-row rule_codes[] for the Mängelbeseitigung × 6 + '
'Beginn-Hauptsache × 2 sets in case m later wants to recover the '
'procedural-context citations.';
-- =============================================================================
-- 1. § 1 Easy wins (6 rows). legal_source already populated; only
-- rule_code missing. The 2 § 123 PatG Wiedereinsetzung twins
-- (c24d494c…, b588fa64…) are handled in § 4 below as part of the
-- FLAG-A dedup.
-- =============================================================================
UPDATE paliad.deadline_rules SET rule_code = '§ 253 ZPO'
WHERE id = '1f532c82-9e6d-4f48-bd16-fa2fc71d5880' AND rule_code IS NULL; -- de_inf.klage / Klageerhebung
UPDATE paliad.deadline_rules SET rule_code = '§ 339 ZPO'
WHERE id = '20254f4e-d213-4cf6-8f5f-1d9d36eeb6ac' AND rule_code IS NULL; -- Einspruch gegen Versäumnisurteil
UPDATE paliad.deadline_rules SET rule_code = '§ 296a ZPO'
WHERE id = '3c36f149-3a81-456e-aac1-d4d18bfcb16b' AND rule_code IS NULL; -- Schriftsatznachreichung
UPDATE paliad.deadline_rules SET rule_code = 'R. 135 EPÜ'
WHERE id = 'f1099cf6-4c87-430e-b1c5-488bd44cb143' AND rule_code IS NULL; -- Weiterbehandlungsantrag (Art. 121 EPÜ)
UPDATE paliad.deadline_rules SET rule_code = '§ 234 ZPO'
WHERE id = 'd40d9be7-e1b6-451c-bee2-6eaee2307ec5' AND rule_code IS NULL; -- Wiedereinsetzungsantrag (§ 233 ZPO)
UPDATE paliad.deadline_rules SET rule_code = 'R. 136 EPÜ'
WHERE id = '23c6f445-4ed2-4ade-8ea0-c4ab6b364bb6' AND rule_code IS NULL; -- Wiedereinsetzungsantrag (Art. 122 EPÜ)
-- =============================================================================
-- 2. § 2 Proceeding-typed HIGH/MED (15 rows). rule_code + legal_source.
-- Note: rule_codes[] is set in § 9 for the 5 UPC initial-submission
-- rows (inf.soc / rev.app / pi.app / damages.app / disc.app) to
-- include the RoP.271.b secondary cite. For DE/EPA rows here,
-- rule_codes[] is left untouched (currently NULL and not used for
-- DE/EPA citations in this corpus).
-- =============================================================================
UPDATE paliad.deadline_rules SET rule_code = 'RoP.013.1', legal_source = 'UPC.RoP.13.1'
WHERE id = '42be6c9b-8e84-4804-962f-94c3315aca1b' AND rule_code IS NULL; -- upc.inf.cfi / inf.soc
UPDATE paliad.deadline_rules SET rule_code = 'RoP.042', legal_source = 'UPC.RoP.42'
WHERE id = '995c108e-e73a-4f9c-b79f-47abe7c94108' AND rule_code IS NULL; -- upc.rev.cfi / rev.app
UPDATE paliad.deadline_rules SET rule_code = 'RoP.206', legal_source = 'UPC.RoP.206'
WHERE id = 'ed0194b7-74ab-4402-8971-7211f6036ff9' AND rule_code IS NULL; -- upc.pi.cfi / pi.app
UPDATE paliad.deadline_rules SET rule_code = 'RoP.243', legal_source = 'UPC.RoP.243', rule_codes = ARRAY['RoP.243']::text[]
WHERE id = '85f92b72-c654-4429-8e91-03402f9438c6' AND rule_code IS NULL; -- upc.apl.merits / app.oral
UPDATE paliad.deadline_rules SET rule_code = 'RoP.131', legal_source = 'UPC.RoP.131'
WHERE id = '3e1719e8-f6f6-4260-8f02-754bd214937f' AND rule_code IS NULL; -- upc.dmgs.cfi / damages.app
UPDATE paliad.deadline_rules SET rule_code = 'RoP.141', legal_source = 'UPC.RoP.141'
WHERE id = 'eb1fa1d1-b345-42ba-ab14-79f5284166b0' AND rule_code IS NULL; -- upc.disc.cfi / disc.app
UPDATE paliad.deadline_rules SET rule_code = '§ 81 PatG', legal_source = 'DE.PatG.81.1'
WHERE id = 'ba33e704-18f6-4486-8107-abdb1e9cbfad' AND rule_code IS NULL; -- de.null.bpatg / de_null.klage
UPDATE paliad.deadline_rules SET rule_code = '§ 58 PatG', legal_source = 'DE.PatG.58.1'
WHERE id = '972f8fe4-8f4c-4497-9736-d60399ae5989' AND rule_code IS NULL; -- dpma.opp.dpma / dpma_opp.publish
UPDATE paliad.deadline_rules SET rule_code = 'Art. 75 EPÜ', legal_source = 'EU.EPÜ.75'
WHERE id = 'a1766364-1478-4b13-ae02-0a94367c585e' AND rule_code IS NULL; -- epa.grant.exa / ep_grant.filing
UPDATE paliad.deadline_rules SET rule_code = 'Art. 92 EPÜ', legal_source = 'EU.EPÜ.92'
WHERE id = '63069ae5-e380-4db5-b020-d1856f31300c' AND rule_code IS NULL; -- epa.grant.exa / ep_grant.search
UPDATE paliad.deadline_rules SET rule_code = 'Art. 97 EPÜ', legal_source = 'EU.EPÜ.97.1'
WHERE id = '86b3a295-d76b-4566-955d-55f7a394524e' AND rule_code IS NULL; -- epa.grant.exa / ep_grant.grant
UPDATE paliad.deadline_rules SET rule_code = 'Art. 97 EPÜ', legal_source = 'EU.EPÜ.97.3'
WHERE id = '520dd205-7b4a-45f4-b87f-e2be5d1e183e' AND rule_code IS NULL; -- epa.opp.opd / epa_opp.grant
UPDATE paliad.deadline_rules SET rule_code = 'Art. 101 EPÜ', legal_source = 'EU.EPÜ.101'
WHERE id = '8961a54b-2645-4af4-b0f5-114128150839' AND rule_code IS NULL; -- epa.opp.opd / epa_opp.entsch
UPDATE paliad.deadline_rules SET rule_code = 'Art. 116 EPÜ', legal_source = 'EU.EPÜ.116'
WHERE id = '926f333d-55d2-4a12-890e-0508a4ea1bd4' AND rule_code IS NULL; -- epa.opp.boa / epa_app.oral
UPDATE paliad.deadline_rules SET rule_code = 'Art. 111 EPÜ', legal_source = 'EU.EPÜ.111'
WHERE id = 'd0949eaf-da69-4972-90c2-7e6c1bebcd79' AND rule_code IS NULL; -- epa.opp.boa / epa_app.entsch2
-- =============================================================================
-- 3. § 3 Orphan HIGH/MED (47 rows). rule_code + legal_source. For UPC
-- rows also normalize rule_codes[] to ARRAY[rule_code] so the
-- structured tooling field matches the display field. The orphan
-- archive destinations (5c0508f4 / 791fd0f7 / d886f46f) are NOT
-- filled here — they're flipped to archived in § 4.
-- =============================================================================
-- § 3.1 main-pleadings track (10 rows)
UPDATE paliad.deadline_rules SET rule_code = 'RoP.023', legal_source = 'UPC.RoP.23.1', rule_codes = ARRAY['RoP.023']::text[]
WHERE id = 'e34097d6-670d-447a-bdfe-b42df20ba459' AND rule_code IS NULL; -- Klageerwiderung
UPDATE paliad.deadline_rules SET rule_code = 'RoP.025.1', legal_source = 'UPC.RoP.25.1', rule_codes = ARRAY['RoP.025.1']::text[]
WHERE id = '7d8a4804-0ebc-42c4-8552-624350cd81f3' AND rule_code IS NULL; -- Nichtigkeitswiderklage
UPDATE paliad.deadline_rules SET rule_code = 'RoP.049.2.b', legal_source = 'UPC.RoP.49.2.b', rule_codes = ARRAY['RoP.049.2.b']::text[]
WHERE id = 'c7523e6b-579d-4d80-afb3-e1cf11238d40' AND rule_code IS NULL; -- Verletzungswiderklage
UPDATE paliad.deadline_rules SET rule_code = 'RoP.019.1', legal_source = 'UPC.RoP.19.1', rule_codes = ARRAY['RoP.019.1']::text[]
WHERE id = 'c57f62f8-bb52-4232-be85-9125fa93f58c' AND rule_code IS NULL; -- Vorgängige Einrede
UPDATE paliad.deadline_rules SET rule_code = 'RoP.029.b', legal_source = 'UPC.RoP.29.b', rule_codes = ARRAY['RoP.029.b']::text[]
WHERE id = '84b390e0-1ca4-461a-942c-4ad94c643750' AND rule_code IS NULL; -- Replik auf Klageerwiderung
UPDATE paliad.deadline_rules SET rule_code = 'RoP.029.c', legal_source = 'UPC.RoP.29.c', rule_codes = ARRAY['RoP.029.c']::text[]
WHERE id = '176cc1ca-2b25-49ee-9c3e-8afed1673b7d' AND rule_code IS NULL; -- Duplik Replik Klageerwiderung
UPDATE paliad.deadline_rules SET rule_code = 'RoP.049.1', legal_source = 'UPC.RoP.49.1', rule_codes = ARRAY['RoP.049.1']::text[]
WHERE id = 'a32dcec1-6aaa-4a3c-936c-9a761d9362f0' AND rule_code IS NULL; -- Erwiderung auf Nichtigkeitsklage
UPDATE paliad.deadline_rules SET rule_code = 'RoP.052', legal_source = 'UPC.RoP.52', rule_codes = ARRAY['RoP.052']::text[]
WHERE id = '1b5c6dee-0032-4be8-864c-f2ab945aacc5' AND rule_code IS NULL; -- Duplik Replik Erwiderung Nichtigkeitsklage
UPDATE paliad.deadline_rules SET rule_code = 'RoP.056.1', legal_source = 'UPC.RoP.56.1', rule_codes = ARRAY['RoP.056.1']::text[]
WHERE id = 'bea86f9b-37d5-4f6e-b6bd-f0c01f053b66' AND rule_code IS NULL; -- Erwiderung auf Verletzungswiderklage
UPDATE paliad.deadline_rules SET rule_code = 'RoP.056.3', legal_source = 'UPC.RoP.56.3', rule_codes = ARRAY['RoP.056.3']::text[]
WHERE id = '4834c957-2518-40e9-ad62-447f3f220d33' AND rule_code IS NULL; -- Replik Erwiderung Verletzungswiderklage
-- § 3.2 Patentänderungs-Track (1 row; FLAG-G twin rows are handled in § 9)
UPDATE paliad.deadline_rules SET rule_code = 'RoP.032.1', legal_source = 'UPC.RoP.32.1', rule_codes = ARRAY['RoP.032.1']::text[]
WHERE id = '7e65a434-f5c6-4391-a65c-d02de735f551' AND rule_code IS NULL; -- Erwiderung auf Patentänderungsantrag
UPDATE paliad.deadline_rules SET rule_code = 'RoP.032.3', legal_source = 'UPC.RoP.32.3', rule_codes = ARRAY['RoP.032.3']::text[]
WHERE id = 'dfd52792-840f-42c4-8b71-0f77d07cbb53' AND rule_code IS NULL; -- Replik Erwiderung Patentänderung
UPDATE paliad.deadline_rules SET rule_code = 'RoP.032.3', legal_source = 'UPC.RoP.32.3', rule_codes = ARRAY['RoP.032.3']::text[]
WHERE id = '8cdf54eb-5189-47fd-a390-6a0ee98e5243' AND rule_code IS NULL; -- Duplik Replik Erwiderung Patentänderung
-- § 3.3 appeal track (8 fills; 2 archive-destinations handled in § 4)
UPDATE paliad.deadline_rules SET rule_code = 'RoP.224.1.a', legal_source = 'UPC.RoP.224.1.a', rule_codes = ARRAY['RoP.224.1.a']::text[]
WHERE id = '1dfba5b1-4ed1-40c1-9cf6-4ed8ff7a0818' AND rule_code IS NULL; -- Berufungsschrift canonical
UPDATE paliad.deadline_rules SET rule_code = 'RoP.224.1.b', legal_source = 'UPC.RoP.224.1.b', rule_codes = ARRAY['RoP.224.1.b']::text[]
WHERE id = 'd560b3b6-9437-4b22-b62c-957d4a37d21a' AND rule_code IS NULL; -- Berufungsschrift Orders
UPDATE paliad.deadline_rules SET rule_code = 'RoP.225.1', legal_source = 'UPC.RoP.225.1', rule_codes = ARRAY['RoP.225.1']::text[]
WHERE id = '573df3d1-8ea2-4a6e-b0d4-fc3cd10506da' AND rule_code IS NULL; -- Berufungsbegründung canonical
UPDATE paliad.deadline_rules SET rule_code = 'RoP.224.1.b', legal_source = 'UPC.RoP.224.1.b', rule_codes = ARRAY['RoP.224.1.b']::text[]
WHERE id = '91e367dd-ffe6-4012-ac6a-b61c32e2b3b7' AND rule_code IS NULL; -- Berufung (Anordnungen & mit Zulassung)
UPDATE paliad.deadline_rules SET rule_code = 'RoP.221.1', legal_source = 'UPC.RoP.221.1', rule_codes = ARRAY['RoP.221.1']::text[]
WHERE id = 'ccb916df-4ee3-4dde-bcb0-6a5b557c0cba' AND rule_code IS NULL; -- Berufungszulassung Kosten
UPDATE paliad.deadline_rules SET rule_code = 'RoP.220.3', legal_source = 'UPC.RoP.220.3', rule_codes = ARRAY['RoP.220.3']::text[]
WHERE id = '342e749d-c2bc-4148-974b-ac0331b76229' AND rule_code IS NULL; -- Ermessensüberprüfung
UPDATE paliad.deadline_rules SET rule_code = 'RoP.235.1', legal_source = 'UPC.RoP.235.1', rule_codes = ARRAY['RoP.235.1']::text[]
WHERE id = '10374392-b8db-4738-8a61-f8ce0fabcc3e' AND rule_code IS NULL; -- Berufungserwiderung (224.2(a))
UPDATE paliad.deadline_rules SET rule_code = 'RoP.237.1', legal_source = 'UPC.RoP.237.1', rule_codes = ARRAY['RoP.237.1']::text[]
WHERE id = '6e39b653-1328-40e1-95f1-071fdf46eed6' AND rule_code IS NULL; -- Anschlussberufung (224.2(a))
UPDATE paliad.deadline_rules SET rule_code = 'RoP.238.1', legal_source = 'UPC.RoP.238.1', rule_codes = ARRAY['RoP.238.1']::text[]
WHERE id = '6b989e85-e739-4e3b-bfd1-52b0e0c35f61' AND rule_code IS NULL; -- Erwiderung Anschlussberufung (224.2(a))
UPDATE paliad.deadline_rules SET rule_code = 'RoP.238.2', legal_source = 'UPC.RoP.238.2', rule_codes = ARRAY['RoP.238.2']::text[]
WHERE id = 'e78f4652-acf9-4ecd-ac48-888ce475173f' AND rule_code IS NULL; -- Erwiderung Anschlussberufung (224.2(b))
-- § 3.4 Schadensbemessung / Rechnungslegung (7 rows)
UPDATE paliad.deadline_rules SET rule_code = 'RoP.137.2', legal_source = 'UPC.RoP.137.2', rule_codes = ARRAY['RoP.137.2']::text[]
WHERE id = 'd414f603-14c1-49f2-91be-e305eba696e3' AND rule_code IS NULL; -- Erwiderung Schadensbemessung
UPDATE paliad.deadline_rules SET rule_code = 'RoP.139', legal_source = 'UPC.RoP.139', rule_codes = ARRAY['RoP.139']::text[]
WHERE id = '9f39e263-e9ec-4805-a82e-c7551a22c78d' AND rule_code IS NULL; -- Replik Schadensbemessung
UPDATE paliad.deadline_rules SET rule_code = 'RoP.139', legal_source = 'UPC.RoP.139', rule_codes = ARRAY['RoP.139']::text[]
WHERE id = '067ffdf0-180b-488f-a369-249f6bcb9faa' AND rule_code IS NULL; -- Duplik Schadensbemessung
UPDATE paliad.deadline_rules SET rule_code = 'RoP.142.2', legal_source = 'UPC.RoP.142.2', rule_codes = ARRAY['RoP.142.2']::text[]
WHERE id = '429b8ec0-227a-4945-8b20-6ad79330a490' AND rule_code IS NULL; -- Erwiderung Rechnungslegung
UPDATE paliad.deadline_rules SET rule_code = 'RoP.142.3', legal_source = 'UPC.RoP.142.3', rule_codes = ARRAY['RoP.142.3']::text[]
WHERE id = '8d36fc76-61b9-4e99-b113-eed4c9c4b2c7' AND rule_code IS NULL; -- Replik Rechnungslegung
UPDATE paliad.deadline_rules SET rule_code = 'RoP.142.3', legal_source = 'UPC.RoP.142.3', rule_codes = ARRAY['RoP.142.3']::text[]
WHERE id = 'ed82fec9-2346-494f-a0ff-f41e64c26942' AND rule_code IS NULL; -- Duplik Rechnungslegung
UPDATE paliad.deadline_rules SET rule_code = 'RoP.151', legal_source = 'UPC.RoP.151', rule_codes = ARRAY['RoP.151']::text[]
WHERE id = 'eed69e8b-0dc8-4d97-83f0-5694d539b46a' AND rule_code IS NULL; -- Kostenentscheidung
-- § 3.5 provisional / PI (2 rows; canonical ba335c99 + the d886f46f archive handled in § 4)
UPDATE paliad.deadline_rules SET rule_code = 'RoP.197.3', legal_source = 'UPC.RoP.197.3', rule_codes = ARRAY['RoP.197.3']::text[]
WHERE id = '1f1f72ef-5a67-4d6a-9a80-82e53375177a' AND rule_code IS NULL; -- Beweissicherungsanordnung
UPDATE paliad.deadline_rules SET rule_code = 'RoP.207.9', legal_source = 'UPC.RoP.207.9', rule_codes = ARRAY['RoP.207.9']::text[]
WHERE id = '3e2f5697-3012-4bae-bd4d-44998dd3b75b' AND rule_code IS NULL; -- Schutzschrift
-- § 3.7 formalities / Registry (4 fills; 5 Mängelbeseitigung dups + FLAG-J 2 rows handled separately)
UPDATE paliad.deadline_rules SET rule_code = 'RoP.016.5', legal_source = 'UPC.RoP.16.5', rule_codes = ARRAY['RoP.016.5']::text[]
WHERE id = '3bc40027-9ebf-4f3d-880d-bf9de6da3ec0' AND rule_code IS NULL; -- Mängelbeseitigung / Stellungnahme
UPDATE paliad.deadline_rules SET rule_code = 'RoP.262.2', legal_source = 'UPC.RoP.262.2', rule_codes = ARRAY['RoP.262.2']::text[]
WHERE id = '69e356b7-79b3-42d7-972b-44d4e35ebdbc' AND rule_code IS NULL; -- Vertraulichkeit
UPDATE paliad.deadline_rules SET rule_code = 'RoP.353', legal_source = 'UPC.RoP.353', rule_codes = ARRAY['RoP.353']::text[]
WHERE id = '57e6eeca-8695-4af3-96cc-16ebd8bc3f2c' AND rule_code IS NULL; -- Berichtigung
UPDATE paliad.deadline_rules SET rule_code = 'RoP.333.1', legal_source = 'UPC.RoP.333.1', rule_codes = ARRAY['RoP.333.1']::text[]
WHERE id = '8ec233b9-3bc4-4015-a158-86af233e52b3' AND rule_code IS NULL; -- Verfahrensleitende Anordnung
-- § 3.8 translation / interpretation (1 row; FLAG-H/J handled in § 9 / left NULL)
UPDATE paliad.deadline_rules SET rule_code = 'RoP.109.1', legal_source = 'UPC.RoP.109.1', rule_codes = ARRAY['RoP.109.1']::text[]
WHERE id = 'bb7bafcb-9d91-4bf7-ae2c-6634652d9906' AND rule_code IS NULL; -- Simultanübersetzung
-- § 3.9 review / rehearing (2 rows)
UPDATE paliad.deadline_rules SET rule_code = 'RoP.247.2', legal_source = 'UPC.RoP.247.2', rule_codes = ARRAY['RoP.247.2']::text[]
WHERE id = '372e86e3-c8ff-4cb5-9389-66acdbc96e57' AND rule_code IS NULL; -- Wiederaufnahme (schwerwiegend)
UPDATE paliad.deadline_rules SET rule_code = 'RoP.247.2', legal_source = 'UPC.RoP.247.2', rule_codes = ARRAY['RoP.247.2']::text[]
WHERE id = '58de9573-07db-4d8d-9b00-8fab0d71d88c' AND rule_code IS NULL; -- Wiederaufnahme (Straftat)
-- =============================================================================
-- 4. § 4 FLAG-A dedup (clean only). 1 canonical fill (the other 2
-- canonicals are filled in § 3.3) + 3 archive flips. Canonical
-- selection per m's spec: lowest UUID. None of the archive
-- candidates have FK references in mgmt.deadline_rules / paliad.
-- appointments / paliad.deadlines / paliad.deadline_rules (parent_id
-- or draft_of) — verified pre-mig. Archive over DELETE per m
-- (audit trail).
--
-- Mängelbeseitigung 6× and Beginn-Hauptsache 2× are intentionally
-- NOT deduped in this mig — see header for the deferred-decision
-- rationale. Their rows stay active+published+rule_code IS NULL
-- until m's call lands.
-- =============================================================================
-- Canonical fill for the § 123 PatG twin (legal_source already
-- DE.PatG.123.2). The other 2 canonicals (Berufungsschrift 1dfba5b1
-- and Berufungsbegründung 573df3d1) are filled in § 3.3 above.
UPDATE paliad.deadline_rules SET rule_code = '§ 123 PatG'
WHERE id = 'b588fa64-a727-4cfb-a45d-69a835a3b05a' AND rule_code IS NULL;
-- Archive flips (3 rows: the non-canonical sides of the 3 clean dedup
-- sets). After this each set has exactly 1 active+published row.
UPDATE paliad.deadline_rules
SET is_active = false, lifecycle_state = 'archived'
WHERE id IN (
'c24d494c-0da1-4f01-aa74-0f37f99fe1ae', -- Wiedereinsetzung § 123 PatG dup
'5c0508f4-020a-4ef5-bcc7-1ee85eafe0b3', -- Berufungsschrift dup
'791fd0f7-a448-4711-b1aa-63e6df1e7c57' -- Berufungsbegründung dup
)
AND is_active = true
AND lifecycle_state = 'published';
-- =============================================================================
-- 5. § 5 FLAG-B court-scheduled events (26 rows). Cite the framing norm
-- that authorises the court to schedule the event. UPC RoP.111 /
-- RoP.118 / RoP.101 / RoP.209 / RoP.211 / RoP.350 / RoP.220.1.c /
-- RoP.157. DE § 285 ZPO / § 300 ZPO / § 89 PatG / § 84 PatG / § 113
-- PatG / § 119 PatG. DPMA § 47 / 78 / 79 / 107 PatG.
-- =============================================================================
-- UPC court-scheduled events
UPDATE paliad.deadline_rules SET rule_code = 'RoP.118', legal_source = 'UPC.RoP.118', rule_codes = ARRAY['RoP.118']::text[]
WHERE id = '60d71f1e-a0e8-42cd-85e9-89f3c808868f' AND rule_code IS NULL; -- inf.decision
UPDATE paliad.deadline_rules SET rule_code = 'RoP.101', legal_source = 'UPC.RoP.101', rule_codes = ARRAY['RoP.101']::text[]
WHERE id = '7b118633-92b2-4c91-8512-6cb929288f10' AND rule_code IS NULL; -- inf.interim
UPDATE paliad.deadline_rules SET rule_code = 'RoP.111', legal_source = 'UPC.RoP.111', rule_codes = ARRAY['RoP.111']::text[]
WHERE id = 'd4c01a6f-d147-4505-bf1c-9aaf88b15287' AND rule_code IS NULL; -- inf.oral
UPDATE paliad.deadline_rules SET rule_code = 'RoP.118', legal_source = 'UPC.RoP.118', rule_codes = ARRAY['RoP.118']::text[]
WHERE id = 'f382cfe4-6703-40f8-a43d-0fe02d62d0fa' AND rule_code IS NULL; -- rev.decision
UPDATE paliad.deadline_rules SET rule_code = 'RoP.101', legal_source = 'UPC.RoP.101', rule_codes = ARRAY['RoP.101']::text[]
WHERE id = 'ccad91ef-da04-4b81-a979-658578fb97c4' AND rule_code IS NULL; -- rev.interim
UPDATE paliad.deadline_rules SET rule_code = 'RoP.111', legal_source = 'UPC.RoP.111', rule_codes = ARRAY['RoP.111']::text[]
WHERE id = '38e8982b-5cc9-41b3-b477-37ce4bd4e7c4' AND rule_code IS NULL; -- rev.oral
UPDATE paliad.deadline_rules SET rule_code = 'RoP.209', legal_source = 'UPC.RoP.209', rule_codes = ARRAY['RoP.209']::text[]
WHERE id = 'e4a61ebf-c49b-450f-9d94-bb06098536b4' AND rule_code IS NULL; -- pi.oral
UPDATE paliad.deadline_rules SET rule_code = 'RoP.211', legal_source = 'UPC.RoP.211', rule_codes = ARRAY['RoP.211']::text[]
WHERE id = '7b93a8b7-115d-42b4-9d1d-34684ddf5206' AND rule_code IS NULL; -- pi.order
UPDATE paliad.deadline_rules SET rule_code = 'RoP.209.1', legal_source = 'UPC.RoP.209.1', rule_codes = ARRAY['RoP.209.1']::text[]
WHERE id = '30ffe572-aa77-4dcb-9292-a4750289f75c' AND rule_code IS NULL; -- pi.response (court-set)
UPDATE paliad.deadline_rules SET rule_code = 'RoP.350', legal_source = 'UPC.RoP.350', rule_codes = ARRAY['RoP.350']::text[]
WHERE id = '685bad4f-3c3e-425d-8839-2f765d0fc96e' AND rule_code IS NULL; -- app.decision
UPDATE paliad.deadline_rules SET rule_code = 'RoP.220.1.c', legal_source = 'UPC.RoP.220.1.c', rule_codes = ARRAY['RoP.220.1.c']::text[]
WHERE id = 'c2865575-d7d6-436d-b61c-0a266217f76c' AND rule_code IS NULL; -- app_ord.order
UPDATE paliad.deadline_rules SET rule_code = 'RoP.157', legal_source = 'UPC.RoP.157', rule_codes = ARRAY['RoP.157']::text[]
WHERE id = '01db67c9-5621-48ca-9dbd-d652b6237b24' AND rule_code IS NULL; -- cost.decision
-- DE court-scheduled events
UPDATE paliad.deadline_rules SET rule_code = '§ 285 ZPO', legal_source = 'DE.ZPO.285'
WHERE id = 'a95af317-2fdb-43c9-ab66-c8b2099aaa5a' AND rule_code IS NULL; -- de_inf.termin
UPDATE paliad.deadline_rules SET rule_code = '§ 300 ZPO', legal_source = 'DE.ZPO.300'
WHERE id = 'e46d2ae7-74bf-4c06-9e55-921242d36f2a' AND rule_code IS NULL; -- de_inf.urteil
UPDATE paliad.deadline_rules SET rule_code = '§ 285 ZPO', legal_source = 'DE.ZPO.285'
WHERE id = '2a16f77f-408f-48c4-9d71-8ea5926d4dca' AND rule_code IS NULL; -- de_inf_olg.termin
UPDATE paliad.deadline_rules SET rule_code = '§ 300 ZPO', legal_source = 'DE.ZPO.300'
WHERE id = '7d7d88c5-895e-4855-8f4d-2e160ff74998' AND rule_code IS NULL; -- de_inf_olg.urteil_olg
UPDATE paliad.deadline_rules SET rule_code = '§ 285 ZPO', legal_source = 'DE.ZPO.285'
WHERE id = 'b1460f90-419e-47ae-978a-8e32ffafad73' AND rule_code IS NULL; -- de_inf_bgh.termin
UPDATE paliad.deadline_rules SET rule_code = '§ 300 ZPO', legal_source = 'DE.ZPO.300'
WHERE id = '803460ac-f6bd-4194-b5ab-140175644648' AND rule_code IS NULL; -- de_inf_bgh.urteil_bgh
UPDATE paliad.deadline_rules SET rule_code = '§ 89 PatG', legal_source = 'DE.PatG.89'
WHERE id = 'ab60e712-bc56-4326-8df0-413881996bf3' AND rule_code IS NULL; -- de_null.termin
UPDATE paliad.deadline_rules SET rule_code = '§ 84 PatG', legal_source = 'DE.PatG.84'
WHERE id = '1476829a-cc92-4221-b182-846fc99ad941' AND rule_code IS NULL; -- de_null.urteil
UPDATE paliad.deadline_rules SET rule_code = '§ 113 PatG', legal_source = 'DE.PatG.113'
WHERE id = 'd077816d-bce4-4cb7-bd67-7b52edbf7fb9' AND rule_code IS NULL; -- de_null_bgh.termin
UPDATE paliad.deadline_rules SET rule_code = '§ 119 PatG', legal_source = 'DE.PatG.119'
WHERE id = '816e9756-efff-4e40-b650-f0b31bdc21e5' AND rule_code IS NULL; -- de_null_bgh.urteil_bgh
-- DPMA / BPatG / BGH-PatG court-scheduled events
UPDATE paliad.deadline_rules SET rule_code = '§ 47 PatG', legal_source = 'DE.PatG.47'
WHERE id = '193a85e2-5794-463a-8c45-73174a54cea9' AND rule_code IS NULL; -- dpma_opp.entscheidung
UPDATE paliad.deadline_rules SET rule_code = '§ 79 PatG', legal_source = 'DE.PatG.79'
WHERE id = 'baaff831-6a3f-43ed-96bb-eae6ad73f6fc' AND rule_code IS NULL; -- dpma_bpatg.entsch_bpatg
UPDATE paliad.deadline_rules SET rule_code = '§ 78 PatG', legal_source = 'DE.PatG.78'
WHERE id = '446694c2-5b34-4ecd-9bf7-7eee055b0d1b' AND rule_code IS NULL; -- dpma_bpatg.termin
UPDATE paliad.deadline_rules SET rule_code = '§ 107 PatG', legal_source = 'DE.PatG.107'
WHERE id = '99c02992-1a77-4694-b773-941ac9876bb5' AND rule_code IS NULL; -- dpma_bgh.entsch_bgh
-- =============================================================================
-- 6. § 6 FLAG-C/D rubber-stamp (5 rows). UPC RoP duration-vs-norm
-- mismatches get the canonical citation per m ("just go ahead"). DE
-- LG patent-practice 4-week replik/duplik cite § 273 ZPO (court-set
-- framing).
-- =============================================================================
UPDATE paliad.deadline_rules SET rule_code = 'RoP.052', legal_source = 'UPC.RoP.52', rule_codes = ARRAY['RoP.052']::text[]
WHERE id = '7e0ea937-d81b-4dee-897e-0d8bc0543f34' AND rule_code IS NULL; -- rev.reply (FLAG-C)
UPDATE paliad.deadline_rules SET rule_code = 'RoP.052', legal_source = 'UPC.RoP.52', rule_codes = ARRAY['RoP.052']::text[]
WHERE id = 'b7890351-c6d6-46e4-b064-0513a1808e6d' AND rule_code IS NULL; -- rev.rejoin (FLAG-C)
UPDATE paliad.deadline_rules SET rule_code = 'RoP.235.1', legal_source = 'UPC.RoP.235.1', rule_codes = ARRAY['RoP.235.1']::text[]
WHERE id = 'd6600ceb-d1d5-408a-a7c9-1026f304ac7f' AND rule_code IS NULL; -- app.response (FLAG-C)
UPDATE paliad.deadline_rules SET rule_code = '§ 273 ZPO', legal_source = 'DE.ZPO.273'
WHERE id = 'd46d915e-fd46-4167-88b5-6d22bcbb8882' AND rule_code IS NULL; -- de_inf.replik (FLAG-D)
UPDATE paliad.deadline_rules SET rule_code = '§ 273 ZPO', legal_source = 'DE.ZPO.273'
WHERE id = 'ca9b52cb-e986-4c3a-9e89-e799e6a6ac33' AND rule_code IS NULL; -- de_inf.duplik (FLAG-D)
-- =============================================================================
-- 7. § 7 FLAG-E service triggers (6 rows, DE/EPA). § 317 ZPO for LG/OLG
-- judgment-service, § 99 / § 47 / § 79 PatG for the PatG variants,
-- R. 111 EPÜ for EPA notification.
-- =============================================================================
UPDATE paliad.deadline_rules SET rule_code = '§ 317 ZPO', legal_source = 'DE.ZPO.317'
WHERE id = '106d8a0b-514b-4021-8b65-7debff71f1d3' AND rule_code IS NULL; -- de_inf_olg.urteil_lg
UPDATE paliad.deadline_rules SET rule_code = '§ 317 ZPO', legal_source = 'DE.ZPO.317'
WHERE id = 'd071b5c6-f33e-44e8-8656-4e9cccf55701' AND rule_code IS NULL; -- de_inf_bgh.urteil_olg
UPDATE paliad.deadline_rules SET rule_code = '§ 99 PatG', legal_source = 'DE.PatG.99.1'
WHERE id = 'bdae7319-7435-40e9-be19-6ce21fdb9946' AND rule_code IS NULL; -- de_null_bgh.urteil_bpatg
UPDATE paliad.deadline_rules SET rule_code = '§ 47 PatG', legal_source = 'DE.PatG.47.1'
WHERE id = '327390f9-3c1b-496f-8e63-2bf19c380dfe' AND rule_code IS NULL; -- dpma_bpatg.entscheidung
UPDATE paliad.deadline_rules SET rule_code = '§ 79 PatG', legal_source = 'DE.PatG.79.1'
WHERE id = 'd3ea5e50-f7e2-40f1-bb16-30664acc2e2b' AND rule_code IS NULL; -- dpma_bgh.entsch_bpatg
UPDATE paliad.deadline_rules SET rule_code = 'R. 111 EPÜ', legal_source = 'EU.EPC-R.111'
WHERE id = '79c27f9b-5195-4272-90d6-ea6a43cd0938' AND rule_code IS NULL; -- epa_app.entsch
-- =============================================================================
-- 8. § 8 FLAG-F combined-pleading rows (5 rows). Primary cite in
-- rule_code + legal_source; full set of citations in rule_codes[]
-- so downstream tooling can resolve any of the combined norms.
-- =============================================================================
UPDATE paliad.deadline_rules
SET rule_code = 'RoP.029.a', legal_source = 'UPC.RoP.29.a',
rule_codes = ARRAY['RoP.029.a', 'RoP.029.b']::text[]
WHERE id = 'cec1a865-30a4-46c9-8abf-630d4478b91a' AND rule_code IS NULL; -- Erwid CCR + Replik SoD
UPDATE paliad.deadline_rules
SET rule_code = 'RoP.029.c', legal_source = 'UPC.RoP.29.c',
rule_codes = ARRAY['RoP.029.c', 'RoP.032.3']::text[]
WHERE id = '02ae9c1f-2aa0-4e0e-acf1-ae235588a64f' AND rule_code IS NULL; -- Duplik Replik + Replik Erwid Patentänderung
UPDATE paliad.deadline_rules
SET rule_code = 'RoP.029.d', legal_source = 'UPC.RoP.29.d',
rule_codes = ARRAY['RoP.029.d', 'RoP.029.c', 'RoP.032.1']::text[]
WHERE id = 'ec2a1274-ffd8-42e7-9e27-582365d04d6e' AND rule_code IS NULL; -- Replik Erwid Widerklage + Duplik Replik Klageerwid + Erwid Patentänderung
UPDATE paliad.deadline_rules
SET rule_code = 'RoP.051', legal_source = 'UPC.RoP.51',
rule_codes = ARRAY['RoP.051', 'RoP.049.2.a', 'RoP.056.1']::text[]
WHERE id = '37bd034b-79e3-4c3c-a21d-b078aaf2ea04' AND rule_code IS NULL; -- Replik Erwid Nichtigkeit + Erwid Patent + Erwid Widerklage
UPDATE paliad.deadline_rules
SET rule_code = 'RoP.056.4', legal_source = 'UPC.RoP.56.4',
rule_codes = ARRAY['RoP.056.4', 'RoP.032.3']::text[]
WHERE id = '7b548c48-6fef-4387-8123-e1f1e4ee6da2' AND rule_code IS NULL; -- Duplik (Verletzungswiderklage + Patentänderung)
-- =============================================================================
-- 9. § 9 FLAG-G/H/I + RoP.271.b. Patentänderung INF/REV split (G),
-- sub-paragraph spot-checks (H, applied as-is per doc), negative-
-- declaration RoP.069 by analogy (I), and the RoP.271.b 10-day
-- service-deferral secondary cite on UPC initial submissions.
-- =============================================================================
-- FLAG-G: Patentänderungs-Twin (INF vs REV context)
UPDATE paliad.deadline_rules SET rule_code = 'RoP.030.1', legal_source = 'UPC.RoP.30.1', rule_codes = ARRAY['RoP.030.1']::text[]
WHERE id = 'fb7050c6-a18b-47e4-8811-46ca3677d549' AND rule_code IS NULL; -- Patentänderung INF
UPDATE paliad.deadline_rules SET rule_code = 'RoP.049.2.a', legal_source = 'UPC.RoP.49.2.a', rule_codes = ARRAY['RoP.049.2.a']::text[]
WHERE id = '21e67ac1-fe40-44d1-ae2e-ea90e0b97598' AND rule_code IS NULL; -- Patentänderung REV
-- FLAG-H: sub-paragraph spot-checks (8 rows, applied per doc proposal)
UPDATE paliad.deadline_rules SET rule_code = 'RoP.225.2', legal_source = 'UPC.RoP.225.2', rule_codes = ARRAY['RoP.225.2']::text[]
WHERE id = 'c3a369f9-4f56-4c88-b11c-f98d05d3b376' AND rule_code IS NULL; -- Berufungsbegründung Orders
UPDATE paliad.deadline_rules SET rule_code = 'RoP.234.1', legal_source = 'UPC.RoP.234.1', rule_codes = ARRAY['RoP.234.1']::text[]
WHERE id = 'd4f739cd-444d-48c0-98c4-70f0521b4916' AND rule_code IS NULL; -- Anfechtung Verwerfung
UPDATE paliad.deadline_rules SET rule_code = 'RoP.235.4', legal_source = 'UPC.RoP.235.4', rule_codes = ARRAY['RoP.235.4']::text[]
WHERE id = '4c585c6d-fb5c-4a99-a798-86a05c757bf7' AND rule_code IS NULL; -- Berufungserwiderung Orders
UPDATE paliad.deadline_rules SET rule_code = 'RoP.237.2', legal_source = 'UPC.RoP.237.2', rule_codes = ARRAY['RoP.237.2']::text[]
WHERE id = 'a00e51bb-bcb6-48d0-9aa5-2216e9480c5c' AND rule_code IS NULL; -- Anschlussberufung Orders
UPDATE paliad.deadline_rules SET rule_code = 'RoP.097.1', legal_source = 'UPC.RoP.97.1', rule_codes = ARRAY['RoP.097.1']::text[]
WHERE id = '0531b6ba-98cc-48f4-adb8-da8b7a7c3535' AND rule_code IS NULL; -- Aufhebung EPA Einheitswirkung
UPDATE paliad.deadline_rules SET rule_code = 'RoP.037.4', legal_source = 'UPC.RoP.37.4', rule_codes = ARRAY['RoP.037.4']::text[]
WHERE id = '6b6b967c-65fd-4172-9640-1ffff8a46704' AND rule_code IS NULL; -- Verweisung Zentralkammer
UPDATE paliad.deadline_rules SET rule_code = 'RoP.109.5', legal_source = 'UPC.RoP.109.5', rule_codes = ARRAY['RoP.109.5']::text[]
WHERE id = '8c682cff-3423-41d8-81ca-b5b461461682' AND rule_code IS NULL; -- Dolmetscher own-cost
UPDATE paliad.deadline_rules SET rule_code = 'RoP.007.2', legal_source = 'UPC.RoP.7.2', rule_codes = ARRAY['RoP.007.2']::text[]
WHERE id = '9ed513c1-68df-455e-810e-a5d8d7b85729' AND rule_code IS NULL; -- Übersetzungen Schriftstücke
-- FLAG-I: negative-declaration track (3 rows, RoP.069 by analogy per m)
UPDATE paliad.deadline_rules SET rule_code = 'RoP.069', legal_source = 'UPC.RoP.69', rule_codes = ARRAY['RoP.069']::text[]
WHERE id = '521bf607-1c69-4dc5-a09e-70339bbe4684' AND rule_code IS NULL; -- Erwid neg. Feststellungsklage
UPDATE paliad.deadline_rules SET rule_code = 'RoP.069', legal_source = 'UPC.RoP.69', rule_codes = ARRAY['RoP.069']::text[]
WHERE id = 'e887b1fb-83ff-4073-b81b-c10dde6dc2c6' AND rule_code IS NULL; -- Replik neg. Feststellung
UPDATE paliad.deadline_rules SET rule_code = 'RoP.069', legal_source = 'UPC.RoP.69', rule_codes = ARRAY['RoP.069']::text[]
WHERE id = '0cf1d755-3ba5-44ce-87ca-f98bb076c995' AND rule_code IS NULL; -- Duplik neg. Feststellung
-- RoP.271.b — 10-day service deferral on UPC initial submissions.
-- Set rule_codes[] to [primary substantive cite, 'RoP.271.b'] for the
-- 5 UPC initial-submission rows whose § 2 UPDATEs above only set
-- rule_code + legal_source. Idempotent via the IS DISTINCT FROM guard
-- — re-running matches no rows.
UPDATE paliad.deadline_rules
SET rule_codes = target.rule_codes
FROM (VALUES
('42be6c9b-8e84-4804-962f-94c3315aca1b'::uuid, ARRAY['RoP.013.1', 'RoP.271.b']::text[]), -- inf.soc
('995c108e-e73a-4f9c-b79f-47abe7c94108'::uuid, ARRAY['RoP.042', 'RoP.271.b']::text[]), -- rev.app
('ed0194b7-74ab-4402-8971-7211f6036ff9'::uuid, ARRAY['RoP.206', 'RoP.271.b']::text[]), -- pi.app
('3e1719e8-f6f6-4260-8f02-754bd214937f'::uuid, ARRAY['RoP.131', 'RoP.271.b']::text[]), -- damages.app
('eb1fa1d1-b345-42ba-ab14-79f5284166b0'::uuid, ARRAY['RoP.141', 'RoP.271.b']::text[]) -- disc.app
) AS target(id, rule_codes)
WHERE paliad.deadline_rules.id = target.id
AND paliad.deadline_rules.rule_codes IS DISTINCT FROM target.rule_codes;
-- =============================================================================
-- 10. § 10 R.19 label rename (inf.prelim / rev.prelim). Defensive
-- idempotent backstop for fermi's live prod write. Matches no rows
-- on the current prod DB (fermi already renamed) and on the first
-- post-mig fresh-deploy too. Catches any future prod that hasn't
-- seen the live write.
-- =============================================================================
UPDATE paliad.deadline_rules
SET name = 'Einspruch (R. 19 VerfO)', rule_code = 'RoP.019.1'
WHERE code = 'inf.prelim' AND name LIKE 'Vorab-Einrede%';
UPDATE paliad.deadline_rules
SET name = 'Einspruch (R. 19 i.V.m. R. 46 VerfO)', rule_code = 'RoP.019.1'
WHERE code = 'rev.prelim' AND name LIKE 'Vorab-Einrede%';
-- =============================================================================
-- 11. § 11 Side-fix: normalize the one un-padded UPC RoP <100 rule_code
-- outlier. legal_source stays 'UPC.RoP.49.1' (structured locator
-- never pads — convention § 0.2 of the proposal doc).
-- =============================================================================
UPDATE paliad.deadline_rules
SET rule_code = 'RoP.049.1'
WHERE rule_code = 'RoP.49.1'
AND code = 'rev.defence';
-- =============================================================================
-- 12. Refresh the deadline_search materialized view so search hits
-- return the newly populated rule_code + legal_source values.
-- =============================================================================
REFRESH MATERIALIZED VIEW paliad.deadline_search;
-- =============================================================================
-- 13. Hard assertions. Verifies the post-state matches the plan.
--
-- a) 11 active+published rows remain rule_code IS NULL: the 3
-- FLAG-J rows (m picks them up via /admin/rules) plus the 8
-- rows whose dedup decision is deferred (Mängelbeseitigung 6×
-- + Beginn-Hauptsache 2×).
-- b) No un-padded RoP.49.1 outlier remains.
-- c) Padded RoP.049.1 present at least twice (rev.defence
-- normalized + a32dcec1 orphan filled).
-- d) Each of the 3 clean-dedup sets has exactly 1 active+published
-- row after the archive flips.
-- =============================================================================
DO $$
DECLARE
v_null_after integer;
v_old_outlier integer;
v_new_padded integer;
v_dup_count integer;
BEGIN
-- (a) 3 FLAG-J + 8 deferred-dedup rows stay NULL.
SELECT count(*) INTO v_null_after
FROM paliad.deadline_rules
WHERE rule_code IS NULL
AND is_active = true
AND lifecycle_state = 'published';
IF v_null_after <> 11 THEN
RAISE EXCEPTION
'mig 097: expected 11 rule_code IS NULL active+published rows after backfill (3 FLAG-J + 8 deferred dedup), got %',
v_null_after;
END IF;
-- (b) RoP.49.1 outlier normalized.
SELECT count(*) INTO v_old_outlier
FROM paliad.deadline_rules
WHERE rule_code = 'RoP.49.1';
IF v_old_outlier <> 0 THEN
RAISE EXCEPTION
'mig 097: expected 0 RoP.49.1 rows after normalization, got %',
v_old_outlier;
END IF;
-- (c) RoP.049.1 present at least twice.
SELECT count(*) INTO v_new_padded
FROM paliad.deadline_rules
WHERE rule_code = 'RoP.049.1';
IF v_new_padded < 2 THEN
RAISE EXCEPTION
'mig 097: expected >= 2 RoP.049.1 rows after normalization + orphan fill, got %',
v_new_padded;
END IF;
-- (d) Each clean-dedup set has exactly 1 active+published row.
SELECT count(*) INTO v_dup_count
FROM paliad.deadline_rules
WHERE is_active = true
AND lifecycle_state = 'published'
AND id IN (
'b588fa64-a727-4cfb-a45d-69a835a3b05a',
'c24d494c-0da1-4f01-aa74-0f37f99fe1ae'
);
IF v_dup_count <> 1 THEN
RAISE EXCEPTION
'mig 097 dedup: Wiedereinsetzung-§123-PatG set must have 1 active+published row, got %',
v_dup_count;
END IF;
SELECT count(*) INTO v_dup_count
FROM paliad.deadline_rules
WHERE is_active = true
AND lifecycle_state = 'published'
AND id IN (
'1dfba5b1-4ed1-40c1-9cf6-4ed8ff7a0818',
'5c0508f4-020a-4ef5-bcc7-1ee85eafe0b3'
);
IF v_dup_count <> 1 THEN
RAISE EXCEPTION
'mig 097 dedup: Berufungsschrift set must have 1 active+published row, got %',
v_dup_count;
END IF;
SELECT count(*) INTO v_dup_count
FROM paliad.deadline_rules
WHERE is_active = true
AND lifecycle_state = 'published'
AND id IN (
'573df3d1-8ea2-4a6e-b0d4-fc3cd10506da',
'791fd0f7-a448-4711-b1aa-63e6df1e7c57'
);
IF v_dup_count <> 1 THEN
RAISE EXCEPTION
'mig 097 dedup: Berufungsbegründung set must have 1 active+published row, got %',
v_dup_count;
END IF;
END $$;

View File

@@ -0,0 +1,162 @@
-- Reverses mig 098. Restores the pre-098 submission codes on
-- paliad.deadline_rules, renames the column back to `code`, recreates
-- the deadline_search matview against the restored column, then drops
-- the snapshot table.
--
-- audit_reason wrapper required by the mig 079 audit trigger.
SELECT set_config(
'paliad.audit_reason',
'mig 098 (down): revert t-paliad-209 workstream B — restore paliad.deadline_rules.code values from deadline_rules_pre_098 snapshot and rename submission_code → code; matview deadline_search rebuilt against the restored column.',
true);
-- =============================================================================
-- 1. Drop the matview so the column rename can succeed.
-- =============================================================================
DROP MATERIALIZED VIEW IF EXISTS paliad.deadline_search;
-- =============================================================================
-- 2. Rename the column back. Guarded so a down run on a DB where the
-- up never ran (or where the column is already named `code`) is a
-- no-op rather than an error.
-- =============================================================================
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'paliad'
AND table_name = 'deadline_rules'
AND column_name = 'submission_code'
) THEN
ALTER TABLE paliad.deadline_rules
RENAME COLUMN submission_code TO code;
END IF;
END $$;
-- =============================================================================
-- 3. Restore code values from the pre_098 snapshot. The snapshot was
-- captured at the first up-migration run; if the table is missing
-- (down run before up), the restore is a no-op.
-- =============================================================================
DO $$
DECLARE
v_snap_exists boolean;
BEGIN
SELECT EXISTS (
SELECT 1
FROM information_schema.tables
WHERE table_schema = 'paliad'
AND table_name = 'deadline_rules_pre_098'
) INTO v_snap_exists;
IF NOT v_snap_exists THEN
RAISE NOTICE
'mig 098 (down): snapshot table paliad.deadline_rules_pre_098 missing — nothing to restore';
RETURN;
END IF;
UPDATE paliad.deadline_rules dr
SET code = snap.code
FROM paliad.deadline_rules_pre_098 snap
WHERE dr.id = snap.id
AND dr.code <> snap.code;
END $$;
-- =============================================================================
-- 4. Recreate the deadline_search matview against the restored column.
-- Identical body to mig 051 §4, reproduced here so the down leaves
-- the schema in the same shape mig 051 created.
-- =============================================================================
CREATE MATERIALIZED VIEW paliad.deadline_search AS
SELECT
'rule'::text AS kind,
'r:' || dr.id::text AS row_key,
dc.id AS concept_id,
dc.slug AS concept_slug,
dc.name_de AS concept_name_de,
dc.name_en AS concept_name_en,
dc.description AS concept_description,
dc.aliases AS concept_aliases,
dc.party AS concept_party,
dc.category AS concept_category,
dc.sort_order AS concept_sort_order,
dr.id AS rule_id,
NULL::bigint AS trigger_event_id,
pt.code AS proceeding_code,
pt.name AS proceeding_name_de,
pt.name_en AS proceeding_name_en,
pt.jurisdiction AS jurisdiction,
pt.display_order AS proceeding_display_order,
dr.code AS rule_local_code,
dr.name AS rule_name_de,
dr.name_en AS rule_name_en,
dr.legal_source AS legal_source,
dr.rule_code AS rule_code,
dr.duration_value,
dr.duration_unit,
dr.timing,
COALESCE(dr.primary_party, dc.party) AS effective_party
FROM paliad.deadline_rules dr
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
JOIN paliad.deadline_concepts dc ON dc.id = dr.concept_id
WHERE dr.is_active
AND pt.is_active
AND pt.category = 'fristenrechner'
UNION ALL
SELECT
'trigger'::text,
't:' || te.id::text,
dc.id,
dc.slug,
dc.name_de,
dc.name_en,
dc.description,
dc.aliases,
dc.party,
dc.category,
dc.sort_order,
NULL::uuid,
te.id,
NULL::text,
NULL::text,
NULL::text,
'cross-cutting'::text,
9999::int AS proceeding_display_order,
te.code,
te.name_de,
te.name,
NULL::text,
NULL::text,
NULL::int,
NULL::text,
NULL::text,
dc.party
FROM paliad.trigger_events te
JOIN paliad.deadline_concepts dc ON dc.slug = te.concept_id
WHERE te.is_active;
CREATE UNIQUE INDEX deadline_search_row_key ON paliad.deadline_search (row_key);
CREATE INDEX deadline_search_concept_id ON paliad.deadline_search (concept_id);
CREATE INDEX deadline_search_proc_code ON paliad.deadline_search (proceeding_code);
CREATE INDEX deadline_search_legal_source ON paliad.deadline_search (legal_source);
CREATE INDEX deadline_search_effective_party ON paliad.deadline_search (effective_party);
CREATE INDEX deadline_search_legal_source_trgm ON paliad.deadline_search USING gin (legal_source gin_trgm_ops);
CREATE INDEX deadline_search_concept_de_trgm ON paliad.deadline_search USING gin (concept_name_de gin_trgm_ops);
CREATE INDEX deadline_search_concept_en_trgm ON paliad.deadline_search USING gin (concept_name_en gin_trgm_ops);
CREATE INDEX deadline_search_rule_de_trgm ON paliad.deadline_search USING gin (rule_name_de gin_trgm_ops);
CREATE INDEX deadline_search_rule_en_trgm ON paliad.deadline_search USING gin (rule_name_en gin_trgm_ops);
CREATE INDEX deadline_search_rule_code_trgm ON paliad.deadline_search USING gin (rule_code gin_trgm_ops);
-- =============================================================================
-- 5. Drop the snapshot table so a re-applied up captures a fresh
-- snapshot of the current state.
-- =============================================================================
DROP TABLE IF EXISTS paliad.deadline_rules_pre_098;

View File

@@ -0,0 +1,275 @@
-- t-paliad-209 / workstream B — submission-code prefix + rename.
--
-- m's 2026-05-18 call: the `paliad.deadline_rules.code` field is a
-- SUBMISSION identifier (the event/filing within a proceeding), not the
-- legal-citation rule code (which lives in `rule_code` / `legal_source`).
-- Two cleanups land here:
--
-- 1. DATA — prefix every existing submission code with its proceeding
-- code so submission codes carry the full hierarchical shape
-- (e.g. `inf.soc` on `upc.inf.cfi` → `upc.inf.cfi.soc`,
-- `de_inf.klage` on `de.inf.lg` → `de.inf.lg.klage`).
-- Algorithm: keep the proceeding-code prefix as-is, strip the
-- old single-segment prefix (everything before the first dot in
-- `dr.code`) and replace it with the proceeding's full `code`.
--
-- 2. SCHEMA — rename `paliad.deadline_rules.code` → `submission_code`
-- so future devs don't conflate it with `rule_code` (legal
-- citation) or `proceeding_types.code`. Explicit name encodes the
-- semantic taxonomy ratified in
-- docs/design-proceeding-code-taxonomy-2026-05-18.md §0.1.
--
-- Materialized-view dependency: `paliad.deadline_search` (mig 051) has
-- `dr.code AS rule_local_code` baked into its SELECT list. Postgres
-- rejects RENAME COLUMN when a matview's column list still resolves
-- via the old name — so the matview is dropped before the rename and
-- recreated against `submission_code` afterwards, with every index
-- reproduced. The mig 047 / 051 indexes are reproduced verbatim here.
--
-- IDs and FKs are untouched. `deadline_rules.proceeding_type_id` /
-- `parent_id` / `spawn_proceeding_type_id` reference ids; no
-- code-string FK exists on submission codes (the parent_id chain is on
-- UUID `id`, not the code string), so the data UPDATE doesn't risk
-- breaking joins.
--
-- Idempotent:
-- * The data UPDATE is gated `WHERE dr.code NOT LIKE pt.code || '.%'`
-- — rows already prefixed with their proceeding code (i.e. the
-- migration ran before) are skipped.
-- * The rename is wrapped in a DO block that checks column existence,
-- so a second run is a no-op.
-- * Snapshot table uses CREATE TABLE IF NOT EXISTS.
-- * Matview drop/recreate is DROP IF EXISTS + CREATE.
--
-- audit_reason wrapper required by the mig 079 audit trigger.
SELECT set_config(
'paliad.audit_reason',
'mig 098: t-paliad-209 workstream B — prefix every paliad.deadline_rules.code with its proceeding code, then rename code → submission_code; matview deadline_search rebuilt against the new column. See docs/design-proceeding-code-taxonomy-2026-05-18.md and the t-paliad-209 task brief.',
true);
-- =============================================================================
-- 1. Backup snapshot of paliad.deadline_rules BEFORE the prefix + rename.
-- Captures the rows as they are; serves as the source for the down
-- migration and the permanent audit anchor.
-- =============================================================================
CREATE TABLE IF NOT EXISTS paliad.deadline_rules_pre_098 AS
SELECT *, now() AS snapshotted_at
FROM paliad.deadline_rules;
COMMENT ON TABLE paliad.deadline_rules_pre_098 IS
'Snapshot of paliad.deadline_rules taken before mig 098 prefixed '
'every `code` with its proceeding code and renamed the column to '
'`submission_code` (t-paliad-209, 2026-05-18). Source-of-truth '
'for the down migration; persists post-rename as the permanent '
'audit record.';
-- =============================================================================
-- 2. Drop the deadline_search materialized view. It bakes `dr.code AS
-- rule_local_code` into its SELECT list (mig 051 §4), and Postgres
-- refuses to rename a column that a matview's column list still
-- resolves via the old name. The matview is recreated verbatim in §5
-- against the renamed column.
-- =============================================================================
DROP MATERIALIZED VIEW IF EXISTS paliad.deadline_search;
-- =============================================================================
-- 3. Data UPDATE — prefix every submission code with its proceeding
-- code. Algorithm:
-- * proceeding_code = pt.code
-- * suffix = portion of dr.code after the first '.'
-- * new code = proceeding_code || '.' || suffix
--
-- regexp_replace('inf.soc', '^[^.]+\.', '') = 'soc'
-- regexp_replace('de_inf_bgh.revision', ...) = 'revision'
--
-- The WHERE clause skips rows that already start with `pt.code || '.'`
-- so re-running the migration is a no-op on already-prefixed rows.
-- Archived rows (proceeding `_archived_litigation`) get the same
-- treatment — they end up as `_archived_litigation.<suffix>`. The
-- shape regex in §6 only inspects active+published rows, so the
-- archived form sits outside the constraint by design.
-- =============================================================================
UPDATE paliad.deadline_rules dr
SET code = pt.code || '.' || regexp_replace(dr.code, '^[^.]+\.', '')
FROM paliad.proceeding_types pt
WHERE pt.id = dr.proceeding_type_id
AND dr.code IS NOT NULL
AND position('.' in dr.code) > 0
AND dr.code NOT LIKE pt.code || '.%';
-- =============================================================================
-- 4. Rename the column. Guarded in a DO block so a second run (e.g. a
-- fresh DB built up to mig 098 from an empty schema, or a manual
-- re-apply) is a no-op rather than a hard error.
-- =============================================================================
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'paliad'
AND table_name = 'deadline_rules'
AND column_name = 'code'
) THEN
ALTER TABLE paliad.deadline_rules
RENAME COLUMN code TO submission_code;
END IF;
END $$;
-- =============================================================================
-- 5. Recreate the deadline_search matview against the renamed column.
-- Column list reproduced verbatim from mig 051 §4 with the single
-- edit: `dr.code AS rule_local_code` → `dr.submission_code AS
-- rule_local_code`. All indexes from mig 051 are reproduced too.
-- =============================================================================
CREATE MATERIALIZED VIEW paliad.deadline_search AS
SELECT
'rule'::text AS kind,
'r:' || dr.id::text AS row_key,
dc.id AS concept_id,
dc.slug AS concept_slug,
dc.name_de AS concept_name_de,
dc.name_en AS concept_name_en,
dc.description AS concept_description,
dc.aliases AS concept_aliases,
dc.party AS concept_party,
dc.category AS concept_category,
dc.sort_order AS concept_sort_order,
dr.id AS rule_id,
NULL::bigint AS trigger_event_id,
pt.code AS proceeding_code,
pt.name AS proceeding_name_de,
pt.name_en AS proceeding_name_en,
pt.jurisdiction AS jurisdiction,
pt.display_order AS proceeding_display_order,
dr.submission_code AS rule_local_code,
dr.name AS rule_name_de,
dr.name_en AS rule_name_en,
dr.legal_source AS legal_source,
dr.rule_code AS rule_code,
dr.duration_value,
dr.duration_unit,
dr.timing,
COALESCE(dr.primary_party, dc.party) AS effective_party
FROM paliad.deadline_rules dr
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
JOIN paliad.deadline_concepts dc ON dc.id = dr.concept_id
WHERE dr.is_active
AND pt.is_active
AND pt.category = 'fristenrechner'
UNION ALL
SELECT
'trigger'::text,
't:' || te.id::text,
dc.id,
dc.slug,
dc.name_de,
dc.name_en,
dc.description,
dc.aliases,
dc.party,
dc.category,
dc.sort_order,
NULL::uuid,
te.id,
NULL::text,
NULL::text,
NULL::text,
'cross-cutting'::text,
9999::int AS proceeding_display_order,
te.code,
te.name_de,
te.name,
NULL::text,
NULL::text,
NULL::int,
NULL::text,
NULL::text,
dc.party
FROM paliad.trigger_events te
JOIN paliad.deadline_concepts dc ON dc.slug = te.concept_id
WHERE te.is_active;
CREATE UNIQUE INDEX deadline_search_row_key ON paliad.deadline_search (row_key);
CREATE INDEX deadline_search_concept_id ON paliad.deadline_search (concept_id);
CREATE INDEX deadline_search_proc_code ON paliad.deadline_search (proceeding_code);
CREATE INDEX deadline_search_legal_source ON paliad.deadline_search (legal_source);
CREATE INDEX deadline_search_effective_party ON paliad.deadline_search (effective_party);
CREATE INDEX deadline_search_legal_source_trgm ON paliad.deadline_search USING gin (legal_source gin_trgm_ops);
CREATE INDEX deadline_search_concept_de_trgm ON paliad.deadline_search USING gin (concept_name_de gin_trgm_ops);
CREATE INDEX deadline_search_concept_en_trgm ON paliad.deadline_search USING gin (concept_name_en gin_trgm_ops);
CREATE INDEX deadline_search_rule_de_trgm ON paliad.deadline_search USING gin (rule_name_de gin_trgm_ops);
CREATE INDEX deadline_search_rule_en_trgm ON paliad.deadline_search USING gin (rule_name_en gin_trgm_ops);
CREATE INDEX deadline_search_rule_code_trgm ON paliad.deadline_search USING gin (rule_code gin_trgm_ops);
-- =============================================================================
-- 6. Hard assertions. Half-applied migrations would leave the rule
-- corpus inconsistent; gate on the shape of every active+published
-- row and on column existence so this fails loudly rather than
-- leaving the schema in a half-renamed state.
-- =============================================================================
DO $$
DECLARE
v_bad_shape integer;
v_null_codes integer;
v_col_exists boolean;
BEGIN
-- 6.1 Every active+published row has the proceeding-code-prefixed
-- 4+-segment shape. Archived rows (`_archived_litigation` ones)
-- keep their shorter shape by design — they're carved out.
-- Suffix segments may include digits (existing data — e.g. EPA rule
-- codes like `epa.opp.boa.r106` / `epa.grant.exa.r71_3` carry the
-- statutory rule number in the suffix). Allow [a-z_0-9] per segment.
SELECT count(*) INTO v_bad_shape
FROM paliad.deadline_rules
WHERE is_active = true
AND lifecycle_state = 'published'
AND submission_code !~ '^[a-z_0-9]+\.[a-z_0-9]+\.[a-z_0-9]+\.[a-z_0-9]+(\..*)?$';
IF v_bad_shape <> 0 THEN
RAISE EXCEPTION
'mig 098: expected every active+published deadline_rules row to match the 4+-segment submission_code shape, got % violators',
v_bad_shape;
END IF;
-- 6.2 No NULL submission_code on active+published rows that BELONG
-- to a proceeding. Orphan rows (`proceeding_type_id IS NULL`)
-- are cross-cutting rules without a fixed proceeding home
-- (Wiedereinsetzung, Schriftsatznachreichung, etc.) — they
-- legitimately carry NULL submission_code because there's no
-- proceeding to prefix with. Exempt them.
SELECT count(*) INTO v_null_codes
FROM paliad.deadline_rules
WHERE is_active = true
AND lifecycle_state = 'published'
AND proceeding_type_id IS NOT NULL
AND submission_code IS NULL;
IF v_null_codes <> 0 THEN
RAISE EXCEPTION
'mig 098: expected 0 NULL submission_code on active+published rows, got %',
v_null_codes;
END IF;
-- 6.3 Column was actually renamed. Catches the case where the DO
-- guard in §4 short-circuited because the schema hadn't yet
-- been migrated.
SELECT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'paliad'
AND table_name = 'deadline_rules'
AND column_name = 'submission_code'
) INTO v_col_exists;
IF NOT v_col_exists THEN
RAISE EXCEPTION
'mig 098: column paliad.deadline_rules.submission_code missing after rename — half-applied migration';
END IF;
END $$;

View File

@@ -0,0 +1,10 @@
-- Revert mig 098 — restore the with_po condition_expr (mig 095 shape).
-- audit_reason required: set via SET LOCAL paliad.audit_reason in tooling.
UPDATE paliad.deadline_rules dr
SET condition_expr = '{"flag":"with_po"}'::jsonb
FROM paliad.proceeding_types pt
WHERE dr.proceeding_type_id = pt.id
AND pt.code IN ('upc.inf.cfi', 'upc.rev.cfi')
AND dr.rule_code = 'RoP.019.1'
AND dr.condition_expr IS NULL;

View File

@@ -0,0 +1,34 @@
-- t-paliad-207 — drop the `with_po` flag from the two RoP 19 rules.
-- m's call 2026-05-18 (interactive session): the Einspruch (R. 19) is
-- not flag-gated — it's just an optional submission the defendant can
-- always make, triggered by the SoC. Same reasoning that drove the
-- always-fire decision for the appeal-spawn rules in t-paliad-203 F2.3
-- ("appeal is always a possibility").
--
-- Net effect: the calculator will surface the R.19 row on every UPC_INF
-- / UPC_REV calc as an optional row (priority='optional' already set
-- by mig 095, unchanged here). The save-modal pre-uncheck behaviour
-- for optional priority handles the "user opts in" gesture without a
-- separate flag.
--
-- Two rows updated; pinned by proceeding code so this stays correct
-- after any rule-id reshuffle. Idempotent: the WHERE clause matches
-- the live shape, so re-apply is a no-op.
--
-- audit_reason set_config required at the top — the mig 079 trigger
-- on paliad.deadline_rules raises EXCEPTION 'audit reason required'
-- on any UPDATE without it. Original mig 099 author missed this and
-- crash-looped paliad prod; this is the recovery patch.
SELECT set_config(
'paliad.audit_reason',
'mig 099: drop with_po condition_expr on the two RoP.019.1 rows — m''s call 2026-05-18 (t-paliad-207 interactive session), R.19 Einspruch is always-available not flag-gated',
true);
UPDATE paliad.deadline_rules dr
SET condition_expr = NULL
FROM paliad.proceeding_types pt
WHERE dr.proceeding_type_id = pt.id
AND pt.code IN ('upc.inf.cfi', 'upc.rev.cfi')
AND dr.rule_code = 'RoP.019.1'
AND dr.condition_expr::text LIKE '%with_po%';

View File

@@ -0,0 +1,26 @@
-- Revert mig 100 — remove the upc.inf.cfi.ccr informational rule and
-- restore the sequence_order values of def_to_ccr / app_to_amend.
SELECT set_config(
'paliad.audit_reason',
'mig 100 down: revert upc.inf.cfi.ccr informational rule + sequence reshuffle',
true);
UPDATE paliad.deadline_rules
SET sequence_order = 12
WHERE submission_code = 'upc.inf.cfi.app_to_amend'
AND proceeding_type_id = 8
AND lifecycle_state = 'published'
AND sequence_order = 13;
UPDATE paliad.deadline_rules
SET sequence_order = 11
WHERE submission_code = 'upc.inf.cfi.def_to_ccr'
AND proceeding_type_id = 8
AND lifecycle_state = 'published'
AND sequence_order = 12;
DELETE FROM paliad.deadline_rules
WHERE submission_code = 'upc.inf.cfi.ccr'
AND proceeding_type_id = 8
AND lifecycle_state = 'published';

View File

@@ -0,0 +1,97 @@
-- t-paliad-207 — make the Nichtigkeitswiderklage (CCR) visible in the
-- calculator output when the `with_ccr` flag is set. m's observation
-- 2026-05-18 (interactive session): toggling "Mit Nichtigkeitswider-
-- klage" surfaces the response rules (def_to_ccr, reply, rejoin, …)
-- but the triggering event itself — the act of filing the CCR — is
-- invisible. Per R.25 VerfO the CCR is filed AS PART OF the Statement
-- of Defence with the same 3-month deadline, so the corpus author
-- (mig 028) skipped it. UX is the problem: users see consequences
-- without the cause.
--
-- Net effect: a new `upc.inf.cfi.ccr` row with priority='informational'
-- renders the CCR as a notice card on the timeline (no save action,
-- no extra deadline-to-track; the SoD's deadline already covers it).
-- Date is identical to the SoD (3 months from SoC, same anchor +
-- duration). condition_expr={"flag":"with_ccr"} so the row only appears
-- when the user has flagged that a CCR is being filed.
--
-- Sequence reshuffle: inserting at sequence_order=11 pushes
-- def_to_ccr 11→12 and app_to_amend 12→13 so the timeline reads
-- SoD → CCR → def_to_ccr → app_to_amend (cause before effect). The
-- two UPDATEs are guarded by the SOURCE values so re-apply is a no-op.
--
-- audit_reason set_config required at the top — the deadline_rules
-- audit trigger raises EXCEPTION 'audit reason required' on any
-- mutation without it (cf. mig 099 hotfix history).
--
-- Idempotency:
-- * INSERT uses NOT EXISTS keyed on (proceeding_type_id,
-- submission_code, lifecycle_state='published').
-- * UPDATEs are guarded by current sequence_order value.
SELECT set_config(
'paliad.audit_reason',
'mig 100: add upc.inf.cfi.ccr informational rule so CCR filing event is visible when with_ccr flag is set (m''s 2026-05-18 ask, t-paliad-207 interactive session)',
true);
INSERT INTO paliad.deadline_rules
(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,
is_spawn, spawn_proceeding_type_id, spawn_label,
is_active, legal_source, is_bilateral,
condition_expr, priority, is_court_set, lifecycle_state)
SELECT
8,
(SELECT id FROM paliad.deadline_rules
WHERE submission_code = 'upc.inf.cfi.soc'
AND proceeding_type_id = 8
AND lifecycle_state = 'published'
AND is_active = true),
'upc.inf.cfi.ccr',
'Nichtigkeitswiderklage',
'Counterclaim for Revocation',
'Widerklage des Beklagten auf Nichtigkeit des Klagepatents. Wird gemeinsam mit der Klageerwiderung (Statement of Defence) eingereicht (R.25 VerfO); selbe Frist von 3 Monaten ab Zustellung der Klage. Eigener adversarialer Schriftsatz, der die Folge-Schriftsätze (Erwiderung auf Nichtigkeitswiderklage, Replik, Duplik) auslöst.',
'defendant',
'filing',
3,
'months',
'after',
'RoP.025',
'Wird mit der Klageerwiderung eingereicht (R.25 VerfO); kein separater Fristtermin — selbes Datum wie die Klageerwiderung. Wird informativ angezeigt, damit der auslösende Schriftsatz für die Folgefristen sichtbar bleibt.',
'Filed together with the Statement of Defence (RoP 25); no separate deadline — same date as the SoD. Surfaced informationally so the triggering submission for the downstream deadlines is visible.',
11,
false,
NULL,
NULL,
true,
'UPC.RoP.25.1',
false,
'{"flag":"with_ccr"}'::jsonb,
'informational',
false,
'published'
WHERE NOT EXISTS (
SELECT 1 FROM paliad.deadline_rules
WHERE submission_code = 'upc.inf.cfi.ccr'
AND proceeding_type_id = 8
AND lifecycle_state = 'published');
-- Sequence reshuffle: bump def_to_ccr and app_to_amend by 1 so the
-- new ccr row at 11 sits between SoD (10) and def_to_ccr. Guarded by
-- the source values to keep idempotency.
UPDATE paliad.deadline_rules
SET sequence_order = 12
WHERE submission_code = 'upc.inf.cfi.def_to_ccr'
AND proceeding_type_id = 8
AND lifecycle_state = 'published'
AND sequence_order = 11;
UPDATE paliad.deadline_rules
SET sequence_order = 13
WHERE submission_code = 'upc.inf.cfi.app_to_amend'
AND proceeding_type_id = 8
AND lifecycle_state = 'published'
AND sequence_order = 12;

View File

@@ -0,0 +1,13 @@
-- Reverse of 101_caldav_multi_calendar.up.sql.
--
-- Drop the new join + binding tables. CASCADE on the FK references
-- isn't needed because we drop targets before bindings, and Postgres
-- handles RLS policies / indexes automatically on DROP TABLE.
--
-- The legacy paliad.appointments.caldav_uid / caldav_etag columns are
-- untouched by the up migration, so they're untouched here too —
-- rollback returns the system to the pre-Slice-1 state where those
-- scalars are the single source of CalDAV truth.
DROP TABLE IF EXISTS paliad.appointment_caldav_targets;
DROP TABLE IF EXISTS paliad.user_calendar_bindings;

View File

@@ -0,0 +1,350 @@
-- t-paliad-212 — Slice 1 of the CalDAV multi-calendar design (see
-- docs/design-caldav-multi-calendar-2026-05-19.md). Pure schema +
-- backfill; the sync engine is NOT touched in this migration. Slice 2
-- wires the per-binding fan-out.
--
-- What we add:
-- 1. paliad.user_calendar_bindings — N bindings per user, each with
-- a scope_kind enum (all_visible / personal_only / project /
-- client / litigation / patent / case) and an optional scope_id
-- pointing at a paliad.projects row when the scope is hierarchy-
-- anchored. The same Appointment can be PUT into multiple of
-- these bindings (e.g. master cal + per-project cal).
-- 2. paliad.appointment_caldav_targets — (appointment_id, binding_id)
-- join carrying the per-target caldav_uid + caldav_etag. The
-- canonical UID is still per-appointment (paliad-appointment-
-- <uuid>@paliad.de) so the same event in N cals shares one UID.
-- 3. Backfill: one all_visible binding per existing
-- user_caldav_config row, plus one target row per Appointment
-- already pushed (caldav_uid IS NOT NULL). Backfill maps the
-- target's binding_id to the appointment creator's binding —
-- that matches today's Phase F semantics, where the creator's
-- sync goroutine owns the etag.
--
-- The scalar columns paliad.appointments.caldav_uid / caldav_etag
-- STAY in place through Slice 1 and Slice 2. Slice 1 keeps them as
-- read-once denormalised pointers to the default binding's target
-- row; Slice 4 drops them after telemetry confirms no path still
-- reads them.
--
-- Idempotent: every CREATE uses IF NOT EXISTS, both backfills are
-- guarded by NOT EXISTS. Safe to re-run.
--
-- audit_reason set_config required at the top because m's recent
-- migration friction had several mig failures from missing reasons.
-- The trigger raising 'audit reason required' is on
-- paliad.deadline_rules only — this migration doesn't touch that
-- table — but we set the reason for symmetry per paliadin's 2026-05-19
-- coder-shift brief.
SELECT set_config(
'paliad.audit_reason',
'mig 101: CalDAV multi-calendar schema + backfill (Slice 1 of t-paliad-212; design doc docs/design-caldav-multi-calendar-2026-05-19.md). No row mutations on existing trigger-guarded tables; this is a defensive symmetry set_config.',
true);
-- =========================================================================
-- 1. paliad.user_calendar_bindings
-- =========================================================================
CREATE TABLE IF NOT EXISTS paliad.user_calendar_bindings (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
-- Full URL or path under user_caldav_config.url. The CalDAV client
-- resolves it against the user's server URL the same way it
-- resolves the legacy user_caldav_config.calendar_path today.
calendar_path text NOT NULL,
-- What the picker UI shows for this binding. Discovered via
-- PROPFIND <displayname/> at add-time and cached here so we don't
-- re-fetch every render. Default '' (Slice 1 backfill leaves it
-- empty; Slice 2 fills it during the picker flow).
display_name text NOT NULL DEFAULT '',
-- Which appointments push into this calendar. Slice 1 only really
-- needs 'all_visible' (that's all the backfill creates) but we
-- ship the full enum now so the schema is final and Slice 2/3
-- don't have to ALTER it.
scope_kind text NOT NULL,
scope_id uuid REFERENCES paliad.projects(id) ON DELETE CASCADE,
-- Only meaningful when scope_kind is hierarchy-anchored
-- (project / client / litigation / patent / case). When true,
-- the binding ALSO receives the user's personal (project_id IS
-- NULL AND created_by = user_id) appointments. Ignored for
-- 'all_visible' (already includes them) and 'personal_only'.
include_personal boolean NOT NULL DEFAULT false,
enabled boolean NOT NULL DEFAULT true,
last_sync_at timestamptz,
last_sync_error text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT user_calendar_bindings_scope_kind_chk CHECK (
scope_kind IN ('all_visible','personal_only','project','client','litigation','patent','case')
),
CONSTRAINT user_calendar_bindings_scope_id_chk CHECK (
(scope_kind IN ('all_visible','personal_only') AND scope_id IS NULL)
OR
(scope_kind NOT IN ('all_visible','personal_only') AND scope_id IS NOT NULL)
)
);
-- One binding per (user, calendar) — can't bind the same external
-- calendar twice for the same user.
CREATE UNIQUE INDEX IF NOT EXISTS user_calendar_bindings_user_path_uniq
ON paliad.user_calendar_bindings (user_id, calendar_path);
-- One hierarchy binding per (user, scope_kind, scope_id) — a user
-- can't have two bindings for the same project, but CAN have a
-- 'project' binding for project X alongside an 'all_visible'
-- master binding (different scope_kind ⇒ different row).
CREATE UNIQUE INDEX IF NOT EXISTS user_calendar_bindings_scope_hier_uniq
ON paliad.user_calendar_bindings (user_id, scope_kind, scope_id)
WHERE scope_id IS NOT NULL;
-- One scope-less binding per (user, scope_kind) — at most one
-- 'all_visible' and one 'personal_only' per user.
CREATE UNIQUE INDEX IF NOT EXISTS user_calendar_bindings_scope_root_uniq
ON paliad.user_calendar_bindings (user_id, scope_kind)
WHERE scope_id IS NULL;
CREATE INDEX IF NOT EXISTS user_calendar_bindings_user_idx
ON paliad.user_calendar_bindings (user_id)
WHERE enabled;
-- No updated_at trigger — paliad.user_caldav_config also doesn't have
-- one. The Go service layer sets updated_at = NOW() explicitly on
-- every write (see SaveConfig in caldav_service.go); we follow the
-- same convention here so all CalDAV-related tables are consistent.
ALTER TABLE paliad.user_calendar_bindings ENABLE ROW LEVEL SECURITY;
-- Same shape as user_caldav_config policies: a user sees + mutates
-- only their own rows. auth.uid() returns the authenticated user's
-- id (mirrors auth.uid()).
DROP POLICY IF EXISTS user_calendar_bindings_self_select ON paliad.user_calendar_bindings;
CREATE POLICY user_calendar_bindings_self_select ON paliad.user_calendar_bindings
FOR SELECT TO authenticated
USING (user_id = auth.uid());
DROP POLICY IF EXISTS user_calendar_bindings_self_insert ON paliad.user_calendar_bindings;
CREATE POLICY user_calendar_bindings_self_insert ON paliad.user_calendar_bindings
FOR INSERT TO authenticated
WITH CHECK (user_id = auth.uid());
DROP POLICY IF EXISTS user_calendar_bindings_self_update ON paliad.user_calendar_bindings;
CREATE POLICY user_calendar_bindings_self_update ON paliad.user_calendar_bindings
FOR UPDATE TO authenticated
USING (user_id = auth.uid())
WITH CHECK (user_id = auth.uid());
DROP POLICY IF EXISTS user_calendar_bindings_self_delete ON paliad.user_calendar_bindings;
CREATE POLICY user_calendar_bindings_self_delete ON paliad.user_calendar_bindings
FOR DELETE TO authenticated
USING (user_id = auth.uid());
-- =========================================================================
-- 2. paliad.appointment_caldav_targets
-- =========================================================================
CREATE TABLE IF NOT EXISTS paliad.appointment_caldav_targets (
appointment_id uuid NOT NULL REFERENCES paliad.appointments(id) ON DELETE CASCADE,
binding_id uuid NOT NULL REFERENCES paliad.user_calendar_bindings(id) ON DELETE CASCADE,
-- 'paliad-appointment-<uuid>@paliad.de' — derived from
-- appointment_id, identical across all bindings of one appointment.
caldav_uid text NOT NULL,
-- ETag returned by the CalDAV server on the last successful PUT.
-- NULLABLE to match the legacy paliad.appointments.caldav_etag
-- column: some servers don't return ETag on PUT and we
-- re-PROPFIND lazily on next tick.
caldav_etag text,
last_pushed_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (appointment_id, binding_id)
);
CREATE INDEX IF NOT EXISTS appointment_caldav_targets_binding_idx
ON paliad.appointment_caldav_targets (binding_id);
CREATE INDEX IF NOT EXISTS appointment_caldav_targets_uid_idx
ON paliad.appointment_caldav_targets (caldav_uid);
ALTER TABLE paliad.appointment_caldav_targets ENABLE ROW LEVEL SECURITY;
-- A target row is visible/mutable to the user who owns the binding.
-- Appointment-side visibility is enforced separately by AppointmentService;
-- the target is a sync-state row, scoped per-user.
DROP POLICY IF EXISTS appointment_caldav_targets_self_select ON paliad.appointment_caldav_targets;
CREATE POLICY appointment_caldav_targets_self_select ON paliad.appointment_caldav_targets
FOR SELECT TO authenticated
USING (EXISTS (
SELECT 1 FROM paliad.user_calendar_bindings b
WHERE b.id = appointment_caldav_targets.binding_id
AND b.user_id = auth.uid()
));
DROP POLICY IF EXISTS appointment_caldav_targets_self_insert ON paliad.appointment_caldav_targets;
CREATE POLICY appointment_caldav_targets_self_insert ON paliad.appointment_caldav_targets
FOR INSERT TO authenticated
WITH CHECK (EXISTS (
SELECT 1 FROM paliad.user_calendar_bindings b
WHERE b.id = appointment_caldav_targets.binding_id
AND b.user_id = auth.uid()
));
DROP POLICY IF EXISTS appointment_caldav_targets_self_update ON paliad.appointment_caldav_targets;
CREATE POLICY appointment_caldav_targets_self_update ON paliad.appointment_caldav_targets
FOR UPDATE TO authenticated
USING (EXISTS (
SELECT 1 FROM paliad.user_calendar_bindings b
WHERE b.id = appointment_caldav_targets.binding_id
AND b.user_id = auth.uid()
))
WITH CHECK (EXISTS (
SELECT 1 FROM paliad.user_calendar_bindings b
WHERE b.id = appointment_caldav_targets.binding_id
AND b.user_id = auth.uid()
));
DROP POLICY IF EXISTS appointment_caldav_targets_self_delete ON paliad.appointment_caldav_targets;
CREATE POLICY appointment_caldav_targets_self_delete ON paliad.appointment_caldav_targets
FOR DELETE TO authenticated
USING (EXISTS (
SELECT 1 FROM paliad.user_calendar_bindings b
WHERE b.id = appointment_caldav_targets.binding_id
AND b.user_id = auth.uid()
));
-- =========================================================================
-- 3. Backfill — one all_visible binding per existing CalDAV-configured user
-- =========================================================================
-- For every paliad.user_caldav_config row, insert an 'all_visible'
-- binding that mirrors today's single-target Phase F push. The new
-- binding inherits the legacy `calendar_path` (or, when that's empty,
-- the server URL itself — same fallback the client uses today). The
-- enabled flag carries over.
--
-- Idempotent: skipped when this user already has an all_visible binding
-- (re-running the migration is a no-op).
INSERT INTO paliad.user_calendar_bindings
(user_id, calendar_path, display_name, scope_kind, scope_id, include_personal, enabled)
SELECT
c.user_id,
COALESCE(NULLIF(c.calendar_path, ''), c.url),
'',
'all_visible',
NULL,
false,
c.enabled
FROM paliad.user_caldav_config c
WHERE NOT EXISTS (
SELECT 1 FROM paliad.user_calendar_bindings b
WHERE b.user_id = c.user_id
AND b.scope_kind = 'all_visible'
);
-- =========================================================================
-- 4. Backfill — one target row per already-pushed appointment
-- =========================================================================
-- For every appointment with a non-null caldav_uid, insert one target
-- row pointing at the appointment creator's new all_visible binding.
-- That preserves the (appointment, calendar) sync state exactly as it
-- existed before this migration.
--
-- Why created_by, not "every visible user": today's Phase F
-- caldav_uid/caldav_etag scalars on appointments are populated by
-- whoever happened to push last; in practice the etag almost always
-- belongs to the creator's calendar because pull-side updates only
-- run when CreatedBy = userID (caldav_service.go:449). Mapping the
-- backfill target to the creator's binding keeps the etag pointing
-- where it actually came from. Other users' goroutines will create
-- their own target rows on their next sync tick after Slice 2 ships.
--
-- Idempotent: skipped when (appointment_id, binding_id) target already
-- exists.
INSERT INTO paliad.appointment_caldav_targets
(appointment_id, binding_id, caldav_uid, caldav_etag, last_pushed_at)
SELECT
a.id,
b.id,
a.caldav_uid,
a.caldav_etag,
a.updated_at
FROM paliad.appointments a
JOIN paliad.user_calendar_bindings b
ON b.user_id = a.created_by
AND b.scope_kind = 'all_visible'
WHERE a.caldav_uid IS NOT NULL
AND a.created_by IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM paliad.appointment_caldav_targets t
WHERE t.appointment_id = a.id
AND t.binding_id = b.id
);
-- =========================================================================
-- 5. Assertions — hard fail if the backfill didn't catch every row
-- =========================================================================
-- Every paliad.user_caldav_config row must have at least one
-- all_visible binding after this migration. If it doesn't, either a
-- row was inserted between the backfill and the assertion (race —
-- run is wrapped in a transaction by golang-migrate, so this can't
-- happen) or the backfill is buggy. Hard fail either way.
DO $$
DECLARE
missing_users int;
BEGIN
SELECT count(*) INTO missing_users
FROM paliad.user_caldav_config c
WHERE NOT EXISTS (
SELECT 1 FROM paliad.user_calendar_bindings b
WHERE b.user_id = c.user_id
AND b.scope_kind = 'all_visible'
);
IF missing_users > 0 THEN
RAISE EXCEPTION
'mig 101 assertion failed: % paliad.user_caldav_config row(s) without an all_visible binding',
missing_users;
END IF;
END $$;
-- Every appointment with a non-null caldav_uid AND a non-null
-- created_by must have a target row pointing at its creator's
-- all_visible binding. created_by can be NULL on legacy rows
-- (e.g. seed data) so we exclude those from the assertion.
DO $$
DECLARE
missing_targets int;
BEGIN
SELECT count(*) INTO missing_targets
FROM paliad.appointments a
WHERE a.caldav_uid IS NOT NULL
AND a.created_by IS NOT NULL
AND NOT EXISTS (
SELECT 1
FROM paliad.appointment_caldav_targets t
JOIN paliad.user_calendar_bindings b
ON b.id = t.binding_id
WHERE t.appointment_id = a.id
AND b.user_id = a.created_by
AND b.scope_kind = 'all_visible'
);
IF missing_targets > 0 THEN
RAISE EXCEPTION
'mig 101 assertion failed: % appointment(s) with caldav_uid but no all_visible target row',
missing_targets;
END IF;
END $$;

View File

@@ -0,0 +1,15 @@
-- Revert mig 102 — drop paliad.system_audit_log and its indexes / policies.
-- audit_reason set_config required by the mig 079 trigger pattern.
SELECT set_config(
'paliad.audit_reason',
'mig 102 down: drop paliad.system_audit_log (t-paliad-214 Slice 1 revert)',
true);
DROP POLICY IF EXISTS system_audit_log_select_admin ON paliad.system_audit_log;
DROP POLICY IF EXISTS system_audit_log_select_self ON paliad.system_audit_log;
DROP INDEX IF EXISTS paliad.system_audit_log_event_type_created_at_idx;
DROP INDEX IF EXISTS paliad.system_audit_log_actor_id_created_at_idx;
DROP TABLE IF EXISTS paliad.system_audit_log;

View File

@@ -0,0 +1,79 @@
-- t-paliad-214 Slice 1 — create paliad.system_audit_log as the 6th source
-- in the AuditService.ListEntries union. Captures org-wide / scope-spanning
-- actions that don't naturally belong on any single project_events row.
--
-- Design: docs/design-paliad-data-export-2026-05-19.md §4.
--
-- Initial use case is data-export auditing (every export run writes one row,
-- before the artifact is generated, then is patched with row_counts +
-- file_size_bytes on completion). The table is intentionally generic
-- (`event_type` + `metadata jsonb`) so future org-wide actions can land here
-- without a new table per concept.
--
-- Idempotent: CREATE TABLE IF NOT EXISTS + CREATE INDEX IF NOT EXISTS.
-- audit_reason set_config required by the mig 079 trigger pattern when
-- migrations touch the database — universal convention even for pure-DDL
-- migrations.
SELECT set_config(
'paliad.audit_reason',
'mig 102: add paliad.system_audit_log for org-wide / scope-spanning audit events (t-paliad-214 Slice 1 — data-export audit chain)',
true);
CREATE TABLE IF NOT EXISTS paliad.system_audit_log (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
event_type text NOT NULL,
actor_id uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
-- actor_email is captured at write time so the audit row survives a
-- subsequent user-deletion (FK above sets NULL, but the historical
-- identity stays readable).
actor_email text NOT NULL,
scope text NOT NULL CHECK (scope IN ('org', 'project', 'personal')),
-- scope_root is the project_id for scope='project'; NULL otherwise.
-- Not a hard FK because we want the audit row to outlive a project
-- deletion. Resolution happens at read time.
scope_root uuid,
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
-- Indexes mirror the read patterns:
-- - actor lookup ("show me what I've exported"): actor_id + created_at desc
-- - scope rollup ("how much org-wide activity in the last 30 days"): event_type + created_at desc
CREATE INDEX IF NOT EXISTS system_audit_log_actor_id_created_at_idx
ON paliad.system_audit_log (actor_id, created_at DESC);
CREATE INDEX IF NOT EXISTS system_audit_log_event_type_created_at_idx
ON paliad.system_audit_log (event_type, created_at DESC);
-- RLS: every authenticated user can SELECT their own rows (actor_id = auth.uid());
-- global_admins see everything. INSERT / UPDATE happen via the Go service path
-- under the migration-runner role (no end-user write surface) so no INSERT
-- policy is needed for end users.
ALTER TABLE paliad.system_audit_log ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS system_audit_log_select_self ON paliad.system_audit_log;
CREATE POLICY system_audit_log_select_self ON paliad.system_audit_log
FOR SELECT
USING (actor_id = auth.uid());
DROP POLICY IF EXISTS system_audit_log_select_admin ON paliad.system_audit_log;
CREATE POLICY system_audit_log_select_admin ON paliad.system_audit_log
FOR SELECT
USING (
EXISTS (
SELECT 1 FROM paliad.users u
WHERE u.id = auth.uid()
AND u.global_role = 'global_admin'
)
);
COMMENT ON TABLE paliad.system_audit_log IS
'Org-wide / scope-spanning audit events. 6th source of AuditService union. Generic event_type + metadata jsonb. Initial users: data-export audit chain (t-paliad-214). Audit rows persist forever; artifact retention is separate.';
COMMENT ON COLUMN paliad.system_audit_log.actor_email IS
'Captured at write time so the audit row survives user deletion (actor_id FK uses ON DELETE SET NULL).';
COMMENT ON COLUMN paliad.system_audit_log.scope_root IS
'project_id for scope=project; NULL otherwise. Not a hard FK so audit survives project deletion.';

View File

@@ -0,0 +1,27 @@
-- Reverse of 103_approval_suggest_changes.up.sql.
--
-- Drops the previous_request_id index + column, drops counter_payload, and
-- restores the original status CHECK (without 'changes_requested'). If any
-- live rows are at status='changes_requested' OR carry a non-NULL
-- counter_payload OR previous_request_id, the down will fail on the CHECK
-- restore. That is intentional: it forces an explicit cleanup decision
-- before tearing the schema back.
SELECT set_config(
'paliad.audit_reason',
'mig 103 DOWN: revert suggest-changes schema extensions (t-paliad-216)',
true);
DROP INDEX IF EXISTS paliad.approval_requests_previous_idx;
ALTER TABLE paliad.approval_requests
DROP COLUMN IF EXISTS previous_request_id;
ALTER TABLE paliad.approval_requests
DROP COLUMN IF EXISTS counter_payload;
ALTER TABLE paliad.approval_requests
DROP CONSTRAINT IF EXISTS approval_requests_status_check;
ALTER TABLE paliad.approval_requests
ADD CONSTRAINT approval_requests_status_check
CHECK (status IN ('pending', 'approved', 'rejected', 'revoked', 'superseded'));

View File

@@ -0,0 +1,57 @@
-- t-paliad-216 Slice A — add the "Suggest changes" action to the approval
-- flow alongside Approve / Reject / Revoke. Design:
-- docs/design-approval-suggest-changes-2026-05-19.md.
--
-- Mental model (m's 2026-05-19 decisions, §0a of the design doc):
-- "Suggest changes" is not a soft-reject with a hint. It is the approver
-- AUTHORING A COUNTER-PROPOSAL that gets re-injected into the approval
-- flow as a fresh `pending` row. The original requester (no longer the
-- new row's requested_by) becomes potentially-eligible to approve the
-- counter — 4-Augen still holds via the standard self-approval guard.
--
-- Three schema additions to paliad.approval_requests:
-- 1. Extend the status CHECK to allow 'changes_requested'.
-- 2. counter_payload jsonb NULL — the approver's edited values,
-- stored on the OLD (changes_requested) row so the audit chain
-- can show "approver edited X, Y, Z" without joining forward.
-- Also used as the `payload` for the NEW row spawned in the same
-- tx by ApprovalService.SuggestChanges.
-- 3. previous_request_id uuid NULL FK — back-pointer on the NEW row
-- to the OLD (changes_requested) row that spawned it. ON DELETE
-- SET NULL keeps a survivor row intact if either end is ever
-- pruned. Partial index covers chain traversal.
--
-- The set_config('paliad.audit_reason', ...) line is the universal
-- convention for paliad migrations (mig 079 trigger pattern) — even
-- pure-DDL migrations set it so an audit trigger that fires on any
-- migration-touched table has a non-NULL reason string to record.
SELECT set_config(
'paliad.audit_reason',
'mig 103: add suggest-changes action — extend approval_requests.status CHECK with changes_requested, add counter_payload jsonb + previous_request_id FK (t-paliad-216 Slice A)',
true);
-- 1. Extend approval_requests.status CHECK.
ALTER TABLE paliad.approval_requests
DROP CONSTRAINT IF EXISTS approval_requests_status_check;
ALTER TABLE paliad.approval_requests
ADD CONSTRAINT approval_requests_status_check
CHECK (status IN (
'pending', 'approved', 'rejected', 'revoked', 'superseded', 'changes_requested'
));
-- 2. counter_payload — the approver's edited values when suggesting
-- changes. Stays NULL for every status other than changes_requested.
ALTER TABLE paliad.approval_requests
ADD COLUMN counter_payload jsonb;
-- 3. previous_request_id — back-pointer FK. NULL for first-attempt rows;
-- set to the prior (changes_requested) row's id on the NEW row spawned
-- by SuggestChanges. ON DELETE SET NULL keeps survivor rows intact.
ALTER TABLE paliad.approval_requests
ADD COLUMN previous_request_id uuid
REFERENCES paliad.approval_requests(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS approval_requests_previous_idx
ON paliad.approval_requests (previous_request_id)
WHERE previous_request_id IS NOT NULL;

View File

@@ -0,0 +1,52 @@
-- Revert mig 104 — restore the bracket-bearing Einspruch names and
-- flip the CCR priority back to 'informational'.
SELECT set_config(
'paliad.audit_reason',
'mig 104 down: restore "Einspruch (R. 19 VerfO)" and "Einspruch (R. 19 i.V.m. R. 46 VerfO)" names + flip upc.inf.cfi.ccr priority back to informational',
true);
UPDATE paliad.deadline_rules dr
SET name_en = 'Preliminary Objection (RoP 19 in conjunction with RoP 46)'
FROM paliad.proceeding_types pt
WHERE dr.proceeding_type_id = pt.id
AND pt.code = 'upc.rev.cfi'
AND dr.submission_code = 'upc.rev.cfi.prelim'
AND dr.lifecycle_state = 'published'
AND dr.name_en = 'Preliminary Objection';
UPDATE paliad.deadline_rules dr
SET name = 'Einspruch (R. 19 i.V.m. R. 46 VerfO)'
FROM paliad.proceeding_types pt
WHERE dr.proceeding_type_id = pt.id
AND pt.code = 'upc.rev.cfi'
AND dr.submission_code = 'upc.rev.cfi.prelim'
AND dr.lifecycle_state = 'published'
AND dr.name = 'Einspruch';
UPDATE paliad.deadline_rules dr
SET name_en = 'Preliminary Objection (RoP 19)'
FROM paliad.proceeding_types pt
WHERE dr.proceeding_type_id = pt.id
AND pt.code = 'upc.inf.cfi'
AND dr.submission_code = 'upc.inf.cfi.prelim'
AND dr.lifecycle_state = 'published'
AND dr.name_en = 'Preliminary Objection';
UPDATE paliad.deadline_rules dr
SET name = 'Einspruch (R. 19 VerfO)'
FROM paliad.proceeding_types pt
WHERE dr.proceeding_type_id = pt.id
AND pt.code = 'upc.inf.cfi'
AND dr.submission_code = 'upc.inf.cfi.prelim'
AND dr.lifecycle_state = 'published'
AND dr.name = 'Einspruch';
UPDATE paliad.deadline_rules dr
SET priority = 'informational'
FROM paliad.proceeding_types pt
WHERE dr.proceeding_type_id = pt.id
AND pt.code = 'upc.inf.cfi'
AND dr.submission_code = 'upc.inf.cfi.ccr'
AND dr.lifecycle_state = 'published'
AND dr.priority = 'optional';

View File

@@ -0,0 +1,89 @@
-- t-paliad-207 (m's interactive session) — two label/priority polish
-- fixes on upc.inf.cfi / upc.rev.cfi:
--
-- 1. **CCR priority informational → optional.** m's correction
-- 2026-05-18 18:01: the Nichtigkeitswiderklage is a substantive
-- defensive choice the defendant makes — not just an informational
-- notice. priority='optional' renders it as an unchecked save row
-- the user can opt into. The fermi amend (commit e8d658a) flipping
-- this didn't land in main — paliadin's merge of mig 100 (commit
-- c10f8cf, merge 4ddcd28) picked up the pre-amend 'informational'
-- version. This is the recovery.
--
-- 2. **Strip rule citation from Einspruch names.** m's correction
-- 2026-05-18 18:08: every other rule name in the corpus carries
-- the act-name without a parenthetical rule cite (Klageerwiderung,
-- Antrag auf Patentänderung, Replik, etc.). The Einspruch rule
-- names are the outliers:
-- upc.inf.cfi.prelim "Einspruch (R. 19 VerfO)" → "Einspruch"
-- upc.rev.cfi.prelim "Einspruch (R. 19 i.V.m. R. 46 VerfO)" → "Einspruch"
-- and EN equivalents:
-- "Preliminary Objection (RoP 19)" → "Preliminary Objection"
-- "Preliminary Objection (RoP 19 in conjunction with RoP 46)"
-- → "Preliminary Objection"
-- The legal_source / rule_code columns already carry the citation
-- and render in the deadline card's meta line, so the name stays
-- clean. The R.46-i.V.m. distinction is preserved in the legal
-- source field (RoP.019.1 for both — m may want to further
-- differentiate; flagged in description text instead).
--
-- audit_reason set_config required at the top — the deadline_rules
-- audit trigger raises EXCEPTION 'audit reason required' on any
-- mutation without it (cf. mig 099 hotfix history).
--
-- Idempotency:
-- * Priority UPDATE guarded on the current 'informational' value.
-- * Name UPDATEs guarded on the current parenthetical-bearing names.
SELECT set_config(
'paliad.audit_reason',
'mig 104: flip upc.inf.cfi.ccr priority informational→optional + strip rule-cite brackets from R.19 Einspruch names on both upc.inf.cfi.prelim and upc.rev.cfi.prelim (m''s corrections 2026-05-18, t-paliad-207 interactive session)',
true);
-- 1) Flip CCR priority
UPDATE paliad.deadline_rules dr
SET priority = 'optional'
FROM paliad.proceeding_types pt
WHERE dr.proceeding_type_id = pt.id
AND pt.code = 'upc.inf.cfi'
AND dr.submission_code = 'upc.inf.cfi.ccr'
AND dr.lifecycle_state = 'published'
AND dr.priority = 'informational';
-- 2a) Strip "(R. 19 VerfO)" from upc.inf.cfi.prelim DE/EN names
UPDATE paliad.deadline_rules dr
SET name = 'Einspruch'
FROM paliad.proceeding_types pt
WHERE dr.proceeding_type_id = pt.id
AND pt.code = 'upc.inf.cfi'
AND dr.submission_code = 'upc.inf.cfi.prelim'
AND dr.lifecycle_state = 'published'
AND dr.name = 'Einspruch (R. 19 VerfO)';
UPDATE paliad.deadline_rules dr
SET name_en = 'Preliminary Objection'
FROM paliad.proceeding_types pt
WHERE dr.proceeding_type_id = pt.id
AND pt.code = 'upc.inf.cfi'
AND dr.submission_code = 'upc.inf.cfi.prelim'
AND dr.lifecycle_state = 'published'
AND dr.name_en = 'Preliminary Objection (RoP 19)';
-- 2b) Strip "(R. 19 i.V.m. R. 46 VerfO)" from upc.rev.cfi.prelim DE/EN names
UPDATE paliad.deadline_rules dr
SET name = 'Einspruch'
FROM paliad.proceeding_types pt
WHERE dr.proceeding_type_id = pt.id
AND pt.code = 'upc.rev.cfi'
AND dr.submission_code = 'upc.rev.cfi.prelim'
AND dr.lifecycle_state = 'published'
AND dr.name = 'Einspruch (R. 19 i.V.m. R. 46 VerfO)';
UPDATE paliad.deadline_rules dr
SET name_en = 'Preliminary Objection'
FROM paliad.proceeding_types pt
WHERE dr.proceeding_type_id = pt.id
AND pt.code = 'upc.rev.cfi'
AND dr.submission_code = 'upc.rev.cfi.prelim'
AND dr.lifecycle_state = 'published'
AND dr.name_en = 'Preliminary Objection (RoP 19 in conjunction with RoP 46)';

View File

@@ -0,0 +1,31 @@
-- Revert mig 105 — restore the pre-mig-105 sequence_order values
-- (post-mig-100 state). Same two-phase swap pattern.
SELECT set_config(
'paliad.audit_reason',
'mig 105 down: restore pre-track-aware sequence_order on upc.inf.cfi rules',
true);
-- Phase 1: park
UPDATE paliad.deadline_rules SET sequence_order = 1011 WHERE submission_code = 'upc.inf.cfi.ccr' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 20;
UPDATE paliad.deadline_rules SET sequence_order = 1012 WHERE submission_code = 'upc.inf.cfi.def_to_ccr' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 22;
UPDATE paliad.deadline_rules SET sequence_order = 1013 WHERE submission_code = 'upc.inf.cfi.app_to_amend' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 30;
UPDATE paliad.deadline_rules SET sequence_order = 1020 WHERE submission_code = 'upc.inf.cfi.reply' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 12;
UPDATE paliad.deadline_rules SET sequence_order = 1021 WHERE submission_code = 'upc.inf.cfi.def_to_amend' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 32;
UPDATE paliad.deadline_rules SET sequence_order = 1022 WHERE submission_code = 'upc.inf.cfi.reply_def_ccr' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 24;
UPDATE paliad.deadline_rules SET sequence_order = 1030 WHERE submission_code = 'upc.inf.cfi.rejoin' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 14;
UPDATE paliad.deadline_rules SET sequence_order = 1031 WHERE submission_code = 'upc.inf.cfi.reply_def_amd' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 34;
UPDATE paliad.deadline_rules SET sequence_order = 1032 WHERE submission_code = 'upc.inf.cfi.rejoin_reply_ccr' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 26;
UPDATE paliad.deadline_rules SET sequence_order = 1033 WHERE submission_code = 'upc.inf.cfi.rejoin_amd' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 36;
-- Phase 2: assign originals
UPDATE paliad.deadline_rules SET sequence_order = 11 WHERE submission_code = 'upc.inf.cfi.ccr' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 1011;
UPDATE paliad.deadline_rules SET sequence_order = 12 WHERE submission_code = 'upc.inf.cfi.def_to_ccr' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 1012;
UPDATE paliad.deadline_rules SET sequence_order = 13 WHERE submission_code = 'upc.inf.cfi.app_to_amend' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 1013;
UPDATE paliad.deadline_rules SET sequence_order = 20 WHERE submission_code = 'upc.inf.cfi.reply' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 1020;
UPDATE paliad.deadline_rules SET sequence_order = 21 WHERE submission_code = 'upc.inf.cfi.def_to_amend' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 1021;
UPDATE paliad.deadline_rules SET sequence_order = 22 WHERE submission_code = 'upc.inf.cfi.reply_def_ccr' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 1022;
UPDATE paliad.deadline_rules SET sequence_order = 30 WHERE submission_code = 'upc.inf.cfi.rejoin' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 1030;
UPDATE paliad.deadline_rules SET sequence_order = 31 WHERE submission_code = 'upc.inf.cfi.reply_def_amd' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 1031;
UPDATE paliad.deadline_rules SET sequence_order = 32 WHERE submission_code = 'upc.inf.cfi.rejoin_reply_ccr' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 1032;
UPDATE paliad.deadline_rules SET sequence_order = 33 WHERE submission_code = 'upc.inf.cfi.rejoin_amd' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 1033;

View File

@@ -0,0 +1,211 @@
-- t-paliad-207 — re-sequence upc.inf.cfi rules so within any tied-date
-- group the infringement-track responses sit ABOVE the revocation-
-- track responses ABOVE the amendment-track responses. m's ask
-- 2026-05-18 18:08: "the infringement parts (like Replik) should show
-- above the part for the revocation (Erwiderung Nichtigkeitswider-
-- klage)".
--
-- Three tracks coexist on upc.inf.cfi once the with_ccr / with_amend
-- flags are set. They share calendar dates because R.29 / R.30 / R.32
-- all key off the SoD or its descendants. The current sequence_orders
-- (post-mig 100) interleave them; the user sees Erwiderung-zur-CCR
-- before Replik even though Replik is the infringement-side response
-- to the same triggering event.
--
-- New sequence_order assignment (preserves the soc=0, prelim=5,
-- sod=10, ccr=11 anchors at the head; phase markers interim/oral/
-- decision/cost_app/appeal_spawn keep their existing 40/50/60/70/80
-- slots at the tail):
--
-- Old → New submission_code track date
-- --- --- --------------- ----- ----
-- 0 0 upc.inf.cfi.soc — D+0
-- 5 5 upc.inf.cfi.prelim — D+1mo
-- 10 10 upc.inf.cfi.sod infringement D+3mo
-- 11 20 upc.inf.cfi.ccr revocation D+3mo
-- 20 12 upc.inf.cfi.reply infringement D+5mo ← MOVED UP
-- 12 22 upc.inf.cfi.def_to_ccr revocation D+5mo
-- 13 30 upc.inf.cfi.app_to_amend amendment D+5mo
-- 30 14 upc.inf.cfi.rejoin infringement D+6mo ← MOVED UP
-- 22 24 upc.inf.cfi.reply_def_ccr revocation D+7mo
-- 21 32 upc.inf.cfi.def_to_amend amendment D+7mo
-- 32 26 upc.inf.cfi.rejoin_reply_ccr revocation D+8mo
-- 31 34 upc.inf.cfi.reply_def_amd amendment D+8mo
-- 33 36 upc.inf.cfi.rejoin_amd amendment D+9mo
-- 40 40 upc.inf.cfi.interim phase later
-- 50 50 upc.inf.cfi.oral phase later
-- 60 60 upc.inf.cfi.decision phase later
-- 70 70 upc.inf.cfi.cost_app phase later
-- 80 80 upc.inf.cfi.appeal_spawn phase later
--
-- Order within each tied-date group after the reshuffle:
-- D+3mo: sod(10), ccr(20) — SoD then its CCR
-- D+5mo: reply(12), def_to_ccr(22), app_to_amend(30) — inf → rev → amd
-- D+7mo: reply_def_ccr(24), def_to_amend(32) — rev → amd
-- D+8mo: rejoin_reply_ccr(26), reply_def_amd(34) — rev → amd
--
-- (no infringement-track rule at +7mo or +8mo so revocation leads
-- those dates; rejoin sits alone at +6mo so it has no peers to order
-- against.)
--
-- audit_reason set_config required at the top — the deadline_rules
-- audit trigger raises EXCEPTION 'audit reason required' on any
-- mutation without it (cf. mig 099 hotfix history).
--
-- Idempotency: every UPDATE is guarded by both the submission_code
-- AND the SOURCE sequence_order, so re-apply is a no-op once the new
-- numbers are in place.
SELECT set_config(
'paliad.audit_reason',
'mig 105: re-sequence upc.inf.cfi rules track-aware (infringement → revocation → amendment within tied-date groups; m''s 2026-05-18 ask, t-paliad-207 interactive session)',
true);
-- Two-phase swap to avoid sequence collisions during the UPDATE
-- (otherwise two rules can briefly share a sequence_order if Postgres
-- evaluates them in parallel). Phase 1: move every reshuffled rule to
-- a high temporary number (1000+). Phase 2: assign final numbers.
-- ─── Phase 1: park reshuffled rules at 1000+ ────────────────────────
UPDATE paliad.deadline_rules
SET sequence_order = 1011
WHERE submission_code = 'upc.inf.cfi.ccr'
AND proceeding_type_id = 8
AND lifecycle_state = 'published'
AND sequence_order = 11;
UPDATE paliad.deadline_rules
SET sequence_order = 1012
WHERE submission_code = 'upc.inf.cfi.def_to_ccr'
AND proceeding_type_id = 8
AND lifecycle_state = 'published'
AND sequence_order = 12;
UPDATE paliad.deadline_rules
SET sequence_order = 1013
WHERE submission_code = 'upc.inf.cfi.app_to_amend'
AND proceeding_type_id = 8
AND lifecycle_state = 'published'
AND sequence_order = 13;
UPDATE paliad.deadline_rules
SET sequence_order = 1020
WHERE submission_code = 'upc.inf.cfi.reply'
AND proceeding_type_id = 8
AND lifecycle_state = 'published'
AND sequence_order = 20;
UPDATE paliad.deadline_rules
SET sequence_order = 1021
WHERE submission_code = 'upc.inf.cfi.def_to_amend'
AND proceeding_type_id = 8
AND lifecycle_state = 'published'
AND sequence_order = 21;
UPDATE paliad.deadline_rules
SET sequence_order = 1022
WHERE submission_code = 'upc.inf.cfi.reply_def_ccr'
AND proceeding_type_id = 8
AND lifecycle_state = 'published'
AND sequence_order = 22;
UPDATE paliad.deadline_rules
SET sequence_order = 1030
WHERE submission_code = 'upc.inf.cfi.rejoin'
AND proceeding_type_id = 8
AND lifecycle_state = 'published'
AND sequence_order = 30;
UPDATE paliad.deadline_rules
SET sequence_order = 1031
WHERE submission_code = 'upc.inf.cfi.reply_def_amd'
AND proceeding_type_id = 8
AND lifecycle_state = 'published'
AND sequence_order = 31;
UPDATE paliad.deadline_rules
SET sequence_order = 1032
WHERE submission_code = 'upc.inf.cfi.rejoin_reply_ccr'
AND proceeding_type_id = 8
AND lifecycle_state = 'published'
AND sequence_order = 32;
UPDATE paliad.deadline_rules
SET sequence_order = 1033
WHERE submission_code = 'upc.inf.cfi.rejoin_amd'
AND proceeding_type_id = 8
AND lifecycle_state = 'published'
AND sequence_order = 33;
-- ─── Phase 2: assign final track-aware numbers ──────────────────────
UPDATE paliad.deadline_rules
SET sequence_order = 12
WHERE submission_code = 'upc.inf.cfi.reply'
AND proceeding_type_id = 8
AND lifecycle_state = 'published'
AND sequence_order = 1020;
UPDATE paliad.deadline_rules
SET sequence_order = 14
WHERE submission_code = 'upc.inf.cfi.rejoin'
AND proceeding_type_id = 8
AND lifecycle_state = 'published'
AND sequence_order = 1030;
UPDATE paliad.deadline_rules
SET sequence_order = 20
WHERE submission_code = 'upc.inf.cfi.ccr'
AND proceeding_type_id = 8
AND lifecycle_state = 'published'
AND sequence_order = 1011;
UPDATE paliad.deadline_rules
SET sequence_order = 22
WHERE submission_code = 'upc.inf.cfi.def_to_ccr'
AND proceeding_type_id = 8
AND lifecycle_state = 'published'
AND sequence_order = 1012;
UPDATE paliad.deadline_rules
SET sequence_order = 24
WHERE submission_code = 'upc.inf.cfi.reply_def_ccr'
AND proceeding_type_id = 8
AND lifecycle_state = 'published'
AND sequence_order = 1022;
UPDATE paliad.deadline_rules
SET sequence_order = 26
WHERE submission_code = 'upc.inf.cfi.rejoin_reply_ccr'
AND proceeding_type_id = 8
AND lifecycle_state = 'published'
AND sequence_order = 1032;
UPDATE paliad.deadline_rules
SET sequence_order = 30
WHERE submission_code = 'upc.inf.cfi.app_to_amend'
AND proceeding_type_id = 8
AND lifecycle_state = 'published'
AND sequence_order = 1013;
UPDATE paliad.deadline_rules
SET sequence_order = 32
WHERE submission_code = 'upc.inf.cfi.def_to_amend'
AND proceeding_type_id = 8
AND lifecycle_state = 'published'
AND sequence_order = 1021;
UPDATE paliad.deadline_rules
SET sequence_order = 34
WHERE submission_code = 'upc.inf.cfi.reply_def_amd'
AND proceeding_type_id = 8
AND lifecycle_state = 'published'
AND sequence_order = 1031;
UPDATE paliad.deadline_rules
SET sequence_order = 36
WHERE submission_code = 'upc.inf.cfi.rejoin_amd'
AND proceeding_type_id = 8
AND lifecycle_state = 'published'
AND sequence_order = 1033;

View File

@@ -0,0 +1,28 @@
-- Revert mig 106 — drop 'madrid' from the office CHECK constraints.
--
-- Will fail if any users.office or partner_units.office row carries
-- 'madrid' — that's intentional (the down has no opinion on the data;
-- caller must clean up first or accept the failure).
SELECT set_config(
'paliad.audit_reason',
'mig 106 down: restore pre-madrid office CHECK on users + partner_units',
true);
ALTER TABLE paliad.users
DROP CONSTRAINT IF EXISTS users_office_check;
ALTER TABLE paliad.users
ADD CONSTRAINT users_office_check
CHECK (office IN (
'munich', 'duesseldorf', 'hamburg',
'amsterdam', 'london', 'paris', 'milan'
));
ALTER TABLE paliad.partner_units
DROP CONSTRAINT IF EXISTS partner_units_office_check;
ALTER TABLE paliad.partner_units
ADD CONSTRAINT partner_units_office_check
CHECK (office IN (
'munich', 'duesseldorf', 'hamburg',
'amsterdam', 'london', 'paris', 'milan'
));

View File

@@ -0,0 +1,42 @@
-- mig 106 — add 'madrid' to firm office CHECK constraints
--
-- m's ask 2026-05-20 09:42: add Madrid as an HLC office, alongside the
-- existing seven (munich, duesseldorf, hamburg, amsterdam, london,
-- paris, milan). Two active CHECK constraints to extend:
-- - paliad.users.office (mig 002)
-- - paliad.partner_units.office (mig 018; renamed mig 024 + mig 027)
--
-- The Go-side source of truth lives in internal/offices/offices.go;
-- this migration keeps the DB in sync.
--
-- Long-term, the admin area will let firms manage their own office
-- list (separate issue) — but for now the list is hard-coded here
-- + offices.go.
--
-- Non-blocking: extending a CHECK constraint is a metadata-only change
-- on a small enum-style column.
SELECT set_config(
'paliad.audit_reason',
'mig 106: add madrid to firm office CHECK on users + partner_units',
true);
ALTER TABLE paliad.users
DROP CONSTRAINT IF EXISTS users_office_check;
ALTER TABLE paliad.users
ADD CONSTRAINT users_office_check
CHECK (office IN (
'munich', 'duesseldorf', 'hamburg',
'amsterdam', 'london', 'paris', 'milan',
'madrid'
));
ALTER TABLE paliad.partner_units
DROP CONSTRAINT IF EXISTS partner_units_office_check;
ALTER TABLE paliad.partner_units
ADD CONSTRAINT partner_units_office_check
CHECK (office IN (
'munich', 'duesseldorf', 'hamburg',
'amsterdam', 'london', 'paris', 'milan',
'madrid'
));

View File

@@ -0,0 +1,5 @@
-- Reverse of 107: drop the binding_id column from caldav_sync_log.
-- The associated index drops automatically with the column.
ALTER TABLE paliad.caldav_sync_log
DROP COLUMN IF EXISTS binding_id;

View File

@@ -0,0 +1,53 @@
-- t-paliad-212 — Slice 2a of CalDAV multi-calendar.
--
-- Adds paliad.caldav_sync_log.binding_id so the per-tick sync log
-- records which binding the entry belongs to. NULL for legacy rows
-- and for "global" log entries that aren't per-binding (Slice 2a
-- still writes one row per user per tick — Slice 2b's sync rewrite
-- moves to one row per (user, binding) per tick).
--
-- FK uses ON DELETE SET NULL so deleting a binding doesn't blow away
-- its historical sync log (audit trail wins over referential tidiness).
--
-- Idempotent: column added via DO block with information_schema check.
SELECT set_config(
'paliad.audit_reason',
'mig 107: add caldav_sync_log.binding_id for per-binding sync log entries (t-paliad-212 Slice 2a)',
true);
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'paliad'
AND table_name = 'caldav_sync_log'
AND column_name = 'binding_id'
) THEN
ALTER TABLE paliad.caldav_sync_log
ADD COLUMN binding_id uuid
REFERENCES paliad.user_calendar_bindings(id) ON DELETE SET NULL;
END IF;
END $$;
CREATE INDEX IF NOT EXISTS caldav_sync_log_binding_idx
ON paliad.caldav_sync_log (binding_id, occurred_at DESC)
WHERE binding_id IS NOT NULL;
-- Assertion: column exists and is nullable.
DO $$
DECLARE
col_nullable text;
BEGIN
SELECT is_nullable INTO col_nullable
FROM information_schema.columns
WHERE table_schema = 'paliad'
AND table_name = 'caldav_sync_log'
AND column_name = 'binding_id';
IF col_nullable IS NULL THEN
RAISE EXCEPTION 'mig 107 assertion failed: caldav_sync_log.binding_id missing';
END IF;
IF col_nullable <> 'YES' THEN
RAISE EXCEPTION 'mig 107 assertion failed: caldav_sync_log.binding_id is NOT NULL (must be nullable)';
END IF;
END $$;

View File

@@ -0,0 +1,5 @@
-- Reverse of 108: drop the capability columns.
ALTER TABLE paliad.user_caldav_config
DROP COLUMN IF EXISTS supports_mkcalendar,
DROP COLUMN IF EXISTS mkcalendar_probed_at;

View File

@@ -0,0 +1,67 @@
-- t-paliad-212 — Slice 2c of CalDAV multi-calendar.
--
-- Adds the MKCALENDAR-capability tri-state to paliad.user_caldav_config:
-- * supports_mkcalendar = NULL → unprobed (probe runs lazily on
-- the first /api/caldav-discover or
-- /api/caldav-mkcalendar call).
-- * supports_mkcalendar = TRUE → server accepts MKCALENDAR; the
-- "Create new calendar" affordance
-- in the picker is visible.
-- * supports_mkcalendar = FALSE → Google-style degrade; UI hides the
-- create button and surfaces the
-- "create it in your provider's UI"
-- notice with a manual-URL input.
-- The probed_at timestamp lets us re-probe stale-cached results when
-- the user changes credentials (SaveConfig invalidates by SetNull in
-- the Go service layer; the column is here so the next round of
-- probing has somewhere to land).
--
-- Idempotent (column-exists DO block) + assertion at the bottom.
SELECT set_config(
'paliad.audit_reason',
'mig 108: add user_caldav_config.supports_mkcalendar tri-state for t-paliad-212 Slice 2c capability probe',
true);
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'paliad'
AND table_name = 'user_caldav_config'
AND column_name = 'supports_mkcalendar'
) THEN
ALTER TABLE paliad.user_caldav_config
ADD COLUMN supports_mkcalendar boolean;
END IF;
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'paliad'
AND table_name = 'user_caldav_config'
AND column_name = 'mkcalendar_probed_at'
) THEN
ALTER TABLE paliad.user_caldav_config
ADD COLUMN mkcalendar_probed_at timestamptz;
END IF;
END $$;
-- Assertion — both columns present and nullable.
DO $$
DECLARE
sup_nullable text;
probed_nullable text;
BEGIN
SELECT is_nullable INTO sup_nullable
FROM information_schema.columns
WHERE table_schema = 'paliad' AND table_name = 'user_caldav_config'
AND column_name = 'supports_mkcalendar';
SELECT is_nullable INTO probed_nullable
FROM information_schema.columns
WHERE table_schema = 'paliad' AND table_name = 'user_caldav_config'
AND column_name = 'mkcalendar_probed_at';
IF sup_nullable <> 'YES' OR probed_nullable <> 'YES' THEN
RAISE EXCEPTION
'mig 108 assertion failed: expected both columns nullable, got supports=% probed=%',
sup_nullable, probed_nullable;
END IF;
END $$;

View File

@@ -0,0 +1,3 @@
-- Reverse of 109_user_dashboard_layouts.up.sql.
DROP TABLE IF EXISTS paliad.user_dashboard_layouts;

View File

@@ -0,0 +1,29 @@
-- t-paliad-219 Slice A1: per-user dashboard layout.
--
-- Design: docs/design-dashboard-configurable-2026-05-20.md §5.1 (newton,
-- m-locked 2026-05-20: single layout per user, Q2).
--
-- Stores one configurable dashboard layout per user as a single jsonb
-- column. The layout is an ordered list of (widget_key, visible, settings)
-- triples; see internal/services/dashboard_layout_spec.go DashboardLayoutSpec.
--
-- Single-row-per-user PK because m's Q2 pick is one layout per user (v1) —
-- no named-layout switcher. Forward path to named layouts (drop the PK, add
-- id+name+is_default columns) stays open if m later changes course.
--
-- RLS owner-only mirrors user_card_layouts / user_views — personal working
-- state, not auditable infrastructure. global_admin gets no override.
CREATE TABLE paliad.user_dashboard_layouts (
user_id uuid PRIMARY KEY REFERENCES paliad.users(id) ON DELETE CASCADE,
layout_json jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE paliad.user_dashboard_layouts ENABLE ROW LEVEL SECURITY;
CREATE POLICY user_dashboard_layouts_owner_all
ON paliad.user_dashboard_layouts FOR ALL
USING (user_id = auth.uid())
WITH CHECK (user_id = auth.uid());

View File

@@ -8,6 +8,7 @@ import (
"github.com/google/uuid"
"mgit.msbls.de/m/paliad/internal/models"
"mgit.msbls.de/m/paliad/internal/services"
)
@@ -311,6 +312,226 @@ func handleTestCalDAVConfig(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
// GET /api/caldav-bindings — list the authenticated user's CalDAV
// bindings (the (calendar, scope) entries layered on the single CalDAV
// server connection). Read-only in Slice 2a; full CRUD lands in Slice 2b.
func handleListCalDAVBindings(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
if dbSvc.caldavBindings == nil {
writeJSON(w, http.StatusNotImplemented, map[string]any{
"error": "CalDAV bindings unavailable (CalDAV service not configured)",
})
return
}
rows, err := dbSvc.caldavBindings.ListForUser(r.Context(), uid)
if err != nil {
writeServiceError(w, err)
return
}
if rows == nil {
rows = []models.UserCalendarBinding{}
}
writeJSON(w, http.StatusOK, rows)
}
// POST /api/caldav-bindings — create a new binding for the
// authenticated user and synchronously fire a first push so the modal
// closes with events already landed. Returns 201 with the binding row.
func handleCreateCalDAVBinding(w http.ResponseWriter, r *http.Request) {
if !requireCalDAV(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
if dbSvc.caldavBindings == nil {
writeJSON(w, http.StatusNotImplemented, map[string]any{"error": "CalDAV bindings unavailable"})
return
}
var input services.CreateBindingInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
// Default to enabled=true so the modal "Hinzufügen" button does the
// expected thing without forcing the user to toggle anything.
if !input.Enabled {
input.Enabled = true
}
binding, err := dbSvc.caldavBindings.Create(r.Context(), uid, input)
if err != nil {
writeCalDAVError(w, err)
return
}
// Synchronous first push per Q5 of the Slice 2 design (m's 2026-05-20
// pick): block the request so the user sees events already landed
// when the modal closes. PushBindingNow logs per-event failures and
// returns; we only surface a hard config/cipher error.
pushed, pushErr := dbSvc.caldav.PushBindingNow(r.Context(), uid, binding)
if pushErr != nil {
// Binding was created; sync failed. Tell the UI both bits so it
// can show "binding added, initial sync had a problem".
writeJSON(w, http.StatusCreated, map[string]any{
"binding": binding,
"initial_pushed": pushed,
"initial_sync_error": pushErr.Error(),
})
return
}
// Ensure the per-user goroutine is running so future ticks happen.
dbSvc.caldav.EnsureLoop(uid)
writeJSON(w, http.StatusCreated, map[string]any{
"binding": binding,
"initial_pushed": pushed,
})
}
// PATCH /api/caldav-bindings/{id} — partial update. Lazy scope cleanup
// per Q6: stale targets get dropped on the next sync tick, not here.
func handlePatchCalDAVBinding(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
if dbSvc.caldavBindings == nil {
writeJSON(w, http.StatusNotImplemented, map[string]any{"error": "CalDAV bindings unavailable"})
return
}
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
return
}
var input services.UpdateBindingInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
binding, err := dbSvc.caldavBindings.Update(r.Context(), uid, id, input)
if err != nil {
writeCalDAVError(w, err)
return
}
writeJSON(w, http.StatusOK, binding)
}
// DELETE /api/caldav-bindings/{id} — best-effort remote cleanup of every
// .ics this binding pushed, then drop the binding row. On partial remote
// failure the binding is disabled (not deleted) so the next sync tick
// can retry; the response is 202 Accepted in that case.
func handleDeleteCalDAVBinding(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
if dbSvc.caldav == nil {
writeJSON(w, http.StatusNotImplemented, map[string]any{"error": "CalDAV bindings unavailable"})
return
}
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
return
}
fully, err := dbSvc.caldav.RemoveBinding(r.Context(), uid, id)
if err != nil {
writeCalDAVError(w, err)
return
}
if !fully {
writeJSON(w, http.StatusAccepted, map[string]any{
"status": "partial",
"message": "Binding disabled; some remote events could not be deleted. Retry on next sync tick.",
})
return
}
w.WriteHeader(http.StatusNoContent)
}
// POST /api/caldav-mkcalendar — creates a new calendar on the user's
// CalDAV server via MKCALENDAR + a matching binding row in one logical
// transaction. Slice 2c only — visible when /api/caldav-discover
// reports supports_mkcalendar=true. Errors:
// - 501 when supports_mkcalendar=false (caller should show the
// Google-degrade UX with the manual-URL input).
// - 409 when the slugified name + 3 retries all collide on the
// server. UI should ask the user to type their own name.
func handleCalDAVMakeCalendar(w http.ResponseWriter, r *http.Request) {
if !requireCalDAV(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
var input services.CreateCalendarInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
result, err := dbSvc.caldav.MakeCalendar(r.Context(), uid, input)
if err != nil {
switch {
case errors.Is(err, services.ErrMKCalendarUnsupported):
writeJSON(w, http.StatusNotImplemented, map[string]any{
"error": err.Error(),
"supports_mkcalendar": false,
})
case errors.Is(err, services.ErrCalendarNameTaken):
writeJSON(w, http.StatusConflict, map[string]any{
"error": err.Error(),
})
default:
// Binding-create / push errors carry the partial result so
// the UI can surface "created remotely but binding failed".
if result != nil {
writeJSON(w, http.StatusCreated, map[string]any{
"calendar_path": result.CalendarPath,
"binding": result.Binding,
"initial_pushed": result.InitialPushed,
"initial_sync_error": err.Error(),
})
return
}
writeCalDAVError(w, err)
}
return
}
writeJSON(w, http.StatusCreated, result)
}
// GET /api/caldav-discover — walks the calendar-home-set chain on the
// user's CalDAV server and returns the calendars they own. Cached
// server-side for 5 minutes per user (Q4 of Slice 2 brief).
func handleCalDAVDiscover(w http.ResponseWriter, r *http.Request) {
if !requireCalDAV(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
result, err := dbSvc.caldav.DiscoverCalendars(r.Context(), uid)
if err != nil {
writeCalDAVError(w, err)
return
}
writeJSON(w, http.StatusOK, result)
}
// GET /api/caldav-config/log — last 5 sync attempts.
func handleCalDAVSyncLog(w http.ResponseWriter, r *http.Request) {
if !requireCalDAV(w) {

View File

@@ -270,7 +270,8 @@ func isValidInboxStatus(s string) bool {
services.RequestStatusApproved,
services.RequestStatusRejected,
services.RequestStatusRevoked,
services.RequestStatusSuperseded:
services.RequestStatusSuperseded,
services.RequestStatusChangesRequested:
return true
}
return false
@@ -325,6 +326,67 @@ func handleRevokeApprovalRequest(w http.ResponseWriter, r *http.Request) {
handleApprovalDecision(w, r, "revoke")
}
// suggestChangesBody is the JSON body for POST /api/approval-requests/{id}/suggest-changes.
// counter_payload is an entity-shaped jsonb of the approver's edited
// values (allowlist enforced server-side); note is the optional free-text
// explanation. The service rejects the call with
// ErrSuggestionRequiresChange when both are no-ops (counter is identical
// to the old row's payload AND note is empty).
type suggestChangesBody struct {
CounterPayload map[string]any `json:"counter_payload"`
Note string `json:"note"`
}
// POST /api/approval-requests/{id}/suggest-changes — t-paliad-216.
//
// In one transaction: close the pending request as 'changes_requested'
// (with the caller's note + counter_payload on the row), revert the entity
// from pre_image, then spawn a NEW pending approval_request authored by
// the caller carrying the counter_payload. Returns the new request id.
//
// Status mapping (see writeApprovalError → mapApprovalError):
//
// 400 suggestion_requires_change — counter == old payload AND no note
// 400 suggestion_lifecycle_invalid — old row's lifecycle ∉ (update, complete)
// 403 self_approval_blocked — caller == old row's requested_by
// 403 not_authorized — caller doesn't satisfy canApprove
// 404 — request not found / not visible
// 409 request_not_pending — old row already decided
// 409 no_qualified_approver — deadlock on the new row
func handleSuggestChangesApprovalRequest(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
requestID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request id"})
return
}
var body suggestChangesBody
if r.Body != nil && r.ContentLength > 0 {
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{
"code": "invalid_body",
"message": "Ungültiger Body.",
})
return
}
}
newID, err := dbSvc.approval.SuggestChanges(r.Context(), requestID, uid, body.CounterPayload, body.Note)
if err != nil {
writeApprovalError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]string{
"status": "ok",
"new_request_id": newID.String(),
})
}
func handleApprovalDecision(w http.ResponseWriter, r *http.Request, action string) {
if !requireDB(w) {
return

View File

@@ -82,6 +82,44 @@ func TestMapApprovalError_MissReturnsFalse(t *testing.T) {
}
}
// TestMapApprovalError_SuggestionRequiresChange400 pins t-paliad-216:
// a no-op suggest-changes (no counter diff + no note) surfaces as a 400
// with code suggestion_requires_change so the frontend can disable the
// submit button instead of letting the user click into a dead-end alert.
func TestMapApprovalError_SuggestionRequiresChange400(t *testing.T) {
w := httptest.NewRecorder()
if !mapApprovalError(w, services.ErrSuggestionRequiresChange) {
t.Fatal("mapApprovalError returned false for ErrSuggestionRequiresChange")
}
if w.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", w.Code)
}
var body map[string]string
_ = json.Unmarshal(w.Body.Bytes(), &body)
if body["code"] != "suggestion_requires_change" {
t.Errorf("code = %q, want suggestion_requires_change", body["code"])
}
}
// TestMapApprovalError_SuggestionLifecycleInvalid400 pins t-paliad-216:
// suggest-changes on a create/delete lifecycle is rejected with a clean
// 400 + code suggestion_lifecycle_invalid so the frontend can hide the
// button for those rows.
func TestMapApprovalError_SuggestionLifecycleInvalid400(t *testing.T) {
w := httptest.NewRecorder()
if !mapApprovalError(w, services.ErrSuggestionLifecycleInvalid) {
t.Fatal("mapApprovalError returned false for ErrSuggestionLifecycleInvalid")
}
if w.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", w.Code)
}
var body map[string]string
_ = json.Unmarshal(w.Body.Bytes(), &body)
if body["code"] != "suggestion_lifecycle_invalid" {
t.Errorf("code = %q, want suggestion_lifecycle_invalid", body["code"])
}
}
// TestParseInboxFilter_DropsUnknownStatus pins t-paliad-160 §D regression
// hardening: a stray ?status=foo from a stale frontend build (or an
// attacker scoping us out of our own list) must NOT shadow rows out of
@@ -97,6 +135,7 @@ func TestParseInboxFilter_DropsUnknownStatus(t *testing.T) {
{"rejected", "rejected"},
{"revoked", "revoked"},
{"superseded", "superseded"},
{"changes_requested", "changes_requested"}, // t-paliad-216
{"foo", ""}, // unknown — dropped
{"DROP+TABLE", ""}, // hostile — dropped
{"PENDING", ""}, // case mismatch — dropped (we don't normalise)

View File

@@ -4,6 +4,7 @@ import (
"net/http"
"mgit.msbls.de/m/paliad/internal/auth"
"mgit.msbls.de/m/paliad/internal/services"
)
// GET /api/dashboard — returns the DashboardData JSON for the logged-in user.
@@ -24,21 +25,29 @@ func handleDashboardAPI(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, data)
}
// GET /dashboard — protected shell page. The client boots, reads the initial
// payload inlined by the server into window.__PALIAD_DASHBOARD__, and renders
// without a second round-trip (audit §2.3: no skeleton→fetch waterfall).
// GET /dashboard — protected shell page. The client boots, reads three
// initial payloads inlined by the server (data, layout, catalog), and
// renders without a second round-trip (audit §2.3: no skeleton→fetch
// waterfall). Each inline is best-effort: if any read fails the
// corresponding blob is left null and the client falls back to fetch.
func handleDashboardPage(w http.ResponseWriter, r *http.Request) {
uid, hasUser := auth.UserIDFromContext(r.Context())
var payload []byte
var payload, layout []byte
if hasUser && dbSvc != nil {
// Best-effort server-render. If the DB read fails we still serve the
// shell; the client will show the inline error state instead of the
// zero-count cards.
if data, err := dbSvc.dashboard.Get(r.Context(), uid); err == nil {
payload = mustJSON(data)
}
if dbSvc.dashboardLayout != nil {
if spec, err := dbSvc.dashboardLayout.GetOrSeed(r.Context(), uid); err == nil {
layout = mustJSON(spec)
}
}
}
serveDashboardShell(w, r, payload)
// Catalog is code-resident — always inline it so the widget picker
// and dispatch logic can boot without an extra fetch even on
// knowledge-platform-only deployments without DATABASE_URL.
catalog := mustJSON(services.WidgetCatalog())
serveDashboardShell(w, r, payload, layout, catalog)
}
// handleRootPage is the public `/` route. Unauthenticated visitors get the

View File

@@ -0,0 +1,109 @@
package handlers
// HTTP handlers for the per-user dashboard layout (t-paliad-219 Slice A2).
//
// Design: docs/design-dashboard-configurable-2026-05-20.md §9.
//
// Four endpoints:
// GET /api/me/dashboard-layout → read (auto-seeds factory default)
// PUT /api/me/dashboard-layout → replace (validates against catalog)
// POST /api/me/dashboard-layout/reset → overwrite with factory default
// GET /api/dashboard-widget-catalog → catalog metadata for the picker
import (
"encoding/json"
"errors"
"net/http"
"mgit.msbls.de/m/paliad/internal/services"
)
// GET /api/me/dashboard-layout — returns the caller's layout, seeding the
// factory default on first call. Always returns 200 with a valid
// DashboardLayoutSpec.
func handleGetDashboardLayout(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
if dbSvc.dashboardLayout == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "dashboard-layout service not configured"})
return
}
spec, err := dbSvc.dashboardLayout.GetOrSeed(r.Context(), uid)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, spec)
}
// PUT /api/me/dashboard-layout — replaces the caller's layout. Body must
// be a complete DashboardLayoutSpec; the service validates against the
// catalog and 400s on a bad spec.
func handlePutDashboardLayout(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
if dbSvc.dashboardLayout == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "dashboard-layout 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.dashboardLayout.Update(r.Context(), uid, spec)
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)
}
// POST /api/me/dashboard-layout/reset — overwrites the caller's layout
// with the factory default. The previous layout is discarded.
func handleResetDashboardLayout(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
if dbSvc.dashboardLayout == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "dashboard-layout service not configured"})
return
}
spec, err := dbSvc.dashboardLayout.ResetToDefault(r.Context(), uid)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, spec)
}
// GET /api/dashboard-widget-catalog — returns the widget catalog. Auth-
// gated only because the catalog includes user-facing copy; nothing
// security-sensitive is exposed. The handler is DB-independent (the
// catalog is code-resident) so the requireDB gate is intentionally
// skipped — knowledge-platform-only deployments can still surface the
// catalog and we never want this endpoint to 503.
func handleGetWidgetCatalog(w http.ResponseWriter, r *http.Request) {
if _, ok := requireUser(w, r); !ok {
return
}
writeJSON(w, http.StatusOK, services.WidgetCatalog())
}

View File

@@ -11,10 +11,15 @@ import (
)
// The dashboard shell is pre-rendered by bun (`renderDashboard()` → dist/dashboard.html)
// and contains the placeholder token below. On each request we splice in a
// JSON blob as `window.__PALIAD_DASHBOARD__` so the client can paint the real
// data on first frame — no skeleton + /api/dashboard waterfall.
const dashboardDataPlaceholder = "/*__PALIAD_DASHBOARD_DATA__*/"
// and contains three placeholder tokens (data, layout, catalog). On each
// request we splice in JSON blobs as window.__PALIAD_DASHBOARD__ /
// __PALIAD_DASHBOARD_LAYOUT__ / __PALIAD_DASHBOARD_CATALOG__ so the client
// can paint the real data on first frame — no skeleton + /api/* waterfall.
const (
dashboardDataPlaceholder = "/*__PALIAD_DASHBOARD_DATA__*/"
dashboardLayoutPlaceholder = "/*__PALIAD_DASHBOARD_LAYOUT__*/"
dashboardCatalogPlaceholder = "/*__PALIAD_DASHBOARD_CATALOG__*/"
)
var (
dashboardShellOnce sync.Once
@@ -38,28 +43,19 @@ func loadDashboardShell() ([]byte, error) {
return dashboardShellBytes, dashboardShellErr
}
// serveDashboardShell writes dist/dashboard.html with the JSON payload spliced
// into the placeholder. A nil payload disables server-side hydration; the
// client then falls back to fetching /api/dashboard on mount.
func serveDashboardShell(w http.ResponseWriter, _ *http.Request, payload []byte) {
// serveDashboardShell writes dist/dashboard.html with three JSON blobs
// spliced in (data, layout, catalog). A nil payload disables server-side
// hydration of that slot; the client falls back to fetching the
// corresponding /api/* endpoint on mount.
func serveDashboardShell(w http.ResponseWriter, _ *http.Request, payload, layout, catalog []byte) {
shell, err := loadDashboardShell()
if err != nil {
http.Error(w, "dashboard shell unavailable", http.StatusInternalServerError)
return
}
var body []byte
if len(payload) > 0 {
// JSON is wrapped so the script block is self-contained even when the
// payload contains `</script>` sequences (defensive: our data is
// server-owned, but future event.description fields could contain
// arbitrary text).
inline := append([]byte("window.__PALIAD_DASHBOARD__="), escapeForScript(payload)...)
inline = append(inline, ';')
body = bytes.Replace(shell, []byte(dashboardDataPlaceholder), inline, 1)
} else {
body = bytes.Replace(shell, []byte(dashboardDataPlaceholder),
[]byte("window.__PALIAD_DASHBOARD__=null;"), 1)
}
body := splicePlaceholder(shell, dashboardDataPlaceholder, "window.__PALIAD_DASHBOARD__=", payload)
body = splicePlaceholder(body, dashboardLayoutPlaceholder, "window.__PALIAD_DASHBOARD_LAYOUT__=", layout)
body = splicePlaceholder(body, dashboardCatalogPlaceholder, "window.__PALIAD_DASHBOARD_CATALOG__=", catalog)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
@@ -67,6 +63,22 @@ func serveDashboardShell(w http.ResponseWriter, _ *http.Request, payload []byte)
_, _ = w.Write(body)
}
// splicePlaceholder replaces a single placeholder token with a JS
// assignment of the given JSON payload to a window.X global. A nil
// payload assigns `null` so the client can detect "no server-side
// hydration" and fall back to fetch.
func splicePlaceholder(shell []byte, placeholder, prefix string, payload []byte) []byte {
var inline []byte
if len(payload) > 0 {
inline = append(inline, []byte(prefix)...)
inline = append(inline, escapeForScript(payload)...)
inline = append(inline, ';')
} else {
inline = append(inline, []byte(prefix+"null;")...)
}
return bytes.Replace(shell, []byte(placeholder), inline, 1)
}
// escapeForScript makes a JSON blob safe to embed directly in an inline
// <script>. JSON strings may contain `</script>` or U+2028/U+2029, both of
// which terminate script blocks in some parsers.

290
internal/handlers/export.go Normal file
View File

@@ -0,0 +1,290 @@
package handlers
// Data-export handlers (t-paliad-214).
//
// Slice 1: personal scope
// GET /api/me/export → streams a personal-scope export .zip
//
// Slice 2: project subtree scope
// GET /api/projects/{id}/export?direct_only=0|1 → streams a project-subtree
// export .zip
//
// Slice 3 (org, async) lands in a follow-up.
//
// Authentication: the existing protected mux middleware (auth.Middleware +
// auth.WithUserID) populates the user UUID in the context. Slice 1 gates
// only on authentication; Slice 2 adds a §4 responsibility + global_admin
// check via handleProjectExportGate.
import (
"bytes"
"context"
"fmt"
"log"
"net/http"
"strconv"
"time"
"github.com/google/uuid"
"mgit.msbls.de/m/paliad/internal/services"
)
// exportRequestTimeout caps any single export request. Personal-scope
// exports at firm-scale data shape complete in well under this; the
// timeout is the watchdog that surfaces "too large for sync" loudly
// (the user gets a 503 and slice 3's async path becomes the answer).
const exportRequestTimeout = 30 * time.Second
// handleMeExport streams the caller's personal-scope export .zip.
//
// Order of operations:
//
// 1. Validate auth + db wiring.
// 2. Look up the caller's user row for actor_email / actor_label.
// 3. Write an audit row (event_type='data_export', scope='personal').
// 4. Run the export into an in-memory buffer (so we can patch the
// audit row with file_size_bytes before flushing to the client).
// 5. Set headers + flush.
// 6. Patch the audit row with success (row_counts + file_size).
// On any error after step 3, the audit row is patched as failed.
func handleMeExport(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
if dbSvc.export == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
"error": "export service not configured",
})
return
}
// Apply the per-request watchdog.
ctx, cancel := context.WithTimeout(r.Context(), exportRequestTimeout)
defer cancel()
user, err := dbSvc.users.GetByID(ctx, uid)
if err != nil || user == nil {
log.Printf("export: user lookup failed for %s: %v", uid, err)
writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": "user lookup failed",
})
return
}
spec := services.ExportSpec{
Scope: services.ExportScopePersonal,
ActorID: uid,
ActorEmail: user.Email,
ActorLabel: user.DisplayName,
GeneratedAt: time.Now().UTC(),
}
auditID, err := dbSvc.export.WriteAuditRow(ctx, spec)
if err != nil {
log.Printf("export: audit insert failed for %s: %v", uid, err)
writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": "audit write failed",
})
return
}
// Generate into a memory buffer so we can size + audit-patch BEFORE
// writing to the response (otherwise headers are committed and we
// can't return a 500 if anything fails). At personal scale this is a
// sub-megabyte buffer.
var buf bytes.Buffer
meta, err := dbSvc.export.WritePersonal(ctx, &buf, spec)
if err != nil {
dbSvc.export.PatchAuditRowFailure(context.Background(), auditID, err.Error())
log.Printf("export: WritePersonal failed for %s (audit=%s): %v", uid, auditID, err)
writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": "export generation failed",
})
return
}
filename := services.ExportFilename(services.ExportScopePersonal, "", uuid.Nil, spec.GeneratedAt)
size := int64(buf.Len())
if err := dbSvc.export.PatchAuditRowSuccess(ctx, auditID, meta, size); err != nil {
// Audit-patch failure isn't fatal to the user — they still get
// their export. Log it; the data already left the system.
log.Printf("export: audit patch failed for %s (audit=%s): %v", uid, auditID, err)
}
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename=%q`, filename))
w.Header().Set("Content-Length", strconv.FormatInt(size, 10))
w.Header().Set("X-Paliad-Export-Audit-Id", auditID.String())
if _, err := w.Write(buf.Bytes()); err != nil {
// Connection dropped mid-flush — the user didn't get the file.
// We don't patch the audit row a second time; the success patch
// already recorded the row counts. A separate event would be
// noise (the failure is at the network layer, not in our path).
log.Printf("export: response write failed for %s (audit=%s): %v", uid, auditID, err)
}
}
// handleProjectExport streams the project-subtree export .zip for the
// project named in the URL path.
//
// Authorization (Slice 2 §4):
//
// - caller must be authenticated (handled by the mux middleware),
// - caller must pass paliad.can_see_project(rootID) — enforced via
// ProjectService.GetByID returning ErrNotVisible → 404,
// - caller must be on paliad.project_teams for the root with
// responsibility ∈ {lead, member}, OR be a global_admin.
// Observers + Externals see but cannot extract — 403 bilingual.
//
// Query params:
// - ?direct_only=1 narrows the export to the root project only (no
// descendants). Default = subtree-inclusive.
func handleProjectExport(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
if dbSvc.export == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
"error": "export service not configured",
})
return
}
rootID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "invalid project id",
})
return
}
directOnly := false
if q := r.URL.Query().Get("direct_only"); q == "1" || q == "true" {
directOnly = true
}
ctx, cancel := context.WithTimeout(r.Context(), exportRequestTimeout)
defer cancel()
// Visibility gate (a + b): GetByID returns ErrNotVisible when the
// caller can't see the project, which we map to 404. The handler
// stays oblivious to whether the project doesn't exist or simply
// isn't visible — that's by design (RLS-style opacity).
project, err := dbSvc.projects.GetByID(ctx, uid, rootID)
if err != nil {
writeServiceError(w, err)
return
}
// Authority gate (c): direct-team responsibility ∈ {lead, member} OR
// global_admin. Derived-only-via-partner-unit users (DerivedPeer)
// don't qualify for extraction — m's Q1 lock-in.
allowed, err := callerCanExportProject(ctx, uid, rootID)
if err != nil {
log.Printf("export: authority check failed for user=%s project=%s: %v", uid, rootID, err)
writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": "authority check failed",
})
return
}
if !allowed {
// Bilingual 403 per Q7. Pattern matches mapApprovalError style.
writeJSON(w, http.StatusForbidden, map[string]string{
"code": "export_not_authorized",
"message": "Datenexport ist nur Team-Mitgliedern (Lead / Member) vorbehalten. / Data export is restricted to project team members (lead / member).",
})
return
}
user, err := dbSvc.users.GetByID(ctx, uid)
if err != nil || user == nil {
log.Printf("export: user lookup failed for %s: %v", uid, err)
writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": "user lookup failed",
})
return
}
spec := services.ExportSpec{
Scope: services.ExportScopeProject,
ScopeRoot: &rootID,
ScopeRootLabel: project.Title,
ScopeRootPath: project.Path,
DirectOnly: directOnly,
ActorID: uid,
ActorEmail: user.Email,
ActorLabel: user.DisplayName,
GeneratedAt: time.Now().UTC(),
}
auditID, err := dbSvc.export.WriteAuditRow(ctx, spec)
if err != nil {
log.Printf("export: audit insert failed for %s/project=%s: %v", uid, rootID, err)
writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": "audit write failed",
})
return
}
var buf bytes.Buffer
meta, err := dbSvc.export.WriteProject(ctx, &buf, spec)
if err != nil {
dbSvc.export.PatchAuditRowFailure(context.Background(), auditID, err.Error())
log.Printf("export: WriteProject failed for %s/project=%s (audit=%s): %v", uid, rootID, auditID, err)
writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": "export generation failed",
})
return
}
filename := services.ExportFilename(services.ExportScopeProject, project.Title, rootID, spec.GeneratedAt)
size := int64(buf.Len())
if err := dbSvc.export.PatchAuditRowSuccess(ctx, auditID, meta, size); err != nil {
log.Printf("export: audit patch failed for %s/project=%s (audit=%s): %v", uid, rootID, auditID, err)
}
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename=%q`, filename))
w.Header().Set("Content-Length", strconv.FormatInt(size, 10))
w.Header().Set("X-Paliad-Export-Audit-Id", auditID.String())
if _, err := w.Write(buf.Bytes()); err != nil {
log.Printf("export: response write failed for %s/project=%s (audit=%s): %v", uid, rootID, auditID, err)
}
}
// callerCanExportProject is the §4 authority check:
//
// - global_admin can extract anything anywhere.
// - else: caller must be on paliad.project_teams for the root with
// responsibility ∈ {lead, member}.
//
// One query, parameterised; returns the boolean. Errors surface to the
// handler as 500.
func callerCanExportProject(ctx context.Context, userID, projectID uuid.UUID) (bool, error) {
const q = `
SELECT
EXISTS (
SELECT 1 FROM paliad.users u
WHERE u.id = $1 AND u.global_role = 'global_admin'
) OR EXISTS (
SELECT 1 FROM paliad.project_teams pt
WHERE pt.user_id = $1
AND pt.project_id = $2
AND pt.responsibility IN ('lead', 'member')
)
`
var ok bool
if err := dbSvc.projects.DB().QueryRowContext(ctx, q, userID, projectID).Scan(&ok); err != nil {
return false, err
}
return ok, nil
}

View File

@@ -3,6 +3,7 @@ package handlers
import (
"encoding/json"
"net/http"
"strings"
"mgit.msbls.de/m/paliad/internal/auth"
"mgit.msbls.de/m/paliad/internal/services"
@@ -21,6 +22,19 @@ func noCacheAssets(h http.Handler) http.Handler {
})
}
// patentstyleDownload sets a Content-Disposition with the spaced filename
// "HL Patents Style.dotm" for .dotm requests under /patentstyle/. The URL
// path stays clean (dashes), browsers and download tools land the file
// with the name PAs expect to see.
func patentstyleDownload(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, ".dotm") {
w.Header().Set("Content-Disposition", `attachment; filename="HL Patents Style.dotm"`)
}
h.ServeHTTP(w, r)
})
}
// noCachePages wraps a handler so its response always revalidates. Combined
// with the build-time `?v=<buildVersion>` stamp on /assets/*.js and /css URLs
// in dist/*.html, this is what makes a deploy actually reach users: the HTML
@@ -43,6 +57,7 @@ type Services struct {
Deadline *services.DeadlineService
Appointment *services.AppointmentService
CalDAV *services.CalDAVService
CalDAVBindings *services.CalendarBindingService
Rules *services.DeadlineRuleService
Calculator *services.DeadlineCalculator
Users *services.UserService
@@ -69,8 +84,19 @@ type Services struct {
UserView *services.UserViewService
Broadcast *services.BroadcastService
Pin *services.PinService
CardLayout *services.CardLayoutService
Projection *services.ProjectionService
CardLayout *services.CardLayoutService
DashboardLayout *services.DashboardLayoutService
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
@@ -88,6 +114,14 @@ 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,
@@ -97,6 +131,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
deadline: svc.Deadline,
appointment: svc.Appointment,
caldav: svc.CalDAV,
caldavBindings: svc.CalDAVBindings,
rules: svc.Rules,
calc: svc.Calculator,
users: svc.Users,
@@ -123,11 +158,24 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
userView: svc.UserView,
broadcast: svc.Broadcast,
pin: svc.Pin,
cardLayout: svc.CardLayout,
projection: svc.Projection,
cardLayout: svc.CardLayout,
dashboardLayout: svc.DashboardLayout,
projection: svc.Projection,
export: svc.Export,
}
}
// Liveness probe. Public, no auth, no DB touch — just confirms the
// process bound the listener and the goroutine is alive. Used by the
// boot-smoke test (cmd/server/main_smoke_test.go) to assert the server
// reaches a serving state after migrations apply; also safe for any
// future container orchestrator or uptime check.
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
_, _ = w.Write([]byte("ok\n"))
})
// API endpoints (JSON, public)
mux.HandleFunc("POST /api/login", handleAPILogin)
mux.HandleFunc("POST /api/register", handleAPIRegister)
@@ -159,6 +207,16 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
mux.Handle("GET /icons/", noCacheAssets(http.StripPrefix("/icons/", http.FileServer(http.Dir("dist/icons")))))
mux.HandleFunc("GET /sw.js", servePWAServiceWorker)
// HL Patents Style auto-update endpoint. version.json is the manifest
// the installed Word client polls; HL-Patents-Style.dotm is fetched on
// version mismatch. Source files live in frontend/public/patentstyle/
// (copied into dist/ at build time). noCacheAssets ensures the manifest
// is never stale after a release. patentstyleDownload renames the .dotm
// to "HL Patents Style.dotm" (with spaces) on download — the on-disk
// filename has dashes so the URL is clean, but Word users expect the
// spaced name in their downloads folder.
mux.Handle("GET /patentstyle/", noCacheAssets(patentstyleDownload(http.StripPrefix("/patentstyle/", http.FileServer(http.Dir("dist/patentstyle"))))))
// Protected routes
protected := http.NewServeMux()
protected.HandleFunc("GET /tools/kostenrechner", handleKostenrechnerPage)
@@ -230,9 +288,18 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
protected.HandleFunc("GET /api/projects/{id}/timeline", handleGetProjectTimeline)
// t-paliad-177 Slice 2 — iCal feed (deadlines + appointments only).
protected.HandleFunc("GET /api/projects/{id}/timeline.ics", handleGetProjectTimelineICS)
// t-paliad-214 Slice 2 — project-subtree data export. ?direct_only=1
// narrows to the root project only; default = root + descendants.
// Permission gate: responsibility ∈ {lead, member} OR global_admin.
protected.HandleFunc("GET /api/projects/{id}/export", handleProjectExport)
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.
protected.HandleFunc("GET /api/projects/{id}/submissions", handleListProjectSubmissions)
protected.HandleFunc("GET /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)
@@ -247,6 +314,11 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
protected.HandleFunc("PATCH /api/user-card-layouts/{id}", handleUpdateCardLayout)
protected.HandleFunc("DELETE /api/user-card-layouts/{id}", handleDeleteCardLayout)
protected.HandleFunc("POST /api/user-card-layouts/{id}/set-default", handleSetDefaultCardLayout)
// t-paliad-219 — per-user configurable dashboard layout.
protected.HandleFunc("GET /api/me/dashboard-layout", handleGetDashboardLayout)
protected.HandleFunc("PUT /api/me/dashboard-layout", handlePutDashboardLayout)
protected.HandleFunc("POST /api/me/dashboard-layout/reset", handleResetDashboardLayout)
protected.HandleFunc("GET /api/dashboard-widget-catalog", handleGetWidgetCatalog)
protected.HandleFunc("GET /api/projects/{id}/ancestors", handleListProjectAncestors)
protected.HandleFunc("GET /api/projects/{id}/parties", handleListParties)
protected.HandleFunc("POST /api/projects/{id}/parties", handleCreateParty)
@@ -291,6 +363,15 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
protected.HandleFunc("DELETE /api/caldav-config", handleDeleteCalDAVConfig)
protected.HandleFunc("POST /api/caldav-config/test", handleTestCalDAVConfig)
protected.HandleFunc("GET /api/caldav-config/log", handleCalDAVSyncLog)
// t-paliad-212 Slice 2a/2b — multi-calendar binding CRUD.
protected.HandleFunc("GET /api/caldav-bindings", handleListCalDAVBindings)
protected.HandleFunc("POST /api/caldav-bindings", handleCreateCalDAVBinding)
protected.HandleFunc("PATCH /api/caldav-bindings/{id}", handlePatchCalDAVBinding)
protected.HandleFunc("DELETE /api/caldav-bindings/{id}", handleDeleteCalDAVBinding)
// /api/caldav-discover — calendar-home-set walk (RFC 6764) for picker.
protected.HandleFunc("GET /api/caldav-discover", handleCalDAVDiscover)
// Slice 2c — MKCALENDAR ("Create new calendar" affordance in picker).
protected.HandleFunc("POST /api/caldav-mkcalendar", handleCalDAVMakeCalendar)
// t-paliad-088 — Event Types (categorization for Deadlines).
protected.HandleFunc("GET /api/event-types", handleListEventTypes)
@@ -333,6 +414,10 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
protected.HandleFunc("GET /api/me", handleGetMe)
protected.HandleFunc("PATCH /api/me", handleUpdateMe)
// t-paliad-214 Slice 1 — personal-scope data export. Bundles xlsx +
// JSON + per-sheet CSVs in one deterministic .zip; streams the result
// inline. Audit row written to paliad.system_audit_log.
protected.HandleFunc("GET /api/me/export", handleMeExport)
protected.HandleFunc("GET /api/users", handleListUsers)
protected.HandleFunc("GET /api/offices", handleListOffices)
protected.HandleFunc("GET /api/dashboard", handleDashboardAPI)
@@ -502,6 +587,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
protected.HandleFunc("POST /api/approval-requests/{id}/approve", handleApproveApprovalRequest)
protected.HandleFunc("POST /api/approval-requests/{id}/reject", handleRejectApprovalRequest)
protected.HandleFunc("POST /api/approval-requests/{id}/revoke", handleRevokeApprovalRequest)
protected.HandleFunc("POST /api/approval-requests/{id}/suggest-changes", handleSuggestChangesApprovalRequest)
// t-paliad-154 — form-time effective policy lookup. Reachable by
// every authenticated user (NOT admin-gated) so deadline +

View File

@@ -24,6 +24,7 @@ type dbServices struct {
deadline *services.DeadlineService
appointment *services.AppointmentService
caldav *services.CalDAVService
caldavBindings *services.CalendarBindingService
rules *services.DeadlineRuleService
calc *services.DeadlineCalculator
users *services.UserService
@@ -51,7 +52,9 @@ type dbServices struct {
broadcast *services.BroadcastService
pin *services.PinService
cardLayout *services.CardLayoutService
dashboardLayout *services.DashboardLayoutService
projection *services.ProjectionService
export *services.ExportService
}
var dbSvc *dbServices
@@ -169,6 +172,18 @@ func mapApprovalError(w http.ResponseWriter, err error) bool {
"message": "Die Anfrage ist nicht mehr offen.",
})
return true
case errors.Is(err, services.ErrSuggestionRequiresChange):
writeJSON(w, http.StatusBadRequest, map[string]string{
"code": "suggestion_requires_change",
"message": "Ein Vorschlag braucht entweder geänderte Werte oder einen Kommentar.",
})
return true
case errors.Is(err, services.ErrSuggestionLifecycleInvalid):
writeJSON(w, http.StatusBadRequest, map[string]string{
"code": "suggestion_lifecycle_invalid",
"message": "Änderungen vorschlagen ist nur für Update- und Complete-Anfragen möglich.",
})
return true
}
return false
}

View File

@@ -0,0 +1,387 @@
package handlers
// Submission generator HTTP layer (t-paliad-215 Slice 1).
//
// 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.
//
// 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).
//
// 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).
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"strconv"
"strings"
"time"
"github.com/google/uuid"
"mgit.msbls.de/m/paliad/internal/branding"
"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.
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.
type submissionListEntry struct {
SubmissionCode string `json:"submission_code"`
Name string `json:"name"`
NameEN string `json:"name_en"`
EventType string `json:"event_type,omitempty"`
PrimaryParty string `json:"primary_party,omitempty"`
LegalSource string `json:"legal_source,omitempty"`
HasTemplate bool `json:"has_template"`
}
// submissionListResponse wraps the list with a project-level header.
type submissionListResponse struct {
ProjectID uuid.UUID `json:"project_id"`
ProceedingTypeID *int `json:"proceeding_type_id,omitempty"`
Entries []submissionListEntry `json:"entries"`
}
// handleListProjectSubmissions returns the filing-type rules for the
// project's proceeding, annotated with template availability.
func handleListProjectSubmissions(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
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"})
return
}
ctx := r.Context()
project, err := dbSvc.projects.GetByID(ctx, uid, projectID)
if err != nil {
writeServiceError(w, err)
return
}
resp := submissionListResponse{
ProjectID: projectID,
ProceedingTypeID: project.ProceedingTypeID,
Entries: []submissionListEntry{},
}
if project.ProceedingTypeID == nil {
writeJSON(w, http.StatusOK, resp)
return
}
rules, err := dbSvc.rules.List(ctx, project.ProceedingTypeID)
if err != nil {
log.Printf("submissions: list rules for proceeding %d: %v", *project.ProceedingTypeID, err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "rule lookup failed"})
return
}
for _, rule := range rules {
if rule.SubmissionCode == nil || *rule.SubmissionCode == "" {
continue
}
if rule.EventType == nil || *rule.EventType != "filing" {
// Hearings + decisions don't generate submissions. The
// "Schriftsätze" panel only lists filings.
continue
}
if rule.LifecycleState != "published" {
continue
}
entry := submissionListEntry{
SubmissionCode: *rule.SubmissionCode,
Name: rule.Name,
NameEN: rule.NameEN,
HasTemplate: submissionRegistry.HasTemplate(ctx, *rule.SubmissionCode),
}
if rule.EventType != nil {
entry.EventType = *rule.EventType
}
if rule.PrimaryParty != nil {
entry.PrimaryParty = *rule.PrimaryParty
}
if rule.LegalSource != nil {
entry.LegalSource = *rule.LegalSource
}
resp.Entries = append(resp.Entries, entry)
}
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.
func handleGenerateProjectSubmission(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
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"})
return
}
submissionCode := strings.TrimSpace(r.PathValue("code"))
if submissionCode == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "submission code required"})
return
}
ctx, cancel := context.WithTimeout(r.Context(), submissionRenderTimeout)
defer cancel()
varsResult, err := submissionVars.Build(ctx, services.SubmissionVarsContext{
UserID: uid,
ProjectID: projectID,
SubmissionCode: submissionCode,
})
if err != nil {
if errors.Is(err, services.ErrSubmissionRuleNotFound) {
writeJSON(w, http.StatusNotFound, map[string]string{
"error": fmt.Sprintf("no published rule for submission_code %q", submissionCode),
})
return
}
writeServiceError(w, err)
return
}
tmpl, err := submissionRegistry.Resolve(ctx, submissionCode)
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",
})
return
}
missing := services.DefaultMissingMarker(varsResult.Lang)
rendered, err := submissionRenderer.Render(tmpl.Bytes, varsResult.Placeholders, missing)
if err != nil {
log.Printf("submissions: render %s for project %s: %v", submissionCode, projectID, err)
writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": "render failed",
})
return
}
filename := submissionFileName(varsResult, projectID)
// 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.
bgCtx, cancelBG := context.WithTimeout(context.Background(), 10*time.Second)
defer cancelBG()
if err := writeSubmissionAuditRow(bgCtx, varsResult, tmpl, submissionCode); 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 {
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
}
return true
}
// 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 {
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)
}
if ruleName == "" {
ruleName = "submission"
}
caseNo := ""
if vars.Project != nil && vars.Project.CaseNumber != nil {
caseNo = strings.TrimSpace(*vars.Project.CaseNumber)
}
if caseNo == "" {
caseNo = projectID.String()[:8]
}
caseNo = strings.ReplaceAll(caseNo, "/", "_")
caseNo = strings.ReplaceAll(caseNo, `\`, "_")
return fmt.Sprintf("%s-%s-%s.docx", ruleName, caseNo, day.Format("2006-01-02"))
}
// 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 {
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,
}
body, _ := json.Marshal(meta)
_, 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,
)
return err
}

View File

@@ -425,28 +425,75 @@ type ChecklistInstanceWithProject struct {
// 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 {
UserID uuid.UUID `db:"user_id" json:"user_id"`
URL string `db:"url" json:"url"`
Username string `db:"username" json:"username"`
PasswordEncrypted []byte `db:"password_encrypted" json:"-"`
CalendarPath string `db:"calendar_path" json:"calendar_path"`
Enabled bool `db:"enabled" json:"enabled"`
LastSyncAt *time.Time `db:"last_sync_at" json:"last_sync_at,omitempty"`
LastSyncError *string `db:"last_sync_error" json:"last_sync_error,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
UserID uuid.UUID `db:"user_id" json:"user_id"`
URL string `db:"url" json:"url"`
Username string `db:"username" json:"username"`
PasswordEncrypted []byte `db:"password_encrypted" json:"-"`
CalendarPath string `db:"calendar_path" json:"calendar_path"`
Enabled bool `db:"enabled" json:"enabled"`
LastSyncAt *time.Time `db:"last_sync_at" json:"last_sync_at,omitempty"`
LastSyncError *string `db:"last_sync_error" json:"last_sync_error,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
// MKCALENDAR-capability tri-state (mig 108, Slice 2c). NULL = unprobed.
SupportsMKCalendar *bool `db:"supports_mkcalendar" json:"supports_mkcalendar,omitempty"`
MKCalendarProbedAt *time.Time `db:"mkcalendar_probed_at" json:"mkcalendar_probed_at,omitempty"`
}
// CalDAVSyncLogEntry is one historical sync record.
// CalDAVSyncLogEntry is one historical sync record. BindingID is populated
// for per-binding sync entries written by the post-Slice-2a sync engine;
// older rows have it NULL and the entry covers the user's default binding.
type CalDAVSyncLogEntry struct {
ID uuid.UUID `db:"id" json:"id"`
UserID uuid.UUID `db:"user_id" json:"user_id"`
OccurredAt time.Time `db:"occurred_at" json:"occurred_at"`
Direction string `db:"direction" json:"direction"`
ItemsPushed int `db:"items_pushed" json:"items_pushed"`
ItemsPulled int `db:"items_pulled" json:"items_pulled"`
Error *string `db:"error" json:"error,omitempty"`
DurationMS *int `db:"duration_ms" json:"duration_ms,omitempty"`
ID uuid.UUID `db:"id" json:"id"`
UserID uuid.UUID `db:"user_id" json:"user_id"`
OccurredAt time.Time `db:"occurred_at" json:"occurred_at"`
Direction string `db:"direction" json:"direction"`
ItemsPushed int `db:"items_pushed" json:"items_pushed"`
ItemsPulled int `db:"items_pulled" json:"items_pulled"`
Error *string `db:"error" json:"error,omitempty"`
DurationMS *int `db:"duration_ms" json:"duration_ms,omitempty"`
BindingID *uuid.UUID `db:"binding_id" json:"binding_id,omitempty"`
}
// UserCalendarBinding is one of N (calendar, scope) bindings a user can
// configure on top of their single CalDAV server connection. The same
// Appointment can land in multiple bindings (e.g. master + per-project),
// with per-binding push state living in AppointmentCalDAVTarget.
type UserCalendarBinding struct {
ID uuid.UUID `db:"id" json:"id"`
UserID uuid.UUID `db:"user_id" json:"user_id"`
CalendarPath string `db:"calendar_path" json:"calendar_path"`
DisplayName string `db:"display_name" json:"display_name"`
ScopeKind string `db:"scope_kind" json:"scope_kind"`
ScopeID *uuid.UUID `db:"scope_id" json:"scope_id,omitempty"`
IncludePersonal bool `db:"include_personal" json:"include_personal"`
Enabled bool `db:"enabled" json:"enabled"`
LastSyncAt *time.Time `db:"last_sync_at" json:"last_sync_at,omitempty"`
LastSyncError *string `db:"last_sync_error" json:"last_sync_error,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
// Scope-kind enum mirrored from paliad.user_calendar_bindings_scope_kind_chk.
const (
BindingScopeAllVisible = "all_visible"
BindingScopePersonalOnly = "personal_only"
BindingScopeProject = "project"
BindingScopeClient = "client"
BindingScopeLitigation = "litigation"
BindingScopePatent = "patent"
BindingScopeCase = "case"
)
// AppointmentCalDAVTarget is the per-(appointment, binding) push state.
// The caldav_uid is canonical per Appointment (same value across all of
// an appointment's targets); caldav_etag varies per binding.
type AppointmentCalDAVTarget struct {
AppointmentID uuid.UUID `db:"appointment_id" json:"appointment_id"`
BindingID uuid.UUID `db:"binding_id" json:"binding_id"`
CalDAVUID string `db:"caldav_uid" json:"caldav_uid"`
CalDAVEtag *string `db:"caldav_etag" json:"caldav_etag,omitempty"`
LastPushedAt time.Time `db:"last_pushed_at" json:"last_pushed_at"`
}
// Party is a party to a Project (Kläger, Beklagter, etc. — typically on
@@ -467,7 +514,7 @@ type DeadlineRule struct {
ID uuid.UUID `db:"id" json:"id"`
ProceedingTypeID *int `db:"proceeding_type_id" json:"proceeding_type_id,omitempty"`
ParentID *uuid.UUID `db:"parent_id" json:"parent_id,omitempty"`
Code *string `db:"code" json:"code,omitempty"`
SubmissionCode *string `db:"submission_code" json:"submission_code,omitempty"`
Name string `db:"name" json:"name"`
NameEN string `db:"name_en" json:"name_en"`
Description *string `db:"description" json:"description,omitempty"`
@@ -805,6 +852,15 @@ type ApprovalRequest struct {
// alongside 👀 with a sparkle ✨ on the eye-pill surface.
RequesterKind string `db:"requester_kind" json:"requester_kind"`
AgentTurnID *uuid.UUID `db:"agent_turn_id" json:"agent_turn_id,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
// CounterPayload carries the approver's edited values on a
// changes_requested row (mig 103, t-paliad-216). NULL for every
// other status. Frontend renders it as a diff against the OLD
// payload to show "approver suggested X→Y on the following fields".
CounterPayload NullableJSON `db:"counter_payload" json:"counter_payload,omitempty"`
// PreviousRequestID is the back-pointer from a row spawned by
// SuggestChanges to the prior changes_requested row that birthed it
// (mig 103, t-paliad-216). NULL on first-attempt rows.
PreviousRequestID *uuid.UUID `db:"previous_request_id" json:"previous_request_id,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}

View File

@@ -1,7 +1,8 @@
// Package offices is the single source of truth for the firm's office list.
//
// The keys here must stay in sync with the CHECK constraint on
// paliad.users.office and paliad.akten.owning_office (migration 001).
// The keys here must stay in sync with the CHECK constraints on
// paliad.users.office (mig 002) and paliad.partner_units.office
// (mig 018, renamed mig 024 + mig 027). Madrid added mig 106.
package offices
// Office is a single firm office with its i18n-ready labels.
@@ -20,6 +21,7 @@ var All = []Office{
{Key: "london", LabelDE: "London", LabelEN: "London"},
{Key: "paris", LabelDE: "Paris", LabelEN: "Paris"},
{Key: "milan", LabelDE: "Mailand", LabelEN: "Milan"},
{Key: "madrid", LabelDE: "Madrid", LabelEN: "Madrid"},
}
// IsValid reports whether the given key names a known office.

View File

@@ -3,7 +3,7 @@ package offices
import "testing"
func TestIsValid(t *testing.T) {
for _, key := range []string{"munich", "duesseldorf", "hamburg", "amsterdam", "london", "paris", "milan"} {
for _, key := range []string{"munich", "duesseldorf", "hamburg", "amsterdam", "london", "paris", "milan", "madrid"} {
if !IsValid(key) {
t.Errorf("IsValid(%q) = false, want true", key)
}

View File

@@ -753,6 +753,86 @@ func (s *AppointmentService) AllForUser(ctx context.Context, userID uuid.UUID) (
return rows, nil
}
// ErrUnsupportedScope is returned by ForBinding when the binding's
// scope_kind is one of the hierarchy scopes (client / litigation /
// patent / case) — those land in Slice 3 of t-paliad-212. Slice 2
// only supports all_visible / personal_only / project.
var ErrUnsupportedScope = errors.New("binding scope_kind not yet supported")
// ForBinding returns the slice of the user's appointments that belongs
// in this binding's calendar. Implements the §2.3 scope filter from
// docs/design-caldav-slice-2-2026-05-20.md.
//
// - all_visible → AllForUser(userID)
// - personal_only → personal (project_id IS NULL) appointments
// created by this user
// - project → appointments attached to scope_id, gated by the
// same visibility predicate as AllForUser. Hidden
// projects return an empty slice (the binding stays
// in place but receives no events). If
// include_personal is true, the user's personal
// appointments are unioned in.
//
// Hierarchy scopes (client / litigation / patent / case) return
// ErrUnsupportedScope; Slice 3 wires them via the existing path-based
// descendant predicate.
func (s *AppointmentService) ForBinding(ctx context.Context, userID uuid.UUID, b *models.UserCalendarBinding) ([]models.Appointment, error) {
if b == nil {
return nil, fmt.Errorf("%w: nil binding", ErrInvalidInput)
}
switch b.ScopeKind {
case models.BindingScopeAllVisible:
return s.AllForUser(ctx, userID)
case models.BindingScopePersonalOnly:
rows := []models.Appointment{}
if err := s.db.SelectContext(ctx, &rows,
`SELECT `+appointmentColumns+`
FROM paliad.appointments t
WHERE t.project_id IS NULL
AND t.created_by = $1`, userID); err != nil {
return nil, fmt.Errorf("for-binding personal_only: %w", err)
}
return rows, nil
case models.BindingScopeProject:
if b.ScopeID == nil {
return nil, fmt.Errorf("%w: project binding missing scope_id", ErrInvalidInput)
}
var query string
if b.IncludePersonal {
query = `
SELECT ` + appointmentColumns + `
FROM paliad.appointments t
LEFT JOIN paliad.projects p ON p.id = t.project_id
WHERE (
t.project_id = $2
AND ` + visibilityPredicatePositional("p", 1) + `
) OR (
t.project_id IS NULL AND t.created_by = $1
)`
} else {
query = `
SELECT ` + appointmentColumns + `
FROM paliad.appointments t
JOIN paliad.projects p ON p.id = t.project_id
WHERE t.project_id = $2
AND ` + visibilityPredicatePositional("p", 1)
}
rows := []models.Appointment{}
if err := s.db.SelectContext(ctx, &rows, query, userID, *b.ScopeID); err != nil {
return nil, fmt.Errorf("for-binding project: %w", err)
}
return rows, nil
case models.BindingScopeClient, models.BindingScopeLitigation, models.BindingScopePatent, models.BindingScopeCase:
return nil, ErrUnsupportedScope
default:
return nil, fmt.Errorf("%w: unknown scope_kind %q", ErrInvalidInput, b.ScopeKind)
}
}
// FindByCalDAVUID resolves a Appointment from its external UID.
func (s *AppointmentService) FindByCalDAVUID(ctx context.Context, uid string) (*models.Appointment, error) {
var t models.Appointment

View File

@@ -61,11 +61,12 @@ const (
// RequestStatus values on paliad.approval_requests.status.
const (
RequestStatusPending = "pending"
RequestStatusApproved = "approved"
RequestStatusRejected = "rejected"
RequestStatusRevoked = "revoked"
RequestStatusSuperseded = "superseded"
RequestStatusPending = "pending"
RequestStatusApproved = "approved"
RequestStatusRejected = "rejected"
RequestStatusRevoked = "revoked"
RequestStatusSuperseded = "superseded"
RequestStatusChangesRequested = "changes_requested"
)
// DecisionKind discriminates 'peer' (normal in-team sign-off) from
@@ -158,12 +159,14 @@ func IsValidResponsibility(r string) bool {
// ErrRequestNotPending -> 409
// ErrUnknownEntityType -> 500 (programming error)
var (
ErrSelfApproval = errors.New("self-approval blocked")
ErrNoQualifiedApprover = errors.New("no qualified approver available")
ErrConcurrentPending = errors.New("entity already has a pending approval request")
ErrNotApprover = errors.New("not authorized to approve this request")
ErrRequestNotPending = errors.New("request is not pending")
ErrUnknownEntityType = errors.New("unknown entity type")
ErrSelfApproval = errors.New("self-approval blocked")
ErrNoQualifiedApprover = errors.New("no qualified approver available")
ErrConcurrentPending = errors.New("entity already has a pending approval request")
ErrNotApprover = errors.New("not authorized to approve this request")
ErrRequestNotPending = errors.New("request is not pending")
ErrUnknownEntityType = errors.New("unknown entity type")
ErrSuggestionRequiresChange = errors.New("suggestion requires a counter_payload diff or a note")
ErrSuggestionLifecycleInvalid = errors.New("suggest-changes is only valid for update / complete lifecycles")
)
// PendingApprovalError wraps ErrConcurrentPending with the in-flight

View File

@@ -35,6 +35,7 @@ package services
// pool, so the deadlock path can't be silently bypassed.
import (
"bytes"
"context"
"database/sql"
"encoding/json"
@@ -363,6 +364,321 @@ func (s *ApprovalService) Revoke(ctx context.Context, requestID, callerID uuid.U
return s.decide(ctx, requestID, callerID, RequestStatusRevoked, "")
}
// SuggestChanges is the fourth approval action (t-paliad-216). The caller
// proposes a counter-payload + optional free-text note; in one transaction
// we close the old request as 'changes_requested', revert the entity from
// pre_image, then immediately spawn a NEW 'pending' approval_request
// authored by the caller carrying counter_payload as the new payload. The
// new row enters the normal pending flow — anyone eligible (including the
// original requester) can approve, reject, or suggest changes back on it.
// 4-Augen still holds: the suggesting caller is now the new row's
// requested_by, so self-approval is blocked by the standard 3-layer guard.
//
// Authorization is the same as Approve/Reject on the OLD row (canApprove).
// The new row's deadlock check (qualified-approver-exists-other-than-
// caller) runs before the new INSERT so we never spawn an unapprovable
// request.
//
// counterPayload must differ from the old row's payload OR a non-empty
// note must be present — a no-op suggestion (same values, no note) is
// indistinguishable from "I have no opinion" and is rejected with
// ErrSuggestionRequiresChange. counterPayload field shape is the same
// allowlist used by Submit*/applyRevert (the date-bearing columns per
// entity_type); unknown keys are silently dropped at apply time.
//
// SuggestChanges is only valid for lifecycle in (update, complete). For
// create the original entity would be deleted by applyRevert, leaving no
// row to apply a counter to. For delete the original is "remove this
// entity" — a counter-proposal would be a different lifecycle entirely.
// Both return ErrSuggestionLifecycleInvalid; the caller (handler) maps
// it to 400.
//
// Returns the new request ID on success.
func (s *ApprovalService) SuggestChanges(ctx context.Context, requestID, callerID uuid.UUID, counterPayload map[string]any, note string) (*uuid.UUID, error) {
trimmedNote := strings.TrimSpace(note)
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback() //nolint:errcheck
old, err := s.getRequestForUpdate(ctx, tx, requestID)
if err != nil {
return nil, err
}
if old.Status != RequestStatusPending {
return nil, fmt.Errorf("%w: status=%s", ErrRequestNotPending, old.Status)
}
if old.LifecycleEvent != LifecycleUpdate && old.LifecycleEvent != LifecycleComplete {
return nil, fmt.Errorf("%w: lifecycle=%s", ErrSuggestionLifecycleInvalid, old.LifecycleEvent)
}
// No-op guard: counter must differ from old.payload OR note must be present.
payloadDiffers, err := payloadsDiffer(old.Payload, counterPayload)
if err != nil {
return nil, err
}
if !payloadDiffers && trimmedNote == "" {
return nil, ErrSuggestionRequiresChange
}
// Authorization on the OLD row: caller must satisfy canApprove (same
// gate as Approve/Reject). Self-approval blocks here too.
decisionKind, err := s.canApprove(ctx, tx, callerID, old)
if err != nil {
return nil, err
}
now := time.Now().UTC()
counterJSON, err := marshalJSONOrNull(counterPayload)
if err != nil {
return nil, fmt.Errorf("marshal counter_payload: %w", err)
}
// Validate counter has at least one counter-allowlisted field for the
// entity type — otherwise the entity-update below would be a no-op
// and the new row would just resubmit the SAME values, which is a
// degenerate case we should reject cleanly. Only run this check when
// the payload "differs" (i.e. caller actually provided something).
// Note: validates against the WIDER counter-allowlist (t-paliad-217
// Slice B), not the date-only revert-allowlist.
if payloadDiffers {
if _, _, err := buildCounterSetClauses(old.EntityType, counterPayload); err != nil {
// buildCounterSetClauses already wraps ErrSuggestionRequiresChange
// for the "no allowlisted fields" + empty-title cases. Propagate.
return nil, err
}
}
// 1. Close the OLD row as changes_requested.
var noteArg any
if trimmedNote != "" {
noteArg = trimmedNote
}
updateOldSQL := `UPDATE paliad.approval_requests
SET status = $1, decided_by = $2, decided_at = $3, decision_kind = $4,
decision_note = $5, counter_payload = $6, updated_at = $3
WHERE id = $7`
if _, err := tx.ExecContext(ctx, updateOldSQL,
RequestStatusChangesRequested, callerID, now, decisionKind,
noteArg, counterJSON, requestID); err != nil {
return nil, fmt.Errorf("close old request: %w", err)
}
// 2. Revert the entity from old.pre_image (same as Reject).
if err := s.applyRevert(ctx, tx, old); err != nil {
return nil, err
}
// 3. Deadlock check on the NEW row: someone other than the caller
// must be qualified to approve. Original requester is no longer
// excluded (they're a regular team member now from the new row's
// POV), so they count if their role is sufficient.
ok, err := s.hasQualifiedApprover(ctx, tx, old.ProjectID, callerID, old.RequiredRole)
if err != nil {
return nil, err
}
if !ok {
return nil, fmt.Errorf("%w: required role %q", ErrNoQualifiedApprover, old.RequiredRole)
}
// 4. Re-apply the counter_payload to the entity row (write-then-approve).
// Reuses buildRevertSetClauses (date-allowlist translation). Always
// runs because we validated payloadDiffers + a valid set of keys
// above; even when only a note was provided (payloadDiffers=false),
// the original payload is re-applied for symmetry with Submit*.
applyPayload := counterPayload
if !payloadDiffers {
// Counter is identical to original — resubmit the same values as
// the new row's payload so the standard Submit* shape holds.
if err := json.Unmarshal(old.Payload, &applyPayload); err != nil {
return nil, fmt.Errorf("unmarshal original payload: %w", err)
}
}
if err := s.applyEntityUpdate(ctx, tx, old.EntityType, old.EntityID, applyPayload); err != nil {
return nil, err
}
// 5. INSERT the NEW pending row, authored by the caller, with
// previous_request_id pointing back at the old row.
newID := uuid.New()
applyPayloadJSON, err := marshalJSONOrNull(applyPayload)
if err != nil {
return nil, fmt.Errorf("marshal new payload: %w", err)
}
insertNewSQL := `INSERT INTO paliad.approval_requests
(id, project_id, entity_type, entity_id, lifecycle_event,
pre_image, payload, requested_by, required_role, status,
requester_kind, agent_turn_id, previous_request_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'pending', 'user', NULL, $10)`
if _, err := tx.ExecContext(ctx, insertNewSQL,
newID, old.ProjectID, old.EntityType, old.EntityID, old.LifecycleEvent,
[]byte(old.PreImage), applyPayloadJSON, callerID, old.RequiredRole,
requestID); err != nil {
return nil, fmt.Errorf("insert new approval_request: %w", err)
}
// 6. Mark the entity pending pointing at the new row.
updateEntitySQL := fmt.Sprintf(`UPDATE paliad.%s
SET approval_status = 'pending', pending_request_id = $1, updated_at = now()
WHERE id = $2 AND approval_status IN ('approved','legacy')`,
entityTableName(old.EntityType))
res, err := tx.ExecContext(ctx, updateEntitySQL, newID, old.EntityID)
if err != nil {
return nil, fmt.Errorf("mark entity pending: %w", err)
}
rows, _ := res.RowsAffected()
if rows != 1 {
return nil, ErrConcurrentPending
}
// 7. Emit *_approval_changes_suggested for the OLD row's transition.
suggestedEvent := approvalEventType(old.EntityType, "changes_suggested")
suggestedDesc := approvalDescription("changes_suggested", old.RequiredRole, old.LifecycleEvent)
suggestedMeta := map[string]any{
"approval_request_id": requestID.String(),
"new_request_id": newID.String(),
"lifecycle_event": old.LifecycleEvent,
"decision_kind": decisionKind,
old.EntityType + "_id": old.EntityID.String(),
}
if trimmedNote != "" {
suggestedMeta["decision_note"] = trimmedNote
}
if counterJSON != nil {
suggestedMeta["counter_payload"] = json.RawMessage(counterJSON)
}
if err := insertProjectEventWithMeta(ctx, tx, old.ProjectID, callerID, suggestedEvent, suggestedEvent, suggestedDesc, suggestedMeta); err != nil {
return nil, err
}
// 8. Emit *_approval_requested for the NEW row (same shape as Submit*).
requestedEvent := approvalEventType(old.EntityType, "requested")
requestedDesc := approvalDescription("requested", old.RequiredRole, old.LifecycleEvent)
requestedMeta := map[string]any{
"approval_request_id": newID.String(),
"previous_request_id": requestID.String(),
"lifecycle_event": old.LifecycleEvent,
"required_role": old.RequiredRole,
"requester_kind": "user",
old.EntityType + "_id": old.EntityID.String(),
}
if err := insertProjectEventWithMeta(ctx, tx, old.ProjectID, callerID, requestedEvent, requestedEvent, requestedDesc, requestedMeta); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit: %w", err)
}
return &newID, nil
}
// applyEntityUpdate writes the counter_payload fields onto the entity
// row (t-paliad-217 Slice B). Uses the WIDER counter-allowlist
// (buildCounterSetClauses) — every editable field on the entity, not
// just the date-allowlist that triggers approval. Handles
// event_type_ids as a junction-table rewrite when present in payload.
func (s *ApprovalService) applyEntityUpdate(ctx context.Context, tx *sqlx.Tx, entityType string, entityID uuid.UUID, payload map[string]any) error {
if len(payload) == 0 {
return fmt.Errorf("%w: empty payload", ErrSuggestionRequiresChange)
}
// 1. Column-level updates via the counter-allowlist.
setClauses, args, err := buildCounterSetClauses(entityType, payload)
if err != nil {
return err
}
if len(setClauses) > 0 {
setClauses = append(setClauses, "updated_at = now()")
args = append(args, entityID)
q := fmt.Sprintf(`UPDATE paliad.%s SET %s WHERE id = $%d`,
entityTableName(entityType), strings.Join(setClauses, ", "), len(args))
if _, err := tx.ExecContext(ctx, q, args...); err != nil {
return fmt.Errorf("apply counter payload to entity: %w", err)
}
}
// 2. event_type_ids junction rewrite (deadline only).
if entityType == EntityTypeDeadline {
if raw, ok := payload["event_type_ids"]; ok {
ids, err := parseUUIDList(raw)
if err != nil {
return fmt.Errorf("%w: invalid event_type_ids: %v", ErrSuggestionRequiresChange, err)
}
if err := rewriteDeadlineEventTypes(ctx, tx, entityID, ids); err != nil {
return err
}
}
}
return nil
}
// parseUUIDList accepts either []any (from json.Unmarshal of a JSON
// array) or []string and returns a []uuid.UUID. Empty list = explicit
// clear; nil-typed list also empty.
func parseUUIDList(raw any) ([]uuid.UUID, error) {
if raw == nil {
return nil, nil
}
arr, ok := raw.([]any)
if !ok {
// Fallback: caller serialized as []string directly.
if sarr, ok := raw.([]string); ok {
out := make([]uuid.UUID, 0, len(sarr))
for _, s := range sarr {
id, err := uuid.Parse(s)
if err != nil {
return nil, fmt.Errorf("not a UUID: %q", s)
}
out = append(out, id)
}
return out, nil
}
return nil, fmt.Errorf("expected array, got %T", raw)
}
out := make([]uuid.UUID, 0, len(arr))
for _, v := range arr {
s, ok := v.(string)
if !ok {
return nil, fmt.Errorf("expected string in array, got %T", v)
}
id, err := uuid.Parse(s)
if err != nil {
return nil, fmt.Errorf("not a UUID: %q", s)
}
out = append(out, id)
}
return out, nil
}
// payloadsDiffer returns true iff the candidate counter map decodes to a
// value that differs from the old row's payload jsonb. Used by
// SuggestChanges to detect "no-op suggestion". Both NULL or both empty
// map = identical → false. Comparison is by canonical re-marshal so
// jsonb-key-ordering doesn't poison the equality check.
func payloadsDiffer(old models.NullableJSON, candidate map[string]any) (bool, error) {
if len(candidate) == 0 && len(old) == 0 {
return false, nil
}
if len(candidate) == 0 || len(old) == 0 {
return true, nil
}
var oldMap map[string]any
if err := json.Unmarshal(old, &oldMap); err != nil {
return false, fmt.Errorf("unmarshal old payload: %w", err)
}
oldCanonical, err := json.Marshal(oldMap)
if err != nil {
return false, fmt.Errorf("re-marshal old payload: %w", err)
}
candCanonical, err := json.Marshal(candidate)
if err != nil {
return false, fmt.Errorf("marshal candidate payload: %w", err)
}
return !bytes.Equal(oldCanonical, candCanonical), nil
}
// decide is the shared kernel for Approve / Reject / Revoke. The decision
// kind is derived from the (caller, request) relationship and the requested
// final status:
@@ -631,11 +947,17 @@ func (s *ApprovalService) applyRevert(ctx context.Context, tx *sqlx.Tx, req *mod
}
// buildRevertSetClauses translates pre_image jsonb keys into SQL SET
// fragments. Only the date-bearing allowlist (Q4) is honoured; unknown
// keys are silently dropped to defend against malformed pre_image rows
// (defence-in-depth: callers should already be sending only allowlisted
// fields, but a hostile UPDATE on the request row shouldn't let arbitrary
// fields be reverted).
// fragments for the Reject / Revoke path. Only the date-bearing
// t-paliad-138 §Q4 allowlist is honoured; unknown keys are silently
// dropped to defend against malformed pre_image rows (defence-in-depth:
// callers should already be sending only allowlisted fields, but a
// hostile UPDATE on the request row shouldn't let arbitrary fields be
// reverted).
//
// This is intentionally NARROWER than buildCounterSetClauses (which
// handles the SuggestChanges counter-payload). Reject restores ONLY what
// was originally captured in pre_image; SuggestChanges can write any
// counter-allowlist field the approver chose to author.
func buildRevertSetClauses(entityType string, preImage map[string]any) ([]string, []any, error) {
var setClauses []string
var args []any
@@ -685,6 +1007,135 @@ func buildRevertSetClauses(entityType string, preImage map[string]any) ([]string
return setClauses, args, nil
}
// buildCounterSetClauses translates a SuggestChanges counter_payload jsonb
// into SQL SET fragments for the entity row (t-paliad-217 Slice B). This
// is the WIDER counter-allowlist — m's 2026-05-20 lock-in: every "real"
// editable field on the entity is in scope for a counter-proposal, not
// just the date-allowlist that triggers approval (t-paliad-138 §Q4).
//
// Unknown keys are silently dropped — defence-in-depth against a hostile
// counter_payload making it past the handler's body decode. Returns an
// error iff zero allowlisted fields are present (caller surfaces as
// ErrSuggestionRequiresChange when paired with an empty note).
//
// event_type_ids is NOT a column on paliad.deadlines — it's a junction
// table (paliad.deadline_event_types). applyEntityUpdate handles it
// separately; this function silently ignores the key.
func buildCounterSetClauses(entityType string, counter map[string]any) ([]string, []any, error) {
var setClauses []string
var args []any
add := func(col string, val any) {
args = append(args, val)
setClauses = append(setClauses, fmt.Sprintf("%s = $%d", col, len(args)))
}
// addText accepts string keys and stores either a non-NULL string or
// NULL when the caller explicitly cleared the value with an empty
// string. Used for the optional-text columns (description, notes,
// location, etc.).
addText := func(col string, raw any) {
if raw == nil {
args = append(args, nil)
} else {
s, _ := raw.(string)
if s == "" {
args = append(args, nil)
} else {
args = append(args, s)
}
}
setClauses = append(setClauses, fmt.Sprintf("%s = $%d", col, len(args)))
}
switch entityType {
case EntityTypeDeadline:
// Date allowlist (existing).
for _, col := range []string{"due_date", "original_due_date", "warning_date"} {
if v, ok := counter[col]; ok {
add(col, v)
}
}
// Required text (NOT NULL on the column — refuse empty).
if v, ok := counter["title"]; ok {
s, _ := v.(string)
if strings.TrimSpace(s) == "" {
return nil, nil, fmt.Errorf("%w: title cannot be empty", ErrSuggestionRequiresChange)
}
add("title", s)
}
// Nullable text (empty string clears).
for _, col := range []string{"description", "notes", "rule_code"} {
if v, ok := counter[col]; ok {
addText(col, v)
}
}
case EntityTypeAppointment:
// Datetime allowlist (existing).
for _, col := range []string{"start_at", "end_at"} {
if v, ok := counter[col]; ok {
add(col, v)
}
}
if v, ok := counter["title"]; ok {
s, _ := v.(string)
if strings.TrimSpace(s) == "" {
return nil, nil, fmt.Errorf("%w: title cannot be empty", ErrSuggestionRequiresChange)
}
add("title", s)
}
for _, col := range []string{"description", "location", "appointment_type"} {
if v, ok := counter[col]; ok {
addText(col, v)
}
}
default:
return nil, nil, fmt.Errorf("%w: %q", ErrUnknownEntityType, entityType)
}
// event_type_ids is handled outside this function (junction-table
// write). Its presence alone in the counter doesn't count as "zero
// fields" — applyEntityUpdate inspects len(setClauses)==0 against the
// combined picture, not this return value.
if len(setClauses) == 0 {
if _, ok := counter["event_type_ids"]; !ok {
return nil, nil, fmt.Errorf("%w: no allowlisted fields in counter for %s", ErrSuggestionRequiresChange, entityType)
}
}
return setClauses, args, nil
}
// rewriteDeadlineEventTypes replaces the deadline_event_types junction
// rows for a deadline with the provided list (t-paliad-217 Slice B).
// Empty list clears the junction (the deadline has no event-type tags).
// nil list = no-op (caller didn't include event_type_ids in the counter).
//
// We don't validate the event_type ids exist — the FK to paliad.event_types
// catches that with an ON DELETE CASCADE-safe failure. Caller wraps in tx.
func rewriteDeadlineEventTypes(ctx context.Context, tx *sqlx.Tx, deadlineID uuid.UUID, ids []uuid.UUID) error {
if _, err := tx.ExecContext(ctx,
`DELETE FROM paliad.deadline_event_types WHERE deadline_id = $1`, deadlineID); err != nil {
return fmt.Errorf("clear deadline_event_types: %w", err)
}
if len(ids) == 0 {
return nil
}
values := make([]string, 0, len(ids))
args := make([]any, 0, len(ids)+1)
args = append(args, deadlineID)
for i, id := range ids {
values = append(values, fmt.Sprintf("($1, $%d)", i+2))
args = append(args, id)
}
q := `INSERT INTO paliad.deadline_event_types (deadline_id, event_type_id) VALUES ` + strings.Join(values, ", ")
if _, err := tx.ExecContext(ctx, q, args...); err != nil {
return fmt.Errorf("insert deadline_event_types: %w", err)
}
return nil
}
// getRequestForUpdate locks an approval_requests row inside the tx for
// decision processing.
func (s *ApprovalService) getRequestForUpdate(ctx context.Context, tx *sqlx.Tx, requestID uuid.UUID) (*models.ApprovalRequest, error) {
@@ -692,6 +1143,8 @@ func (s *ApprovalService) getRequestForUpdate(ctx context.Context, tx *sqlx.Tx,
q := `SELECT id, project_id, entity_type, entity_id, lifecycle_event,
pre_image, payload, requested_by, requested_at, required_role,
status, decided_by, decided_at, decision_kind, decision_note,
requester_kind, agent_turn_id,
counter_payload, previous_request_id,
created_at, updated_at
FROM paliad.approval_requests
WHERE id = $1
@@ -816,14 +1269,20 @@ func marshalJSONOrNull(m map[string]any) ([]byte, error) {
// server would reject, replacing the previous click-then-alert UX.
type ApprovalRequestView struct {
models.ApprovalRequest
ProjectTitle string `db:"project_title" json:"project_title"`
EntityTitle *string `db:"entity_title" json:"entity_title,omitempty"`
RequesterName string `db:"requester_name" json:"requester_name"`
RequesterEmail string `db:"requester_email" json:"requester_email"`
DeciderName *string `db:"decider_name" json:"decider_name,omitempty"`
DeciderEmail *string `db:"decider_email" json:"decider_email,omitempty"`
ViewerCanApprove bool `db:"viewer_can_approve" json:"viewer_can_approve"`
ViewerIsRequester bool `db:"viewer_is_requester" json:"viewer_is_requester"`
ProjectTitle string `db:"project_title" json:"project_title"`
EntityTitle *string `db:"entity_title" json:"entity_title,omitempty"`
RequesterName string `db:"requester_name" json:"requester_name"`
RequesterEmail string `db:"requester_email" json:"requester_email"`
DeciderName *string `db:"decider_name" json:"decider_name,omitempty"`
DeciderEmail *string `db:"decider_email" json:"decider_email,omitempty"`
ViewerCanApprove bool `db:"viewer_can_approve" json:"viewer_can_approve"`
ViewerIsRequester bool `db:"viewer_is_requester" json:"viewer_is_requester"`
// NextRequestID is the forward-pointer from a changes_requested row
// to the new pending row spawned by SuggestChanges (t-paliad-216).
// Hydrated via correlated subquery on previous_request_id; the
// partial index approval_requests_previous_idx keeps the lookup O(1).
// NULL on every row that hasn't been counter-proposed.
NextRequestID *uuid.UUID `db:"next_request_id" json:"next_request_id,omitempty"`
}
// approvalEligibilitySQL is the SELECT-and-WHERE-compatible boolean
@@ -875,6 +1334,7 @@ const approvalRequestViewColumns = `
ar.pre_image, ar.payload, ar.requested_by, ar.requested_at, ar.required_role,
ar.status, ar.decided_by, ar.decided_at, ar.decision_kind, ar.decision_note,
ar.requester_kind, ar.agent_turn_id,
ar.counter_payload, ar.previous_request_id,
ar.created_at, ar.updated_at,
p.title AS project_title,
CASE WHEN ar.entity_type = 'deadline' THEN d.title
@@ -885,7 +1345,11 @@ const approvalRequestViewColumns = `
du.display_name AS decider_name,
du.email AS decider_email,
(ar.status = 'pending' AND ar.requested_by <> $1 AND ` + approvalEligibilitySQL + `) AS viewer_can_approve,
(ar.requested_by = $1) AS viewer_is_requester`
(ar.requested_by = $1) AS viewer_is_requester,
(SELECT nxt.id FROM paliad.approval_requests nxt
WHERE nxt.previous_request_id = ar.id
ORDER BY nxt.requested_at DESC
LIMIT 1) AS next_request_id`
const approvalRequestViewJoins = `
paliad.approval_requests ar

View File

@@ -946,3 +946,470 @@ func TestApprovalService_ViewerFlags(t *testing.T) {
t.Error("ListSubmittedByUser: viewer_is_requester = false on self-authored row, want true")
}
}
// ============================================================================
// SuggestChanges — t-paliad-216 Slice A. The fourth approval action: the
// approver authors a counter-proposal which becomes a NEW pending row
// requested by the approver. 4-Augen still holds via the standard
// self-approval guard.
// ============================================================================
// seedPendingUpdate spins up the {policy, deadline, pending update
// request} triple SuggestChanges needs. Returns the deadline id, the
// pending request id, and the pre-image due_date (so callers can assert
// applyRevert restored it correctly).
func (e *approvalTestEnv) seedPendingUpdate(t *testing.T) (uuid.UUID, uuid.UUID, time.Time) {
t.Helper()
ctx := context.Background()
e.seedPolicy(EntityTypeDeadline, LifecycleUpdate, "associate")
originalDue := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
deadlineID := e.seedDeadline(originalDue)
newDue := time.Date(2026, 6, 15, 0, 0, 0, 0, time.UTC)
tx, err := e.pool.BeginTxx(ctx, nil)
if err != nil {
t.Fatalf("begin: %v", err)
}
if _, err := tx.ExecContext(ctx,
`UPDATE paliad.deadlines SET due_date = $1 WHERE id = $2`,
newDue, deadlineID); err != nil {
tx.Rollback()
t.Fatalf("UPDATE pre-submit: %v", err)
}
preImage := map[string]any{"due_date": "2026-06-01"}
payload := map[string]any{"due_date": "2026-06-15"}
reqID, err := e.approvals.SubmitUpdate(ctx, tx, e.projectID, deadlineID, e.requester, EntityTypeDeadline, preImage, payload)
if err != nil {
tx.Rollback()
t.Fatalf("SubmitUpdate: %v", err)
}
if err := tx.Commit(); err != nil {
t.Fatalf("commit: %v", err)
}
if reqID == nil {
t.Fatal("SubmitUpdate returned nil request id")
}
return deadlineID, *reqID, originalDue
}
// TestApprovalService_SuggestChanges_HappyPath: approver suggests a
// different due_date + note. Expected end state:
// - OLD request: status='changes_requested', decision_note set,
// counter_payload set, decided_by=approver.
// - Entity: approval_status='pending', pending_request_id points at
// a NEW pending row, due_date == approver's counter_payload value.
// - NEW request: status='pending', requested_by=approver,
// payload=counter_payload, previous_request_id=OLD.
// - Two project_events emitted: *_approval_changes_suggested and
// *_approval_requested.
func TestApprovalService_SuggestChanges_HappyPath(t *testing.T) {
env := setupApprovalTest(t)
defer env.cleanup()
ctx := context.Background()
deadlineID, oldReqID, _ := env.seedPendingUpdate(t)
counterDue := time.Date(2026, 6, 20, 0, 0, 0, 0, time.UTC)
counter := map[string]any{"due_date": "2026-06-20"}
newReqID, err := env.approvals.SuggestChanges(ctx, oldReqID, env.approver, counter, "Bitte später, Raumkonflikt am 15.6.")
if err != nil {
t.Fatalf("SuggestChanges: %v", err)
}
if newReqID == nil {
t.Fatal("expected new request id, got nil")
}
if *newReqID == oldReqID {
t.Fatal("new request id must differ from old")
}
// OLD row.
oldRow := struct {
Status string `db:"status"`
DecidedBy *uuid.UUID `db:"decided_by"`
DecidedAt *time.Time `db:"decided_at"`
DecisionNote *string `db:"decision_note"`
CounterPayload []byte `db:"counter_payload"`
PreviousRequest *uuid.UUID `db:"previous_request_id"`
DecisionKind *string `db:"decision_kind"`
}{}
if err := env.pool.GetContext(ctx, &oldRow,
`SELECT status, decided_by, decided_at, decision_note, counter_payload,
previous_request_id, decision_kind
FROM paliad.approval_requests WHERE id = $1`, oldReqID); err != nil {
t.Fatalf("read old row: %v", err)
}
if oldRow.Status != RequestStatusChangesRequested {
t.Errorf("old row status = %q, want %q", oldRow.Status, RequestStatusChangesRequested)
}
if oldRow.DecidedBy == nil || *oldRow.DecidedBy != env.approver {
t.Errorf("old row decided_by = %v, want %v", oldRow.DecidedBy, env.approver)
}
if oldRow.DecisionNote == nil || *oldRow.DecisionNote == "" {
t.Error("old row decision_note should be set")
}
if len(oldRow.CounterPayload) == 0 {
t.Error("old row counter_payload should be set")
}
if oldRow.PreviousRequest != nil {
t.Errorf("old row previous_request_id = %v, want NULL", oldRow.PreviousRequest)
}
if oldRow.DecisionKind == nil || (*oldRow.DecisionKind != DecisionKindPeer && *oldRow.DecisionKind != DecisionKindAdminOverride) {
t.Errorf("old row decision_kind = %v, want peer or admin_override", oldRow.DecisionKind)
}
// NEW row.
newRow := struct {
Status string `db:"status"`
RequestedBy uuid.UUID `db:"requested_by"`
Payload []byte `db:"payload"`
PreviousRequestID *uuid.UUID `db:"previous_request_id"`
LifecycleEvent string `db:"lifecycle_event"`
}{}
if err := env.pool.GetContext(ctx, &newRow,
`SELECT status, requested_by, payload, previous_request_id, lifecycle_event
FROM paliad.approval_requests WHERE id = $1`, *newReqID); err != nil {
t.Fatalf("read new row: %v", err)
}
if newRow.Status != RequestStatusPending {
t.Errorf("new row status = %q, want pending", newRow.Status)
}
if newRow.RequestedBy != env.approver {
t.Errorf("new row requested_by = %v, want %v (approver)", newRow.RequestedBy, env.approver)
}
if newRow.PreviousRequestID == nil || *newRow.PreviousRequestID != oldReqID {
t.Errorf("new row previous_request_id = %v, want %v", newRow.PreviousRequestID, oldReqID)
}
if newRow.LifecycleEvent != LifecycleUpdate {
t.Errorf("new row lifecycle = %q, want update", newRow.LifecycleEvent)
}
// Entity: pending, due_date == counter.
entity := struct {
Status string `db:"approval_status"`
PendingRequest *uuid.UUID `db:"pending_request_id"`
DueDate time.Time `db:"due_date"`
}{}
if err := env.pool.GetContext(ctx, &entity,
`SELECT approval_status, pending_request_id, due_date FROM paliad.deadlines WHERE id = $1`,
deadlineID); err != nil {
t.Fatalf("read entity: %v", err)
}
if entity.Status != "pending" {
t.Errorf("entity approval_status = %q, want pending", entity.Status)
}
if entity.PendingRequest == nil || *entity.PendingRequest != *newReqID {
t.Errorf("entity pending_request_id = %v, want %v", entity.PendingRequest, *newReqID)
}
if !entity.DueDate.Equal(counterDue) {
t.Errorf("entity due_date = %v, want %v (counter)", entity.DueDate, counterDue)
}
// Two project_events: one *_approval_changes_suggested + one *_approval_requested
// for the NEW row.
var nSuggested, nRequested int
if err := env.pool.GetContext(ctx, &nSuggested,
`SELECT COUNT(*) FROM paliad.project_events
WHERE project_id = $1 AND event_type = 'deadline_approval_changes_suggested'`,
env.projectID); err != nil {
t.Fatalf("count changes_suggested events: %v", err)
}
if nSuggested != 1 {
t.Errorf("expected 1 deadline_approval_changes_suggested event, got %d", nSuggested)
}
if err := env.pool.GetContext(ctx, &nRequested,
`SELECT COUNT(*) FROM paliad.project_events
WHERE project_id = $1 AND event_type = 'deadline_approval_requested'`,
env.projectID); err != nil {
t.Fatalf("count requested events: %v", err)
}
// Two requested events expected: one from the original SubmitUpdate +
// one from the SuggestChanges spawn.
if nRequested != 2 {
t.Errorf("expected 2 deadline_approval_requested events (original + spawn), got %d", nRequested)
}
}
// TestApprovalService_SuggestChanges_NoOpRejected: identical counter +
// empty note returns ErrSuggestionRequiresChange.
func TestApprovalService_SuggestChanges_NoOpRejected(t *testing.T) {
env := setupApprovalTest(t)
defer env.cleanup()
ctx := context.Background()
_, oldReqID, _ := env.seedPendingUpdate(t)
// Same payload as the original SubmitUpdate. No note.
identical := map[string]any{"due_date": "2026-06-15"}
_, err := env.approvals.SuggestChanges(ctx, oldReqID, env.approver, identical, "")
if !errors.Is(err, ErrSuggestionRequiresChange) {
t.Errorf("no-op suggest: got %v, want ErrSuggestionRequiresChange", err)
}
// Empty counter, empty note → also rejected.
_, err = env.approvals.SuggestChanges(ctx, oldReqID, env.approver, nil, "")
if !errors.Is(err, ErrSuggestionRequiresChange) {
t.Errorf("empty suggest: got %v, want ErrSuggestionRequiresChange", err)
}
}
// TestApprovalService_SuggestChanges_NoteOnlyAccepted: when the counter
// is unchanged but a non-empty note is present, the call succeeds. The
// new row's payload equals the OLD payload (the approver said "I want a
// fresh look from someone else; here's why", without a different value).
func TestApprovalService_SuggestChanges_NoteOnlyAccepted(t *testing.T) {
env := setupApprovalTest(t)
defer env.cleanup()
ctx := context.Background()
deadlineID, oldReqID, _ := env.seedPendingUpdate(t)
identical := map[string]any{"due_date": "2026-06-15"}
newReqID, err := env.approvals.SuggestChanges(ctx, oldReqID, env.approver, identical, "Bitte nochmal prüfen.")
if err != nil {
t.Fatalf("note-only suggest: %v", err)
}
if newReqID == nil {
t.Fatal("expected new request id, got nil")
}
// Entity's due_date stays at 2026-06-15 (the original counter == original payload).
var got time.Time
if err := env.pool.GetContext(ctx, &got,
`SELECT due_date FROM paliad.deadlines WHERE id = $1`, deadlineID); err != nil {
t.Fatalf("read due_date: %v", err)
}
want := time.Date(2026, 6, 15, 0, 0, 0, 0, time.UTC)
if !got.Equal(want) {
t.Errorf("entity due_date = %v, want %v", got, want)
}
}
// TestApprovalService_SuggestChanges_SelfApprovalBlocked: the original
// requester cannot suggest changes on their own row (would equal
// self-approval).
func TestApprovalService_SuggestChanges_SelfApprovalBlocked(t *testing.T) {
env := setupApprovalTest(t)
defer env.cleanup()
ctx := context.Background()
_, oldReqID, _ := env.seedPendingUpdate(t)
counter := map[string]any{"due_date": "2026-06-20"}
_, err := env.approvals.SuggestChanges(ctx, oldReqID, env.requester, counter, "")
if !errors.Is(err, ErrSelfApproval) {
t.Errorf("self suggest: got %v, want ErrSelfApproval", err)
}
}
// TestApprovalService_SuggestChanges_RequestNotPending: a row already
// decided (approved/rejected/revoked/changes_requested) rejects further
// suggest-changes calls.
func TestApprovalService_SuggestChanges_RequestNotPending(t *testing.T) {
env := setupApprovalTest(t)
defer env.cleanup()
ctx := context.Background()
_, oldReqID, _ := env.seedPendingUpdate(t)
// Approve first.
if err := env.approvals.Approve(ctx, oldReqID, env.approver, "ok"); err != nil {
t.Fatalf("Approve: %v", err)
}
counter := map[string]any{"due_date": "2026-06-20"}
_, err := env.approvals.SuggestChanges(ctx, oldReqID, env.approver, counter, "too late")
if !errors.Is(err, ErrRequestNotPending) {
t.Errorf("decided row suggest: got %v, want ErrRequestNotPending", err)
}
}
// TestApprovalService_SuggestChanges_LifecycleInvalid: lifecycle ∉
// (update, complete) rejects with ErrSuggestionLifecycleInvalid. A
// create-lifecycle pending request is the easiest to set up.
func TestApprovalService_SuggestChanges_LifecycleInvalid(t *testing.T) {
env := setupApprovalTest(t)
defer env.cleanup()
ctx := context.Background()
env.seedPolicy(EntityTypeDeadline, LifecycleCreate, "associate")
deadlineID := env.seedDeadline(time.Now().AddDate(0, 0, 14))
tx, err := env.pool.BeginTxx(ctx, nil)
if err != nil {
t.Fatalf("begin: %v", err)
}
reqID, err := env.approvals.SubmitCreate(ctx, tx, env.projectID, deadlineID, env.requester, EntityTypeDeadline, map[string]any{"due_date": "2026-05-20"})
if err != nil {
tx.Rollback()
t.Fatalf("SubmitCreate: %v", err)
}
if err := tx.Commit(); err != nil {
t.Fatalf("commit: %v", err)
}
counter := map[string]any{"due_date": "2026-06-01"}
_, err = env.approvals.SuggestChanges(ctx, *reqID, env.approver, counter, "different date")
if !errors.Is(err, ErrSuggestionLifecycleInvalid) {
t.Errorf("create-lifecycle suggest: got %v, want ErrSuggestionLifecycleInvalid", err)
}
}
// TestApprovalService_SuggestChanges_OriginalRequesterCanApproveCounter:
// the cleanest verification of m's Q6 mental model — after the approver
// suggests changes, the ORIGINAL REQUESTER is no longer the new row's
// requested_by and can now approve the counter themselves (provided
// their profession is sufficient). For this test we promote the requester
// to 'partner' profession so they pass the canApprove gate.
func TestApprovalService_SuggestChanges_OriginalRequesterCanApproveCounter(t *testing.T) {
env := setupApprovalTest(t)
defer env.cleanup()
ctx := context.Background()
// Promote the requester so they qualify as an approver of the counter.
// The original Submit was theirs (excluded as requested_by); for the
// counter their role lets them sign off.
if _, err := env.pool.ExecContext(ctx,
`UPDATE paliad.users SET profession='partner' WHERE id = $1`, env.requester); err != nil {
t.Fatalf("promote requester profession: %v", err)
}
if _, err := env.pool.ExecContext(ctx,
`UPDATE paliad.users SET profession='partner' WHERE id = $1`, env.approver); err != nil {
t.Fatalf("promote approver profession: %v", err)
}
deadlineID, oldReqID, _ := env.seedPendingUpdate(t)
counter := map[string]any{"due_date": "2026-06-22"}
newReqID, err := env.approvals.SuggestChanges(ctx, oldReqID, env.approver, counter, "Lieber den 22.")
if err != nil {
t.Fatalf("SuggestChanges: %v", err)
}
// Original requester approves the counter.
if err := env.approvals.Approve(ctx, *newReqID, env.requester, "Ja, passt."); err != nil {
t.Fatalf("original requester approves counter: %v", err)
}
// Entity is back to approved with the counter date.
row := struct {
Status string `db:"approval_status"`
ApprovedBy *uuid.UUID `db:"approved_by"`
DueDate time.Time `db:"due_date"`
}{}
if err := env.pool.GetContext(ctx, &row,
`SELECT approval_status, approved_by, due_date FROM paliad.deadlines WHERE id = $1`,
deadlineID); err != nil {
t.Fatalf("read entity: %v", err)
}
if row.Status != "approved" {
t.Errorf("entity approval_status = %q, want approved", row.Status)
}
if row.ApprovedBy == nil || *row.ApprovedBy != env.requester {
t.Errorf("approved_by = %v, want %v (original requester)", row.ApprovedBy, env.requester)
}
want := time.Date(2026, 6, 22, 0, 0, 0, 0, time.UTC)
if !row.DueDate.Equal(want) {
t.Errorf("due_date = %v, want %v", row.DueDate, want)
}
}
// TestApprovalService_SuggestChanges_CounterApproverCannotSelfApprove:
// after suggest-changes, the approver who suggested (= new row's
// requested_by) is blocked from approving their own counter — 4-Augen
// still holds.
func TestApprovalService_SuggestChanges_CounterApproverCannotSelfApprove(t *testing.T) {
env := setupApprovalTest(t)
defer env.cleanup()
ctx := context.Background()
_, oldReqID, _ := env.seedPendingUpdate(t)
counter := map[string]any{"due_date": "2026-06-22"}
newReqID, err := env.approvals.SuggestChanges(ctx, oldReqID, env.approver, counter, "")
if err != nil {
t.Fatalf("SuggestChanges: %v", err)
}
if err := env.approvals.Approve(ctx, *newReqID, env.approver, ""); !errors.Is(err, ErrSelfApproval) {
t.Errorf("counter author self-approves: got %v, want ErrSelfApproval", err)
}
}
// TestApprovalService_SuggestChanges_TitleOnlyCounter pins t-paliad-217
// Slice B: the counter-allowlist now accepts the wider field set
// (title / description / notes / rule_code / event_type_ids on
// deadlines). A counter that ONLY changes the title (no date diff) must
// succeed — the new pending row's payload carries the title, and the
// entity row's title field is updated in-tx.
func TestApprovalService_SuggestChanges_TitleOnlyCounter(t *testing.T) {
env := setupApprovalTest(t)
defer env.cleanup()
ctx := context.Background()
deadlineID, oldReqID, _ := env.seedPendingUpdate(t)
counter := map[string]any{"title": "Klageerwiderung — Vorschlag Hertz"}
newReqID, err := env.approvals.SuggestChanges(ctx, oldReqID, env.approver, counter, "")
if err != nil {
t.Fatalf("title-only suggest: %v", err)
}
if newReqID == nil {
t.Fatal("expected new request id, got nil")
}
// Entity's title flipped.
var gotTitle string
if err := env.pool.GetContext(ctx, &gotTitle,
`SELECT title FROM paliad.deadlines WHERE id = $1`, deadlineID); err != nil {
t.Fatalf("read title: %v", err)
}
if gotTitle != "Klageerwiderung — Vorschlag Hertz" {
t.Errorf("entity title = %q, want %q", gotTitle, "Klageerwiderung — Vorschlag Hertz")
}
}
// TestApprovalService_SuggestChanges_NotesOnlyCounter pins t-paliad-217
// Slice B: notes is in the counter-allowlist and a notes-only counter
// must succeed. Empty-string clears the column (NULLable text).
func TestApprovalService_SuggestChanges_NotesOnlyCounter(t *testing.T) {
env := setupApprovalTest(t)
defer env.cleanup()
ctx := context.Background()
deadlineID, oldReqID, _ := env.seedPendingUpdate(t)
counter := map[string]any{"notes": "Bitte vor Einreichung mit Mandant abstimmen."}
if _, err := env.approvals.SuggestChanges(ctx, oldReqID, env.approver, counter, ""); err != nil {
t.Fatalf("notes-only suggest: %v", err)
}
var gotNotes *string
if err := env.pool.GetContext(ctx, &gotNotes,
`SELECT notes FROM paliad.deadlines WHERE id = $1`, deadlineID); err != nil {
t.Fatalf("read notes: %v", err)
}
if gotNotes == nil || *gotNotes != "Bitte vor Einreichung mit Mandant abstimmen." {
t.Errorf("entity notes = %v, want set", gotNotes)
}
}
// TestApprovalService_SuggestChanges_EmptyTitleRejected pins the title
// non-empty CHECK on the counter-allowlist: title is NOT NULL on the
// deadlines column, so a counter that explicitly sends "" for title
// must be rejected with ErrSuggestionRequiresChange (not silently
// dropped or written as a NULL).
func TestApprovalService_SuggestChanges_EmptyTitleRejected(t *testing.T) {
env := setupApprovalTest(t)
defer env.cleanup()
ctx := context.Background()
_, oldReqID, _ := env.seedPendingUpdate(t)
counter := map[string]any{"title": " "} // whitespace-only
_, err := env.approvals.SuggestChanges(ctx, oldReqID, env.approver, counter, "")
if !errors.Is(err, ErrSuggestionRequiresChange) {
t.Errorf("empty-title suggest: got %v, want ErrSuggestionRequiresChange", err)
}
}

View File

@@ -9,6 +9,7 @@ package services
// - paliad.reminder_log — bundled-digest reminder sends
// - paliad.partner_unit_events — partner-unit CRUD + membership changes
// - paliad.policy_audit_log — approval-policy CRUD (t-paliad-154)
// - paliad.system_audit_log — org-wide / scope-spanning actions (t-paliad-214)
//
// The union happens in SQL (one round-trip, server-side ordering) and is
// keyset-paginated on (timestamp, id) DESC so the cursor stays stable across
@@ -37,6 +38,7 @@ const (
AuditSourceReminderLog = "reminder_log"
AuditSourcePartnerUnitEvents = "partner_unit_events"
AuditSourcePolicyAuditLog = "policy_audit_log"
AuditSourceSystemAuditLog = "system_audit_log"
)
// MaxAuditPageLimit caps a single ListEntries page.
@@ -216,6 +218,27 @@ WITH unioned AS (
WHERE ($1::text IS NULL OR $1 = '' OR $1 = 'policy_audit_log')
AND ($2::timestamptz IS NULL OR pal.created_at >= $2)
AND ($3::timestamptz IS NULL OR pal.created_at <= $3)
UNION ALL
-- t-paliad-214 — org-wide / scope-spanning actions. First user is the
-- data-export audit chain. scope_root is the project_id for
-- scope='project'; NULL otherwise. project_id forwarded so timeline
-- filtering by project surfaces project-scope exports too.
SELECT
'system_audit_log'::text AS source,
sal.id AS id,
sal.created_at AS ts,
sal.event_type AS event_type,
sal.actor_email AS actor,
COALESCE(sal.scope, 'system') AS subject,
sal.scope_root AS project_id,
NULL::text AS title,
sal.metadata::text AS description
FROM paliad.system_audit_log sal
WHERE ($1::text IS NULL OR $1 = '' OR $1 = 'system_audit_log')
AND ($2::timestamptz IS NULL OR sal.created_at >= $2)
AND ($3::timestamptz IS NULL OR sal.created_at <= $3)
)
SELECT source, id, ts, event_type, actor, subject, project_id, title, description
FROM unioned

View File

@@ -0,0 +1,265 @@
package services
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"mgit.msbls.de/m/paliad/internal/models"
)
// CalendarBindingService — CRUD on paliad.user_calendar_bindings.
//
// Each row is one of N (calendar, scope) bindings layered on top of the
// user's single CalDAV server connection in paliad.user_caldav_config.
// Slice 1 (t-paliad-212) introduced the table + an auto-backfilled
// 'all_visible' binding per existing user; Slice 2a wires the service
// that owns the rows. The sync engine (CalDAVService) drives off
// ListEnabled to discover where to push.
//
// Validation of (scope_kind, scope_id) combinatorics is enforced both
// here (so the API returns a useful 400) and by the table's CHECK
// constraints (so direct SQL or older clients can't slip a bad row in).
type CalendarBindingService struct {
db *sqlx.DB
}
func NewCalendarBindingService(db *sqlx.DB) *CalendarBindingService {
return &CalendarBindingService{db: db}
}
const bindingColumns = `
id, user_id, calendar_path, display_name,
scope_kind, scope_id, include_personal, enabled,
last_sync_at, last_sync_error, created_at, updated_at`
// ListForUser returns every binding owned by the user, ordered by
// scope_kind then created_at so the all_visible / personal_only roots
// always sort to the top.
func (s *CalendarBindingService) ListForUser(ctx context.Context, userID uuid.UUID) ([]models.UserCalendarBinding, error) {
rows := []models.UserCalendarBinding{}
if err := s.db.SelectContext(ctx, &rows,
`SELECT `+bindingColumns+`
FROM paliad.user_calendar_bindings
WHERE user_id = $1
ORDER BY
CASE scope_kind
WHEN 'all_visible' THEN 0
WHEN 'personal_only' THEN 1
ELSE 2
END,
created_at`, userID); err != nil {
return nil, fmt.Errorf("list bindings: %w", err)
}
return rows, nil
}
// ListEnabled returns the user's bindings with enabled = true.
// Used by the CalDAVService sync loop.
func (s *CalendarBindingService) ListEnabled(ctx context.Context, userID uuid.UUID) ([]models.UserCalendarBinding, error) {
rows := []models.UserCalendarBinding{}
if err := s.db.SelectContext(ctx, &rows,
`SELECT `+bindingColumns+`
FROM paliad.user_calendar_bindings
WHERE user_id = $1 AND enabled = true
ORDER BY created_at`, userID); err != nil {
return nil, fmt.Errorf("list enabled bindings: %w", err)
}
return rows, nil
}
// ListAllEnabled returns every enabled binding across all users.
// Used at server boot to spawn one sync goroutine per (user) that
// owns at least one enabled binding.
func (s *CalendarBindingService) ListAllEnabled(ctx context.Context) ([]models.UserCalendarBinding, error) {
rows := []models.UserCalendarBinding{}
if err := s.db.SelectContext(ctx, &rows,
`SELECT `+bindingColumns+`
FROM paliad.user_calendar_bindings
WHERE enabled = true
ORDER BY user_id, created_at`); err != nil {
return nil, fmt.Errorf("list all enabled bindings: %w", err)
}
return rows, nil
}
// Get returns one binding scoped to the user; ErrNotVisible when the row
// doesn't exist or belongs to someone else.
func (s *CalendarBindingService) Get(ctx context.Context, userID, bindingID uuid.UUID) (*models.UserCalendarBinding, error) {
var b models.UserCalendarBinding
err := s.db.GetContext(ctx, &b,
`SELECT `+bindingColumns+`
FROM paliad.user_calendar_bindings
WHERE id = $1 AND user_id = $2`, bindingID, userID)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotVisible
}
if err != nil {
return nil, fmt.Errorf("get binding: %w", err)
}
return &b, nil
}
// CreateInput is the payload for POST /api/caldav-bindings. Slice 2b
// wires this; Slice 2a exposes Create for tests + SQL-equivalent
// integration tests.
type CreateBindingInput struct {
CalendarPath string `json:"calendar_path"`
DisplayName string `json:"display_name"`
ScopeKind string `json:"scope_kind"`
ScopeID *uuid.UUID `json:"scope_id,omitempty"`
IncludePersonal bool `json:"include_personal"`
Enabled bool `json:"enabled"`
}
// Create inserts a new binding. Validates scope_kind / scope_id
// combinatorics; returns ErrInvalidInput on a bad payload.
func (s *CalendarBindingService) Create(ctx context.Context, userID uuid.UUID, in CreateBindingInput) (*models.UserCalendarBinding, error) {
if err := validateScope(in.ScopeKind, in.ScopeID); err != nil {
return nil, err
}
if in.CalendarPath == "" {
return nil, fmt.Errorf("%w: calendar_path is required", ErrInvalidInput)
}
now := time.Now().UTC()
var b models.UserCalendarBinding
err := s.db.GetContext(ctx, &b,
`INSERT INTO paliad.user_calendar_bindings
(user_id, calendar_path, display_name, scope_kind, scope_id,
include_personal, enabled, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8)
RETURNING `+bindingColumns,
userID, in.CalendarPath, in.DisplayName, in.ScopeKind, in.ScopeID,
in.IncludePersonal, in.Enabled, now)
if err != nil {
return nil, fmt.Errorf("insert binding: %w", err)
}
return &b, nil
}
// UpdateInput captures the PATCH-shaped fields. Pointer fields = "leave
// as-is when nil".
type UpdateBindingInput struct {
DisplayName *string `json:"display_name,omitempty"`
ScopeKind *string `json:"scope_kind,omitempty"`
ScopeID *uuid.UUID `json:"scope_id,omitempty"`
IncludePersonal *bool `json:"include_personal,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
}
// Update mutates the binding. Validates the resulting (scope_kind, scope_id)
// combinatorics if either field changes.
func (s *CalendarBindingService) Update(ctx context.Context, userID, bindingID uuid.UUID, in UpdateBindingInput) (*models.UserCalendarBinding, error) {
existing, err := s.Get(ctx, userID, bindingID)
if err != nil {
return nil, err
}
if in.ScopeKind != nil || in.ScopeID != nil {
kind := existing.ScopeKind
if in.ScopeKind != nil {
kind = *in.ScopeKind
}
var sid *uuid.UUID
if in.ScopeID != nil {
sid = in.ScopeID
} else {
sid = existing.ScopeID
}
if err := validateScope(kind, sid); err != nil {
return nil, err
}
}
sets := []string{"updated_at = NOW()"}
args := []any{}
next := 1
addSet := func(col string, val any) {
sets = append(sets, fmt.Sprintf("%s = $%d", col, next))
args = append(args, val)
next++
}
if in.DisplayName != nil {
addSet("display_name", *in.DisplayName)
}
if in.ScopeKind != nil {
addSet("scope_kind", *in.ScopeKind)
}
if in.ScopeID != nil {
addSet("scope_id", *in.ScopeID)
}
if in.IncludePersonal != nil {
addSet("include_personal", *in.IncludePersonal)
}
if in.Enabled != nil {
addSet("enabled", *in.Enabled)
}
// Append WHERE clause args last.
args = append(args, bindingID, userID)
q := fmt.Sprintf(`UPDATE paliad.user_calendar_bindings
SET %s
WHERE id = $%d AND user_id = $%d
RETURNING %s`, strings.Join(sets, ", "), next, next+1, bindingColumns)
var b models.UserCalendarBinding
if err := s.db.GetContext(ctx, &b, q, args...); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotVisible
}
return nil, fmt.Errorf("update binding: %w", err)
}
return &b, nil
}
// Delete removes the binding row. Caller is responsible for the remote
// .ics cleanup (CalDAVService handles that via §2.6 of the Slice 2 brief)
// before invoking this; this method is the bare DB delete.
func (s *CalendarBindingService) Delete(ctx context.Context, userID, bindingID uuid.UUID) error {
res, err := s.db.ExecContext(ctx,
`DELETE FROM paliad.user_calendar_bindings
WHERE id = $1 AND user_id = $2`, bindingID, userID)
if err != nil {
return fmt.Errorf("delete binding: %w", err)
}
n, _ := res.RowsAffected()
if n == 0 {
return ErrNotVisible
}
return nil
}
// SetSyncStatus is called by CalDAVService after each sync attempt for
// this binding. last_sync_error nil clears the previous error.
func (s *CalendarBindingService) SetSyncStatus(ctx context.Context, bindingID uuid.UUID, errStr *string) error {
_, err := s.db.ExecContext(ctx,
`UPDATE paliad.user_calendar_bindings
SET last_sync_at = NOW(), last_sync_error = $1, updated_at = NOW()
WHERE id = $2`, errStr, bindingID)
if err != nil {
return fmt.Errorf("update binding sync status: %w", err)
}
return nil
}
// validateScope mirrors the table's CHECK constraints — we duplicate
// the rule here so the API can return a useful 400 instead of letting
// Postgres reject the row with a generic check_violation.
func validateScope(kind string, scopeID *uuid.UUID) error {
switch kind {
case models.BindingScopeAllVisible, models.BindingScopePersonalOnly:
if scopeID != nil {
return fmt.Errorf("%w: scope_id must be NULL when scope_kind = %q", ErrInvalidInput, kind)
}
case models.BindingScopeProject, models.BindingScopeClient, models.BindingScopeLitigation, models.BindingScopePatent, models.BindingScopeCase:
if scopeID == nil {
return fmt.Errorf("%w: scope_id is required when scope_kind = %q", ErrInvalidInput, kind)
}
default:
return fmt.Errorf("%w: unknown scope_kind %q", ErrInvalidInput, kind)
}
return nil
}

View File

@@ -2,15 +2,28 @@ package services
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/xml"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"slices"
"strings"
"time"
)
// ErrCalendarNameTaken is returned by MakeCalendar when the server
// rejects MKCALENDAR with 405 — name already in use.
var ErrCalendarNameTaken = errors.New("calendar name already taken on server")
// ErrMKCalendarUnsupported is returned by MakeCalendar when the server
// outright rejects MKCALENDAR (403/501) — should never fire after a
// successful probe, but kept as a defence so we don't loop.
var ErrMKCalendarUnsupported = errors.New("server does not support MKCALENDAR")
// Tiny CalDAV HTTP client — only the verbs Paliad needs:
// - PUT (create / replace event)
// - GET (fetch event by path)
@@ -169,6 +182,77 @@ func (c *calDAVClient) PropfindCalendar(ctx context.Context, calendarPath string
return parseMultiStatus(resp.Body)
}
// multigetMaxHrefs caps the number of hrefs in one REPORT request to keep
// us well within Google's documented limit (~200) and iCloud's
// rate-shaping. Callers chunk larger lists into multiple requests.
const multigetMaxHrefs = 100
// MultigetEvent is one (href, etag, calendar-data) result returned by
// ReportMultiget. CalendarData is the raw iCalendar body and is fed
// straight into parseICalendar; ETag matches the value that would have
// been returned by PROPFIND for the same href.
type MultigetEvent struct {
Href string
ETag string
CalendarData string
}
// ReportMultiget runs a `REPORT calendar-multiget` (RFC 4791 §7.9)
// against calendarPath and returns one MultigetEvent per requested href.
// Hrefs missing from the response (404 inside the multistatus) are
// omitted from the returned slice — callers should treat that as a
// remote deletion. Hrefs are auto-chunked at multigetMaxHrefs.
func (c *calDAVClient) ReportMultiget(ctx context.Context, calendarPath string, hrefs []string) ([]MultigetEvent, error) {
if len(hrefs) == 0 {
return nil, nil
}
out := []MultigetEvent{}
for start := 0; start < len(hrefs); start += multigetMaxHrefs {
end := min(start+multigetMaxHrefs, len(hrefs))
chunk, err := c.reportMultigetChunk(ctx, calendarPath, hrefs[start:end])
if err != nil {
return nil, err
}
out = append(out, chunk...)
}
return out, nil
}
func (c *calDAVClient) reportMultigetChunk(ctx context.Context, calendarPath string, hrefs []string) ([]MultigetEvent, error) {
var b strings.Builder
b.WriteString(`<?xml version="1.0" encoding="utf-8"?>
<C:calendar-multiget xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
<D:prop>
<D:getetag/>
<C:calendar-data/>
</D:prop>
`)
for _, h := range hrefs {
b.WriteString(" <D:href>")
_ = xml.EscapeText(&b, []byte(h))
b.WriteString("</D:href>\n")
}
b.WriteString(`</C:calendar-multiget>`)
req, err := http.NewRequestWithContext(ctx, "REPORT", c.absURL(calendarPath), strings.NewReader(b.String()))
if err != nil {
return nil, err
}
req.SetBasicAuth(c.username, c.password)
req.Header.Set("Depth", "1")
req.Header.Set("Content-Type", "application/xml; charset=utf-8")
resp, err := c.hc.Do(req)
if err != nil {
return nil, fmt.Errorf("REPORT: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 207 {
raw, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("REPORT %s: %d %s — %s", calendarPath, resp.StatusCode, resp.Status, string(raw))
}
return parseMultigetResponse(resp.Body)
}
// PropfindRoot performs a Depth:0 PROPFIND on the calendar URL — used by
// the "Test connection" button to verify auth + URL without storing creds.
func (c *calDAVClient) PropfindRoot(ctx context.Context, path string) error {
@@ -198,6 +282,338 @@ func (c *calDAVClient) PropfindRoot(ctx context.Context, path string) error {
return nil
}
// DiscoveredCalendar is one calendar collection enumerated by
// DiscoverCalendars. supportedComponents lists the iCal component types
// the server advertises (VEVENT, VTODO, …); the picker filters to ones
// supporting VEVENT.
type DiscoveredCalendar struct {
Href string
DisplayName string
SupportedComponents []string
}
// DiscoverCalendars walks the CalDAV discovery chain (RFC 6764 §6 /
// RFC 6638 §10): server root → current-user-principal → calendar-home-set
// → enumeration of child calendar collections.
//
// Returns the discovered calendars + the calendar-home-set URL so the
// caller can issue MKCALENDAR against it in Slice 2c. Hrefs are
// returned as-is (absolute or path-rooted) per server response; the
// client's absURL handles both at PUT time.
func (c *calDAVClient) DiscoverCalendars(ctx context.Context, serverURL string) ([]DiscoveredCalendar, string, error) {
principal, err := c.findCurrentUserPrincipal(ctx, serverURL)
if err != nil {
return nil, "", fmt.Errorf("current-user-principal: %w", err)
}
home, err := c.findCalendarHomeSet(ctx, principal)
if err != nil {
return nil, "", fmt.Errorf("calendar-home-set: %w", err)
}
calendars, err := c.listCalendars(ctx, home)
if err != nil {
return nil, home, fmt.Errorf("list calendars: %w", err)
}
return calendars, home, nil
}
func (c *calDAVClient) findCurrentUserPrincipal(ctx context.Context, urlPath string) (string, error) {
body := `<?xml version="1.0" encoding="utf-8"?>
<d:propfind xmlns:d="DAV:">
<d:prop><d:current-user-principal/></d:prop>
</d:propfind>`
hrefs, err := c.propfindHrefs(ctx, urlPath, "0", body, "current-user-principal")
if err != nil {
return "", err
}
if len(hrefs) == 0 {
return "", fmt.Errorf("server returned no current-user-principal")
}
return hrefs[0], nil
}
func (c *calDAVClient) findCalendarHomeSet(ctx context.Context, principalPath string) (string, error) {
body := `<?xml version="1.0" encoding="utf-8"?>
<d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:prop><c:calendar-home-set/></d:prop>
</d:propfind>`
hrefs, err := c.propfindHrefs(ctx, principalPath, "0", body, "calendar-home-set")
if err != nil {
return "", err
}
if len(hrefs) == 0 {
return "", fmt.Errorf("server returned no calendar-home-set")
}
return hrefs[0], nil
}
func (c *calDAVClient) listCalendars(ctx context.Context, homePath string) ([]DiscoveredCalendar, error) {
body := `<?xml version="1.0" encoding="utf-8"?>
<d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:prop>
<d:resourcetype/>
<d:displayname/>
<c:supported-calendar-component-set/>
</d:prop>
</d:propfind>`
req, err := http.NewRequestWithContext(ctx, "PROPFIND", c.absURL(homePath), strings.NewReader(body))
if err != nil {
return nil, err
}
req.SetBasicAuth(c.username, c.password)
req.Header.Set("Depth", "1")
req.Header.Set("Content-Type", "application/xml; charset=utf-8")
resp, err := c.hc.Do(req)
if err != nil {
return nil, fmt.Errorf("PROPFIND: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 207 {
raw, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("PROPFIND %s: %d %s — %s", homePath, resp.StatusCode, resp.Status, string(raw))
}
var ms calendarHomeMultiStatus
if err := xml.NewDecoder(resp.Body).Decode(&ms); err != nil {
return nil, fmt.Errorf("decode home-set multistatus: %w", err)
}
out := []DiscoveredCalendar{}
for _, r := range ms.Responses {
var displayname string
isCalendar := false
comps := []string{}
for _, ps := range r.Propstat {
if !strings.Contains(ps.Status, "200") {
continue
}
if ps.Prop.ResourceType.Calendar != nil {
isCalendar = true
}
if ps.Prop.DisplayName != "" {
displayname = ps.Prop.DisplayName
}
for _, comp := range ps.Prop.SupportedCalendarComponentSet.Comp {
if comp.Name != "" {
comps = append(comps, comp.Name)
}
}
}
if !isCalendar {
continue
}
// Filter to calendars that advertise VEVENT support — task / address
// books slip into the home-set on Apple iCloud and we don't want
// those in the picker.
if len(comps) > 0 && !slices.Contains(comps, "VEVENT") {
continue
}
out = append(out, DiscoveredCalendar{
Href: r.Href,
DisplayName: displayname,
SupportedComponents: comps,
})
}
return out, nil
}
// propfindHrefs runs a PROPFIND and returns the hrefs nested under the
// named property's value. Used for current-user-principal +
// calendar-home-set extraction where the property body is a single href.
func (c *calDAVClient) propfindHrefs(ctx context.Context, urlPath, depth, body, propName string) ([]string, error) {
req, err := http.NewRequestWithContext(ctx, "PROPFIND", c.absURL(urlPath), strings.NewReader(body))
if err != nil {
return nil, err
}
req.SetBasicAuth(c.username, c.password)
req.Header.Set("Depth", depth)
req.Header.Set("Content-Type", "application/xml; charset=utf-8")
resp, err := c.hc.Do(req)
if err != nil {
return nil, fmt.Errorf("PROPFIND: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 207 && resp.StatusCode != 200 {
raw, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("PROPFIND %s: %d %s — %s", urlPath, resp.StatusCode, resp.Status, string(raw))
}
var ms propHrefMultiStatus
if err := xml.NewDecoder(resp.Body).Decode(&ms); err != nil {
return nil, fmt.Errorf("decode multistatus for %s: %w", propName, err)
}
out := []string{}
for _, r := range ms.Responses {
for _, ps := range r.Propstat {
if !strings.Contains(ps.Status, "200") {
continue
}
for _, h := range ps.Prop.CurrentUserPrincipal.Hrefs {
out = append(out, h)
}
for _, h := range ps.Prop.CalendarHomeSet.Hrefs {
out = append(out, h)
}
}
}
return out, nil
}
// --- MKCALENDAR capability probe + provisioning (Slice 2c) ---
// ProbeMKCalendar reports whether the CalDAV server accepts MKCALENDAR
// against the calendar-home-set. Two-step per design §4.2:
//
// 1. OPTIONS on the home URL — if the server returns `Allow:` listing
// MKCALENDAR, we're done.
// 2. Synthetic probe — issue MKCALENDAR against a random
// `.paliad-probe-<short>/` path and DELETE it. Catches legacy SOGo
// and misconfigured Radicales that don't list MKCALENDAR in Allow
// but still accept it. Servers that 405/501 the synthetic probe
// are recorded as no-MKCALENDAR; further attempts skip the probe.
//
// The probe never persists state — that's the service-layer's job via
// CalDAVService.MakeCalendar.
func (c *calDAVClient) ProbeMKCalendar(ctx context.Context, homePath string) (bool, error) {
if allows, err := c.optionsAllows(ctx, homePath); err == nil {
if slices.Contains(allows, "MKCALENDAR") {
return true, nil
}
// OPTIONS responded but doesn't list MKCALENDAR — fall through to
// synthetic probe; some servers omit MKCALENDAR from Allow even
// when they accept it. OPTIONS-returns-no-MKCALENDAR is not a
// hard negative.
}
// Synthetic probe — a single MKCALENDAR against a randomised name
// that the server is overwhelmingly unlikely to already have.
probePath := joinPath(homePath, ".paliad-probe-"+randomToken(6)+"/")
mkBody := `<?xml version="1.0" encoding="utf-8"?>
<C:mkcalendar xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
<D:set><D:prop><D:displayname>paliad-probe</D:displayname></D:prop></D:set>
</C:mkcalendar>`
req, err := http.NewRequestWithContext(ctx, "MKCALENDAR", c.absURL(probePath), strings.NewReader(mkBody))
if err != nil {
return false, err
}
req.SetBasicAuth(c.username, c.password)
req.Header.Set("Content-Type", "application/xml; charset=utf-8")
resp, err := c.hc.Do(req)
if err != nil {
return false, fmt.Errorf("MKCALENDAR probe: %w", err)
}
resp.Body.Close()
switch resp.StatusCode {
case http.StatusCreated, http.StatusOK:
// Server accepted the probe. Tear down the probe collection so
// we don't leak a junk calendar; if the DELETE fails we shrug
// (best effort — the user's calendar list will have one
// .paliad-probe-* entry; not the end of the world).
_ = c.deleteCollection(ctx, probePath)
return true, nil
case http.StatusMethodNotAllowed, http.StatusNotImplemented, http.StatusForbidden:
return false, nil
default:
// Unknown — treat as no-MKCALENDAR to be safe; the user can
// still bind by URL.
return false, nil
}
}
// MakeCalendar issues MKCALENDAR against home/<calendarName>/ and
// returns the absolute path that was created. The caller is
// responsible for picking a free slug; 405 from the server means
// "name already taken — pick another".
func (c *calDAVClient) MakeCalendar(ctx context.Context, homePath, calendarName, displayName string) (string, error) {
path := joinPath(homePath, calendarName+"/")
body := mkcalendarBody(displayName)
req, err := http.NewRequestWithContext(ctx, "MKCALENDAR", c.absURL(path), strings.NewReader(body))
if err != nil {
return "", err
}
req.SetBasicAuth(c.username, c.password)
req.Header.Set("Content-Type", "application/xml; charset=utf-8")
resp, err := c.hc.Do(req)
if err != nil {
return "", fmt.Errorf("MKCALENDAR: %w", err)
}
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusCreated, http.StatusOK:
return path, nil
case http.StatusMethodNotAllowed:
return "", ErrCalendarNameTaken
case http.StatusForbidden, http.StatusNotImplemented:
return "", ErrMKCalendarUnsupported
default:
raw, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("MKCALENDAR %s: %d %s — %s", path, resp.StatusCode, resp.Status, string(raw))
}
}
func mkcalendarBody(displayName string) string {
var b strings.Builder
b.WriteString(`<?xml version="1.0" encoding="utf-8"?>
<C:mkcalendar xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
<D:set>
<D:prop>
<D:displayname>`)
_ = xml.EscapeText(&b, []byte(displayName))
b.WriteString(`</D:displayname>
<C:supported-calendar-component-set>
<C:comp name="VEVENT"/>
</C:supported-calendar-component-set>
</D:prop>
</D:set>
</C:mkcalendar>`)
return b.String()
}
// optionsAllows returns the methods listed in the Allow header of an
// OPTIONS response. Caseless match per RFC 7231 §7.4.1.
func (c *calDAVClient) optionsAllows(ctx context.Context, path string) ([]string, error) {
req, err := http.NewRequestWithContext(ctx, "OPTIONS", c.absURL(path), nil)
if err != nil {
return nil, err
}
req.SetBasicAuth(c.username, c.password)
resp, err := c.hc.Do(req)
if err != nil {
return nil, fmt.Errorf("OPTIONS: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("OPTIONS %s: %d", path, resp.StatusCode)
}
out := []string{}
for _, h := range resp.Header.Values("Allow") {
for _, m := range strings.Split(h, ",") {
out = append(out, strings.ToUpper(strings.TrimSpace(m)))
}
}
return out, nil
}
// deleteCollection sends a DELETE that doesn't care about 404.
func (c *calDAVClient) deleteCollection(ctx context.Context, path string) error {
req, err := http.NewRequestWithContext(ctx, "DELETE", c.absURL(path), nil)
if err != nil {
return err
}
req.SetBasicAuth(c.username, c.password)
resp, err := c.hc.Do(req)
if err != nil {
return err
}
resp.Body.Close()
return nil
}
// randomToken returns a short hex string of `n` bytes. Used for the
// synthetic MKCALENDAR probe path; doesn't need to be cryptographically
// strong (the worst-case is a collision with an existing calendar of
// the same name, which we catch as ErrCalendarNameTaken upstream).
func randomToken(n int) string {
buf := make([]byte, n)
_, _ = rand.Read(buf)
return hex.EncodeToString(buf)
}
// joinPath cleans up double slashes between calendar path and uid.
func joinPath(base, name string) string {
base = strings.TrimRight(base, "/")
@@ -221,6 +637,7 @@ type propStat struct {
Status string `xml:"DAV: status"`
Prop struct {
ETag string `xml:"DAV: getetag"`
CalendarData string `xml:"urn:ietf:params:xml:ns:caldav calendar-data"`
ResourceType struct {
Collection *struct{} `xml:"DAV: collection"`
} `xml:"DAV: resourcetype"`
@@ -232,6 +649,92 @@ type multiStatus struct {
Responses []msResponse `xml:"DAV: response"`
}
// propHrefMultiStatus is used to extract <DAV:href> children out of the
// <D:current-user-principal/> and <C:calendar-home-set/> properties.
// Both render as: <prop><name><href>…</href></name></prop>.
type propHrefMultiStatus struct {
XMLName xml.Name `xml:"DAV: multistatus"`
Responses []propHrefResponse `xml:"DAV: response"`
}
type propHrefResponse struct {
XMLName xml.Name `xml:"DAV: response"`
Href string `xml:"DAV: href"`
Propstat []propHrefPropstat `xml:"DAV: propstat"`
}
type propHrefPropstat struct {
XMLName xml.Name `xml:"DAV: propstat"`
Status string `xml:"DAV: status"`
Prop struct {
CurrentUserPrincipal struct {
Hrefs []string `xml:"DAV: href"`
} `xml:"DAV: current-user-principal"`
CalendarHomeSet struct {
Hrefs []string `xml:"DAV: href"`
} `xml:"urn:ietf:params:xml:ns:caldav calendar-home-set"`
} `xml:"DAV: prop"`
}
// calendarHomeMultiStatus parses the response to a Depth:1 PROPFIND on
// calendar-home-set asking for resourcetype + displayname +
// supported-calendar-component-set.
type calendarHomeMultiStatus struct {
XMLName xml.Name `xml:"DAV: multistatus"`
Responses []calendarHomeResponse `xml:"DAV: response"`
}
type calendarHomeResponse struct {
XMLName xml.Name `xml:"DAV: response"`
Href string `xml:"DAV: href"`
Propstat []calendarHomePropstat `xml:"DAV: propstat"`
}
type calendarHomePropstat struct {
XMLName xml.Name `xml:"DAV: propstat"`
Status string `xml:"DAV: status"`
Prop struct {
DisplayName string `xml:"DAV: displayname"`
ResourceType struct {
Calendar *struct{} `xml:"urn:ietf:params:xml:ns:caldav calendar"`
} `xml:"DAV: resourcetype"`
SupportedCalendarComponentSet struct {
Comp []struct {
Name string `xml:"name,attr"`
} `xml:"urn:ietf:params:xml:ns:caldav comp"`
} `xml:"urn:ietf:params:xml:ns:caldav supported-calendar-component-set"`
} `xml:"DAV: prop"`
}
func parseMultigetResponse(r io.Reader) ([]MultigetEvent, error) {
var ms multiStatus
dec := xml.NewDecoder(r)
if err := dec.Decode(&ms); err != nil {
return nil, fmt.Errorf("decode multistatus: %w", err)
}
out := []MultigetEvent{}
for _, resp := range ms.Responses {
var etag, data string
ok := false
for _, ps := range resp.Propstat {
if !strings.Contains(ps.Status, "200") {
continue
}
etag = strings.Trim(ps.Prop.ETag, `"`)
data = ps.Prop.CalendarData
if data != "" {
ok = true
}
}
if !ok {
// 404 / 403 on this specific href — treat as missing, skip.
continue
}
out = append(out, MultigetEvent{Href: resp.Href, ETag: etag, CalendarData: data})
}
return out, nil
}
func parseMultiStatus(r io.Reader) ([]CalDAVEntry, error) {
var ms multiStatus
dec := xml.NewDecoder(r)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,157 @@
package services
// DashboardLayoutService is the CRUD layer for paliad.user_dashboard_layouts —
// per-user configurable dashboard layout for /dashboard.
//
// Design: docs/design-dashboard-configurable-2026-05-20.md §5.4.
//
// Visibility: every read and write is scoped to the calling user via the
// RLS policy `user_dashboard_layouts_owner_all` on auth.uid() = user_id.
// The service also AND-joins user_id in SQL for defense-in-depth.
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
)
// DashboardLayoutService manages paliad.user_dashboard_layouts.
type DashboardLayoutService struct {
db *sqlx.DB
}
// NewDashboardLayoutService wires the service.
func NewDashboardLayoutService(db *sqlx.DB) *DashboardLayoutService {
return &DashboardLayoutService{db: db}
}
// 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
// ON CONFLICT DO NOTHING clause.
//
// The returned spec has SanitizeForRead applied; if any entries were
// dropped (catalog shrank) the cleaned spec is also persisted back so the
// next write doesn't trip on stale entries.
func (s *DashboardLayoutService) GetOrSeed(ctx context.Context, userID uuid.UUID) (DashboardLayoutSpec, error) {
spec, found, err := s.fetch(ctx, userID)
if err != nil {
return DashboardLayoutSpec{}, err
}
if !found {
return s.seedFactoryDefault(ctx, userID)
}
if spec.SanitizeForRead() {
// Best-effort cleanup; on failure we still return the in-memory
// sanitized spec — the user sees a clean dashboard either way.
_ = s.upsert(ctx, userID, spec)
}
return spec, nil
}
// Update validates the spec and UPSERTs it. Returns the persisted spec
// (round-tripped through the DB to confirm storage).
func (s *DashboardLayoutService) Update(ctx context.Context, userID uuid.UUID, spec DashboardLayoutSpec) (DashboardLayoutSpec, error) {
if err := spec.Validate(); err != nil {
return DashboardLayoutSpec{}, err
}
if err := s.upsert(ctx, userID, spec); err != nil {
return DashboardLayoutSpec{}, err
}
out, found, err := s.fetch(ctx, userID)
if err != nil {
return DashboardLayoutSpec{}, err
}
if !found {
return DashboardLayoutSpec{}, fmt.Errorf("dashboard layout vanished after upsert for user %s", userID)
}
return out, nil
}
// ResetToDefault overwrites the user's layout with the factory default.
func (s *DashboardLayoutService) ResetToDefault(ctx context.Context, userID uuid.UUID) (DashboardLayoutSpec, error) {
def := FactoryDefaultLayout()
if err := s.upsert(ctx, userID, def); err != nil {
return DashboardLayoutSpec{}, err
}
return def, nil
}
// fetch returns (spec, found, err). found=false means the user has no row
// yet — the seed path takes over.
func (s *DashboardLayoutService) fetch(ctx context.Context, userID uuid.UUID) (DashboardLayoutSpec, bool, error) {
var raw json.RawMessage
err := s.db.GetContext(ctx, &raw, `
SELECT layout_json
FROM paliad.user_dashboard_layouts
WHERE user_id = $1
`, userID)
if errors.Is(err, sql.ErrNoRows) {
return DashboardLayoutSpec{}, false, nil
}
if err != nil {
return DashboardLayoutSpec{}, false, fmt.Errorf("fetch dashboard layout: %w", err)
}
var spec DashboardLayoutSpec
if err := json.Unmarshal(raw, &spec); err != nil {
// Stored row is unparseable — treat as a missing row, the seed
// path will overwrite it. Log via the returned error wrapper.
return DashboardLayoutSpec{}, false, fmt.Errorf("dashboard layout JSON decode for user %s: %w", userID, err)
}
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.
func (s *DashboardLayoutService) seedFactoryDefault(ctx context.Context, userID uuid.UUID) (DashboardLayoutSpec, error) {
def := FactoryDefaultLayout()
bytes, err := json.Marshal(def)
if err != nil {
return DashboardLayoutSpec{}, fmt.Errorf("seed dashboard layout marshal: %w", err)
}
if _, err := s.db.ExecContext(ctx, `
INSERT INTO paliad.user_dashboard_layouts (user_id, layout_json)
VALUES ($1, $2)
ON CONFLICT (user_id) DO NOTHING
`, userID, json.RawMessage(bytes)); err != nil {
return DashboardLayoutSpec{}, fmt.Errorf("seed dashboard layout insert: %w", err)
}
// Re-fetch in case ON CONFLICT DO NOTHING let another writer's row win;
// either way the user now has a row.
out, found, err := s.fetch(ctx, userID)
if err != nil {
return DashboardLayoutSpec{}, err
}
if !found {
// Extremely unlikely — would mean the row vanished between
// INSERT and SELECT. Return the factory default in-memory.
return def, nil
}
return out, nil
}
// upsert overwrites the layout. updated_at gets bumped on conflict so
// callers can observe write recency.
func (s *DashboardLayoutService) upsert(ctx context.Context, userID uuid.UUID, spec DashboardLayoutSpec) error {
bytes, err := json.Marshal(spec)
if err != nil {
return fmt.Errorf("dashboard layout marshal: %w", err)
}
_, err = s.db.ExecContext(ctx, `
INSERT INTO paliad.user_dashboard_layouts (user_id, layout_json)
VALUES ($1, $2)
ON CONFLICT (user_id) DO UPDATE
SET layout_json = EXCLUDED.layout_json,
updated_at = now()
`, userID, json.RawMessage(bytes))
if err != nil {
return fmt.Errorf("dashboard layout upsert: %w", err)
}
return nil
}

View File

@@ -0,0 +1,181 @@
package services
// Live-DB tests for DashboardLayoutService. Skipped when TEST_DATABASE_URL
// is unset.
import (
"context"
"encoding/json"
"os"
"testing"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
"mgit.msbls.de/m/paliad/internal/db"
)
type dashboardLayoutTestEnv struct {
t *testing.T
pool *sqlx.DB
svc *DashboardLayoutService
userID uuid.UUID
cleanup func()
}
func setupDashboardLayoutTest(t *testing.T) *dashboardLayoutTestEnv {
t.Helper()
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
}
if err := db.ApplyMigrations(url); err != nil {
t.Fatalf("apply migrations: %v", err)
}
pool, err := sqlx.Connect("postgres", url)
if err != nil {
t.Fatalf("connect: %v", err)
}
ctx := context.Background()
userID := uuid.New()
if _, err := pool.ExecContext(ctx,
`INSERT INTO auth.users (id, email) VALUES ($1, $1::text || '@test.local')
ON CONFLICT (id) DO NOTHING`, userID); err != nil {
t.Logf("skip auth.users seed: %v", err)
}
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.users (id, email, display_name, office, global_role)
VALUES ($1, $1::text || '@test.local', 'Dashboard Layout Test', 'munich', 'standard')
ON CONFLICT (id) DO NOTHING`, userID); err != nil {
t.Fatalf("seed paliad.users: %v", err)
}
cleanup := func() {
c := context.Background()
pool.ExecContext(c, `DELETE FROM paliad.user_dashboard_layouts WHERE user_id = $1`, userID)
pool.ExecContext(c, `DELETE FROM paliad.users WHERE id = $1`, userID)
pool.ExecContext(c, `DELETE FROM auth.users WHERE id = $1`, userID)
pool.Close()
}
return &dashboardLayoutTestEnv{
t: t,
pool: pool,
svc: NewDashboardLayoutService(pool),
userID: userID,
cleanup: cleanup,
}
}
func TestDashboardLayoutService_GetOrSeedAutoSeeds(t *testing.T) {
env := setupDashboardLayoutTest(t)
defer env.cleanup()
ctx := context.Background()
spec, err := env.svc.GetOrSeed(ctx, env.userID)
if err != nil {
t.Fatalf("GetOrSeed: %v", err)
}
if spec.Version != LayoutSpecVersion {
t.Errorf("seeded version=%d; want %d", spec.Version, LayoutSpecVersion)
}
if len(spec.Widgets) != len(KnownWidgetKeys) {
t.Errorf("seeded widget count=%d; want %d", len(spec.Widgets), len(KnownWidgetKeys))
}
// Second call returns the same row, not a second seed.
spec2, err := env.svc.GetOrSeed(ctx, env.userID)
if err != nil {
t.Fatalf("GetOrSeed second: %v", err)
}
if len(spec2.Widgets) != len(spec.Widgets) {
t.Errorf("second call widget count drifted: %d vs %d", len(spec2.Widgets), len(spec.Widgets))
}
}
func TestDashboardLayoutService_UpdateRoundTrips(t *testing.T) {
env := setupDashboardLayoutTest(t)
defer env.cleanup()
ctx := context.Background()
// Seed first so the row exists.
if _, err := env.svc.GetOrSeed(ctx, env.userID); err != nil {
t.Fatalf("GetOrSeed: %v", err)
}
// Custom layout: hide matter-summary, reorder.
custom := DashboardLayoutSpec{
Version: LayoutSpecVersion,
Widgets: []DashboardWidgetRef{
{Key: WidgetUpcomingDeadlines, Visible: true, Settings: json.RawMessage(`{"count": 5, "horizon_days": 14}`)},
{Key: WidgetMatterSummary, Visible: false},
{Key: WidgetDeadlineSummary, Visible: true},
},
}
out, err := env.svc.Update(ctx, env.userID, custom)
if err != nil {
t.Fatalf("Update: %v", err)
}
if len(out.Widgets) != 3 {
t.Fatalf("Update returned %d widgets; want 3", len(out.Widgets))
}
if out.Widgets[0].Key != WidgetUpcomingDeadlines {
t.Errorf("Update returned widgets[0]=%q; want %q", out.Widgets[0].Key, WidgetUpcomingDeadlines)
}
if out.Widgets[1].Visible {
t.Errorf("Update returned widgets[1].Visible=true; want false")
}
// Re-read confirms persistence.
got, err := env.svc.GetOrSeed(ctx, env.userID)
if err != nil {
t.Fatalf("GetOrSeed after update: %v", err)
}
if len(got.Widgets) != 3 {
t.Errorf("GetOrSeed after update: %d widgets; want 3", len(got.Widgets))
}
}
func TestDashboardLayoutService_UpdateRejectsInvalid(t *testing.T) {
env := setupDashboardLayoutTest(t)
defer env.cleanup()
ctx := context.Background()
bad := DashboardLayoutSpec{
Version: LayoutSpecVersion,
Widgets: []DashboardWidgetRef{
{Key: "fake-widget-key", Visible: true},
},
}
if _, err := env.svc.Update(ctx, env.userID, bad); err == nil {
t.Fatalf("Update accepted invalid layout")
}
}
func TestDashboardLayoutService_ResetToDefault(t *testing.T) {
env := setupDashboardLayoutTest(t)
defer env.cleanup()
ctx := context.Background()
// Custom layout first.
custom := DashboardLayoutSpec{
Version: LayoutSpecVersion,
Widgets: []DashboardWidgetRef{
{Key: WidgetDeadlineSummary, Visible: true},
},
}
if _, err := env.svc.Update(ctx, env.userID, custom); err != nil {
t.Fatalf("Update: %v", err)
}
// Reset.
reset, err := env.svc.ResetToDefault(ctx, env.userID)
if err != nil {
t.Fatalf("ResetToDefault: %v", err)
}
if len(reset.Widgets) != len(KnownWidgetKeys) {
t.Errorf("reset widget count=%d; want %d", len(reset.Widgets), len(KnownWidgetKeys))
}
}

View File

@@ -0,0 +1,176 @@
package services
// DashboardLayoutSpec — JSON shape for paliad.user_dashboard_layouts.layout_json.
//
// Design: docs/design-dashboard-configurable-2026-05-20.md §5.2.
//
// Validation surface:
// - version must be 1 (v0 / unknown versions seed the factory default at
// read time; the validator only ever sees writes from a current client).
// - widgets is at most 32 entries (sanity cap; catalog can grow but a
// single user's layout shouldn't).
// - each widget.key must be in KnownWidgetKeys on WRITE.
// - no duplicate keys.
// - each widget.settings (if present) is validated against its catalog
// entry's WidgetSettingsSchema.
//
// On READ, unknown keys are dropped silently — see SanitizeForRead.
import (
"encoding/json"
"fmt"
"slices"
)
// LayoutSpecVersion is the only supported version for v1.
const LayoutSpecVersion = 1
// LayoutWidgetCap is the sanity cap on widgets per layout. The v1 catalog
// has 7 entries; 32 leaves room for catalog growth without unbounded JSON
// blobs.
const LayoutWidgetCap = 32
// 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.
type DashboardWidgetRef struct {
Key WidgetKey `json:"key"`
Visible bool `json:"visible"`
Settings json.RawMessage `json:"settings,omitempty"`
}
// DashboardLayoutSpec is the persisted layout shape.
type DashboardLayoutSpec struct {
Version int `json:"v"`
Widgets []DashboardWidgetRef `json:"widgets"`
}
// 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.
func FactoryDefaultLayout() DashboardLayoutSpec {
catalog := WidgetCatalog()
byKey := make(map[WidgetKey]WidgetDef, len(catalog))
for _, def := range catalog {
byKey[def.Key] = def
}
widgets := make([]DashboardWidgetRef, 0, len(KnownWidgetKeys))
for _, k := range KnownWidgetKeys {
def, ok := byKey[k]
if !ok {
continue
}
ref := DashboardWidgetRef{Key: k, Visible: def.DefaultVisible}
if settings := defaultSettingsJSON(def); settings != nil {
ref.Settings = settings
}
widgets = append(widgets, ref)
}
return DashboardLayoutSpec{
Version: LayoutSpecVersion,
Widgets: widgets,
}
}
// defaultSettingsJSON encodes the per-widget defaults declared on the
// catalog entry. Returns nil when the widget has no settings.
func defaultSettingsJSON(def WidgetDef) json.RawMessage {
if def.DefaultCount == nil && def.DefaultHorizon == nil {
return nil
}
out := map[string]int{}
if def.DefaultCount != nil {
out["count"] = *def.DefaultCount
}
if def.DefaultHorizon != nil {
out["horizon_days"] = *def.DefaultHorizon
}
b, err := json.Marshal(out)
if err != nil {
return nil
}
return b
}
// Validate enforces the structural invariants on write. Returns
// ErrInvalidInput wrapped with a precise message on the first violation.
func (s DashboardLayoutSpec) Validate() error {
if s.Version != LayoutSpecVersion {
return fmt.Errorf("%w: layout version %d not supported (want %d)",
ErrInvalidInput, s.Version, LayoutSpecVersion)
}
if len(s.Widgets) > LayoutWidgetCap {
return fmt.Errorf("%w: layout has %d widgets (cap %d)",
ErrInvalidInput, len(s.Widgets), LayoutWidgetCap)
}
seen := make(map[WidgetKey]bool, len(s.Widgets))
for i, w := range s.Widgets {
if !slices.Contains(KnownWidgetKeys, w.Key) {
return fmt.Errorf("%w: widgets[%d].key %q is not a known widget",
ErrInvalidInput, i, w.Key)
}
if seen[w.Key] {
return fmt.Errorf("%w: widgets has duplicate key %q",
ErrInvalidInput, w.Key)
}
seen[w.Key] = true
def, ok := LookupWidgetDef(w.Key)
if !ok {
// Defense in depth — KnownWidgetKeys was checked above.
return fmt.Errorf("%w: widgets[%d].key %q has no catalog entry",
ErrInvalidInput, i, w.Key)
}
if err := def.Settings.Validate(w.Settings); err != nil {
return fmt.Errorf("widgets[%d]: %w", i, err)
}
}
return nil
}
// SanitizeForRead applies the forgiving read-path rules: drop entries whose
// keys are not in the catalog (catalog has shrunk) and bump the version to
// the current one if missing. Settings on surviving entries pass through
// unchanged — invalid settings on read are not worth aborting over and the
// next write will reject them anyway.
//
// Returns true if anything was changed; callers can use that to decide
// whether to PUT the cleaned spec back.
func (s *DashboardLayoutSpec) SanitizeForRead() bool {
changed := false
if s.Version != LayoutSpecVersion {
s.Version = LayoutSpecVersion
changed = true
}
if len(s.Widgets) == 0 {
return changed
}
out := make([]DashboardWidgetRef, 0, len(s.Widgets))
for _, w := range s.Widgets {
if _, ok := LookupWidgetDef(w.Key); !ok {
changed = true
continue
}
out = append(out, w)
}
s.Widgets = out
return changed
}
// ParseDashboardLayoutSpec decodes JSON bytes and validates. Used by the
// HTTP handler on incoming request bodies.
func ParseDashboardLayoutSpec(b []byte) (DashboardLayoutSpec, error) {
var s DashboardLayoutSpec
if err := json.Unmarshal(b, &s); err != nil {
return DashboardLayoutSpec{}, fmt.Errorf("%w: layout JSON decode: %v", ErrInvalidInput, err)
}
if err := s.Validate(); err != nil {
return DashboardLayoutSpec{}, err
}
return s, nil
}

View File

@@ -0,0 +1,241 @@
package services
// Pure-function tests for DashboardLayoutSpec + WidgetCatalog.
// No DB; safe to run in any environment.
import (
"encoding/json"
"errors"
"strings"
"testing"
)
func TestFactoryDefaultLayout_AllKnownWidgetsPresent(t *testing.T) {
def := FactoryDefaultLayout()
if def.Version != LayoutSpecVersion {
t.Errorf("FactoryDefaultLayout version=%d; want %d", def.Version, LayoutSpecVersion)
}
if len(def.Widgets) != len(KnownWidgetKeys) {
t.Fatalf("FactoryDefaultLayout has %d widgets; want %d", len(def.Widgets), len(KnownWidgetKeys))
}
for i, k := range KnownWidgetKeys {
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)
}
}
}
func TestFactoryDefaultLayout_SettingsDefaultsPresent(t *testing.T) {
def := FactoryDefaultLayout()
for _, w := range def.Widgets {
catalogDef, ok := LookupWidgetDef(w.Key)
if !ok {
t.Errorf("factory widget %q is not in catalog", w.Key)
continue
}
hasDefaults := catalogDef.DefaultCount != nil || catalogDef.DefaultHorizon != nil
if hasDefaults && len(w.Settings) == 0 {
t.Errorf("widget %q has catalog defaults but factory layout has empty settings", w.Key)
}
if !hasDefaults && len(w.Settings) > 0 {
t.Errorf("widget %q has no catalog defaults but factory layout has settings %s", w.Key, string(w.Settings))
}
}
}
func TestFactoryDefaultLayout_PassesValidation(t *testing.T) {
def := FactoryDefaultLayout()
if err := def.Validate(); err != nil {
t.Fatalf("factory default failed Validate(): %v", err)
}
}
func TestDashboardLayoutSpec_Validate_WrongVersion(t *testing.T) {
s := DashboardLayoutSpec{Version: 99, Widgets: []DashboardWidgetRef{{Key: WidgetDeadlineSummary, Visible: true}}}
err := s.Validate()
if !errors.Is(err, ErrInvalidInput) {
t.Fatalf("Validate returned %v; want ErrInvalidInput", err)
}
if !strings.Contains(err.Error(), "version") {
t.Errorf("error %q should mention 'version'", err.Error())
}
}
func TestDashboardLayoutSpec_Validate_TooManyWidgets(t *testing.T) {
widgets := make([]DashboardWidgetRef, LayoutWidgetCap+1)
for i := range widgets {
widgets[i] = DashboardWidgetRef{Key: WidgetDeadlineSummary, Visible: true}
}
s := DashboardLayoutSpec{Version: 1, Widgets: widgets}
err := s.Validate()
if !errors.Is(err, ErrInvalidInput) {
t.Fatalf("Validate returned %v; want ErrInvalidInput", err)
}
}
func TestDashboardLayoutSpec_Validate_UnknownKey(t *testing.T) {
s := DashboardLayoutSpec{Version: 1, Widgets: []DashboardWidgetRef{
{Key: "not-a-real-widget", Visible: true},
}}
err := s.Validate()
if !errors.Is(err, ErrInvalidInput) {
t.Fatalf("Validate returned %v; want ErrInvalidInput", err)
}
}
func TestDashboardLayoutSpec_Validate_DuplicateKey(t *testing.T) {
s := DashboardLayoutSpec{Version: 1, Widgets: []DashboardWidgetRef{
{Key: WidgetDeadlineSummary, Visible: true},
{Key: WidgetDeadlineSummary, Visible: false},
}}
err := s.Validate()
if !errors.Is(err, ErrInvalidInput) {
t.Fatalf("Validate returned %v; want ErrInvalidInput", err)
}
if !strings.Contains(err.Error(), "duplicate") {
t.Errorf("error %q should mention 'duplicate'", err.Error())
}
}
func TestDashboardLayoutSpec_Validate_BadSettings(t *testing.T) {
// count not in CountOptions for upcoming-deadlines (legal: 1,3,5,10,20)
s := DashboardLayoutSpec{Version: 1, Widgets: []DashboardWidgetRef{
{Key: WidgetUpcomingDeadlines, Visible: true, Settings: json.RawMessage(`{"count": 7}`)},
}}
err := s.Validate()
if !errors.Is(err, ErrInvalidInput) {
t.Fatalf("Validate returned %v; want ErrInvalidInput", err)
}
}
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}`)},
{Key: WidgetInlineAgenda, Visible: true, Settings: json.RawMessage(`{"horizon_days": 60}`)},
{Key: WidgetRecentActivity, Visible: false},
}}
if err := s.Validate(); err != nil {
t.Fatalf("Validate returned %v; want nil", err)
}
}
func TestDashboardLayoutSpec_Validate_SettingsOnNoSettingsWidget(t *testing.T) {
// deadline-summary has no Settings schema.
s := DashboardLayoutSpec{Version: 1, Widgets: []DashboardWidgetRef{
{Key: WidgetDeadlineSummary, Visible: true, Settings: json.RawMessage(`{"count": 5}`)},
}}
err := s.Validate()
if !errors.Is(err, ErrInvalidInput) {
t.Fatalf("Validate returned %v; want ErrInvalidInput", err)
}
}
func TestDashboardLayoutSpec_SanitizeForRead_DropsUnknownKeys(t *testing.T) {
s := DashboardLayoutSpec{Version: 1, Widgets: []DashboardWidgetRef{
{Key: WidgetDeadlineSummary, Visible: true},
{Key: "deprecated-widget", Visible: true},
{Key: WidgetInlineAgenda, Visible: true},
}}
changed := s.SanitizeForRead()
if !changed {
t.Errorf("SanitizeForRead returned false; expected true (one entry dropped)")
}
if len(s.Widgets) != 2 {
t.Errorf("after sanitize: %d widgets; want 2", len(s.Widgets))
}
if s.Widgets[0].Key != WidgetDeadlineSummary || s.Widgets[1].Key != WidgetInlineAgenda {
t.Errorf("after sanitize: keys = %v %v; want %v %v",
s.Widgets[0].Key, s.Widgets[1].Key, WidgetDeadlineSummary, WidgetInlineAgenda)
}
}
func TestDashboardLayoutSpec_SanitizeForRead_NoopOnClean(t *testing.T) {
s := FactoryDefaultLayout()
if s.SanitizeForRead() {
t.Errorf("SanitizeForRead on factory default returned true; want false (already clean)")
}
}
func TestDashboardLayoutSpec_SanitizeForRead_BumpsVersion(t *testing.T) {
s := DashboardLayoutSpec{Version: 0, Widgets: []DashboardWidgetRef{{Key: WidgetDeadlineSummary, Visible: true}}}
if !s.SanitizeForRead() {
t.Errorf("SanitizeForRead returned false; expected version bump")
}
if s.Version != LayoutSpecVersion {
t.Errorf("after sanitize: Version=%d; want %d", s.Version, LayoutSpecVersion)
}
}
func TestParseDashboardLayoutSpec_RoundTrip(t *testing.T) {
def := FactoryDefaultLayout()
bytes, err := json.Marshal(def)
if err != nil {
t.Fatalf("marshal: %v", err)
}
parsed, err := ParseDashboardLayoutSpec(bytes)
if err != nil {
t.Fatalf("parse: %v", err)
}
if parsed.Version != def.Version {
t.Errorf("version mismatch: %d vs %d", parsed.Version, def.Version)
}
if len(parsed.Widgets) != len(def.Widgets) {
t.Errorf("widget count mismatch: %d vs %d", len(parsed.Widgets), len(def.Widgets))
}
}
func TestParseDashboardLayoutSpec_InvalidJSON(t *testing.T) {
_, err := ParseDashboardLayoutSpec([]byte(`{not-json}`))
if !errors.Is(err, ErrInvalidInput) {
t.Fatalf("ParseDashboardLayoutSpec returned %v; want ErrInvalidInput", err)
}
}
func TestWidgetCatalog_AllKnownKeysHaveDef(t *testing.T) {
for _, k := range KnownWidgetKeys {
def, ok := LookupWidgetDef(k)
if !ok {
t.Errorf("KnownWidgetKeys entry %q has no WidgetDef", k)
continue
}
if def.TitleDE == "" || def.TitleEN == "" {
t.Errorf("widget %q missing title (de=%q en=%q)", k, def.TitleDE, def.TitleEN)
}
if def.DescriptionDE == "" || def.DescriptionEN == "" {
t.Errorf("widget %q missing description", k)
}
}
}
func TestWidgetCatalog_NoOrphanDefs(t *testing.T) {
known := make(map[WidgetKey]bool, len(KnownWidgetKeys))
for _, k := range KnownWidgetKeys {
known[k] = true
}
for _, def := range WidgetCatalog() {
if !known[def.Key] {
// Orphans are allowed (forward-compat: pinned-projects const
// exists in widget_catalog.go before its widget module ships).
// But verify the catalog entry is internally coherent.
if def.TitleDE == "" || def.TitleEN == "" {
t.Errorf("orphan catalog entry %q must still have titles", def.Key)
}
}
}
}
func TestWidgetSettingsSchema_NilRejectsNonEmpty(t *testing.T) {
var sch *WidgetSettingsSchema
if err := sch.Validate(json.RawMessage(`{"count": 5}`)); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("nil schema accepted settings; got %v", err)
}
if err := sch.Validate(nil); err != nil {
t.Errorf("nil schema rejected empty settings: %v", err)
}
if err := sch.Validate(json.RawMessage(`null`)); err != nil {
t.Errorf("nil schema rejected 'null' settings: %v", err)
}
}

Some files were not shown because too many files have changed in this diff Show More