Compare commits

..

26 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
53 changed files with 6821 additions and 1156 deletions

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,14 +178,23 @@ 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

View File

@@ -1,474 +0,0 @@
# Design — gap-tolerant migration runner (applied-set tracker)
**Status:** inventor draft, awaiting m
**Task:** t-paliad-218
**Gitea issue:** m/paliad#44
**Branch:** `mai/boltzmann/inventor-gap-tolerant`
**Author:** boltzmann (inventor)
**Date:** 2026-05-20
---
## §0 Live state verified (2026-05-20, 10:38)
Three things checked against the youpc Supabase before designing on top of any premise:
| Check | Result |
|---|---|
| Live tracker location | `paliad.paliad_schema_migrations` exists, `version=106, dirty=false` |
| Stale tracker | `public.paliad_schema_migrations` exists, `version=2, dirty=true` — leftover from a prior outage; not read by anything today |
| Mig 103 status on prod | Applied (cols `counter_payload`, `previous_request_id` present; CHECK constraint includes `'changes_requested'`) |
| golang-migrate version | `v4.19.1` (per `go.mod`) |
| Migration count on disk | 210 files (105 up + 105 down), highest version `106_add_madrid_office` |
| `migrate.Down`/`m.Steps(-N)` call sites | **Zero**. No code path rolls migrations back. `.down.sql` files are reference-only. |
The `migrate.go:36` comment claims the tracker lives in `public` schema. That is misleading — the live tracker is in `paliad`. The `public.paliad_schema_migrations` row at v2 is a corpse from an earlier incident captured in memory `652b856f…` and `638694f8…`. **This is a doc bug**`migrate.go`'s schema-routing rationale paragraph needs replacing with the truth: the runner connection's `search_path` includes `paliad`, so the tracker lands in `paliad`.
`main_smoke_test.go:145` and `migrate_test.go:135` both `SELECT FROM public.paliad_schema_migrations`. Against the production DB those tests would read `version=2, dirty=true` and fail. They pass today because they run against a separate scratch DB (`TEST_DATABASE_URL`) whose tracker landed in `public` historically. Both tests must be updated as part of this rollout (covered in §6).
---
## §1 The problem in one paragraph
`golang-migrate/migrate/v4` tracks applied state as a **single integer** in `paliad_schema_migrations(version int, dirty bool)`. Semantics: "every version ≤ current is considered applied; `migrate.Up()` runs only versions strictly greater than current." In a parallel-merge workflow where two workers' migration numbers race, whichever lands first claims the counter and the other gets **permanently skipped** with no visible failure. The 2026-05-20 hertz/fermi race did exactly that to mig 103; production was running approval-suggest-changes code against a schema that didn't have it. Recovery was manual SQL apply, which is invisible to migrate — `migrate.Down(1)` would skip mig 103's down too.
Convention-level mitigations (head reads `ls migrations/ | tail` before every merge; per the friction note in memory `8e4a2853…`) are real but brittle. The **runner itself should be the safety net**, not the convention.
---
## §2 Target shape — one paragraph
Replace the single-integer counter with a **set** of applied versions stored in `paliad.applied_migrations(version int PK, name text, applied_at timestamptz, checksum text NULL)`. On every deploy, the new runner scans the embedded migrations FS, computes `pending = on_disk \ applied`, and applies pending in ascending order — each one in its own transaction together with the `INSERT INTO applied_migrations`. "Applied" is set-membership, not counter-comparison; gaps in the version space are first-class. After `applied_migrations` exists, the old `paliad.paliad_schema_migrations` tracker becomes irrelevant and gets dropped (along with the stale `public.` one).
---
## §3 Schema — `paliad.applied_migrations`
```sql
CREATE TABLE paliad.applied_migrations (
version int NOT NULL PRIMARY KEY,
name text NOT NULL,
applied_at timestamptz NOT NULL DEFAULT now(),
checksum text NULL
);
-- One row per applied .up.sql. checksum is SHA-256 hex of the .up.sql file
-- contents at apply time, or NULL for migrations applied by the legacy
-- runner before checksum tracking existed (1..106 are NULL on backfill).
```
**Why `version` as the PK** — guarantees set semantics: trying to apply the same version twice fails on PK violation (caught by the per-mig transaction and surfaced as a deploy error, not silent dup). One row per version, no soft state, no compound keys.
**Why `name` separate from version** — debuggability. `SELECT * FROM applied_migrations ORDER BY version` reads like a deploy log without needing to cross-reference the filename layout on disk. Also lets the runner verify name agreement between disk and DB (catches "I renamed mig 098_x to 098_y but kept the version" → mismatched name in DB triggers a hard-fail on next deploy).
**Why `applied_at` not `applied_at_ms`**`timestamptz` is the project convention. Microsecond precision is not load-bearing; ordering is by `version`, not by time.
**Why `checksum` nullable** — backfilled rows (1..106) have no content provenance — those migrations applied via golang-migrate which didn't hash anything. Rather than fabricate a hash by re-reading the .up.sql today (which would give a false sense of "this is what was applied"), we admit ignorance and leave it NULL. The new runner populates it going forward.
---
## §4 Runner contract — `internal/db/migrate.go`
`ApplyMigrations(databaseURL string) error` keeps its current signature. The implementation changes underneath.
### §4.1 Algorithm
```
1. open DB; ping; bootstrap: CREATE SCHEMA IF NOT EXISTS paliad
2. acquire pg_advisory_lock(<paliad-namespace-int>); release on defer
3. CREATE TABLE IF NOT EXISTS paliad.applied_migrations (...)
4. if applied_migrations is empty:
seed from paliad.paliad_schema_migrations: for every version v ≤ old.version,
for the on-disk filename of v, INSERT (v, name, now(), NULL)
— mig 103 is included because v ≤ 106; checksum left NULL
— see §5 for the bootstrap details
5. scan embed.FS for *.up.sql files
6. parse into list of (version, name, filename)
7. hard-fail if two files share a version (collision detection)
8. hard-fail if any filename has a name in DB that doesn't match
(rename detection — see §3 "Why `name` separate")
9. pending = filter(on-disk where version NOT IN applied_migrations)
10. sort pending by version ascending
11. for each pending:
BEGIN
execute .up.sql contents
INSERT INTO applied_migrations(version, name, applied_at, checksum)
VALUES (v, n, now(), sha256(file_bytes))
COMMIT — on any error, ROLLBACK + return the error
12. release advisory lock
```
Each `.up.sql + INSERT` is **one transaction**. All-or-nothing per migration: if the SQL fails, the INSERT doesn't happen and we re-try on the next deploy. Same fail-fast posture as today.
### §4.2 Pseudocode skeleton
```go
func ApplyMigrations(databaseURL string) error {
conn := openAndPing(databaseURL)
defer conn.Close()
if _, err := conn.Exec(`CREATE SCHEMA IF NOT EXISTS paliad`); err != nil { ... }
// Advisory lock — int picked from the hash of "paliad.applied_migrations".
// pg_advisory_lock blocks until acquired; matches golang-migrate's own
// lock pattern and survives connection death (released on disconnect).
if _, err := conn.Exec(`SELECT pg_advisory_lock($1)`, advisoryLockID); err != nil { ... }
defer conn.Exec(`SELECT pg_advisory_unlock($1)`, advisoryLockID)
if _, err := conn.Exec(createAppliedMigrationsSQL); err != nil { ... }
if err := bootstrapFromLegacyTracker(conn); err != nil { ... }
onDisk, err := scanEmbedFS() // returns sorted []migration; fails on collision
if err != nil { ... }
applied, err := readAppliedMigrations(conn)
if err != nil { ... }
if err := checkNameAgreement(onDisk, applied); err != nil { ... } // rename detection
pending := diff(onDisk, applied)
for _, m := range pending {
if err := applyOne(conn, m); err != nil {
return fmt.Errorf("migration %s: %w", m.filename, err)
}
}
return nil
}
func applyOne(conn *sql.DB, m migration) error {
body, _ := migrationFS.ReadFile("migrations/" + m.filename)
checksum := fmt.Sprintf("%x", sha256.Sum256(body))
tx, err := conn.Begin()
if err != nil { return err }
defer tx.Rollback()
if _, err := tx.Exec(string(body)); err != nil { return 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 err }
return tx.Commit()
}
```
### §4.3 Advisory lock ID
`pg_advisory_lock(int8)` — pick a constant derived from `hash('paliad.applied_migrations')` truncated to int64. Documented in code. Standard pattern; matches what golang-migrate does internally.
### §4.4 Collision detection (§7 of issue acceptance)
`scanEmbedFS` groups by `version` prefix. If any version has ≥2 distinct `.up.sql` files, return a fatal error before any tx opens. Deploy fails fast with the filenames in the error message.
### §4.5 Rename detection (defensive, not in issue acceptance — recommended)
Pure git accidents — renaming `098_foo.up.sql` to `098_bar.up.sql` after merge — currently break tests but don't break prod because golang-migrate only cares about version. With `applied_migrations.name`, a rename would land a DB row whose name doesn't match the file. Catch it: if `(version, name)` on disk doesn't match `(version, name)` in DB for an already-applied version, hard-fail the deploy. Operator's recovery: revert the rename, or run a one-shot `UPDATE applied_migrations SET name = '<new>' WHERE version = N` if the rename is intentional.
---
## §5 Bootstrap — getting from old world to new
Two viable paths. Recommendation: **runner-bootstrap (option B)**, see §10/Q4.
### §5.1 Option A — mig 107 SQL file (issue body's proposal)
`107_backfill_applied_migrations.up.sql`:
```sql
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
);
-- One INSERT per historical version 001..106, hardcoded.
-- ON CONFLICT DO NOTHING for re-run safety.
INSERT INTO paliad.applied_migrations(version, name, applied_at) VALUES
(1, 'paliad_schema', now()),
(2, 'users', now()),
...
(103, 'approval_suggest_changes', now()),
...
(106, 'add_madrid_office', now()),
(107, 'backfill_applied_migrations', now()) -- self-record
ON CONFLICT (version) DO NOTHING;
```
**Down step:**
```sql
DROP TABLE IF EXISTS paliad.applied_migrations;
```
**Pros:** visible in `git log -- internal/db/migrations/`, explainable.
**Cons:** the in-transition deploy still runs golang-migrate (to apply mig 107). Adds a step where two runners coexist for one deploy. Also: mig 107 has special "self-record" semantics that no other migration has, which is a minor footgun for future readers.
### §5.2 Option B — runner-bootstrap (recommended)
No mig 107 file. The new runner code path includes a one-shot bootstrap:
```go
func bootstrapFromLegacyTracker(conn *sql.DB) error {
var count int
if err := conn.QueryRow(`SELECT count(*) FROM paliad.applied_migrations`).Scan(&count); err != nil {
return err
}
if count > 0 {
return nil // already bootstrapped
}
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 // virgin DB, applied_migrations stays empty; runner will apply 001..N from scratch
}
if err != nil { return err }
if legacyDirty {
return fmt.Errorf("legacy tracker is dirty at version %d — recover manually before deploying", legacyVer)
}
// Backfill: every version ≤ legacyVer that exists on disk. checksum NULL.
for _, m := range loadAllOnDisk() {
if m.version > legacyVer { continue }
_, 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)
if err != nil { return err }
}
return nil
}
```
**Pros:** idempotent (re-running is a no-op via the empty-check + ON CONFLICT). No SQL file with one-off semantics. One code path. Naturally handles "virgin DB during fresh test setup" (no legacy tracker → bootstrap is a no-op, runner applies 001..N normally).
**Cons:** invisible in `git log -- migrations/`. Future devs see `applied_migrations` rows with NULL checksums and need to understand why.
### §5.3 What about mig 103 specifically?
Both paths put mig 103 in `applied_migrations` with `checksum=NULL`. That's the right answer:
- The mig 103 .up.sql DID run on prod (verified §0).
- We don't know whether what ran on prod (manual SQL apply via Supabase MCP) was byte-identical to today's embedded `103_approval_suggest_changes.up.sql`. It almost certainly is, but "almost" doesn't earn a non-NULL checksum.
- Leaving checksum NULL means: "this version is recorded as applied, but we cannot verify content fidelity." That's honest.
### §5.4 Deploy ordering — what happens in production
After this design ships:
1. **Deploy N (today):** old runner. `paliad.paliad_schema_migrations` at v106. No `paliad.applied_migrations`.
2. **Deploy N+1 (this change):** new runner first deploy.
- `bootstrapFromLegacyTracker` sees `applied_migrations` empty + legacy tracker at v106 + clean.
- INSERTs rows for every on-disk version 1..106 with checksum=NULL.
- `pending` = empty (every disk version is now in DB).
- No migrations actually run. The deploy is a code-only change.
3. **Deploy N+2 (first deploy with a new mig 107):** new runner.
- `applied_migrations` has 106 rows from bootstrap.
- Disk has 107 files (001..107).
- Pending = {107}. Apply 107 in tx, INSERT row.
4. **Cleanup (separate PR, after burn-in):** drop `paliad.paliad_schema_migrations` and `public.paliad_schema_migrations` via mig 108_drop_legacy_trackers.
---
## §6 Test updates
Two existing tests read `public.paliad_schema_migrations`:
1. **`internal/db/migrate_test.go:135`** — `TestMigrations_DryRun` reads the tracker version to compute "pending." Update to read `paliad.applied_migrations`:
- `SELECT version FROM paliad.applied_migrations` — set of applied versions
- `pending = on_disk \ applied`
- Same per-migration BEGIN/ROLLBACK probe. Same skip-without-TEST_DATABASE_URL semantics.
2. **`cmd/server/main_smoke_test.go:145`** — `TestBootSmoke` reads `version, dirty FROM public.paliad_schema_migrations` after `ApplyMigrations`. Update to:
- `SELECT max(version) FROM paliad.applied_migrations` — assert equals `highestEmbeddedMigrationVersion`
- No dirty flag in the new schema (each row is fully committed or not present); drop the dirty check.
- The boot-smoke contract gets stronger: "applied set after ApplyMigrations matches the on-disk set exactly." Failure mode is no longer "tracker dirty" but "row missing" — more direct.
A third test-impacting change: every service test that calls `db.ApplyMigrations(url)` (45 call sites per grep) continues to work because `ApplyMigrations` is the same function signature. The first call on a virgin scratch DB now creates `applied_migrations` and applies every embedded migration; the second call sees `applied = on_disk` and no-ops. Identical to today's behavior with golang-migrate. No service-test changes needed.
The CI dry-run gate (`make verify-migrations` or its equivalent) similarly continues working — `TestMigrations_DryRun` just reads from a different table.
---
## §7 Down-migration support
Today's reality:
- `migrate.Down`/`m.Steps(-N)` is called from **zero** places in the codebase (verified).
- `.down.sql` files exist as reference material and ship in the embed.FS but never run via `db.ApplyMigrations`.
Three options for the new runner:
**A. Implement minimal `RollbackOne(databaseURL)` or `RollbackTo(databaseURL, version)`** — runs the highest-version `.down.sql` whose version is currently in `applied_migrations`, plus `DELETE FROM applied_migrations WHERE version = N`, in a tx. No `migrate.Force()`-style override. Adds ~30 LoC.
**B. Up-only runner.** Document in `migrate.go` comment that down is manual: operator runs `.down.sql` through psql, then `DELETE FROM paliad.applied_migrations WHERE version = N`. Recover-by-runbook. Adds 0 LoC, 0 risk.
**C. Drop `.down.sql` files entirely.** Aggressive. Not recommended — they're useful as reference even if never auto-applied.
Recommendation: **B**. Nobody calls down today; designing a Down method for hypothetical future use is premature. If down is needed later, add it as a focused PR. Issue body §"down-migration path still works" reads to me as "don't break the .down.sql file convention," not "build an auto-down runner" — but I want m to confirm (Q3 in §10).
---
## §8 Checksum drift detection — three pour-overs
The optional-v1 piece. Three positions on the dial:
**A. Full drift detection in v1.** Every deploy re-hashes embedded migrations; if a previously-applied version's checksum differs from DB, hard-fail with the diff. Catches "I edited a shipped migration" instantly. Backfilled rows (NULL checksum) skip the check.
**B. Populate-but-don't-verify.** Store the checksum on apply (cheap). Skip the verify step. We accumulate the data needed for future drift detection without burning deploys on "you edited a file that's already shipped" before m wants that behavior. Easy to flip on later (one if-block).
**C. Defer entirely.** No checksum column. Add it when we want drift detection.
Recommendation: **B**. The cost of storing the checksum is one `sha256` call per migration on apply (~µs). The cost of populating later is "go back through prod history and reconstruct what shipped" — not feasible. So store now, even if we don't verify yet. If m wants verification on day 1, A is the next step up — just flip the if-block.
---
## §9 Alternatives considered and rejected
### §9.1 Swap to goose or dbmate
OOS per issue body. Both are mature, both support gap-tolerant apply, both would require rewriting all 105 migration files into their preferred format (different up/down separator conventions, different tracker shapes). Not worth the migration overhead for what's structurally a small fix.
### §9.2 Wrap golang-migrate with a custom `Driver` that uses the applied-set table
Possible — golang-migrate's Driver interface is small. We'd implement a custom `database.Driver` whose `SetVersion()` becomes `INSERT INTO applied_migrations`, `Version()` becomes `MAX(version)`, etc. Then `migrate.Up()` would Just Work.
Why I rejected: golang-migrate's contract is **a counter**. `Version()` returns a single int. Inside golang-migrate the algorithm is `for v := current+1; v <= max; v++ { apply(v) }`. If we tell it `Version() = 102` while applied_migrations has {1..102, 104, 105, 106} (the parallel-merge state pre-recovery), it'll try to re-apply 104 — wrong. We'd have to lie about Version() in ways that defeat the library's own loop semantics. **A custom driver doesn't actually buy us much over hand-rolling**, because golang-migrate's algorithm fundamentally assumes the counter model.
Sticking with golang-migrate as a thin wrapper with the same `iofs.Source` but a different driver is technically feasible but adds indirection without value. Hand-roll is cleaner.
### §9.3 Per-row INSERT with `ON CONFLICT DO NOTHING` instead of advisory lock
Without a lock, two concurrent deploys could both decide "version 107 is pending" and both try to apply its SQL. One would commit, the other would get a PK violation on the INSERT and roll back — and ALSO leave half a CREATE TABLE / ALTER COLUMN on prod from the SQL that ran before the failing INSERT (if the .up.sql isn't transactional or uses CONCURRENTLY).
Advisory lock prevents that race entirely. Cost is negligible (Dokploy doesn't run concurrent deploys typically; the lock is belt-and-braces for the rolling-deploy edge case the issue calls out in OOS).
### §9.4 Keep `paliad.paliad_schema_migrations` updated as a compat view
A view over `applied_migrations` that exposes `max(version)` + `false` as `dirty` could keep the old shape readable for any consumer expecting it. Today there is no such consumer — no Grafana dashboard reads it, no alert. So this is gold-plating. Drop the old tables in mig 108.
### §9.5 Per-deploy snapshot of the applied set
Tempting: write a "deploy event" row each time the runner runs, with `(deploy_id, applied_at, applied_set jsonb)`. Useful audit trail. Out of scope for v1 — `applied_at` per migration already gives a chronological view.
---
## §10 Open questions for m
Eight decisions, batched as two AskUserQuestion calls (four each).
### Batch 1 — core design
**Q1 (Library)** — How should the new runner relate to golang-migrate?
- A. **Hand-roll** over `embed.FS` (recommended). ~150 LoC in `internal/db/migrate.go`. Drop the `golang-migrate/migrate/v4` dependency. Cleanest contract.
- B. Thin wrapper: keep `golang-migrate/migrate/v4` for source/file reading; replace the driver with one that writes `applied_migrations`. Same complexity, more indirection.
- C. Don't change the library, just add `applied_migrations` as a secondary tracker the runner consults. Counterproductive — two trackers means two sources of truth, which is exactly the bug class we're trying to leave.
**Q2 (Checksum drift detection)** — When does the runner enforce content fidelity?
- A. **Populate-but-don't-verify in v1** (recommended). Store `sha256(file_bytes)` on apply. No verify-on-deploy step. Backfilled rows have NULL. Verification ships as a separate small PR when m wants it.
- B. Full drift detection in v1. Verify on every deploy; hard-fail if a previously-applied migration's content changed. Catches "you edited a shipped migration" immediately.
- C. Defer entirely. No checksum column for now. Add it (and the verify) together when we want drift detection.
**Q3 (Down migrations)** — Does v1 support `migrate down`?
- A. **Up-only runner** (recommended). `.down.sql` files stay as reference material. Operator runs them through psql + `DELETE FROM applied_migrations` manually if a roll-back is needed. Zero call sites today; YAGNI applies.
- B. Implement minimal `RollbackOne(databaseURL)` — pops the highest applied version, runs its `.down.sql` + DELETE in a tx. Adds ~30 LoC.
- C. Drop `.down.sql` files entirely from `embed.FS`. Aggressive.
**Q4 (Bootstrap path)** — How does production transition from old tracker to applied_migrations?
- A. **Runner-bootstrap on first deploy** (recommended). The new runner detects "applied_migrations empty + legacy tracker at v106" and INSERTs rows 1..106 (checksum NULL). Idempotent. No SQL file. One code path.
- B. SQL migration `107_backfill_applied_migrations.up.sql` that CREATE TABLE + INSERTs 1..107. The old runner applies it on the first deploy of the new code; from mig 108+ the new runner takes over. Visible in `git log`, but adds a one-off "self-recording" migration with special semantics.
- C. Hybrid: write the mig 107 SQL file AND have the runner be defensive (detect either "table exists with rows from mig 107" or "table missing, bootstrap from legacy"). More code, more paths, no clear win.
### Batch 2 — operational / cleanup
**Q5 (Concurrent deploys)** — How do we prevent two rolling deploys racing on the apply loop?
- A. **`pg_advisory_lock` around the apply loop** (recommended). Standard pattern, matches what golang-migrate does internally. Belt-and-braces against Dokploy's rolling deploy edge cases.
- B. No lock; rely on per-mig INSERT with `ON CONFLICT DO NOTHING` to settle races. Risky — non-transactional DDL (CREATE INDEX CONCURRENTLY, e.g.) can land partial state if two deploys race on the same migration before either INSERTs.
- C. Both. Lock + ON CONFLICT for double safety.
**Q6 (Old tracker tables)** — What happens to `paliad.paliad_schema_migrations` (v106 live) and `public.paliad_schema_migrations` (v2 dirty, stale)?
- A. **Drop both in a follow-up mig 108** (recommended), after one or two deploys of burn-in on the new runner. Clean break, single source of truth.
- B. Keep `paliad.paliad_schema_migrations` updated as a `MAX(version)` view over `applied_migrations` indefinitely. No consumer exists today; this is gold-plating for hypothetical compat.
- C. Drop the stale `public.paliad_schema_migrations` immediately (mig 107) but leave `paliad.paliad_schema_migrations` untouched. Half-measure.
**Q7 (Tests — boot smoke + dry-run)** — How do `main_smoke_test.go` and `migrate_test.go` get updated?
- A. **Both updated to read `paliad.applied_migrations`** (recommended). Boot-smoke asserts `max(applied.version) == highestEmbeddedMigrationVersion`. Dry-run computes `pending = on_disk \ applied`. The dirty-flag check disappears (rows are committed or absent, not "dirty").
- B. Keep old tests reading the legacy tracker for a transition period; add new tests against `applied_migrations` alongside. Doubles the test surface during a window where both trackers must agree — adds flakiness risk.
- C. Drop the old smoke test entirely; rely on the dry-run + a new "applied-set smoke" test only. Loses the bind-and-serve half of `TestBootSmoke`.
**Q8 (Collision detection — same version twice)** — How aggressively does the runner refuse on disk collisions?
- A. **Hard-fail in `ApplyMigrations`** on startup (recommended). Scan `embed.FS`, group by version; if any group has >1 distinct file, return an error before any tx opens. Deploy fails fast.
- B. Add a `go generate` check (e.g., `go run ./internal/db/migrations/check`) that runs during build. Catches it earlier (compile time) but adds tooling and a CI step.
- C. Convention-only: rely on PR review + `ls migrations/ | tail`. Status quo. Doesn't address the post-mortem's root cause.
---
## §11 Implementation slicing (for the eventual coder)
Not for m to decide — included so the head can scope.
1. **Slice 1 — schema + runner + bootstrap + tests.** New runner replaces `ApplyMigrations` body. `bootstrapFromLegacyTracker` runs on first deploy. Tests updated. Existing `migrate.go` comment about `public` schema rewritten with the §0 truth. CI dry-run gate still passes.
2. **Slice 2 — drop legacy trackers.** `mig 108_drop_legacy_schema_migrations.up.sql`: `DROP TABLE paliad.paliad_schema_migrations; DROP TABLE public.paliad_schema_migrations;`. Ships after one or two deploys of burn-in on Slice 1.
3. **(Optional) Slice 3 — drift detection verify.** Flip on the checksum verify if m picks Q2=A in v1 and wants A→B promotion later. ~10 LoC + a smoke test.
Slice 1 is ~250 LoC counting tests; one focused PR. Slice 2 is two SQL files. Pattern-fluent Sonnet coder is the right pick — substrate is well-trodden (`internal/db/migrate.go`, `embed.FS`, `sql.Tx`), no novel libraries.
---
## §12 Risks called out
1. **Bootstrap race on first deploy of the new runner.** If Deploy N+1 (new code) starts in two pods simultaneously, both might see `applied_migrations` empty + legacy at v106 and both try to insert. The advisory lock (Q5=A) prevents this — second pod waits for the first.
2. **Mig 103 content drift between manual SQL apply and today's embedded file.** Verified §0 that the prod schema matches what `103_approval_suggest_changes.up.sql` produces (cols, CHECK). If they're not byte-identical, future drift detection (when we turn on Q2's verify) would flag 103 — but Q5/Q2 leave 103's checksum NULL, so the verify skips it. Safe.
3. **Schema-search-path drift.** The `migrate.go` connection uses default search_path. The current `paliad.paliad_schema_migrations` table exists because some prior connection had `search_path=paliad,public`. The new `applied_migrations` is created with the schema explicit (`paliad.applied_migrations`) — no search_path dependency. Removes a class of latent bug.
4. **`paliad_schema_migrations` (live tracker) being read by anything we don't know about.** Searched: no Go code, no migrations file, no skill, no doc. If a Grafana dashboard or monitoring alert reads it, the Slice 2 drop will surprise it. Low probability given paliad is a small app, but worth a `mai instruct head` heads-up before Slice 2 lands.
5. **The 210-file embed.FS scan on every deploy boot.** O(N) for N=210; ~µs. Not a concern at this scale. If migrations grow to thousands (won't, given application size), revisit.
---
## §13 What this design does NOT change
- The `NNN_description.up.sql` / `.down.sql` filename convention. Stays.
- The `ApplyMigrations(databaseURL string) error` signature. Stays.
- The 45 service-test call sites of `db.ApplyMigrations`. Untouched.
- The CI dry-run pattern (per-mig BEGIN/ROLLBACK against scratch DB). Stays; just reads from the new tracker.
- The `paliad` schema and every existing migration's behavior. Untouched.
- The Dokploy auto-deploy hook. Untouched.
The change is **localized to `internal/db/migrate.go` + two test files + one mig 108 to drop legacy trackers** (after burn-in). Coder LoC estimate: ~250 net new + ~40 net deleted. One PR for Slice 1 should clear review in a single pass.
---
## §14 m's decisions (2026-05-20)
All eight picks matched the inventor recommendation. No tip-against, no reasoning addendum needed.
- **Q1 (Library):** Hand-roll over `embed.FS`. Drop the `golang-migrate/migrate/v4` dependency.
- **Q2 (Drift detect):** Populate-but-don't-verify in v1. Store `sha256(file_bytes)` on apply; rows 1..106 stay NULL on backfill. Verify ships as a separate small PR if/when we want it.
- **Q3 (Down migs):** Up-only runner. `.down.sql` files stay as reference. No `RollbackOne` in v1.
- **Q4 (Bootstrap):** Runner-bootstrap on first deploy. No mig 107 SQL file. Runner detects empty `applied_migrations` + legacy tracker at v106 and INSERTs rows 1..106 (checksum NULL) with `ON CONFLICT DO NOTHING`.
- **Q5 (Locking):** `pg_advisory_lock` around the apply loop. Released on `defer`. Lock ID derived from `hash('paliad.applied_migrations')` truncated to int64.
- **Q6 (Old trackers):** Drop both `paliad.paliad_schema_migrations` and `public.paliad_schema_migrations` in a follow-up mig 108, after one or two deploys of burn-in.
- **Q7 (Tests):** Both `TestBootSmoke` and `TestMigrations_DryRun` updated to read `paliad.applied_migrations`. Dirty-flag check removed. Smoke asserts `max(applied.version) == highestEmbeddedMigrationVersion`.
- **Q8 (Collisions):** Hard-fail in `ApplyMigrations` on startup. Scan groups by version; ≥2 distinct files at the same version → error before any tx opens, both filenames in the message.
### Locked scope for the coder shift (Slice 1)
- New `internal/db/migrate.go`: hand-rolled `ApplyMigrations` that
- opens DB, ensures `paliad` schema,
- acquires `pg_advisory_lock(<paliad-namespace-int>)`,
- creates `paliad.applied_migrations` if missing,
- bootstraps from `paliad.paliad_schema_migrations` if `applied_migrations` is empty,
- scans `embed.FS`, hard-fails on version collisions,
- hard-fails on rename mismatch (DB name vs disk name for an already-applied version — §4.5),
- applies pending in ascending order, each `.up.sql + INSERT` in one tx with `checksum=sha256(file_bytes)`.
- Drop `github.com/golang-migrate/migrate/v4` from `go.mod`.
- Rewrite the misleading `public` schema comment block at the top of `migrate.go` with the §0 truth.
- Update `internal/db/migrate_test.go:135` and `cmd/server/main_smoke_test.go:145` to read `paliad.applied_migrations`. Drop dirty-flag check from the smoke test.
- All 45 service-test call sites of `db.ApplyMigrations` keep working unchanged.
### Deferred to follow-up PRs (NOT in Slice 1)
- Mig 108: drop legacy tracker tables. After Slice 1 has burned in on one or two deploys.
- Optional drift-detection verify (flip Q2 from "populate" to "populate + verify"). Single if-block, single test.
- Optional `RollbackOne` if a real call site materializes.
### Recommended implementer
Pattern-fluent Sonnet coder. Substrate is well-trodden (`embed.FS`, `sql.Tx`, `crypto/sha256`, advisory locks). No novel libraries. ~250 net new LoC counting tests; ~40 deleted (`migrate.go` body + golang-migrate imports). One focused PR for Slice 1 should clear review in a single pass.
Branch convention for the coder shift: `mai/<coder>/migration-runner-applied-set` (separate worker — inventor's branch is design-only per project gate protocol).

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.

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

@@ -1,27 +1,35 @@
// t-paliad-216 Slice B — modal for the "Suggest changes" approval action.
// 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 of the date-allowlist
// fields (per entity_type) 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.
// 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 (v1):
// - update-lifecycle only — the suggest_changes button is hidden for
// create / complete / delete lifecycles in shape-list.ts, so the modal
// never opens on them. If callers somehow trigger it on an unsupported
// lifecycle, openApprovalEditModal() resolves with null (cancel) after
// surfacing the unsupported-lifecycle copy.
// - Hard-coded fields per entity_type. We deliberately don't build a
// generic field-editor framework — only two entity_types exist and
// both have small fixed allowlists.
// 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 original proposed values
// preImage: {...}, // pre-mutation values (for diff display)
// 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
@@ -30,12 +38,25 @@
// }
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 {
@@ -43,213 +64,342 @@ export interface ApprovalEditModalResult {
note: string;
}
// Per-entity-type editable field allowlist. Matches buildRevertSetClauses
// in internal/services/approval_service.go — the server side rejects any
// key outside this set anyway. Keeping the UI list in sync is a
// safety-vs-confusion trade-off: a stray key here would be silently
// dropped server-side, so it's harmless but misleading.
const DEADLINE_FIELDS: ReadonlyArray<{ key: string; type: "date" }> = [
{ key: "due_date", type: "date" },
{ key: "original_due_date", type: "date" },
{ key: "warning_date", type: "date" },
// 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<{ key: string; type: "datetime-local" }> = [
{ key: "start_at", type: "datetime-local" },
{ key: "end_at", type: "datetime-local" },
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 function openApprovalEditModal(
export async function openApprovalEditModal(
args: ApprovalEditModalArgs,
): Promise<ApprovalEditModalResult | null> {
return new Promise((resolve) => {
if (args.lifecycleEvent !== "update") {
// Defence-in-depth: shape-list.ts hides the button for non-update
// lifecycles, but if some caller bypasses that gate, fail cleanly.
window.alert(t("approvals.suggest.unsupported_lifecycle"));
resolve(null);
return;
}
if (args.lifecycleEvent !== "update") {
window.alert(t("approvals.suggest.unsupported_lifecycle"));
return null;
}
document.getElementById("approval-edit-modal")?.remove();
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>;
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";
const overlay = document.createElement("div");
overlay.id = "approval-edit-modal";
overlay.className = "modal-overlay";
overlay.innerHTML = renderShell(args, fields, original, preImage);
document.body.appendChild(overlay);
body.appendChild(renderIntro());
body.appendChild(renderFieldsSection(fields, original, preImage));
const close = (result: ApprovalEditModalResult | null) => {
overlay.remove();
document.removeEventListener("keydown", onKey);
resolve(result);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") close(null);
};
document.addEventListener("keydown", onKey);
overlay.querySelectorAll("[data-suggest-cancel]").forEach((el) =>
el.addEventListener("click", () => close(null)),
);
overlay.addEventListener("click", (e) => {
if (e.target === overlay) close(null);
});
const submitBtn = overlay.querySelector<HTMLButtonElement>("[data-suggest-submit]");
const noteEl = overlay.querySelector<HTMLTextAreaElement>("[data-suggest-note]");
const inputs = Array.from(
overlay.querySelectorAll<HTMLInputElement>("[data-suggest-field]"),
);
const refreshSubmit = () => {
if (!submitBtn) return;
const dirty = inputs.some((el) => {
const orig = formatFieldForInput(original[el.dataset.suggestField || ""]);
return el.value !== orig;
});
const hasNote = !!(noteEl && noteEl.value.trim());
submitBtn.disabled = !(dirty || hasNote);
submitBtn.title = submitBtn.disabled
? t("approvals.suggest.submit_disabled_hint")
: "";
};
inputs.forEach((el) => el.addEventListener("input", refreshSubmit));
noteEl?.addEventListener("input", refreshSubmit);
refreshSubmit();
const form = overlay.querySelector<HTMLFormElement>("[data-suggest-form]");
form?.addEventListener("submit", (e) => {
e.preventDefault();
if (submitBtn?.disabled) return;
// Build counter_payload from inputs that differ from original.
// Fields unchanged stay out of the payload — the server's
// buildRevertSetClauses only writes the keys it sees, so we don't
// need to send untouched fields.
const counterPayload: Record<string, unknown> = {};
for (const el of inputs) {
const key = el.dataset.suggestField || "";
const orig = formatFieldForInput(original[key]);
if (el.value !== orig) {
counterPayload[key] = formatFieldForServer(el.value, el.type);
}
// 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");
}
close({
counterPayload,
note: (noteEl?.value ?? "").trim(),
});
});
})();
}
// Focus first input (or note if no fields).
(inputs[0] ?? noteEl)?.focus();
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 renderShell(
args: ApprovalEditModalArgs,
fields: ReadonlyArray<{ key: string; type: string }>,
original: Record<string, unknown>,
preImage: Record<string, unknown>,
): string {
const entityLabel = esc(t(("approvals.entity." + args.entityType) as never));
const fieldRows = fields
.map((f) => {
const label = fieldLabel(args.entityType, f.key);
const value = esc(formatFieldForInput(original[f.key]));
const preVal = formatFieldForInput(preImage[f.key]);
const preHint = preVal
? `<span class="suggest-field-prehint">${esc(t("approvals.diff.before"))}: ${esc(preVal)}</span>`
: "";
return `
<label class="suggest-field">
<span class="suggest-field-label">${esc(label)}</span>
<input type="${esc(f.type)}" data-suggest-field="${esc(f.key)}" value="${value}" />
${preHint}
</label>
`;
})
.join("");
return `
<div class="modal modal-approval-suggest" role="dialog" aria-modal="true" aria-labelledby="approval-suggest-title">
<header class="modal-header">
<h2 id="approval-suggest-title">${esc(t("approvals.suggest.modal_title"))}${entityLabel}</h2>
<button type="button" class="modal-close" data-suggest-cancel aria-label="${esc(t("approvals.suggest.cancel"))}">&times;</button>
</header>
<form data-suggest-form>
<div class="modal-body">
<p class="suggest-intro muted">${esc(t("approvals.suggest.intro"))}</p>
<div class="suggest-fields">${fieldRows}</div>
<label class="suggest-note">
<span class="suggest-field-label">${esc(t("approvals.suggest.note_label"))}</span>
<textarea data-suggest-note rows="3" placeholder="${esc(t("approvals.suggest.note_placeholder"))}"></textarea>
</label>
</div>
<footer class="modal-footer">
<button type="button" class="btn btn-ghost" data-suggest-cancel>${esc(t("approvals.suggest.cancel"))}</button>
<button type="submit" class="btn btn-primary" data-suggest-submit disabled>${esc(t("approvals.suggest.submit"))}</button>
</footer>
</form>
</div>
`;
function renderIntro(): HTMLElement {
const p = document.createElement("p");
p.className = "approval-suggest-intro muted";
p.textContent = t("approvals.suggest.intro");
return p;
}
// fieldLabel — pick the user-facing label for a given (entity_type, key)
// tuple. Reuses existing entity-field i18n where it exists so the same
// label that's used on the deadline / appointment edit forms also shows
// in this modal.
function fieldLabel(entityType: string, key: string): string {
const lookups: Record<string, string> = {
"deadline.due_date": t("deadlines.field.due" as never) || "Fälligkeitsdatum",
"deadline.original_due_date": "Ursprüngliches Fälligkeitsdatum",
"deadline.warning_date": "Warndatum",
"appointment.start_at": t("appointments.field.start" as never) || "Beginn",
"appointment.end_at": t("appointments.field.end" as never) || "Ende",
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,
};
return lookups[`${entityType}.${key}`] || key;
}
// formatFieldForInput — convert a server-side payload value to the format
// the <input> wants. Dates round-trip cleanly 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.
function formatFieldForInput(v: unknown): string {
// 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);
// Pure date: keep first 10 chars.
if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return s;
// ISO timestamp: keep YYYY-MM-DDTHH:MM (drop seconds + tz).
const m = s.match(/^(\d{4}-\d{2}-\d{2})[T\s](\d{2}:\d{2})/);
if (m) return `${m[1]}T${m[2]}`;
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 the input element's string value back to
// a server-friendly shape. Date inputs send YYYY-MM-DD; datetime-local
// sends YYYY-MM-DDTHH:MM (we let the server interpret as local time, same
// as the existing entity-edit forms — there's no tz-shift specific to
// suggest-changes).
// 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 (!value) return null;
if (inputType === "date") return value; // YYYY-MM-DD
if (inputType === "datetime-local") return value; // YYYY-MM-DDTHH:MM
if (inputType === "date" || inputType === "datetime-local") {
return value || null;
}
return value;
}
// HTML-escape helper. Local to this module so the modal doesn't bring in a
// utility from elsewhere.
function esc(s: string): string {
return s.replace(/[&<>"]/g, (c) => {
switch (c) {
case "&": return "&amp;";
case "<": return "&lt;";
case ">": return "&gt;";
case '"': return "&quot;";
default: return c;
}
});
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

@@ -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;
@@ -430,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 = "";
@@ -648,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);

View File

@@ -911,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",
@@ -1262,6 +1268,8 @@ const translations: Record<Lang, Record<string, string>> = {
"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",
@@ -1683,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",
@@ -2113,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",
@@ -2244,6 +2292,17 @@ const translations: Record<Lang, Record<string, string>> = {
"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",
@@ -3536,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",
@@ -3879,6 +3943,8 @@ const translations: Record<Lang, Record<string, string>> = {
"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",
@@ -4296,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",
@@ -4726,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",
@@ -4857,6 +4963,17 @@ const translations: Record<Lang, Record<string, string>> = {
"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",

View File

@@ -184,6 +184,9 @@ async function handleSuggestChanges(
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) {
@@ -192,11 +195,17 @@ async function handleSuggestChanges(
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
@@ -208,6 +217,9 @@ async function handleSuggestChanges(
lifecycleEvent,
payload,
preImage,
projectTitle,
requesterName,
requestedAt,
});
if (!result) return; // cancel

View File

@@ -2064,6 +2064,7 @@ async function main() {
initAttachUnitForm(id);
initNotesContainer(id);
mountVerlaufFilterBar(id);
wireExportButton(id);
showTab(parseTab());
}
@@ -2686,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

@@ -412,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> {
@@ -597,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
@@ -717,6 +1131,18 @@ document.addEventListener("DOMContentLoaded", () => {
document.getElementById("caldav-form")!.addEventListener("submit", saveCalDAV);
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);

View File

@@ -17,11 +17,21 @@ 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";
@@ -125,10 +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;
@@ -180,8 +194,8 @@ function renderResults(data: DeadlineResponse) {
</div>`;
const bodyHtml = procedureView === "columns"
? renderColumnsBody(data, { showNotes })
: renderTimelineBody(data, { showParty: true, showNotes });
? renderColumnsBody(data, { editable: true, showNotes })
: renderTimelineBody(data, { showParty: true, editable: true, showNotes });
container.innerHTML = headerHtml + bodyHtml;
if (printBtn) printBtn.style.display = "block";
@@ -229,7 +243,12 @@ function syncInfAmendEnabled() {
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;
// Trigger-event label fires from the calc response (root rule).
// Until step 3 renders, fall back to an em-dash placeholder.
@@ -312,6 +331,21 @@ document.addEventListener("DOMContentLoaded", () => {
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;

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

@@ -299,6 +299,87 @@ export function deadlineCardHtml(dl: CalculatedDeadline, opts: CardOpts): string
${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 {
let html = '<div class="timeline">';
for (const dl of data.deadlines) {

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

@@ -642,11 +642,22 @@ export type I18nKey =
| "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"
@@ -698,6 +709,43 @@ export type I18nKey =
| "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"
@@ -879,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"
@@ -1670,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"
@@ -1954,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"

View File

@@ -81,6 +81,20 @@ export function renderProjectsDetail(): string {
<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.

View File

@@ -323,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">
@@ -392,6 +411,89 @@ export function renderSettings(): string {
<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

@@ -3882,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;
@@ -12202,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;

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

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

View File

@@ -2,16 +2,19 @@ package handlers
// Data-export handlers (t-paliad-214).
//
// Slice 1 ships the personal scope only:
//
// Slice 1: personal scope
// GET /api/me/export → streams a personal-scope export .zip
//
// Slices 2 + 3 (project + org) layer onto this file when they ship.
// 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. We do not gate
// on global_role here — personal export is available to every authenticated
// user.
// 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"
@@ -22,6 +25,8 @@ import (
"strconv"
"time"
"github.com/google/uuid"
"mgit.msbls.de/m/paliad/internal/services"
)
@@ -102,7 +107,7 @@ func handleMeExport(w http.ResponseWriter, r *http.Request) {
return
}
filename := services.ExportFilename(services.ExportScopePersonal, "", spec.GeneratedAt)
filename := services.ExportFilename(services.ExportScopePersonal, "", uuid.Nil, spec.GeneratedAt)
size := int64(buf.Len())
if err := dbSvc.export.PatchAuditRowSuccess(ctx, auditID, meta, size); err != nil {
@@ -123,3 +128,163 @@ func handleMeExport(w http.ResponseWriter, r *http.Request) {
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

@@ -57,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
@@ -83,9 +84,10 @@ type Services struct {
UserView *services.UserViewService
Broadcast *services.BroadcastService
Pin *services.PinService
CardLayout *services.CardLayoutService
Projection *services.ProjectionService
Export *services.ExportService
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
@@ -129,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,
@@ -155,9 +158,10 @@ 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,
export: svc.Export,
cardLayout: svc.CardLayout,
dashboardLayout: svc.DashboardLayout,
projection: svc.Projection,
export: svc.Export,
}
}
@@ -284,6 +288,10 @@ 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)
@@ -306,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)
@@ -350,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)

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,6 +52,7 @@ type dbServices struct {
broadcast *services.BroadcastService
pin *services.PinService
cardLayout *services.CardLayoutService
dashboardLayout *services.DashboardLayoutService
projection *services.ProjectionService
export *services.ExportService
}

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

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

@@ -436,17 +436,18 @@ func (s *ApprovalService) SuggestChanges(ctx context.Context, requestID, callerI
return nil, fmt.Errorf("marshal counter_payload: %w", err)
}
// Validate counter has at least one 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).
// 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 := buildRevertSetClauses(old.EntityType, counterPayload); err != nil {
// ErrUnknownEntityType wraps "empty pre_image for X" when no
// allowlisted key is present. Rebrand as suggestion-input
// failure for the handler's 400 mapping.
return nil, fmt.Errorf("%w: %v", ErrSuggestionRequiresChange, err)
if _, _, err := buildCounterSetClauses(old.EntityType, counterPayload); err != nil {
// buildCounterSetClauses already wraps ErrSuggestionRequiresChange
// for the "no allowlisted fields" + empty-title cases. Propagate.
return nil, err
}
}
@@ -573,31 +574,84 @@ func (s *ApprovalService) SuggestChanges(ctx context.Context, requestID, callerI
return &newID, nil
}
// applyEntityUpdate writes the allowlisted fields from payload onto the
// entity row. Mirrors the write side of write-then-approve (which lives in
// DeadlineService / AppointmentService for the user-driven path) — used
// by SuggestChanges to apply an approver's counter-proposal back onto the
// entity inside the same tx. Reuses buildRevertSetClauses for the
// jsonb-key-to-SQL-SET translation so the allowlist is one source of
// truth.
// 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)
}
setClauses, args, err := buildRevertSetClauses(entityType, payload)
// 1. Column-level updates via the counter-allowlist.
setClauses, args, err := buildCounterSetClauses(entityType, payload)
if err != nil {
return err
}
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)
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
@@ -893,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
@@ -947,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) {

View File

@@ -1336,3 +1336,80 @@ func TestApprovalService_SuggestChanges_CounterApproverCannotSelfApprove(t *test
}
}
// 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

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

View File

@@ -21,14 +21,24 @@ import (
// DashboardService reads paliad.projects/deadlines/appointments/project_events for
// the Dashboard page.
type DashboardService struct {
db *sqlx.DB
users *UserService
db *sqlx.DB
users *UserService
approvals *ApprovalService
}
func NewDashboardService(db *sqlx.DB, users *UserService) *DashboardService {
return &DashboardService{db: db, users: users}
}
// SetApprovalService wires the inbox-approvals widget data source. Called
// post-construction so that DashboardService and ApprovalService can be
// stitched together at boot without a circular constructor dependency.
// Safe to leave nil — InboxSummary will then carry pending_count=0 and an
// empty entries list, and the widget renders its empty state.
func (s *DashboardService) SetApprovalService(a *ApprovalService) {
s.approvals = a
}
// DashboardData is the full payload returned to the frontend.
type DashboardData struct {
User *DashboardUser `json:"user"`
@@ -38,8 +48,42 @@ type DashboardData struct {
UpcomingDeadlines []UpcomingDeadline `json:"upcoming_deadlines"`
UpcomingAppointments []UpcomingAppointment `json:"upcoming_appointments"`
RecentActivity []ActivityEntry `json:"recent_activity"`
InboxSummary InboxSummary `json:"inbox_summary"`
}
// InboxSummary feeds the inbox-approvals widget on the configurable
// dashboard (t-paliad-219). PendingCount is the precise number of
// approval requests that await this user's approval; Top is a small
// preview list (up to InboxTopCap entries) ordered oldest-pending-first
// so the most urgent appears first.
//
// When the ApprovalService dependency is unwired (knowledge-platform-only
// deployments, tests), PendingCount=0 and Top=[] so the widget renders
// its empty state. The data path is read-only — no writes go through
// the dashboard payload.
type InboxSummary struct {
PendingCount int `json:"pending_count"`
Top []InboxEntry `json:"top"`
}
// InboxEntry is a single row in InboxSummary.Top — the minimum needed
// to render a clickable preview ("Frist X auf Akte Y, vorgeschlagen am Z").
type InboxEntry struct {
RequestID uuid.UUID `json:"id"`
EntityType string `json:"entity_type"`
EntityTitle *string `json:"entity_title,omitempty"`
ProjectID uuid.UUID `json:"project_id"`
ProjectTitle string `json:"project_title"`
RequestedAt time.Time `json:"requested_at"`
RequesterID uuid.UUID `json:"requester_id"`
RequesterName string `json:"requester_name"`
}
// InboxTopCap caps the preview list. The widget's count setting tops out
// at 10 (see WidgetCatalog inboxCounts); we fetch the cap once and let
// the client trim further per the user's setting.
const InboxTopCap = 10
type DashboardUser struct {
ID uuid.UUID `json:"id"`
Email string `json:"email"`
@@ -146,7 +190,12 @@ func (s *DashboardService) Get(ctx context.Context, userID uuid.UUID) (*Dashboar
now := time.Now()
today := now.Format("2006-01-02")
endOfWindow := now.AddDate(0, 0, 7).Format("2006-01-02")
// t-paliad-219 §18 Note B: widen the upcoming windows from 7d → 60d
// so the per-widget horizon dropdown (7/14/30/60) can filter client-
// side without re-querying. LIMIT bumps from 10 to 40 for the same
// reason — the widget's count setting tops out at 20 plus headroom
// for the agenda widget which can read from the same payload.
endOfWindow := now.AddDate(0, 0, 60).Format("2006-01-02")
bounds := computeDeadlineBucketBounds(now.UTC())
if err := s.loadSummary(ctx, data, user, bounds); err != nil {
@@ -161,6 +210,9 @@ func (s *DashboardService) Get(ctx context.Context, userID uuid.UUID) (*Dashboar
if err := s.loadRecentActivity(ctx, data, user); err != nil {
return nil, err
}
if err := s.loadInboxSummary(ctx, data, user); err != nil {
return nil, err
}
annotateUrgency(data.UpcomingDeadlines, now)
return data, nil
@@ -261,7 +313,7 @@ SELECT f.id,
AND f.due_date <= $3::date
AND ` + visibilityPredicatePositional("p", 1) + `
ORDER BY f.due_date ASC
LIMIT 10`
LIMIT 40`
if err := s.db.SelectContext(ctx, &data.UpcomingDeadlines, query,
user.ID, today, endOfWeek); err != nil {
return fmt.Errorf("dashboard upcoming deadlines: %w", err)
@@ -269,6 +321,45 @@ SELECT f.id,
return nil
}
// loadInboxSummary populates DashboardData.InboxSummary — the open-
// approval count + top InboxTopCap entries for the inbox-approvals
// widget (t-paliad-219). When ApprovalService is unwired (knowledge-
// platform-only deployments, tests), the function is a no-op and the
// widget renders its empty state.
func (s *DashboardService) loadInboxSummary(ctx context.Context, data *DashboardData, user *models.User) error {
data.InboxSummary = InboxSummary{Top: []InboxEntry{}}
if s.approvals == nil {
return nil
}
cnt, err := s.approvals.PendingCountForUser(ctx, user.ID)
if err != nil {
return fmt.Errorf("dashboard inbox count: %w", err)
}
data.InboxSummary.PendingCount = cnt
if cnt == 0 {
return nil
}
rows, err := s.approvals.ListPendingForApprover(ctx, user.ID, InboxFilter{Limit: InboxTopCap})
if err != nil {
return fmt.Errorf("dashboard inbox top: %w", err)
}
top := make([]InboxEntry, 0, len(rows))
for _, r := range rows {
top = append(top, InboxEntry{
RequestID: r.ID,
EntityType: r.EntityType,
EntityTitle: r.EntityTitle,
ProjectID: r.ProjectID,
ProjectTitle: r.ProjectTitle,
RequestedAt: r.RequestedAt,
RequesterID: r.RequestedBy,
RequesterName: r.RequesterName,
})
}
data.InboxSummary.Top = top
return nil
}
func (s *DashboardService) loadUpcomingAppointments(ctx context.Context, data *DashboardData, user *models.User, now time.Time) error {
query := `
SELECT t.id,
@@ -282,13 +373,13 @@ SELECT t.id,
FROM paliad.appointments t
LEFT JOIN paliad.projects p ON p.id = t.project_id
WHERE t.start_at >= $2
AND t.start_at < ($2 + interval '7 days')
AND t.start_at < ($2 + interval '60 days')
AND (
(t.project_id IS NULL AND t.created_by = $1)
OR (t.project_id IS NOT NULL AND ` + visibilityPredicatePositional("p", 1) + `)
)
ORDER BY t.start_at ASC
LIMIT 10`
LIMIT 40`
if err := s.db.SelectContext(ctx, &data.UpcomingAppointments, query,
user.ID, now); err != nil {
return fmt.Errorf("dashboard upcoming appointments: %w", err)

View File

@@ -0,0 +1,51 @@
package services
// Pure-function tests for DashboardService extensions in Slice A3.
import (
"context"
"testing"
"github.com/google/uuid"
"mgit.msbls.de/m/paliad/internal/models"
)
func TestDashboardService_InboxSummary_NilApprovalsIsNoop(t *testing.T) {
s := &DashboardService{} // approvals nil
data := &DashboardData{}
user := &models.User{ID: uuid.New()}
if err := s.loadInboxSummary(context.Background(), data, user); err != nil {
t.Fatalf("loadInboxSummary with nil approvals returned %v; want nil", err)
}
if data.InboxSummary.PendingCount != 0 {
t.Errorf("PendingCount=%d; want 0", data.InboxSummary.PendingCount)
}
if data.InboxSummary.Top == nil {
t.Errorf("Top is nil; want empty slice")
}
if len(data.InboxSummary.Top) != 0 {
t.Errorf("Top has %d entries; want 0", len(data.InboxSummary.Top))
}
}
func TestDashboardService_SetApprovalService_WiringWorks(t *testing.T) {
s := &DashboardService{}
if s.approvals != nil {
t.Fatalf("freshly-constructed DashboardService has non-nil approvals")
}
a := &ApprovalService{} // empty shell; we only check the pointer wiring
s.SetApprovalService(a)
if s.approvals != a {
t.Errorf("SetApprovalService did not wire the pointer")
}
}
func TestInboxTopCap_NonZero(t *testing.T) {
// Sanity guard: if someone zeros this const, the inbox-approvals
// widget falls back to an empty top-N silently. Pin it ≥ the
// largest catalog count option for the inbox widget (10).
if InboxTopCap < 10 {
t.Errorf("InboxTopCap=%d; must be ≥ 10 to satisfy widget catalog max count", InboxTopCap)
}
}

View File

@@ -0,0 +1,215 @@
package services
// Tests for the Slice 2 (project-subtree) sheet registry. Pure-function
// shape tests — live-DB integration coverage of the SQL itself stays in
// the existing query patterns the personal-scope tests already cover.
import (
"strings"
"testing"
"time"
"github.com/google/uuid"
)
// TestProjectSheetQueries_RegistryShape pins the sheet inventory + the
// design's §2 contract: every entity sheet binds rootID as $1, and the
// approval_policies sheet ships with all three sources (project +
// ancestor + partner_unit_default).
func TestProjectSheetQueries_RegistryShape(t *testing.T) {
rootID := uuid.MustParse("61e3fb9e-29fb-44aa-867e-a89469e2cacb")
qs := projectSheetQueries(rootID, false)
wantSheets := []string{
"projects",
"project_teams",
"project_partner_units",
"deadlines",
"appointments",
"parties",
"notes",
"documents",
"project_events",
"approval_requests",
"approval_policies",
"checklist_instances",
"partner_units",
"partner_unit_members",
"users_referenced",
"system_audit_log_subset",
"ref__proceeding_types",
"ref__event_types",
"ref__event_categories",
"ref__deadline_rules",
"ref__deadline_concepts",
"ref__courts",
"ref__countries",
"ref__holidays",
}
gotSheets := []string{}
for _, q := range qs {
gotSheets = append(gotSheets, q.SheetName)
}
if len(gotSheets) != len(wantSheets) {
t.Fatalf("sheet count = %d, want %d (got %v)", len(gotSheets), len(wantSheets), gotSheets)
}
for i, want := range wantSheets {
if gotSheets[i] != want {
t.Errorf("sheet[%d] = %q, want %q", i, gotSheets[i], want)
}
}
// Every NON-reference sheet binds rootID as $1.
for _, q := range qs {
if strings.HasPrefix(q.SheetName, "ref__") {
if len(q.Args) != 0 {
t.Errorf("ref sheet %q has %d args, want 0", q.SheetName, len(q.Args))
}
continue
}
if len(q.Args) != 1 {
t.Errorf("entity sheet %q has %d args, want 1", q.SheetName, len(q.Args))
continue
}
if got, ok := q.Args[0].(uuid.UUID); !ok || got != rootID {
t.Errorf("entity sheet %q first arg = %v, want rootID %v", q.SheetName, q.Args[0], rootID)
}
}
}
// TestProjectSheetQueries_ApprovalPoliciesTripleSource verifies that the
// approval_policies sheet's SQL carries all three source tags so an
// importer can reconstruct the effective gate (Q4 lock-in).
func TestProjectSheetQueries_ApprovalPoliciesTripleSource(t *testing.T) {
qs := projectSheetQueries(uuid.New(), false)
var found *sheetQuery
for i := range qs {
if qs[i].SheetName == "approval_policies" {
found = &qs[i]
break
}
}
if found == nil {
t.Fatal("approval_policies sheet missing from registry")
}
for _, src := range []string{
`'project'::text AS source`,
`'ancestor'::text AS source`,
`'partner_unit_default'::text AS source`,
} {
if !strings.Contains(found.SQL, src) {
t.Errorf("approval_policies SQL missing %q tag — Q4 triple-source attribution broken.\nSQL:\n%s",
src, found.SQL)
}
}
}
// TestProjectSheetQueries_DirectOnlyNarrowsSubtree pins that direct_only=true
// produces a subtree subquery resolving to exactly the root (no LIKE-walk).
func TestProjectSheetQueries_DirectOnlyNarrowsSubtree(t *testing.T) {
subtreeAll := projectSubtreeProjectIDsSQL(false)
subtreeRoot := projectSubtreeProjectIDsSQL(true)
if !strings.Contains(subtreeAll, `LIKE r.path`) {
t.Errorf("default subtree SQL missing path-LIKE descendant walk:\n%s", subtreeAll)
}
if strings.Contains(subtreeRoot, `LIKE`) {
t.Errorf("direct_only subtree SQL still has LIKE walk — should be root-only:\n%s", subtreeRoot)
}
if !strings.Contains(subtreeRoot, `$1::uuid`) {
t.Errorf("direct_only subtree SQL missing $1::uuid root reference:\n%s", subtreeRoot)
}
}
// TestProjectSheetQueries_NoPersonalSidecars guards against an accidental
// inclusion of personal sidecars (caldav config, views, pins, paliadin
// turns) in the project-scope export. These are per-user, not per-project,
// and don't belong in a matter handover.
func TestProjectSheetQueries_NoPersonalSidecars(t *testing.T) {
qs := projectSheetQueries(uuid.New(), false)
for _, q := range qs {
switch q.SheetName {
case "my_caldav_config", "my_views", "my_pinned_projects", "my_card_layouts", "my_paliadin_turns", "me":
t.Errorf("project-scope export must not include personal sidecar sheet %q", q.SheetName)
}
// Also defence-in-depth on the SQL: no SELECT from
// user_caldav_config or paliadin_turns from project scope.
if strings.Contains(q.SQL, "user_caldav_config") {
t.Errorf("sheet %q SQL touches user_caldav_config — never in project scope", q.SheetName)
}
if strings.Contains(q.SQL, "paliadin_turns") {
t.Errorf("sheet %q SQL touches paliadin_turns — never in project scope", q.SheetName)
}
}
}
// TestProjectSheetQueries_AttachedPartnerUnitsOnly pins that the
// partner_units sheet is filtered to attached units only (not the full
// org chart).
func TestProjectSheetQueries_AttachedPartnerUnitsOnly(t *testing.T) {
qs := projectSheetQueries(uuid.New(), false)
for _, q := range qs {
if q.SheetName != "partner_units" {
continue
}
if !strings.Contains(q.SQL, "project_partner_units") {
t.Errorf("partner_units sheet SQL must filter via project_partner_units (got attached-only requirement):\n%s",
q.SQL)
}
return
}
t.Fatal("partner_units sheet missing from registry")
}
// TestShortUUIDSuffix_ReturnsLast8Hex pins the §3 filename disambiguator
// shape — Q5 lock-in.
func TestShortUUIDSuffix_ReturnsLast8Hex(t *testing.T) {
cases := []struct {
in uuid.UUID
want string
}{
{uuid.Nil, ""},
{uuid.MustParse("11111111-1111-1111-1111-aaaaaaaaaaaa"), "aaaaaaaaaaaa"},
{uuid.MustParse("61e3fb9e-29fb-44aa-867e-a89469e2cacb"), "a89469e2cacb"},
}
for _, c := range cases {
got := shortUUIDSuffix(c.in)
if got != c.want {
t.Errorf("shortUUIDSuffix(%v) = %q, want %q", c.in, got, c.want)
}
}
}
// TestMetaToKeyValueRows_ProjectScopeRows verifies that project-scope
// meta picks up scope_root_label + scope_root_path + direct_only rows
// (so the __meta sheet carries Q6 lock-in details).
func TestMetaToKeyValueRows_ProjectScopeRows(t *testing.T) {
rootID := uuid.MustParse("61e3fb9e-29fb-44aa-867e-a89469e2cacb")
m := ExportMeta{
SchemaVersion: 1,
FirmName: "HLC",
Scope: ExportScopeProject,
ScopeRootID: &rootID,
ScopeRootLabel: "Siemens AG",
ScopeRootPath: "61e3fb9e_29fb_44aa_867e_a89469e2cacb",
DirectOnly: false,
GeneratedAt: time.Date(2026, 5, 20, 14, 23, 0, 0, time.UTC),
RowCounts: map[string]int{},
}
rows := metaToKeyValueRows(m)
want := map[string]string{
"scope_root_label": "Siemens AG",
"scope_root_path": "61e3fb9e_29fb_44aa_867e_a89469e2cacb",
"direct_only": "FALSE",
}
seen := map[string]string{}
for _, r := range rows {
seen[r[0]] = r[1]
}
for k, v := range want {
if seen[k] != v {
t.Errorf("meta key %q = %q, want %q (full rows: %v)", k, seen[k], v, rows)
}
}
}

View File

@@ -93,6 +93,16 @@ type ExportMeta struct {
FirmName string `json:"firm_name"`
Scope string `json:"scope"`
ScopeRootID *uuid.UUID `json:"scope_root_id,omitempty"`
// ScopeRootLabel is the project title (project scope only). Empty
// for personal + org scope.
ScopeRootLabel string `json:"scope_root_label,omitempty"`
// ScopeRootPath is the ltree path of the root project (project scope
// only). Preserved in the audit row so closed-out projects retain a
// usable ancestry pointer (Q6 lock-in).
ScopeRootPath string `json:"scope_root_path,omitempty"`
// DirectOnly is true when ?direct_only=1 was passed (project scope
// only) — narrows the export to the root project, no descendants.
DirectOnly bool `json:"direct_only,omitempty"`
GeneratedAt time.Time `json:"generated_at"`
GeneratedByID uuid.UUID `json:"generated_by_user_id"`
GeneratedByEml string `json:"generated_by_user_email"`
@@ -107,6 +117,14 @@ type ExportMeta struct {
type ExportSpec struct {
Scope string
ScopeRoot *uuid.UUID // project_id when Scope==ExportScopeProject; nil otherwise
// ScopeRootLabel + ScopeRootPath are populated by the project-export
// handler (resolved from the root project row) so the audit + __meta
// carry stable labels even if the project is later renamed.
ScopeRootLabel string
ScopeRootPath string
// DirectOnly narrows the export to the root project only (project
// scope, ?direct_only=1).
DirectOnly bool
ActorID uuid.UUID
ActorEmail string
ActorLabel string // display_name for the audit + meta
@@ -173,6 +191,102 @@ func (s *ExportService) WritePersonal(ctx context.Context, w io.Writer, spec Exp
return meta, nil
}
// WriteProject streams the project-subtree bundle for the project named
// in spec.ScopeRoot into w. Returns the meta (incl. row_counts) for the
// audit-row patch.
//
// Behavior contract (per Slice 2 design §2):
//
// - Every entity sheet is filtered to the subtree (project + descendants
// via ltree path). When spec.DirectOnly is true, narrows to the root
// project only (no descendants).
// - approval_policies carries all 3 sources (project rows + ancestor
// rows + partner-unit-default rows) tagged with a `source` column —
// m's Q4 lock-in lets recipients reconstruct the effective gate.
// - users_referenced restricts the user disclosure to FK-referenced
// users only (avoids dumping the full firm roster into a per-matter
// handover).
// - Cross-subtree FKs (projects.counterclaim_of pointing outside the
// subtree) are kept but warned about in __meta.warnings — m's Q3
// lock-in preserves the no-lock-in promise.
//
// Permission gate (§4) lives on the handler, NOT here — the service
// trusts the caller has already authorised. Wiring is in handlers/export.go.
func (s *ExportService) WriteProject(ctx context.Context, w io.Writer, spec ExportSpec) (ExportMeta, error) {
if spec.Scope == "" {
spec.Scope = ExportScopeProject
}
if spec.GeneratedAt.IsZero() {
spec.GeneratedAt = time.Now().UTC()
}
if spec.ScopeRoot == nil {
return ExportMeta{}, fmt.Errorf("WriteProject: ScopeRoot is required")
}
meta := ExportMeta{
SchemaVersion: ExportSchemaVersion,
FirmName: s.firmName,
Scope: spec.Scope,
ScopeRootID: spec.ScopeRoot,
ScopeRootLabel: spec.ScopeRootLabel,
ScopeRootPath: spec.ScopeRootPath,
DirectOnly: spec.DirectOnly,
GeneratedAt: spec.GeneratedAt,
GeneratedByID: spec.ActorID,
GeneratedByEml: spec.ActorEmail,
GeneratedByLbl: spec.ActorLabel,
RowCounts: map[string]int{},
}
sheets := projectSheetQueries(*spec.ScopeRoot, spec.DirectOnly)
if err := s.writeBundle(ctx, w, sheets, &meta); err != nil {
return meta, err
}
// Cross-subtree FK detection (Q3 lock-in: keep FK + warn). After the
// bundle is built we run one lightweight scan to surface
// counterclaim_of references that escape the subtree. The result
// gets appended to meta.Warnings so it lands in __meta + the audit
// row + the README's warning list.
if warns, err := s.detectCrossSubtreeFKs(ctx, *spec.ScopeRoot, spec.DirectOnly); err == nil && len(warns) > 0 {
meta.Warnings = append(meta.Warnings, warns...)
sort.Strings(meta.Warnings)
}
return meta, nil
}
// detectCrossSubtreeFKs scans subtree-resident projects for FKs that
// point outside the subtree (today: only projects.counterclaim_of). One
// warning row per outbound reference. Best-effort: a query error here
// degrades silently (the export still ships) since the warning is
// informational, not load-bearing.
func (s *ExportService) detectCrossSubtreeFKs(ctx context.Context, rootID uuid.UUID, directOnly bool) ([]string, error) {
subtreeSQL := projectSubtreeProjectIDsSQL(directOnly)
q := `
SELECT p.id, p.title, p.counterclaim_of
FROM paliad.projects p
WHERE p.id IN ` + subtreeSQL + `
AND p.counterclaim_of IS NOT NULL
AND p.counterclaim_of NOT IN ` + subtreeSQL + `
ORDER BY p.id`
type row struct {
ID uuid.UUID `db:"id"`
Title string `db:"title"`
CounterclaimOf uuid.UUID `db:"counterclaim_of"`
}
var rows []row
if err := s.db.SelectContext(ctx, &rows, q, rootID); err != nil {
return nil, err
}
out := make([]string, 0, len(rows))
for _, r := range rows {
out = append(out, fmt.Sprintf(
"cross-subtree FK: project %q (%s).counterclaim_of → %s (not in this export)",
r.Title, r.ID, r.CounterclaimOf,
))
}
return out, nil
}
// collectedSheet holds one sheet's data after column-discovery + row
// materialisation. Used to hand data from writeBundle to buildXLSX +
// buildJSON + buildCSV.
@@ -565,6 +679,20 @@ func metaToKeyValueRows(m ExportMeta) [][2]string {
} else {
rows = append(rows, [2]string{"scope_root_id", ""})
}
// Project-scope-only rows (Slice 2 §2.4). Surface as empty rows for
// other scopes so the __meta layout stays stable + Excel users can
// see "this field exists but doesn't apply here".
rows = append(rows,
[2]string{"scope_root_label", m.ScopeRootLabel},
[2]string{"scope_root_path", m.ScopeRootPath},
)
if m.Scope == ExportScopeProject {
if m.DirectOnly {
rows = append(rows, [2]string{"direct_only", "TRUE"})
} else {
rows = append(rows, [2]string{"direct_only", "FALSE"})
}
}
rows = append(rows,
[2]string{"generated_at", m.GeneratedAt.UTC().Format(time.RFC3339)},
[2]string{"generated_by_user_id", m.GeneratedByID.String()},
@@ -637,6 +765,19 @@ func buildREADME(m ExportMeta) string {
fmt.Fprintf(&b, "Erstellt am : %s\n", m.GeneratedAt.UTC().Format(time.RFC3339))
fmt.Fprintf(&b, "Erstellt von : %s <%s>\n", m.GeneratedByLbl, m.GeneratedByEml)
fmt.Fprintf(&b, "Umfang : %s\n", m.Scope)
if m.Scope == ExportScopeProject {
if m.ScopeRootLabel != "" {
fmt.Fprintf(&b, "Projekt : %s\n", m.ScopeRootLabel)
}
if m.ScopeRootID != nil {
fmt.Fprintf(&b, "Projekt-ID : %s\n", m.ScopeRootID.String())
}
if m.DirectOnly {
fmt.Fprintf(&b, "Hinweis : nur das Root-Projekt (?direct_only=1), keine Unter-Projekte.\n")
} else {
fmt.Fprintf(&b, "Hinweis : Root-Projekt + alle Unter-Projekte.\n")
}
}
fmt.Fprintf(&b, "Schema-Version: %d\n", m.SchemaVersion)
fmt.Fprintf(&b, "\n")
fmt.Fprintf(&b, "Inhalt\n------\n")
@@ -681,7 +822,16 @@ func buildREADME(m ExportMeta) string {
// ExportFilename returns the canonical filename for a download. Slugify is
// minimal — only the project-scope variant has a free-text component to
// sanitise.
func ExportFilename(scope string, scopeLabel string, generatedAt time.Time) string {
//
// Project-scope filenames include an 8-hex-char disambiguator derived from
// the root project's UUID (Slice 2 §3 Q5). Two projects with identical
// titles (common: "Standard NDA" per client) would otherwise produce
// filename collisions when archived together; 4-billion-class disambiguation
// is cheap insurance.
//
// rootID is consumed only for ExportScopeProject; pass uuid.Nil for the
// other scopes.
func ExportFilename(scope string, scopeLabel string, rootID uuid.UUID, generatedAt time.Time) string {
ts := generatedAt.UTC().Format("2006-01-02T1504Z")
switch scope {
case ExportScopePersonal:
@@ -693,12 +843,30 @@ func ExportFilename(scope string, scopeLabel string, generatedAt time.Time) stri
if slug == "" {
slug = randomSlug()
}
return fmt.Sprintf("paliad-export-project-%s-%s.zip", slug, ts)
short := shortUUIDSuffix(rootID)
if short == "" {
return fmt.Sprintf("paliad-export-project-%s-%s.zip", slug, ts)
}
return fmt.Sprintf("paliad-export-project-%s-%s-%s.zip", slug, short, ts)
default:
return fmt.Sprintf("paliad-export-%s.zip", ts)
}
}
// shortUUIDSuffix returns the last 8 hex chars of the UUID's canonical
// representation (the trailing block after the final dash). Empty string
// for uuid.Nil so callers can fall back to the slug-only variant.
func shortUUIDSuffix(id uuid.UUID) string {
if id == uuid.Nil {
return ""
}
s := id.String()
if i := strings.LastIndex(s, "-"); i != -1 && i+1 < len(s) {
return s[i+1:]
}
return ""
}
var filenameSafeRegex = regexp.MustCompile(`[^A-Za-z0-9-]+`)
func slugifyFilename(s string) string {
@@ -943,10 +1111,25 @@ func personalSheetQueries(actorID uuid.UUID) []sheetQuery {
// WriteAuditRow inserts a system_audit_log row before the export runs and
// returns the new row id. The handler PATCHes the row with file_size_bytes
// + final row_counts on success or marks it failed on error.
//
// For project-scope exports the metadata jsonb carries the ltree path
// (Q6 lock-in) so the audit row remains interpretable after a project
// deletion: scope_root → just the UUID; metadata.root_path → the
// ancestry. Same goes for root_label + direct_only so dashboards don't
// need to round-trip back to paliad.projects on render.
func (s *ExportService) WriteAuditRow(ctx context.Context, spec ExportSpec) (uuid.UUID, error) {
meta := map[string]any{
"requested_at": spec.GeneratedAt.UTC().Format(time.RFC3339),
}
if spec.Scope == ExportScopeProject {
if spec.ScopeRootLabel != "" {
meta["root_label"] = spec.ScopeRootLabel
}
if spec.ScopeRootPath != "" {
meta["root_path"] = spec.ScopeRootPath
}
meta["direct_only"] = spec.DirectOnly
}
mb, _ := json.Marshal(meta)
var id uuid.UUID
err := s.db.QueryRowContext(ctx,
@@ -1005,3 +1188,285 @@ func (s *ExportService) PatchAuditRowFailure(ctx context.Context, id uuid.UUID,
id, string(mb),
)
}
// ---------------------------------------------------------------------------
// Project-scope sheet registry (Slice 2).
// ---------------------------------------------------------------------------
//
// Subtree-aware queries via paliad.projects.path (ltree as text). The
// subtree predicate works on the materialised path column:
//
// p.path LIKE root.path || '%' -- descendants + self
// p.path = root.path -- self only (direct_only=true)
//
// We use the path-prefix-LIKE form instead of ltree `<@` because the
// schema stores path as text (the underlying ltree is materialised in
// the projects.path column). The LIKE pattern is anchored at the start
// and uses indexes built on path.
//
// Ordering: every SELECT uses ORDER BY id (or another stable tuple) so
// byte-determinism holds across runs.
// projectSubtreeProjectIDsSQL returns a SQL subquery expression that
// resolves to "the set of project ids in the subtree of $1". Use as the
// right-hand side of `IN`. The $1 placeholder must bind the root
// project's UUID.
//
// When directOnly is true, narrows to the root project itself only.
func projectSubtreeProjectIDsSQL(directOnly bool) string {
if directOnly {
// Tighter: just the root, no descendants. Still framed as a
// subquery so the outer SQL can be uniformly composed.
return `(SELECT $1::uuid AS id)`
}
// Subtree = root + descendants. The materialised path column on
// every project includes its own UUID as the trailing label, so the
// LIKE pattern matches both the root and every descendant in one
// expression. r.path is read from the root row keyed by $1.
return `(
SELECT p.id
FROM paliad.projects p
JOIN paliad.projects r ON r.id = $1::uuid
WHERE p.path = r.path
OR p.path LIKE r.path || '.%'
)`
}
// projectSheetQueries returns the sheet registry for a project-scope
// export. rootID is bound to $1 in every query; directOnly narrows the
// subtree to just the root project.
//
// Sheet inclusion follows design §2.2. Same shape as personalSheetQueries
// but with subtree filtering instead of RLS-visibility and a tighter
// users-disclosure profile.
func projectSheetQueries(rootID uuid.UUID, directOnly bool) []sheetQuery {
subtree := projectSubtreeProjectIDsSQL(directOnly)
queries := []sheetQuery{
// --- entity sheets (subtree-scoped) ---
{
SheetName: "projects",
SQL: `SELECT * FROM paliad.projects
WHERE id IN ` + subtree + `
ORDER BY id`,
Args: []any{rootID},
},
{
SheetName: "project_teams",
SQL: `SELECT * FROM paliad.project_teams
WHERE project_id IN ` + subtree + `
ORDER BY project_id, user_id`,
Args: []any{rootID},
},
{
SheetName: "project_partner_units",
SQL: `SELECT * FROM paliad.project_partner_units
WHERE project_id IN ` + subtree + `
ORDER BY project_id, partner_unit_id`,
Args: []any{rootID},
},
{
SheetName: "deadlines",
SQL: `SELECT * FROM paliad.deadlines
WHERE project_id IN ` + subtree + `
ORDER BY id`,
Args: []any{rootID},
},
{
SheetName: "appointments",
SQL: `SELECT * FROM paliad.appointments
WHERE project_id IN ` + subtree + `
ORDER BY id`,
Args: []any{rootID},
},
{
SheetName: "parties",
SQL: `SELECT * FROM paliad.parties
WHERE project_id IN ` + subtree + `
ORDER BY id`,
Args: []any{rootID},
},
{
SheetName: "notes",
SQL: `SELECT * FROM paliad.notes
WHERE COALESCE(project_id,
(SELECT d.project_id FROM paliad.deadlines d WHERE d.id = notes.deadline_id),
(SELECT a.project_id FROM paliad.appointments a WHERE a.id = notes.appointment_id),
(SELECT pe.project_id FROM paliad.project_events pe WHERE pe.id = notes.project_event_id)
) IN ` + subtree + `
ORDER BY id`,
Args: []any{rootID},
},
{
SheetName: "documents",
SQL: `SELECT id, project_id, title, doc_type, file_path, file_size, mime_type, uploaded_by, created_at, updated_at
FROM paliad.documents
WHERE project_id IN ` + subtree + `
ORDER BY id`,
Args: []any{rootID},
},
{
SheetName: "project_events",
SQL: `SELECT * FROM paliad.project_events
WHERE project_id IN ` + subtree + `
ORDER BY id`,
Args: []any{rootID},
},
{
SheetName: "approval_requests",
SQL: `SELECT * FROM paliad.approval_requests
WHERE project_id IN ` + subtree + `
ORDER BY id`,
Args: []any{rootID},
},
// Approval policies — m's Q4 lock: ship all three sources with
// `source` attribution column so an importer can reconstruct
// "what gate applies" without re-running paliad's resolver.
//
// Source 1: project rows for any project in the subtree.
// Source 2: project rows for ancestors of the root (so a
// descendant export still sees the gate inherited
// from above the subtree).
// Source 3: partner-unit-default rows for units attached to
// any subtree project.
//
// One UNION query, with a `source` column tagged per branch.
// We hand-pick the columns to keep the shape stable across the
// three sources (approval_policies.project_id is nullable when
// the row is a partner-unit-default, etc.).
{
SheetName: "approval_policies",
SQL: `
SELECT 'project'::text AS source,
id, project_id, partner_unit_id, entity_type, lifecycle_event,
required_role, requires_approval, min_role,
created_by, created_at, updated_at
FROM paliad.approval_policies
WHERE project_id IN ` + subtree + `
UNION ALL
SELECT 'ancestor'::text AS source,
ap.id, ap.project_id, ap.partner_unit_id, ap.entity_type, ap.lifecycle_event,
ap.required_role, ap.requires_approval, ap.min_role,
ap.created_by, ap.created_at, ap.updated_at
FROM paliad.approval_policies ap
JOIN paliad.projects r ON r.id = $1::uuid
WHERE ap.project_id IS NOT NULL
AND ap.project_id <> $1::uuid
AND ap.project_id IN (
SELECT pa.id
FROM paliad.projects pa
WHERE r.path LIKE pa.path || '.%'
)
UNION ALL
SELECT 'partner_unit_default'::text AS source,
ap.id, ap.project_id, ap.partner_unit_id, ap.entity_type, ap.lifecycle_event,
ap.required_role, ap.requires_approval, ap.min_role,
ap.created_by, ap.created_at, ap.updated_at
FROM paliad.approval_policies ap
WHERE ap.partner_unit_id IS NOT NULL
AND ap.partner_unit_id IN (
SELECT ppu.partner_unit_id
FROM paliad.project_partner_units ppu
WHERE ppu.project_id IN ` + subtree + `
)
ORDER BY source, id`,
Args: []any{rootID},
},
{
SheetName: "checklist_instances",
SQL: `SELECT * FROM paliad.checklist_instances
WHERE project_id IN ` + subtree + `
ORDER BY id`,
Args: []any{rootID},
},
// --- attached partner-unit subset ---
// Only units attached to any subtree project (avoids dumping
// the full org chart into a per-matter handover).
{
SheetName: "partner_units",
SQL: `SELECT * FROM paliad.partner_units pu
WHERE pu.id IN (
SELECT ppu.partner_unit_id
FROM paliad.project_partner_units ppu
WHERE ppu.project_id IN ` + subtree + `
)
ORDER BY pu.id`,
Args: []any{rootID},
},
{
SheetName: "partner_unit_members",
SQL: `SELECT * FROM paliad.partner_unit_members pum
WHERE pum.partner_unit_id IN (
SELECT ppu.partner_unit_id
FROM paliad.project_partner_units ppu
WHERE ppu.project_id IN ` + subtree + `
)
ORDER BY partner_unit_id, user_id`,
Args: []any{rootID},
},
// --- restricted users sheet ---
// Limit user disclosure to those referenced by some FK in the
// export. Keeps a per-matter handover from leaking the full
// firm roster (47 users → typically 3-5 per matter).
{
SheetName: "users_referenced",
SQL: `SELECT id, email, display_name, office, profession
FROM paliad.users u
WHERE u.id IN (
SELECT created_by FROM paliad.projects WHERE id IN ` + subtree + `
UNION SELECT created_by FROM paliad.deadlines WHERE project_id IN ` + subtree + `
UNION SELECT created_by FROM paliad.appointments WHERE project_id IN ` + subtree + `
UNION SELECT created_by FROM paliad.project_events WHERE project_id IN ` + subtree + `
UNION SELECT user_id FROM paliad.project_teams WHERE project_id IN ` + subtree + `
UNION SELECT requested_by FROM paliad.approval_requests WHERE project_id IN ` + subtree + `
UNION SELECT decided_by FROM paliad.approval_requests WHERE project_id IN ` + subtree + ` AND decided_by IS NOT NULL
UNION SELECT created_by FROM paliad.notes WHERE COALESCE(project_id,
(SELECT d.project_id FROM paliad.deadlines d WHERE d.id = notes.deadline_id),
(SELECT a.project_id FROM paliad.appointments a WHERE a.id = notes.appointment_id),
(SELECT pe.project_id FROM paliad.project_events pe WHERE pe.id = notes.project_event_id)
) IN ` + subtree + `
UNION SELECT uploaded_by FROM paliad.documents WHERE project_id IN ` + subtree + ` AND uploaded_by IS NOT NULL
UNION SELECT user_id FROM paliad.partner_unit_members pum
WHERE pum.partner_unit_id IN (
SELECT ppu.partner_unit_id
FROM paliad.project_partner_units ppu
WHERE ppu.project_id IN ` + subtree + `
)
)
ORDER BY id`,
Args: []any{rootID},
},
// --- system_audit_log subset (the export's own audit trail) ---
// Includes prior export events scoped to this subtree's
// projects — lets a recipient see "who has previously
// exported this matter".
{
SheetName: "system_audit_log_subset",
SQL: `SELECT * FROM paliad.system_audit_log
WHERE scope_root IN ` + subtree + `
ORDER BY created_at, id`,
Args: []any{rootID},
},
// --- reference data (same set as personal scope) ---
{SheetName: "ref__proceeding_types", SQL: `SELECT * FROM paliad.proceeding_types ORDER BY id`},
{SheetName: "ref__event_types", SQL: `SELECT * FROM paliad.event_types ORDER BY id`},
{SheetName: "ref__event_categories", SQL: `SELECT * FROM paliad.event_categories ORDER BY id`},
{SheetName: "ref__deadline_rules", SQL: `SELECT * FROM paliad.deadline_rules ORDER BY id`},
{SheetName: "ref__deadline_concepts", SQL: `SELECT * FROM paliad.deadline_concepts ORDER BY id`},
{SheetName: "ref__courts", SQL: `SELECT * FROM paliad.courts ORDER BY id`},
{SheetName: "ref__countries", SQL: `SELECT * FROM paliad.countries ORDER BY code`},
{SheetName: "ref__holidays", SQL: `SELECT * FROM paliad.holidays ORDER BY date, country`},
}
return queries
}

View File

@@ -269,22 +269,51 @@ func TestMetaToKeyValueRows_StableOrder(t *testing.T) {
func TestExportFilename_PerScope(t *testing.T) {
ts := time.Date(2026, 5, 19, 14, 23, 0, 0, time.UTC)
// Project-scope filenames carry an 8-hex disambiguator (last UUID
// block); personal + org omit it.
rootID := uuid.MustParse("61e3fb9e-29fb-44aa-867e-a89469e2cacb")
cases := []struct {
scope, label, want string
scope, label string
id uuid.UUID
want string
}{
{ExportScopePersonal, "", "paliad-export-personal-2026-05-19T1423Z.zip"},
{ExportScopeOrg, "", "paliad-export-org-2026-05-19T1423Z.zip"},
{ExportScopeProject, "Siemens AG", "paliad-export-project-Siemens-AG-2026-05-19T1423Z.zip"},
{ExportScopeProject, "Hügel & Söhne", "paliad-export-project-H-gel-S-hne-2026-05-19T1423Z.zip"},
{ExportScopePersonal, "", uuid.Nil, "paliad-export-personal-2026-05-19T1423Z.zip"},
{ExportScopeOrg, "", uuid.Nil, "paliad-export-org-2026-05-19T1423Z.zip"},
{ExportScopeProject, "Siemens AG", rootID, "paliad-export-project-Siemens-AG-a89469e2cacb-2026-05-19T1423Z.zip"},
{ExportScopeProject, "Hügel & Söhne", rootID, "paliad-export-project-H-gel-S-hne-a89469e2cacb-2026-05-19T1423Z.zip"},
// Nil UUID falls back to the slug-only variant — same as Slice 1's
// pre-disambiguator filename. Useful for unit tests of label-only
// behaviour.
{ExportScopeProject, "Siemens AG", uuid.Nil, "paliad-export-project-Siemens-AG-2026-05-19T1423Z.zip"},
}
for _, c := range cases {
got := ExportFilename(c.scope, c.label, ts)
got := ExportFilename(c.scope, c.label, c.id, ts)
if got != c.want {
t.Errorf("ExportFilename(%q, %q) → %q, want %q", c.scope, c.label, got, c.want)
t.Errorf("ExportFilename(%q, %q, %q) → %q, want %q", c.scope, c.label, c.id, got, c.want)
}
}
}
func TestExportFilename_ShortUUIDDisambiguator(t *testing.T) {
// Two projects with identical titles must produce different filenames
// when the UUID suffix is present — that's the whole point of Q5's
// disambiguator.
ts := time.Date(2026, 5, 19, 14, 23, 0, 0, time.UTC)
idA := uuid.MustParse("11111111-1111-1111-1111-aaaaaaaaaaaa")
idB := uuid.MustParse("22222222-2222-2222-2222-bbbbbbbbbbbb")
a := ExportFilename(ExportScopeProject, "Standard NDA", idA, ts)
b := ExportFilename(ExportScopeProject, "Standard NDA", idB, ts)
if a == b {
t.Fatalf("same-title same-ts filenames collide: %q", a)
}
if !strings.Contains(a, "aaaaaaaaaaaa") {
t.Errorf("filename missing UUID-A suffix: %q", a)
}
if !strings.Contains(b, "bbbbbbbbbbbb") {
t.Errorf("filename missing UUID-B suffix: %q", b)
}
}
func TestSlugifyFilename_StripsUnsafe(t *testing.T) {
cases := []struct{ in, want string }{
{"Siemens AG", "Siemens-AG"},

View File

@@ -352,3 +352,38 @@ func TestTemplateRegistry_Tiers(t *testing.T) {
}
}
}
// TestPatentNumberUPC covers the kind-code parenthesisation that UPC
// briefs use (t-paliad-215 Slice 2, design §22 Q-S2-4).
func TestPatentNumberUPC(t *testing.T) {
tests := []struct {
in, want string
}{
// EP variants — the common case.
{"EP 1 234 567 B1", "EP 1 234 567 (B1)"},
{"EP 4 056 049 A1", "EP 4 056 049 (A1)"},
// DE national number with kind code.
{"DE 10 2020 123 456 A1", "DE 10 2020 123 456 (A1)"},
// No kind code → pass-through unchanged.
{"EP 1 234 567", "EP 1 234 567"},
// Leading + trailing whitespace trimmed.
{" EP 1 234 567 B1 ", "EP 1 234 567 (B1)"},
// Empty input.
{"", ""},
// Slash-separated forms (WO publication numbers) don't match
// the kind-code shape → pass through.
{"WO/2023/123456", "WO/2023/123456"},
// Two-digit kind code (e.g. B12) doesn't match the single-digit
// pattern; pass through. This is intentional — real EP kind
// codes are single-letter + single-digit.
{"EP 1 234 567 B12", "EP 1 234 567 B12"},
}
for _, tc := range tests {
t.Run(tc.in, func(t *testing.T) {
got := patentNumberUPC(tc.in)
if got != tc.want {
t.Errorf("patentNumberUPC(%q) = %q, want %q", tc.in, got, tc.want)
}
})
}
}

View File

@@ -27,6 +27,7 @@ import (
"database/sql"
"errors"
"fmt"
"regexp"
"strings"
"time"
@@ -264,6 +265,12 @@ func addProjectVars(bag PlaceholderMap, p *models.Project, pt *models.Proceeding
bag["project.case_number"] = derefString(p.CaseNumber)
bag["project.court"] = derefString(p.Court)
bag["project.patent_number"] = derefString(p.PatentNumber)
// project.patent_number_upc is the UPC-brief convention — kind code
// parenthesised ("EP 1 234 567 (B1)") instead of the DE form
// ("EP 1 234 567 B1"). Pure-function rewrite; pass-through when no
// kind code is present so the lawyer's draft never sees a worse
// number than the source value.
bag["project.patent_number_upc"] = patentNumberUPC(derefString(p.PatentNumber))
bag["project.filing_date"] = formatDatePtr(p.FilingDate, "2006-01-02")
bag["project.grant_date"] = formatDatePtr(p.GrantDate, "2006-01-02")
bag["project.our_side"] = derefString(p.OurSide)
@@ -482,3 +489,47 @@ func legalSourcePretty(src, lang string) string {
}
return src
}
// patentNumberKindCodeRegex matches a trailing kind code on a patent
// number: a whitespace-separated single uppercase letter followed by
// a single digit (B1, A1, A2, B2, B9, C1, T2, U1, …). Capturing
// groups split the base from the kind code so the formatter can
// parenthesise the kind without touching the rest of the number.
var patentNumberKindCodeRegex = regexp.MustCompile(`^(.*?)\s+([A-Z]\d)$`)
// patentNumberUPC reformats a patent number from the DE convention
// ("EP 1 234 567 B1") to the UPC-brief convention
// ("EP 1 234 567 (B1)"). The kind code is parenthesised; everything
// else is preserved verbatim. Numbers without a recognised trailing
// kind code pass through unchanged so a lawyer's draft never sees a
// number worse than the source value.
//
// Recognised inputs:
//
// "EP 1 234 567 B1" → "EP 1 234 567 (B1)"
// "EP 4 056 049 A1" → "EP 4 056 049 (A1)"
// "DE 10 2020 123 456 A1" → "DE 10 2020 123 456 (A1)"
// " EP 1 234 567 B1 " → "EP 1 234 567 (B1)" (trimmed)
//
// Pass-through:
//
// "EP 1 234 567" → "EP 1 234 567"
// "WO/2023/123456" → "WO/2023/123456" (no kind code shape)
// "" → ""
//
// Pure function; unit-tested in submission_vars_test.go.
func patentNumberUPC(s string) string {
s = strings.TrimSpace(s)
if s == "" {
return ""
}
if m := patentNumberKindCodeRegex.FindStringSubmatch(s); m != nil {
base := strings.TrimSpace(m[1])
kind := m[2]
if base == "" {
return s
}
return base + " (" + kind + ")"
}
return s
}

View File

@@ -0,0 +1,126 @@
package services
import (
"context"
"database/sql"
"errors"
"fmt"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"mgit.msbls.de/m/paliad/internal/models"
)
// AppointmentTargetService — CRUD on paliad.appointment_caldav_targets.
//
// Each row is the per-(appointment, binding) push state: the caldav_uid
// PUT into that binding's calendar (canonical per Appointment) plus the
// ETag returned by the server on the last successful PUT. Replaces the
// scalar paliad.appointments.caldav_uid / caldav_etag columns for the
// post-Slice-2a sync engine; those scalars stay populated for back-compat
// through Slice 4.
type AppointmentTargetService struct {
db *sqlx.DB
}
func NewAppointmentTargetService(db *sqlx.DB) *AppointmentTargetService {
return &AppointmentTargetService{db: db}
}
const targetColumns = `appointment_id, binding_id, caldav_uid, caldav_etag, last_pushed_at`
// UpsertAfterPush records the result of a successful PUT to the binding's
// calendar. Called by CalDAVService.pushAll after each PUT.
func (s *AppointmentTargetService) UpsertAfterPush(ctx context.Context, appointmentID, bindingID uuid.UUID, uid, etag string) error {
var etagPtr *string
if etag != "" {
etagPtr = &etag
}
_, err := s.db.ExecContext(ctx,
`INSERT INTO paliad.appointment_caldav_targets
(appointment_id, binding_id, caldav_uid, caldav_etag, last_pushed_at)
VALUES ($1, $2, $3, $4, NOW())
ON CONFLICT (appointment_id, binding_id)
DO UPDATE SET caldav_uid = EXCLUDED.caldav_uid,
caldav_etag = EXCLUDED.caldav_etag,
last_pushed_at = NOW()`,
appointmentID, bindingID, uid, etagPtr)
if err != nil {
return fmt.Errorf("upsert caldav target: %w", err)
}
return nil
}
// FindByUIDAndBinding returns the target row matching this (uid, binding)
// pair, or nil when no such row exists.
func (s *AppointmentTargetService) FindByUIDAndBinding(ctx context.Context, uid string, bindingID uuid.UUID) (*models.AppointmentCalDAVTarget, error) {
var t models.AppointmentCalDAVTarget
err := s.db.GetContext(ctx, &t,
`SELECT `+targetColumns+`
FROM paliad.appointment_caldav_targets
WHERE caldav_uid = $1 AND binding_id = $2`, uid, bindingID)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("find target by uid+binding: %w", err)
}
return &t, nil
}
// ListForBinding returns every target row attached to this binding.
// Used by the pull-reconciliation pass to detect remote deletions.
func (s *AppointmentTargetService) ListForBinding(ctx context.Context, bindingID uuid.UUID) ([]models.AppointmentCalDAVTarget, error) {
rows := []models.AppointmentCalDAVTarget{}
if err := s.db.SelectContext(ctx, &rows,
`SELECT `+targetColumns+`
FROM paliad.appointment_caldav_targets
WHERE binding_id = $1`, bindingID); err != nil {
return nil, fmt.Errorf("list targets for binding: %w", err)
}
return rows, nil
}
// DeleteByAppointmentAndBinding removes one specific target row.
// Used after a successful remote DELETE.
func (s *AppointmentTargetService) DeleteByAppointmentAndBinding(ctx context.Context, appointmentID, bindingID uuid.UUID) error {
_, err := s.db.ExecContext(ctx,
`DELETE FROM paliad.appointment_caldav_targets
WHERE appointment_id = $1 AND binding_id = $2`, appointmentID, bindingID)
if err != nil {
return fmt.Errorf("delete target: %w", err)
}
return nil
}
// StaleForBinding returns target rows whose appointment_id is no longer
// in the in-scope set. Used by the post-pull cleanup pass to delete
// appointments that left the binding's scope (e.g. project unshared,
// scope_kind PATCHed). currentAppointmentIDs may be empty — in that
// case every target row is considered stale.
func (s *AppointmentTargetService) StaleForBinding(ctx context.Context, bindingID uuid.UUID, currentAppointmentIDs []uuid.UUID) ([]models.AppointmentCalDAVTarget, error) {
rows := []models.AppointmentCalDAVTarget{}
if len(currentAppointmentIDs) == 0 {
if err := s.db.SelectContext(ctx, &rows,
`SELECT `+targetColumns+`
FROM paliad.appointment_caldav_targets
WHERE binding_id = $1`, bindingID); err != nil {
return nil, fmt.Errorf("stale-targets all: %w", err)
}
return rows, nil
}
query, args, err := sqlx.In(
`SELECT `+targetColumns+`
FROM paliad.appointment_caldav_targets
WHERE binding_id = ?
AND appointment_id NOT IN (?)`, bindingID, currentAppointmentIDs)
if err != nil {
return nil, fmt.Errorf("stale-targets prepare: %w", err)
}
query = s.db.Rebind(query)
if err := s.db.SelectContext(ctx, &rows, query, args...); err != nil {
return nil, fmt.Errorf("stale-targets exec: %w", err)
}
return rows, nil
}

View File

@@ -0,0 +1,219 @@
package services
// Widget catalog for the configurable dashboard (t-paliad-219).
//
// Design: docs/design-dashboard-configurable-2026-05-20.md §4 (catalog) and
// §18 Note B (settings schema).
//
// The catalog is the source of truth for which widgets a user can pick.
// Adding a new widget = add a WidgetKey const + append a WidgetDef in
// WidgetCatalog. Frontend has its own mirror in
// frontend/src/client/widgets/registry.ts; the two must stay in sync.
//
// Versioning rule (design §10): unknown keys in a user's saved layout are
// dropped silently at read time; write paths validate against the catalog.
import (
"encoding/json"
"fmt"
"slices"
)
// WidgetKey is the catalog identifier for a single widget.
type WidgetKey string
const (
WidgetDeadlineSummary WidgetKey = "deadline-summary"
WidgetMatterSummary WidgetKey = "matter-summary"
WidgetUpcomingDeadlines WidgetKey = "upcoming-deadlines"
WidgetUpcomingAppointments WidgetKey = "upcoming-appointments"
WidgetInlineAgenda WidgetKey = "inline-agenda"
WidgetRecentActivity WidgetKey = "recent-activity"
WidgetInboxApprovals WidgetKey = "inbox-approvals"
WidgetPinnedProjects WidgetKey = "pinned-projects"
)
// KnownWidgetKeys is the canonical order used when seeding the factory
// default layout. New entries land at the bottom by default.
var KnownWidgetKeys = []WidgetKey{
WidgetDeadlineSummary,
WidgetMatterSummary,
WidgetUpcomingDeadlines,
WidgetUpcomingAppointments,
WidgetInlineAgenda,
WidgetRecentActivity,
WidgetInboxApprovals,
// WidgetPinnedProjects ships in Slice C (catalog expansion) — not in
// the Slice A1 baseline. Keep the const above for forward-compat;
// omit from KnownWidgetKeys until the widget module lands.
}
// WidgetSettingsSchema declares which knobs a widget exposes. nil = no
// per-widget settings (the gear icon is hidden in edit mode).
type WidgetSettingsSchema struct {
// CountOptions lists permitted "count" values. Empty = no count knob.
CountOptions []int
// HorizonOptions lists permitted "horizon_days" values. Empty = no
// horizon knob.
HorizonOptions []int
// CountAllowsAll is true when "all" is a legal value for count
// (rendered as the literal -1 in the JSON). pinned-projects uses this.
CountAllowsAll bool
}
// Validate enforces the schema against a raw settings blob. nil schema
// rejects any non-empty settings; empty settings always pass.
func (sch *WidgetSettingsSchema) Validate(raw json.RawMessage) error {
if len(raw) == 0 || string(raw) == "null" {
return nil
}
if sch == nil {
return fmt.Errorf("%w: widget has no settings; got %s", ErrInvalidInput, string(raw))
}
var parsed struct {
Count *int `json:"count,omitempty"`
HorizonDays *int `json:"horizon_days,omitempty"`
}
if err := json.Unmarshal(raw, &parsed); err != nil {
return fmt.Errorf("%w: widget settings decode: %v", ErrInvalidInput, err)
}
if parsed.Count != nil {
if len(sch.CountOptions) == 0 {
return fmt.Errorf("%w: widget has no count knob", ErrInvalidInput)
}
if !(sch.CountAllowsAll && *parsed.Count == -1) && !slices.Contains(sch.CountOptions, *parsed.Count) {
return fmt.Errorf("%w: count %d not in %v", ErrInvalidInput, *parsed.Count, sch.CountOptions)
}
}
if parsed.HorizonDays != nil {
if len(sch.HorizonOptions) == 0 {
return fmt.Errorf("%w: widget has no horizon knob", ErrInvalidInput)
}
if !slices.Contains(sch.HorizonOptions, *parsed.HorizonDays) {
return fmt.Errorf("%w: horizon_days %d not in %v", ErrInvalidInput, *parsed.HorizonDays, sch.HorizonOptions)
}
}
return nil
}
// WidgetDef is one entry in the catalog. Title/description fields are the
// translation-key seeds; frontend resolves them via the i18n registry.
type WidgetDef struct {
Key WidgetKey `json:"key"`
TitleDE string `json:"title_de"`
TitleEN string `json:"title_en"`
DescriptionDE string `json:"description_de"`
DescriptionEN string `json:"description_en"`
DefaultVisible bool `json:"default_visible"`
DefaultCount *int `json:"default_count,omitempty"`
DefaultHorizon *int `json:"default_horizon_days,omitempty"`
Settings *WidgetSettingsSchema `json:"settings,omitempty"`
}
// WidgetCatalog returns the v1 catalog. Returned by value (small struct
// slice) so callers can freely append i18n overrides for the wire format.
func WidgetCatalog() []WidgetDef {
listCounts := []int{1, 3, 5, 10, 20}
listHorizon := []int{7, 14, 30, 60}
inboxCounts := []int{1, 3, 5, 10}
agendaHorizon := []int{14, 30, 60}
tenDefault := 10
threeDefault := 3
thirtyDefault := 30
return []WidgetDef{
{
Key: WidgetDeadlineSummary,
TitleDE: "Fristen auf einen Blick",
TitleEN: "Deadlines at a glance",
DescriptionDE: "Ampel-Karten für überfällige, heutige und kommende Fristen.",
DescriptionEN: "Traffic-light cards for overdue, today, and upcoming deadlines.",
DefaultVisible: true,
},
{
Key: WidgetMatterSummary,
TitleDE: "Meine Akten",
TitleEN: "My Matters",
DescriptionDE: "Aktiv-, archiviert- und Gesamtzahl deiner sichtbaren Akten.",
DescriptionEN: "Active, archived and total counts of your visible matters.",
DefaultVisible: true,
},
{
Key: WidgetUpcomingDeadlines,
TitleDE: "Kommende Fristen",
TitleEN: "Upcoming deadlines",
DescriptionDE: "Liste der nächsten Fristen — Anzahl und Zeitraum konfigurierbar.",
DescriptionEN: "List of upcoming deadlines — count and horizon configurable.",
DefaultVisible: true,
DefaultCount: &tenDefault,
DefaultHorizon: &thirtyDefault,
Settings: &WidgetSettingsSchema{
CountOptions: listCounts,
HorizonOptions: listHorizon,
},
},
{
Key: WidgetUpcomingAppointments,
TitleDE: "Kommende Termine",
TitleEN: "Upcoming appointments",
DescriptionDE: "Liste der nächsten Termine — Anzahl und Zeitraum konfigurierbar.",
DescriptionEN: "List of upcoming appointments — count and horizon configurable.",
DefaultVisible: true,
DefaultCount: &tenDefault,
DefaultHorizon: &thirtyDefault,
Settings: &WidgetSettingsSchema{
CountOptions: listCounts,
HorizonOptions: listHorizon,
},
},
{
Key: WidgetInlineAgenda,
TitleDE: "Agenda",
TitleEN: "Agenda",
DescriptionDE: "30-Tage-Agenda mit Fristen und Terminen kombiniert.",
DescriptionEN: "30-day agenda combining deadlines and appointments.",
DefaultVisible: true,
DefaultHorizon: &thirtyDefault,
Settings: &WidgetSettingsSchema{
HorizonOptions: agendaHorizon,
},
},
{
Key: WidgetRecentActivity,
TitleDE: "Letzte Aktivität",
TitleEN: "Recent activity",
DescriptionDE: "Verlauf der letzten Ereignisse in deinen sichtbaren Akten.",
DescriptionEN: "Recent events across your visible matters.",
DefaultVisible: true,
DefaultCount: &tenDefault,
Settings: &WidgetSettingsSchema{
CountOptions: listCounts,
},
},
{
Key: WidgetInboxApprovals,
TitleDE: "Offene Freigaben",
TitleEN: "Open approvals",
DescriptionDE: "Deine offenen Freigaben mit Anzahl und einer kurzen Liste.",
DescriptionEN: "Your open approval requests with count and a short list.",
DefaultVisible: true,
DefaultCount: &threeDefault,
Settings: &WidgetSettingsSchema{
CountOptions: inboxCounts,
},
},
}
}
// LookupWidgetDef returns the catalog entry for a key, or false if unknown.
func LookupWidgetDef(key WidgetKey) (WidgetDef, bool) {
for _, def := range WidgetCatalog() {
if def.Key == key {
return def, true
}
}
return WidgetDef{}, false
}