Compare commits

..

26 Commits

Author SHA1 Message Date
mAi
bcfde73815 feat(inbox): t-paliad-249 Slice A frontend — inbox dispatch + UI axes (m/paliad#80)
The /inbox surface drops "Genehmigungen" framing in favour of "Inbox"
and renders the unified feed.

- shape-list.ts: factor renderApprovalRow out of renderApprovalList so
  it can be reused alongside renderProjectEventInboxRow inside the new
  renderInboxList (row_action="inbox"). Project_event rows show a
  compact stream layout with an Öffnen link pointing at the right
  project tab (deadlines / appointments / notes).
- filter-bar gets two new axes: unread_only (binary chip cluster) +
  inbox_focus (4-chip coarse cluster: Alles / Genehmigungen / +Termine
  / +Fristen). Both round-trip via url-codec; inbox_focus translates
  to (sources, project_event.event_types, approval_request.entity_types)
  at the bar's resolve step (applyInboxFocusOverlay).
- FilterSpec gains a top-level unread_only flag; the bar writes it
  when the user toggles the chip; the server overlays the cursor.
- /inbox header: new "Alles als gelesen markieren" button POSTs
  /api/inbox/mark-all-seen with up_to=<newest visible row> for
  race-safety against a second tab.
- INBOX_AXES adds project + project_event_kind as advanced override
  chips so power users can still narrow per kind.
- i18n: inbox.title.feed / inbox.heading.feed / inbox.action.mark_all_seen
  / inbox.action.open / inbox.empty.feed / views.bar.unread_only.*  /
  views.bar.inbox_focus.* (DE + EN).
- url-codec round-trip tests for the two new axes.
2026-05-25 15:49:54 +02:00
mAi
4ead2d08c1 feat(inbox): t-paliad-249 Slice A backend — project_event feed + read cursor (m/paliad#80)
Substrate changes that turn /inbox from approvals-only into the
unified notification surface m asked for.

- Migration 126: paliad.users.inbox_seen_at (high-watermark read cursor;
  pending approval_requests bypass it per design §3).
- KnownProjectEventKinds gains note_created, our_side_changed,
  deadline_updated/deleted, deadlines_imported. New
  InboxProjectEventKinds curated subset (head's Q1=A lock).
- InboxSystemView spans [approval_request, project_event]; defaults to
  past 30 days, newest first, row_action="inbox".
- view_service.allowedProjectEventKinds drops *_approval_* audits when
  ApprovalRequest is also in spec.Sources (no double-count).
- RunSpec resolves the caller's inbox_seen_at once and threads it
  through viewSpecBounds; runProjectEvents excludes self-authored
  events and rows older than the cursor when unread_only is set.
  Decided approval_requests follow the cursor; pending always survives.
- ApprovalService.UnseenInboxCountForUser (unified badge count) +
  MarkInboxSeen + InboxSeenAt service methods.
- GET /api/inbox/count returns the unified count; new
  POST /api/inbox/mark-all-seen advances the cursor (optional up_to=).

Tests cover the InboxSystemView shape, the audit-dedup helper, the
isApprovalAuditKind matcher, and the no-narrow-no-approvals nil path.
2026-05-25 15:49:39 +02:00
mAi
2683c5f9cf docs(inbox): t-paliad-249 — inbox overhaul inventor design (m/paliad#80)
LOCKED design with head decisions (Q1=A) folded in §12. Slice plan
A/B/C reuses existing FilterSpec + RunSpec engine; no new aggregation
service. Slice A adds inbox_seen_at cursor + project_event source on
InboxSystemView + RowActionInbox dispatch in shape-list; Slice B adds
shape toggle (list/cards/calendar) + member_role_changed narrowing;
Slice C upgrades the badge + per-item dismiss.
2026-05-25 15:33:36 +02:00
mAi
51fca9383f Merge: t-paliad-246 — Backup Mode Slice A (on-demand admin org export, local disk, .zip bundle, mig 123) (m/paliad#77) 2026-05-25 15:29:48 +02:00
mAi
99c9d89daa feat(backups): t-paliad-246 — Backup Mode Slice A (on-demand admin org export)
m/paliad#77 Slice A. Folds the unbuilt t-paliad-214 Slice 3 (org async
export) into a new "Backup Mode" surface gated by adminGate.

m's calls (all 4 material picks per design §2):
- Storage: local disk PALIAD_EXPORT_DIR (LocalDiskStore only)
- Format: .zip bundle (xlsx + JSON + CSV + README) — no-lock-in preserved
- paliadin_turns + paliadin_aichat_conversation: EXCLUDE structurally
- Scheduler (Slice B): nightly 03:00 UTC, env-tunable

Wiring:
- mig 123 adds paliad.backups catalog table (kind/status/storage_uri/
  size/row_counts/warnings/error/deleted_at + admin-only RLS).
- ExportService.WriteOrg + orgSheetQueries enumerate 37 entity sheets
  + 12 ref sheets; REPEATABLE READ READ ONLY tx wraps the dump for
  snapshot consistency (design §3.3).
- writeBundle + runSheetQuery refactored to take a sqlx.QueryerContext
  so both *sqlx.DB (personal/project paths, unchanged) and *sqlx.Tx
  (org snapshot path) work.
- BackupRunner orchestrates: catalog INSERT → audit INSERT
  (event_type='backup_created') → WriteOrg → ArtifactStore.Put → patch
  catalog + audit on success/failure.
- ArtifactStore interface + LocalDiskStore impl (defense-in-depth key
  validation + URI-outside-dir guard).
- Sentinel actor for scheduled runs: actor_email='system@paliad',
  actor_id=NULL — no phantom user in paliad.users.
- Admin handlers POST /api/admin/backups/run + GET list/get/download
  behind adminGate(users, …); /admin/backups page + sidebar entry +
  bilingual i18n keys.
- BackupRunner only wired when PALIAD_EXPORT_DIR is set; routes return
  503 otherwise (same shape as requireDB).

Tests: 8 pure-function tests cover registry shape (no dups, paliadin
absent both as sheet name and SQL substring, ref__* sheets unscoped,
every sheet has ORDER BY) and LocalDiskStore (round-trip, bad-key
rejection, URI-traversal rejection, mkdir on construction).

go build ./... + go test ./internal/... clean. bun run build clean.

Slice B (BackupScheduler + retention cleanup) and Slice C (UI polish)
are separate follow-ups per head's instruction.
2026-05-25 15:28:37 +02:00
mAi
7bc6fdb18a Merge: t-paliad-263 — bulletproof deadline-rules completeness audit (m/paliad#94) 2026-05-25 15:24:57 +02:00
mAi
94a9e7e5fb docs: t-paliad-263 bulletproof deadline-rules completeness audit
Read-only audit of paliad.deadline_rules against UPC RoP + EPC +
PatG/ZPO/GebrMG statutory sources, with verbatim verification of
all citations against youpc data.laws_contents (UPC RoP + EPC) and
gesetze-im-internet.de (PatG/ZPO).

Headline findings:
- 5 hard user-visible bugs: 2 UPC_REV duration bugs (R.49.1 3mo->2mo,
  R.52 2mo->1mo), 1 UPC appeal-response duration bug (R.235.1 2mo->3mo),
  2 DE-LG-Verletzung sequencing bugs (beruf_begr anchor + replik/duplik
  parent_id NULL).
- 11 citation drift bugs (rule_code/legal_source point at wrong rule).
- 6 court-set-mismodelled-as-fixed (DPMA + DE + EPA richterliche Fristen
  carrying made-up statutory citations).
- ~30 statutory deadlines unmodelled (12 high-frequency in Tier 1).
- 13 ambiguity questions for m's judgement (court-set policy,
  working-days arithmetic, Wiedereinsetzung modelling).

Slices into Wave 0 (16 Tier-0 fixes) and Wave 1-6 (Tier 1-4 + spikes).
No DB writes; findings only.

Refs: m/paliad#94, t-paliad-263
2026-05-25 15:23:39 +02:00
mAi
f55648944c Merge: t-paliad-261 — submission-draft autosave focus + click-variable-in-preview jump (m/paliad#92) 2026-05-25 15:13:35 +02:00
mAi
7e66da8def mAi: #92 - t-paliad-261 — submission-draft autosave focus + click-variable-in-preview jump
Two related editor polish fixes.

(A) Autosave-refresh focus preservation
  paintVariables() replaces every input via innerHTML, blowing away
  the focused-input reference and dropping the cursor mid-edit. Fix:
  capture the active variable input's data-var key + selectionStart/
  End/Direction before the repaint, restore on the new element after
  (by data-var lookup + setSelectionRange). Cursor stays put across
  autosave, rename, and reset cycles. Works for <input> and
  <textarea> via the shared selectionRange contract.

(B) Click variable in preview → jump to sidebar input
  Go renderer wraps every substituted placeholder value in the HTML
  preview with <span class="draft-var" data-var="key">…</span>.
  Implemented via a valueWrapperFn plumbed through
  substituteInDocumentXML → substituteInTextNodes /
  substituteAcrossRuns → replacePlaceholders. RenderHTML passes
  htmlPreviewWrapper which marks values with three PUA sentinels
  (U+E100/U+E101/U+E102) that emitTextWithDraftVars converts to the
  span pair inside docXMLToHTML. Missing-marker text is wrapped too
  so a clicked [KEIN WERT: foo] jumps to the empty field.

  Render() (.docx export) passes nil for wrap → output is byte-
  identical to pre-261. New test
  TestRender_DocxOutputUnchangedByPreviewWrap asserts the .docx never
  carries draft-var/data-var markup or PUA sentinels.

  Client wireDraftVars() adds .draft-var--has-input only to spans
  whose key resolves to a sidebar input — derived variables (e.g.
  today.iso) stay non-clickable. Click handler:
    scrollIntoView(smooth, center) → focus + select after 50ms →
    1.2s lime flash on the row.
  Keyboard accessible (Enter / Space) with role=button + aria-label.

CSS adds a subtle lime tint to every .draft-var so the user sees
what was substituted; --has-input layers cursor: pointer + brighter
hover background. Flash animation respects prefers-reduced-motion
via a steps(1, end) fallback.

Tests: TestRenderHTML_ExtractsParagraphsAndFormatting updated to
assert the new span wrap. New tests for missing-marker wrap +
.docx-path-untouched. Go + frontend builds clean.
2026-05-25 15:12:10 +02:00
mAi
ef21e43375 Merge: t-paliad-260 — submission-draft mobile layout (m/paliad#91) 2026-05-25 14:59:53 +02:00
mAi
4cb99fb627 mAi: #91 - t-paliad-260 — submission-draft mobile layout: drop sticky on sidebar at ≤900px
Approach A: stack vertically. At single-column widths the variable
editor was sticky + max-height: calc(100vh - 2rem), so it stayed
pinned at the top of the viewport while the user scrolled down to
read the preview, visually overlaying the preview pane.

Add a media-query override that switches the sidebar to position:
static, max-height: none, overflow-y: visible at the same ≤900px
breakpoint where the grid already collapses to one column. The
sidebar now reflows above the preview, takes its natural height,
and scrolls away as the user moves down — no overlay, no
horizontal scroll. Desktop (≥901px) layout unchanged: sidebar
keeps its sticky behavior side-by-side with the preview.

Verified at 375 / 414 / 768 / 1280 px in Playwright on the
populated editor body — same renderer serves both URL shapes
(/submissions/draft/{id} and
/projects/{id}/submissions/{code}/draft/{id}).
2026-05-25 14:58:21 +02:00
mAi
452ccdf127 Merge: t-paliad-258 — Deadline form Auto/Custom rule field + canonical rule-label display (m/paliad#89) 2026-05-25 14:56:18 +02:00
mAi
045accc6d9 mAi: #89 - deadline rule field binary Auto/Custom + canonical rule-label display
t-paliad-258. m's verdict on t-paliad-251's rule UI: "too many options"
(4 'Oral hearings' across courts, etc.). Replace the full deadline_rules
catalog dropdown + sort selector with a binary model and unify the rule
display contract across every surface that prints a rule label.

Binary Rule field on the deadline form
- Auto (default): rule_id is derived from the chosen Type. The resolved
  rule renders read-only as 'Auto | <Name · Citation>' next to the
  field. No catalog picker, no sort options.
- Custom: free-text input. Stored as deadlines.custom_rule_text (new
  nullable column, migration 122). Mutually exclusive with rule_id at
  the persistence boundary.
- Toggle link flips between modes. Re-toggling to Auto re-resolves from
  the current Type — no stale state.

Schema + service (additive)
- migration 122 adds paliad.deadlines.custom_rule_text (nullable).
  Existing rows: empty custom_rule_text + non-null rule_id = Auto-
  equivalent. Both NULL = "keine Regel" (consistent with today).
- models.Deadline.CustomRuleText + service SELECTs include the column.
- CreateDeadlineInput accepts custom_rule_text; the service drops it
  when rule_id is set (catalog wins; simple invariant at the boundary).
- UpdateDeadlineInput grows a {RuleSet, RuleID, CustomRuleText} triple.
  RuleSet=true is the discriminator so absent fields don't overwrite
  the row (PATCH semantics). RuleID and CustomRuleText are mutually
  exclusive in one request; service rejects "both set".
- EventListItem (the /api/events union) carries CustomRuleText so list
  surfaces can render it.

Frontend: deadlines-new
- Drop the rule <select>, the by_proceeding/by_court/alpha sort
  dropdown, the override-warning slot, and the collapsed-by-Regel Typ
  view. Strip the (Rule→Type) auto-fill machinery — direction is now
  one-way (Type → Auto-resolved Rule).
- Keep Type→Rule resolution: resolveAutoRuleForType picks the canonical
  rule by project's proceeding, then jurisdiction match, then first
  candidate. Same logic, just re-aimed at the read-only display.
- Standardtitel preserves the chain (event type → Auto rule label →
  Custom text → proceeding → fallback) so the recipe still produces a
  sensible title even when Custom is used.

Frontend: deadlines-detail
- Read-only display: catalog rule → Name · Citation, else
  custom_rule_text + Custom badge, else legacy rule_code, else "—".
- Edit mode: mirror the create form with the Auto/Custom toggle.
  enterEdit initialises the mode from the persisted deadline; Save
  PATCHes with rule_set:true + the chosen rule pointer.

Rule-label addendum (m's 14:31 follow-up)
- Canonical contract everywhere: Name primary, Citation muted secondary
  ("Notice of Appeal · UPC.RoP.220.1"). Custom rules render the text
  with a "Custom" pill.
- New frontend/src/client/rule-label.ts exports formatRuleLabel /
  formatRuleLabelHTML / formatCustomRuleLabelHTML — one helper per
  shape (plain text vs muted-citation HTML).
- Wired into: deadlines-new Auto display, deadlines-detail read +
  Standardtitel, events.ts ruleDisplay (REGEL column on /events),
  projects-detail.ts Fristen table, views/shape-list.ts generic
  rule column.
- Verfahrensablauf (views/verfahrensablauf-core.ts) already renders
  name + citation chip separately and matches the canonical pattern;
  no change needed. Schriftsätze table is column-shaped (name + code
  in distinct columns) and out of scope per the addendum.

CSS
- New .rule-mode-auto / .rule-mode-custom / .rule-label-* family.
- Drop the dead .rule-sort-select rule and the .event-type-collapsed*
  family (retired with the catalog dropdown).

i18n
- DE+EN. Remove 10 stale keys (rule.none, autofill, autofill_inline,
  mismatch, override, override_warn, sort.*). Add 6 (auto_no_match,
  auto_pick_type, custom_badge, custom_placeholder,
  mode.toggle_to_auto, mode.toggle_to_custom).

Build hygiene
- go build + go test ./internal/... clean.
- frontend bun build clean (2803 keys, scan clean).

Out of scope (per issue)
- Promoting Custom entries back to the catalog ("save as new rule").
- Filtering/searching custom_rule_text in deadline lists.
- Touching the event-type browse modal (Part 1 of #82 — that stays).

Files
- internal/db/migrations/122_deadlines_custom_rule_text.{up,down}.sql
- internal/models/models.go
- internal/services/deadline_service.go (Create+Update+SELECT)
- internal/services/event_service.go (union projection)
- frontend/src/client/rule-label.ts (new helper)
- frontend/src/client/deadlines-new.ts (rewrite)
- frontend/src/client/deadlines-detail.ts (Auto/Custom editor + display)
- frontend/src/client/events.ts (REGEL column)
- frontend/src/client/projects-detail.ts (Fristen table cell)
- frontend/src/client/views/shape-list.ts (generic rule column)
- frontend/src/client/i18n.ts + i18n-keys.ts (DE+EN delta)
- frontend/src/deadlines-new.tsx (strip dropdown+sort, add toggle)
- frontend/src/deadlines-detail.tsx (Auto/Custom edit slots)
- frontend/src/styles/global.css (rule-mode + rule-label families)
2026-05-25 14:54:51 +02:00
mAi
e6b61b4d2e Merge: t-paliad-259 — universal _skeleton.docx fallback for submission preview/generate (m/paliad#90) 2026-05-25 14:45:50 +02:00
mAi
940df95418 fix(submissions): t-paliad-259 — universal _skeleton.docx for fallback chain
Issue: m noticed the submission generator's preview still shows the raw
HL Patents Style .dotm letterhead for every submission_code that has no
per-firm template. Confirmed live: paliad.de's /healthz is green, the
preview path and /generate path both flow through resolveSubmissionTemplate,
and the only code wired in submissionTemplateRegistry is de.inf.lg.erwidg
(t-paliad-241). For every other code, the fallback was the bare letterhead
with zero placeholders — exactly what m observed.

Fix: slot a universal _skeleton.docx between the per-firm code-specific
template and the macro-only HL Patents Style:

  per-firm/{code}.docx → _skeleton.docx → HL Patents Style.dotm

The skeleton carries every placeholder SubmissionVarsService resolves
(all 48 keys across firm.*, today.*, user.*, project.*, parties.*, rule.*,
deadline.*) without baking in submission_code-specific prose, so any
code lands with variables substituted instead of the bare letterhead.

Changes:
- scripts/gen-skeleton-submission-template/main.go: byte-reproducible
  .docx generator mirroring gen-demo-submission-template but with a
  code-agnostic body (no Klageerwiderung "I./II./III." structure, a
  single [Schriftsatztext] block the lawyer replaces). One run per
  placeholder so the renderer's pass-1 substitution catches every token.
- internal/handlers/files.go: register slug submission/_skeleton.docx +
  fetchSubmissionSkeletonBytes helper (same stale-while-revalidate
  semantics as the existing per-code and HL-Patents-Style fetchers).
- internal/handlers/submission_drafts.go: insert the skeleton lookup
  between fetchSubmissionTemplateBytes (per-firm code) and
  fetchHLPatentsStyleBytes (bare letterhead). HL Patents Style remains
  the final fallback for resilience if mWorkRepo is unreachable.

The companion _skeleton.docx is committed to m/mWorkRepo at
6 - material/Templates/Word/Paliad/HLC/_skeleton.docx (commit f2659e4)
so the file proxy can fetch it on first request.

Build hygiene: go build ./... clean, go test ./internal/... clean,
bun run build clean.
2026-05-25 14:44:58 +02:00
mAi
538c2d2da9 Merge: t-paliad-257 — Verfahrensablauf user-perspective column axis (Unsere Seite / Gericht / Gegnerseite) (m/paliad#88) 2026-05-25 14:34:38 +02:00
mAi
a9a9adbd2a mAi: #88 - Verfahrensablauf: column axis reframed to user-perspective
Replaces the misleading Proaktiv/Reaktiv column pair with a static
"Unsere Seite" / "Gericht" / "Gegnerseite" axis ("WE always on the
left", per m's t-paliad-257 ask). The side toggle now drives row
PLACEMENT into the ours/opponent buckets — the column labels stay
truthful regardless of which physical party occupies them.

Old framing lied half the time: Klägerseite is sometimes proactive
(filing the claim) and sometimes reactive (responding to a CCR),
so "Proaktiv (Klägerseite)" was wrong whenever the user's perspective
flipped. New axis is purely positional with semantic labels.

Changes:

- frontend/src/client/views/verfahrensablauf-core.ts:
  • ColumnsRow fields proactive/reactive → ours/opponent.
  • renderColumnsBody picks static "Unsere Seite" / "Gegnerseite"
    labels — no more variant-by-side label keys.
  • bucketDeadlinesIntoColumns routes the user's party into `ours`
    when opts.side ∈ {"defendant"}; default (null) keeps the legacy
    "we are claimant" fallback so claimant-on-left layout survives.

- verfahrensablauf-core.test.ts: rewritten expectations on the new
  ours/opponent fields. Added two new tests pinning the WE-on-left
  semantics and the side+appellant interaction (side=defendant +
  appellant=claimant → "both" collapses into opponent).

- fristenrechner.ts: wires currentPerspective into renderColumnsBody
  as `side` so the columns honour the chip-strip perspective.
  Without this, a defendant-perspective user would see claimant
  filings under the "Unsere Seite" header — the old code didn't
  need the wire-up because the labels weren't perspective-aware.

- i18n.ts: replaces deadlines.col.proactive(.defendant) +
  deadlines.col.reactive(.claimant) with deadlines.col.ours +
  deadlines.col.opponent ("Unsere Seite"/"Client Side",
  "Gegnerseite"/"Opponent Side"). Court key unchanged.

- i18n-keys.ts: regenerated key union.

- global.css: .fr-col-proactive/.fr-col-reactive renamed to
  .fr-col-ours/.fr-col-opponent.

Out of scope (kept intact):
- Side and appellant URL-state plumbing.
- Appellant selector for Appeal-type proceedings (separate axis).
- Project-default side-from-our_side wiring — /tools/verfahrensablauf
  has no project context, and /tools/fristenrechner already does this
  via applyOurSidePredefine().

Build: bun run build clean (2794 keys), go build ./... clean.
Tests: 112 frontend tests pass (was 110, +2 new); all Go tests
cached green.
2026-05-25 14:32:57 +02:00
mAi
f24a90b722 Merge: t-paliad-252 — Approval withdraw warning modal + edit-instead path (m/paliad#83) 2026-05-25 14:26:20 +02:00
mAi
55bfe439f2 Merge: t-paliad-256 — test-data reset + Example Projects seed exercising chain codes (m/paliad#87) 2026-05-25 14:26:03 +02:00
mAi
0ac26fe0ee chore(seed): t-paliad-256 — wipe + seed Example Projects exercising chain code
Re-runnable Go script under scripts/seed-example-projects/ that wipes
every paliad.projects row (FK CASCADE handles dependents) and seeds 18
realistic patent-litigation projects across 3 example clients:

  SIEMENS  — UPC + LG cases incl. CCR (Widerklage) on EP3456789
  BAYER    — EPA Einspruch + BPatG Nichtigkeit on EP2222333
  BEISPL   — sparse DPMA demo on DE10987654

Every node carries the chain-code-driving fields (reference on client,
opponent_code on litigation, patent_number on patent, proceeding_type_id
on case), producing codes like SIEMENS.HUAW.789.INF.CFI and
SIEMENS.HUAW.789.CCR.CFI via services.BuildProjectCode.

One transaction wraps both wipe and seed; -dry-run rolls back so the
script can be sanity-checked before commit. Reference tables
(proceeding_types, deadline_rules, event_types, gerichte, checklists
templates, firms) are untouched.

Ran live against youpc Postgres 2026-05-25: 12 rows wiped, 18 seeded.
2026-05-25 14:25:16 +02:00
mAi
72b64140e9 mAi: #83 - approval withdraw warning modal + edit-instead path
t-paliad-252. Replace the silent confirm()-then-DELETE with a three-path
warning modal: Cancel / Edit event (primary) / Withdraw and delete
(destructive). The edit-instead path lets the requester revise the
in-flight entity without withdrawing the approval request.

Backend — new service method + endpoint
- ApprovalService.EditPendingEntity(requestID, callerID, fields):
  - validates caller == requested_by AND status = pending
  - reuses the existing wider counter-allowlist (buildCounterSetClauses
    from SuggestChanges) — every editable field on the entity, not just
    the date triggers
  - applies the field updates to the entity row via applyEntityUpdate
    (including the event_type_ids junction rewrite for deadlines)
  - merges new fields into approval_requests.payload (jsonb) so the
    approver inbox sees what was revised
  - emits a distinct *_approval_edited_by_requester project_event so the
    Verlauf surfaces the revision separately from the original *_requested
    row and any decision row
  - request stays pending; entity.approval_status stays pending
- POST /api/approval-requests/{id}/edit-entity
  - Body: {"fields": {<entity-shape>}}
  - Errors reuse the existing mapApprovalError mapping:
    400 suggestion_requires_change, 403 not_authorized,
    404, 409 request_not_pending
- Distinguishing audit event types per the spec:
  - destructive Withdraw path: existing <entity>_approval_revoked
    (no behaviour change — for CREATE deletes the entity, for UPDATE /
    COMPLETE reverts to pre_image, for DELETE cancels the delete request)
  - edit-instead path: new <entity>_approval_edited_by_requester

Frontend — shared withdraw warning modal
- frontend/src/client/components/withdraw-warning-modal.ts
  - Built on the unified openModal() primitive (t-paliad-217 Slice A)
  - Primary CTA "Termin bearbeiten" highlights the non-destructive path
  - Secondary defaults to "Abbrechen" (handled by openModal)
  - Destructive button "Endgültig zurückziehen und löschen" lives inside
    the body (red, separated by a dashed border) so the safe path stays
    visually primary in the footer
  - Copy adapts per lifecycle:
    CREATE   → "Wenn Sie zurückziehen, wird die Frist/der Termin gelöscht."
    UPDATE   → "Ihre vorgeschlagenen Änderungen werden verworfen."
    DELETE   → "Der Eintrag bleibt bestehen."

Frontend — wiring on both detail pages
- deadlines-detail.ts + appointments-detail.ts:
  - Replace confirm() in withdraw flow with openWithdrawWarningModal()
  - Edit path: set module-level pendingEditMode = true + enter edit mode
    (override existing pending-state freeze on appointments; expose
    enterEdit() via late-bound pendingEnterEdit on deadlines)
  - Save handler in pendingEditMode routes to /edit-entity instead of
    PATCH /api/<entity>/{id} (which still 409s on pending state)
  - Destructive Withdraw path: existing /revoke endpoint unchanged
  - For CREATE-lifecycle revokes the entity is gone — bounce to the
    /events list instead of trying to re-fetch (was reload() before)

i18n: +14 keys DE+EN under approvals.withdraw.* (modal title, primary,
destructive, cancel, lead.create.{deadline,appointment}, lead.update,
lead.delete, sub.create, sub.update, sub.delete)

CSS: .withdraw-warning-body + .withdraw-warning-{intro,sub,
destructive-row,destructive-btn} — lime-tint sibling palette consistent
with the existing form-hint pattern; destructive button uses .btn-danger.

Build hygiene:
- go build + go vet + go test ./internal/... clean
- frontend bun run build clean (2807 keys, +14 new, scan clean)

Files of note:
- internal/services/approval_service.go (EditPendingEntity + sortedKeys
  helper; maps.Copy for the payload merge)
- internal/handlers/approvals.go (handleEditPendingEntity)
- internal/handlers/handlers.go (route registration)
- frontend/src/client/components/withdraw-warning-modal.ts (new shared
  component)
- frontend/src/client/deadlines-detail.ts (initWithdraw rewrite + Save
  pending-edit branch)
- frontend/src/client/appointments-detail.ts (withdrawAppointmentRequest
  rewrite + Save pending-edit branch + form-freeze respects
  pendingEditMode)

Out of scope (intentionally):
- Reopening already-deleted approval requests (the destructive path
  stays final).
- Approval-request analytics / metrics.
- Notifying the original approval-requester via channel.
2026-05-25 14:24:55 +02:00
mAi
50cd80a4a6 Merge: t-paliad-255 — kill /events horizontal scroll on mobile (m/paliad#86) 2026-05-25 14:10:07 +02:00
mAi
716f6d7ece fix(events): t-paliad-255 — kill /events horizontal scroll on mobile
A native <select> sizes itself to the widest <option> text. With long
project titles in the matters filter, the select grew wider than the
viewport and the /events page scrolled horizontally on mobile.

The existing 480px media query forced .entity-select to width:100% on
phones, but the 481-1000px range (tablet portrait + landscape phones)
had no constraint at all and inherited the intrinsic select width.

Fix: cap .filter-group and .entity-select at max-width:100% with
min-width:0 so the cell can shrink to fit its flex container at every
viewport. Desktop layout is preserved — normal-length options still
sit in one row across the page; only pathological content (a single
title wider than the row) wraps onto its own line.

Approach: A — let the trigger respect its container at every width.

Verified: zero horizontal scroll at 320 / 375 / 414 / 768 px with a
realistic 130-character project title injected into the matters
selector. Desktop (1280px) keeps all four filter-groups in one row.
2026-05-25 14:08:44 +02:00
mAi
1bf62c78e3 Merge: t-paliad-251 — Deadline form overhaul (m/paliad#82) 2026-05-25 14:05:06 +02:00
mAi
9a774ba3ad Merge: t-paliad-254 — Sidebar scroll position persists across nav (m/paliad#85) 2026-05-25 14:04:39 +02:00
mAi
8caaf6a631 mAi: #82 - deadline form overhaul: type-modal filter chips, type→rule autofill, Auto mode, Standardtitel
t-paliad-251. Four bundled concerns from m's 2026-05-25 reports, one
worker, one branch.

Part 1 — Event-type browse modal (search + filters)
- Modal already had a search input; added court-type filter chips
  (UPC / EPA / DPMA / DE / Allgemein) under the search.
- Chips render only the jurisdictions actually present in the data;
  any future flavour lands at the end of the row.
- Active chip uses the lime-tint chip palette already established by
  the .event-type-collapsed* family (t-paliad-165).
- Search input keeps autofocus; chip + search filters intersect.

Part 2 — Type → Rule auto-fill + sort options
- Inverted the existing rule.concept_default_event_type_id mapping
  client-side: given a chosen event_type X, candidate rules are
  those with concept_default_event_type_id === X.
- Resolution picks (1) exact match on the project's
  proceeding_type_id, (2) jurisdiction match on the rule's
  proceeding (EPA→EPO canonicalised), (3) first candidate.
- Sort dropdown next to the Rule label: by proceeding sequence,
  by court (jurisdiction grouping with optgroup), alphabetical.
  Defaults to "by court"; localStorage-persisted per browser.
- All sorts are client-side over the existing /api/deadline-rules
  payload — no new endpoint.

Part 3 — Auto rule mode + clearer override warning
- Auto badge (.form-hint--auto, lime-tint pill + " — <rule name>")
  surfaces whenever the Rule was derived from the chosen Type.
  Disappears the moment the user manually picks a different rule.
- Override warning names BOTH sides + the actually-applied rule:
  "Typ ergibt Regel: X. Gewählte Regel: Y. Es wird Y angewendet."
- Symmetric `lastAutoFilledRuleID` sticky-replace flag mirrors the
  existing `lastAutoFilledEventTypeID` (t-paliad-165) so the auto-
  fill only replaces its own previous suggestion, never a manual
  pick.
- Collapsed Typ view (t-paliad-165) is suppressed when the rule was
  auto-derived from the type — the "vorgegeben durch Regel" copy
  reads backwards in that case; show picker + Auto badge instead.

Part 4 — Standardtitel button (create + edit)
- Button rendered next to the Title field on both /deadlines/new
  and /deadlines/{id} (edit mode only).
- Recipe (recipe-docs-here-so-future-templates-can-mirror-it):
    head =
      1. event_type label (if exactly one Typ chip is set)
      2. rule code+name (when a Rule is set — "RoP.023 — Klageerwiderung")
      3. proceeding type name from project (create form only)
      4. fallback: t("deadlines.field.title.default_fallback")
    suffix = " — <project.reference>" when ref is set and not
             already in head.
  Examples:
    Klageerwiderung — C-UPC-0042       (type known)
    RoP.023 — Klageerwiderung — REF    (rule known, no type)
    UPC — Verletzungsverfahren — REF   (only proceeding type)
    Neue Frist — REF                   (fallback)
- Click REPLACES current title; no destructive confirmation
  because the user invoked it explicitly. Focus moves into the
  title input afterwards so the user can fine-tune.

Build hygiene:
- go build + go vet + go test ./internal/... clean.
- frontend/build.ts clean (2786 keys, +10 new DE+EN, scan clean).
- All changes client-side / CSS / i18n + 2 small TSX edits; no
  schema, no service, no migration.

Files touched:
- frontend/src/client/event-types.ts (browse-modal chips)
- frontend/src/client/deadlines-new.ts (rewrite — Type→Rule, sort,
  Auto badge, override warn, Standardtitel)
- frontend/src/client/deadlines-detail.ts (edit-mode Standardtitel
  + show/hide on enter/exit edit)
- frontend/src/deadlines-new.tsx (label-row + sort dropdown + Auto
  badge slot + override-warn slot + Standardtitel button)
- frontend/src/deadlines-detail.tsx (Standardtitel button)
- frontend/src/styles/global.css (.event-type-browse-chip*,
  .form-hint--auto, .form-hint-badge, .form-field-label-row,
  .btn-link-action, .rule-sort-select)
- frontend/src/client/i18n.ts (+10 keys DE+EN)
2026-05-25 14:03:04 +02:00
62 changed files with 7573 additions and 576 deletions

View File

@@ -220,6 +220,23 @@ func main() {
Export: services.NewExportService(pool, branding.Name),
}
// t-paliad-246 Slice A — Backup Mode runner. Wired only when
// PALIAD_EXPORT_DIR is set (LocalDiskStore needs a target
// directory). Without it the /admin/backups handlers return 503
// in the same shape as Paliadin's gate. The directory is created
// (0700) on first use; a malformed path fails fast at boot so
// misconfig surfaces before the server starts taking traffic.
if exportDir := strings.TrimSpace(os.Getenv("PALIAD_EXPORT_DIR")); exportDir != "" {
store, err := services.NewLocalDiskStore(exportDir)
if err != nil {
log.Fatalf("PALIAD_EXPORT_DIR: %v", err)
}
svcBundle.Backup = services.NewBackupRunner(pool, svcBundle.Export, store)
log.Printf("backup: LocalDiskStore at %s (/admin/backups active)", exportDir)
} else {
log.Println("PALIAD_EXPORT_DIR not set — /admin/backups will return 503")
}
// 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

View File

@@ -0,0 +1,848 @@
# Design: /inbox overhaul — project-events feed + filtering + list/cards/calendar toggles
**Task:** t-paliad-249
**Gitea:** m/paliad#80
**Author:** icarus (inventor)
**Date:** 2026-05-25
**Status:** LOCKED — head confirmed Q1=A with two refinements (2026-05-25), see §12.
**Branch:** `mai/icarus/inventor-inbox-overhaul`
---
## 0. TL;DR
`/inbox` today is approval-requests only. m wants it to become the actual
"what's new on my projects" surface — approval requests **plus** recent
project_events on visible projects — with the same view-toggle paradigm
as `/events` (list / cards / calendar) and a meaningful filter row.
The good news: the substrate already exists.
- `view_service.RunSpec` unions four sources (deadline, appointment,
**project_event**, **approval_request**) into one ranked `[]ViewRow`.
- `FilterSpec` has predicates for every axis we need
(`ProjectEventPredicates.EventTypes`, `ApprovalRequestPredicates`).
- `filter-bar` knows the axes we need: `time`, `project`,
`approval_viewer_role`, `approval_status`, `approval_entity_type`,
`project_event_kind`, plus `shape` / `sort` / `density`.
- Shape renderers exist: `shape-list` (table + compact + approval), `shape-cards`
(day-grouped), `shape-calendar` (thin adapter on `mountCalendar`).
So the work is **mostly re-mix**:
1. Extend `InboxSystemView` from `Sources=[ApprovalRequest]` to
`Sources=[ApprovalRequest, ProjectEvent]`, default
`Time.Horizon=Past30d`, and add a curated `project_event.event_types`
default that filters out noise (approvals duplicate-suppression,
checklist mutations, status churn).
2. Extend `shape-list.ts` so `row_action="approve"` no longer assumes
every row is an approval — rename it `"inbox"`, dispatch per
`row.kind` (approval → existing approve-card layout; project_event →
navigate-style stream row).
3. Wire the existing view-axis selector (the chip cluster on `/events`)
onto `/inbox`'s host, persisting selection via the filter-bar URL
codec (axis `shape` already in `AxisKey`).
4. Add a high-watermark read cursor (`paliad.users.inbox_seen_at`) +
`POST /api/inbox/mark-all-seen` + extend `/api/inbox/count` to count
unseen project_events too. Adds one new axis `unread_only` to the bar.
That's Slice A. Slice B layers cards + calendar toggles cleanly. Slice C
is per-item dismissal — keep out of v1 unless the cursor proves not
enough (m's pick Q3 is the cursor).
No new aggregation service, no new endpoint family — the inbox runs on
`/api/views/inbox/run` like every other system view does today.
---
## 1. Current `/inbox` state
**Routes (`internal/handlers/approvals.go`):**
| Path | Behaviour |
|---------------------------------------|--------------------------------------------------------------|
| `GET /inbox` | Serves `dist/inbox.html`, a thin shell. No SSR data. |
| `GET /api/inbox/pending-mine` | Approval requests I can approve. |
| `GET /api/inbox/mine` | Approval requests I submitted (all statuses by default). |
| `GET /api/inbox/count` | `{count: N}` for the sidebar bell badge — `PendingCountForUser`. |
| `GET /api/approval-requests/{id}` | Hydrate one request (used by suggest-changes modal). |
| `POST /api/approval-requests/{id}/{action}` | `approve` / `reject` / `revoke` / `suggest-changes`. |
**Data path:** `frontend/src/client/inbox.ts` mounts the universal
`FilterBar` over the inbox `SystemView` (slug `"inbox"`, sources
`[approval_request]`, viewer_role `any_visible`, status `[pending]`).
The bar fetches `/api/views/system`, hands the spec to itself, calls
`/api/views/inbox/run?…`, and stamps rows via `shape-list.ts`'s
`renderApprovalList(rows)` path (gated by `row_action="approve"`).
**Action wiring:** `wireApprovalActions(host)` listens on
`.views-approval-action` clicks; on success it triggers
`bar.refresh()` and `refreshInboxBadge()` (which pokes
`/api/inbox/count`).
**Empty state + admin nudge:** when the result list is empty AND the
caller is `global_admin` AND no `approval_policies` row exists firm-wide,
the page shows a "configure policies" CTA. Otherwise the localized
"no items" empty-state text.
**Sidebar bell:** `Sidebar.tsx:143` `navItem("/inbox", BELL_ICON, …)`
plus `client/sidebar.ts:320345`'s `initInboxBadge` which polls
`/api/inbox/count` every 60s. Badge clamps to `"9+"`.
### What aggregates cleanly
The whole approval flow already plugs into `RunSpec`'s union pipeline.
That's the win — extending sources from `[ApprovalRequest]` to
`[ApprovalRequest, ProjectEvent]` is a `[]DataSource` literal edit in
`InboxSystemView()` and the engine fans out per source, sorts, returns
one `[]ViewRow`. The hard work (`runProjectEvents` + the
visibility predicate + project metadata join) is already in
`view_service.go:344430`.
### What doesn't aggregate (yet)
- **Read state.** There is no `inbox_seen_at` on `paliad.users` (verified
via information_schema). The bell badge counts pending **approval
requests for the caller** only — it has no notion of "new project
events since last visit". We have to add it.
- **Mixed `row_action`.** `shape-list.ts`'s `renderApprovalList` assumes
every row is an approval and unconditionally parses
`row.detail` as an `ApprovalDetail`. Project_event rows in the same
list would crash the parse. We need to branch per `row.kind` inside
the inbox row stamper.
- **`/inbox` shape toggle.** `client/inbox.ts` hardcodes `shape-list`;
the `shape` axis is wired into `filter-bar/axes.ts` but `/inbox`'s
`INBOX_AXES` deliberately omits it (because today the only meaningful
shape was list). Adding it onto INBOX_AXES + a small dispatcher in
`onResult` gives us cards + calendar for free.
Everything else (sidebar entry, /api/views machinery, FilterBar URL
codec, RowAction validation) carries through unchanged.
---
## 2. Event-type catalogue for inbox v1 (Q1)
This is the only design pick that requires a head/m signal. **Open
question Q1 in §9 — defaulting to (A) until head answers.**
### (R) Recommendation (A): curated subset
Sources: `[approval_request, project_event]`.
**Approval requests:** all rows whose `viewer_role=any_visible` AND
status ∈ {pending} by default; the existing chip cluster
(approver_eligible / self_requested / any_visible) stays. Decided
requests are filtered by the chip, not hidden by source-removal — so a
user who wants to see "what got approved this week" toggles the status
chip rather than the source.
**Project events:** filter by `event_type ∈ InboxProjectEventKinds`
where InboxProjectEventKinds is a new sub-list of KnownProjectEventKinds:
| event_type | In inbox v1? | Reason |
|-------------------------|--------------|---------------------------------------------------------------------|
| `project_created` | no | The author already saw the page; not news to the team yet (the team grows post-creation). |
| `project_archived` | **yes** | High-signal lifecycle event ("Akte XY wurde archiviert"). |
| `project_reparented` | **yes** | Hierarchy moves matter to everyone with access. |
| `project_type_changed` | **yes** | Same reason. |
| `status_changed` | no | Currently too granular; surface in Verlauf, revisit if m disagrees. |
| `deadline_created` | **yes** | New deadline on a project I can see — exactly the kind of event m named ("we should also display new events"). |
| `deadline_completed` | **yes** | Likewise. |
| `deadline_reopened` | **yes** | Likewise. |
| `deadline_updated` | **yes** | Currently in DB (11 rows live) but not in KnownProjectEventKinds — add it. |
| `deadline_deleted` | **yes** | Likewise — add to KnownProjectEventKinds. |
| `deadlines_imported` | **yes** | Bulk-import event surfaces what got added. |
| `appointment_created` | **yes** | |
| `appointment_updated` | **yes** | |
| `appointment_deleted` | **yes** | |
| `note_created` | **yes** | A note is "someone said something about this project". High-signal; add to KnownProjectEventKinds. |
| `our_side_changed` | **yes** | Party-side flip; high-signal, add to KnownProjectEventKinds. |
| `member_role_changed` | no | Admin churn; would dominate active users' inbox. Revisit slice B. |
| `*_approval_requested` | **no — de-duped** | The approval_request row itself carries the signal; the audit event is the same fact in a different table. Filtering it out avoids duplicate inbox entries. |
| `*_approval_approved/rejected/revoked` | **no — de-duped** | Same reason. The approval_request row's status flip is what the user sees. |
| `*_approval_changes_suggested` | **no — de-duped** | Same. |
| `approval_decided` | no | This is the umbrella audit-only kind; superseded by the approval_request row. |
| `checklist_*` | no | Low signal; checklists are surfaced on the project's checklist page. |
The de-dup pattern means: if a row exists in `approval_requests` for an
entity, the corresponding `*_approval_*` project_event is **not** shown
in the inbox — we trust the approval_request row.
### Alternative (B): everything in KnownProjectEventKinds + approvals
Simpler — no curated sub-list, no de-dup. Two drawbacks:
1. `*_approval_*` duplicates would render twice per request.
2. `status_changed` and `member_role_changed` are admin churn; in firm
tests both would dominate.
If head picks B, we need at minimum the `*_approval_*` de-dup; otherwise
the inbox renders the same fact twice.
### Alternative (C): minimal — approvals + appointment_* + deadline_*
Tightest set. Drops notes + our_side_changed + project_*. Risk: m's
brief literally says "new events that relate to one's projects" — notes
and side changes ARE such events. C feels too narrow.
---
## 3. Read/unread model (Q3 → R: high-watermark cursor)
### (R) Decision: per-user high-watermark `inbox_seen_at`
**Schema:**
```sql
ALTER TABLE paliad.users
ADD COLUMN inbox_seen_at timestamptz NULL;
```
NULL means "never visited" → everything counts as unread. The high-water
cursor advances exactly when the user POSTs to
`/api/inbox/mark-all-seen` (UI affordance: a button in the inbox header
+ implicit advance on page-mount, see Slice A wiring below).
### Why cursor, not per-item
m's recommendation: cursor. Mine matches: single column, no fan-out
table, covers the common case ("I checked my inbox, mark everything
read"). Per-item dismiss is Slice C — opt-in only if the cursor proves
inadequate. The risk we're guarding against: a single high-value pending
approval that's a week old gets buried by 80 fresh deadline_updated
events; the user clears the badge and may now never look at the
approval. Mitigation: **approval_requests with status=pending never
fall behind the cursor** — they count toward the badge regardless of
seen_at. This is a tiny conditional in the count query (Slice A).
### Cursor advance behaviour
- **Explicit:** "Alles als gelesen markieren" button in the inbox
header. POSTs `/api/inbox/mark-all-seen`; server sets
`inbox_seen_at = now()`.
- **Implicit:** when the page mounts AND the bar surfaces at least one
row that's newer than the current cursor, the *new* cursor is
remembered locally as the timestamp of the **newest visible row**.
We do **not** auto-advance the server cursor on mount — too easy to
lose items behind a stray pageview. The "neu" highlight on rows
newer than the saved cursor is the silent UX. Explicit click is the
one and only path to clearing the badge.
### `unread_only` axis
New filter-bar axis (Slice A):
```ts
// types.ts
unread_only?: boolean;
```
When `true`, the bar overlays a FilterSpec predicate:
`row.event_date > inbox_seen_at` (substrate-side filter; for project_events
that's `pe.created_at > $cursor`, for approval_requests that's
`requested_at > $cursor` OR `status='pending'` per the carve-out above).
Default: **unread_only=true** for first paint (per Slice A — landing on
the inbox shows you what's new). The "Alle" chip flips it off so the
user can see history.
---
## 4. Filter contract
The bar surfaces these axes on `/inbox` (`INBOX_AXES` constant in
`client/inbox.ts`):
| Axis | Why on /inbox | New? |
|--------------------------|----------------------------------------------------------------------|------|
| `time` | "Last 30 days" (default) with chip cluster + "Älter anzeigen" . | already |
| `project` | Single-select autocomplete from visible projects. | already |
| `approval_viewer_role` | "Zur Genehmigung" / "Eigene Anfragen" / "Alle sichtbaren". | already |
| `approval_status` | pending / approved / rejected / revoked / changes_requested. | already |
| `approval_entity_type` | Frist / Termin (chip pair). | already |
| `project_event_kind` | Chip cluster over InboxProjectEventKinds. | already |
| **`unread_only`** | Boolean toggle ("Nur ungelesen" / "Alle"); defaults to ungelesen. | **Slice A new axis** |
| `shape` | list / cards / calendar. | already in `AxisKey`, not yet on `/inbox` |
| `sort` | Newest first (default) / oldest first. | already |
| `density` | comfortable / compact. | already |
**Default landing state** for a brand-new pageview:
`?time=past_30d&unread_only=true&a_status=pending&shape=list&sort=date_desc`.
Bookmarks from older clients (e.g. the legacy `?tab=pending-mine`)
still work because `client/inbox.ts:4658` already applies the legacy
tab → `a_role` redirect at hydration.
### Source-removal not exposed as an axis
Users do **not** see a "show approvals only / show events only" chip.
The signal we want is "what's new across my projects"; splitting the
two via the filter row is busywork. If they want approvals-only they
chip-pick `project_event_kind` empty + status=any (or future axis pick
`source=approval_request`). If feedback shows otherwise after Slice A
ships, we add the axis in Slice B trivially (`Sources` is a
spec.Sources literal flip).
---
## 5. View toggle implementation plan (Q5 → R: list / cards / calendar)
The pattern `/events` uses today (see `frontend/src/events.tsx:107141`
for the `<div className="events-view-selector">` block and
`client/events.ts:617650` for the `applyView` function):
- One chip cluster `data-event-view="cards|list|calendar"`.
- Active class toggle.
- Per-shape `display: none` on the table-wrap / cards-wrap / cal-wrap
hosts.
- For calendar, `mountCalendar()` constructs a month/week/day grid
into a dedicated `events-calendar-wrap` host; the handle is destroyed
on shape-leave so its URL state doesn't leak into the other shapes.
### Mapping onto /inbox
The cleanest path: **use `filter-bar`'s built-in `shape` axis instead of
a per-page selector.** The axis already round-trips into the URL via
`url-codec.ts` and serialises into `RenderSpec.Shape`. `client/inbox.ts`
just needs:
1. Add `"shape"` to `INBOX_AXES`.
2. Dispatch in the `onResult` callback by `effective.render.shape`:
```ts
onResult: (result, effective) => {
switch (effective.render.shape) {
case "cards": return paintCards(result.rows, effective.render, ...);
case "calendar": return paintCalendar(result.rows, ...);
case "list":
default: return paintList(result.rows, effective.render, ...);
}
}
```
3. The renderers exist already: `renderCardsShape` in
`views/shape-cards.ts`, `renderCalendarShape` in
`views/shape-calendar.ts`, `renderListShape` in `views/shape-list.ts`.
The only piece of new code is the per-shape host-clearing on switch
(so we don't leak a stale shape's DOM into the new host).
### Calendar shape — items without dates
Calendar can only render rows with a calendar-mappable date. Today:
- **approval_request:** `requested_at` (timestamp). Maps fine, but
shows up as a single point — rendering an approval-request on a month
grid is semantically "you got asked on this day". OK for v1.
- **project_event:** `created_at`. Same shape.
- **deadline:** `due_date`. Already supported.
- **appointment:** `start_at`. Already supported.
So every row in the inbox v1 has a calendar position. No
need to filter rows on calendar-mount. **One caveat:** the calendar
shape currently doesn't render action affordances (approve/reject) — it
opens a detail dialog on click. Slice B accepts that: clicking an
approval row on the calendar opens the inbox-list-style detail in a
modal (re-using the existing per-row /api/approval-requests/{id}
fetch). Out of scope for Slice A.
### Cards shape — day-grouped chronological cards
`shape-cards.ts` groups by day and renders one card per row, with
title + meta + actor. The approval-card layout there is the standard
card (no approve buttons — same caveat as calendar). For Slice B, we
extend `shape-cards.ts` to detect `row.kind === "approval_request"
&& row.detail.status === "pending"` and stamp the approve/reject button
strip inline. The DOM template is the same as
`shape-list.ts:renderApprovalRow`, so most of the work is hoisting that
template into a shared util.
---
## 6. Backend aggregation service (Q6 → R: reuse RunSpec)
**Decision: do not build a new aggregation service.** The
substrate-level work is exactly two edits:
### 6.1 InboxSystemView (system_views.go:103144)
```go
func InboxSystemView() SystemView {
return SystemView{
Slug: "inbox",
Name: "Inbox",
Filter: FilterSpec{
Version: SpecVersion,
Sources: []DataSource{
SourceApprovalRequest,
SourceProjectEvent,
},
Scope: ScopeSpec{Projects: ScopeProjects{Mode: ScopeAllVisible}},
Time: TimeSpec{Horizon: HorizonPast30d, Field: FieldAuto},
Predicates: map[DataSource]Predicates{
SourceApprovalRequest: {ApprovalRequest: &ApprovalRequestPredicates{
ViewerRole: "any_visible",
Status: []string{"pending"}, // default; bar can override
}},
SourceProjectEvent: {ProjectEvent: &ProjectEventPredicates{
EventTypes: InboxProjectEventKinds, // curated subset
}},
},
},
Render: RenderSpec{
Shape: ShapeList,
List: &ListConfig{
Density: DensityComfortable,
Sort: SortDateDesc, // newest first — different from today's date_asc
RowAction: RowActionInbox, // new — see §6.3
},
},
}
}
```
Curated sub-list lives in `filter_spec.go` next to KnownProjectEventKinds:
```go
var InboxProjectEventKinds = []string{
"project_archived", "project_reparented", "project_type_changed",
"deadline_created", "deadline_completed", "deadline_reopened",
"deadline_updated", "deadline_deleted", "deadlines_imported",
"appointment_created", "appointment_updated", "appointment_deleted",
"note_created", "our_side_changed",
}
```
(With Q1 pick A locked. If head picks B, drop the InboxProjectEventKinds
list and remove the `EventTypes` predicate. If head picks C, narrow the
list to deadline_* + appointment_* only.)
KnownProjectEventKinds in `filter_spec.go:186` needs **additions** so
`note_created`, `our_side_changed`, `deadline_updated`, `deadline_deleted`,
`deadlines_imported` are valid filter values — without this the
validator rejects the InboxSystemView spec. Migrate this list at the
same time. (`event_categories` and similar grouping infra are already
covered by `event_category_service.go` and won't move.)
### 6.2 Approval-duplicate suppression
In `view_service.runProjectEvents` (or in a tiny new predicate helper),
skip `event_type LIKE '%_approval_%'` when source-set includes
ApprovalRequest. This avoids the double-count described in Q1 §2.
Implementation: extend `allowedProjectEventKinds` (view_service.go:649) to
auto-drop the `*_approval_*` strings when the same RunSpec already
fans out the approval_request source. One conditional, six lines.
### 6.3 Mixed-row row_action
`shape-list.ts` today: `row_action="approve"` → calls
`renderApprovalList(rows)` which assumes every row is an approval.
Need a new value:
```go
// render_spec.go
const RowActionInbox ListRowAction = "inbox"
```
And register it in `KnownRowActions`.
Frontend (`shape-list.ts`):
```ts
if (rowAction === "inbox") {
host.appendChild(renderInboxList(sorted));
return;
}
```
Where `renderInboxList(rows)`:
- approval_request rows → existing `renderApprovalRow(row)` template (the
per-row factor-out from `renderApprovalList`).
- project_event rows → a new `renderProjectEventRow(row)` template:
timestamp + actor + title + project chip + optional "Öffnen" link
to the underlying entity (deadline / appointment / note / project
detail). Modelled on the Verlauf row in
`client/projects-detail.ts:651700` (`.entity-event` markup).
This makes the inbox stamping kind-aware. The
existing `wireApprovalActions` continues to find buttons via class
`.views-approval-action` and works unchanged.
### 6.4 Endpoints — what's new vs reused
| Path | Behaviour | Slice |
|-------------------------------------|----------------------------------------------------------|-------|
| `GET /api/views/inbox/run` | **Already exists** — fans the InboxSystemView spec. | A reuse |
| `GET /api/inbox/count` | **Behaviour change:** count includes unread project_events on visible projects + pending approval_requests (the latter regardless of cursor). | A |
| `POST /api/inbox/mark-all-seen` | New. Sets `users.inbox_seen_at = now()` for the caller. | A |
| `GET /api/inbox/pending-mine` | **Keep** — backwards-compat for clients (sidebar bell may still use it). | unchanged |
| `GET /api/inbox/mine` | **Keep** — used by the saved view `inbox-mine`. | unchanged |
The two `/api/inbox/{pending-mine,mine}` endpoints stay because they're
narrower-than-RunSpec optimisations and used by the dashboard's
`loadInboxSummary`. No reason to remove them.
### 6.5 InboxSummary on the dashboard (out of scope, but flag)
`DashboardData.InboxSummary` (dashboard_service.go:89) currently counts
only pending approvals. If Slice C extends the badge count to include
unread project_events, the dashboard widget also needs to swap
`PendingCountForUser` for the new unified count — keep this as a small
follow-up after Slice A ships and the cursor semantics are proven.
---
## 7. Slice plan
### Slice A — Project-event aggregation + read cursor + list view
**Goal:** /inbox shows pending approvals + curated project_events for
visible projects in the last 30 days, with the new "Nur ungelesen"
toggle. List view only.
Tasks:
1. **Migration `NNN_inbox_seen_at.up.sql`:**
`ALTER TABLE paliad.users ADD COLUMN inbox_seen_at timestamptz NULL;`
2. **`filter_spec.go`:** extend `KnownProjectEventKinds` (add
`note_created`, `our_side_changed`, `deadline_updated`,
`deadline_deleted`, `deadlines_imported`). Add
`InboxProjectEventKinds` (curated subset, Q1=A).
3. **`system_views.go`:** rewrite `InboxSystemView` per §6.1 with
both sources, `HorizonPast30d`, `SortDateDesc`,
`RowAction=RowActionInbox`.
4. **`render_spec.go`:** add `RowActionInbox`, register in
`KnownRowActions`.
5. **`view_service.go`:** in `runProjectEvents`, auto-drop
`*_approval_*` event_types when ApprovalRequest is in
`spec.Sources` (§6.2).
6. **`approvals.go`:**
- New handler `handleInboxMarkAllSeen` →
`UPDATE paliad.users SET inbox_seen_at = now() WHERE id = $1`.
- Modify `handleInboxCount` to return
`pending_approvals_count + unread_project_events_count`. SQL
in approval_service.go: one new method
`UnseenInboxCountForUser(userID)` returning that union. Keep
`PendingCountForUser` (dashboard still uses it).
7. **`shape-list.ts`:** factor `renderApprovalRow(row)` out of
`renderApprovalList`. Add `renderInboxList(rows)` that dispatches
per `row.kind`. Wire `row_action="inbox"` to it.
8. **`client/inbox.ts`:**
- Add the `unread_only` axis to `INBOX_AXES` and wire to a FilterSpec
overlay (sub-spec `Time.Horizon=Past30d` AND
filter predicate "newer than cursor OR pending-approval").
- Render "Alles als gelesen markieren" button in the page header
(in `inbox.tsx`); on click POST `/api/inbox/mark-all-seen`,
refresh bar + badge.
- Listen for cursor update (server response) and refresh.
9. **Sidebar badge (`client/sidebar.ts:initInboxBadge`):** unchanged code
path, but the new server count includes project_events. Add no client
changes for v1 — server returns the wider count.
10. **i18n:** new keys —
- `inbox.title.feed` ("Inbox") replaces "Genehmigungen" in the page
header (since the page is now more than approvals).
- `inbox.subtitle.feed` ("Neuigkeiten zu Ihren Projekten und offene
Genehmigungen.").
- `inbox.action.mark_all_seen` ("Alles als gelesen markieren").
- `inbox.axis.unread_only.on/off`.
- `inbox.empty.feed` ("Keine Neuigkeiten in den letzten 30 Tagen.").
- `views.col.event_kind` (for the kind column in
table-density list).
- DE primary, EN secondary, both in `i18n.ts`.
11. **Tests:** `system_views_test.go` covers the
InboxSystemView spec shape; new test for the de-dup helper in
view_service. `approval_service_test.go` adds tests for the new
`UnseenInboxCountForUser` method. New
`inbox_seen_at_test.go` covers the cursor migration + the POST
handler.
12. **Verify** the page renders for a sample user with both event types
visible, "Nur ungelesen" toggles correctly, mark-all-seen clears the
badge, the project-events deduplicate against approval requests.
### Slice B — Cards + calendar shape toggles
**Goal:** `?shape=cards` and `?shape=calendar` work on /inbox; users can
switch via the bar's shape chip. Approval rows on cards/calendar are
*read-only* (open detail modal on click; no inline approve/reject).
Tasks:
1. **`client/inbox.ts`:** add `"shape"` to `INBOX_AXES`. Add the
per-shape host divs to `inbox.tsx` (one for cards, one for calendar)
matching the `/events` pattern. Implement `onResult` dispatch.
2. **`shape-cards.ts`:** when `row.kind==="approval_request"` AND
`row.detail.status==="pending"`, stamp the approval row template
inline. Hoist the template out of `shape-list.ts` if reuse pays.
3. **`shape-calendar.ts`:** approval_request rows render as date-point
chips; click opens a detail modal. The modal reuses the existing
`approval-edit-modal` for suggest-changes when the user is the
approver; otherwise a read-only summary.
4. **CSS:** ensure `.entity-event` and `.views-approval-row` markup
coexist on the cards view without z-index clashes; lightweight
targeting via `.views-cards-list[data-surface="inbox"]`.
5. **Tests:** shape toggle persistence via URL codec (already covered
in `url-codec.test.ts`; add one inbox-surface case).
### Slice C — Badge upgrade + per-item dismiss (deferred)
**Goal:** sidebar badge reflects unified count; per-item dismiss for
power-users.
Tasks:
1. **`paliad.inbox_dismissals` table** —
`(user_id, source, row_id, dismissed_at)` PK `(user_id, source, row_id)`.
"source" is `approval_request` / `project_event`; "row_id" is the
row's UUID. New endpoint `POST /api/inbox/dismiss` body
`{source, row_id}`. RunSpec for inbox subtracts dismissed rows.
2. **`/api/inbox/count`:** subtract dismissed rows from the count.
3. **Dashboard widget:** `DashboardData.InboxSummary` swaps to a new
`UnifiedInboxSummary` that mirrors the page count. Backwards-compat
JSON: keep old fields, add `total_count` and `top_unified`.
4. **Empty-state:** "Alle Einträge gelesen — gut gemacht."
5. **Optional `member_role_changed` etc.:** if Slice A surfaces that
one of the excluded event_types is actually wanted, this slice opens
up `InboxProjectEventKinds` accordingly.
### Why Slice A alone is shippable
Slice A delivers m's full ask except the cards/calendar views — which
are aesthetic shape toggles, not data changes. Slice A gives:
- Inbox feed across approvals + project_events for visible projects
- Project / type / time / read-state filters
- Newest-first list with mark-all-seen
- Sidebar badge reflects unified unread count (server-side)
Slice B + C are layer cake on top with no schema or substrate changes.
---
## 8. Out of scope
- **Push notifications.** Telegram / WhatsApp / email — different
channel concerns, separate design.
- **Cross-user inbox views.** No "admin sees others' inboxes" in v1.
- **Pinning / starring items.** Not in m's ask. If feedback after Slice
A wants it, opens its own design.
- **Paliadin chat unread.** Not part of project_events; paliadin lives
in its own pane. Slice C could surface a banner if asked.
- **Replacement of the existing /api/inbox/{pending-mine,mine} endpoints.**
They stay because the dashboard's `loadInboxSummary` uses them and
no benefit to consolidating.
- **Detail-page changes.** Clicking a project_event row in the inbox
navigates to the existing entity detail page (deadline, appointment,
note); we don't build a new "event detail" view.
- **InboxSummary on the dashboard.** Out of Slice A. Slice C upgrades
it; for now the widget keeps showing approval-only.
---
## 9. Open questions for m
Defaulted to (R) per the inventor protocol — only **Q1** is escalated
to head for explicit confirmation because it changes the
inbox's surface area. Everything else falls to the recommended pick
unless head/m flag otherwise.
**Q1 — Event-type catalogue (material pick, head answered):**
**LOCKED = A** (curated subset with `*_approval_*` de-dup). Head added
`member_role_changed` to the curated list with a Slice B narrowing
follow-up + a coarser `inbox_focus` chip cluster on the bar. Full
decision recorded in §12.
**Q2 — Time window:** (R) Past30d default + chip cluster
(today / past_7d / past_30d / past_90d / any) + custom range via the
existing time picker. Locked unless head overrides.
**Q3 — Read/unread model:** (R) High-watermark cursor
(`users.inbox_seen_at`). Pending approval_requests carry forward even
when older than the cursor — guards against burying a high-value
approval. Per-item dismiss is Slice C, opt-in. Locked.
**Q4 — Filters surfaced on the bar:** (R) time / project /
approval_viewer_role / approval_status / approval_entity_type /
project_event_kind / unread_only / shape / sort / density. Locked
unless head wants `source` (approvals-only vs events-only chip)
added — defaulting to "not in v1".
**Q5 — View toggle parity with /events:** (R) list (default — newest
first) / cards (day-grouped) / calendar (date-point). Wired via the
filter-bar's existing `shape` axis, not a per-page selector. Locked.
**Q6 — Architecture:** (R) Reuse `view_service.RunSpec` with both
sources in the InboxSystemView spec; no new aggregation service.
Approval-event de-dup applied in `runProjectEvents`. Locked.
**Q7 — Notification badge:** (R) Yes — Slice A makes the existing
`/api/inbox/count` return the unified unread count; sidebar badge
client unchanged. Locked.
**Q8 — Acknowledgement flow:** (R) Approval rows keep
approve/reject/revoke buttons inline (list shape only). project_event
rows have no inline action — click row → navigate to the underlying
entity. Cursor advance is via "Alles als gelesen markieren" only —
no per-row mark-read in v1. Locked.
**Q9 — Empty-state copy:** (R) "Keine Neuigkeiten in den letzten 30
Tagen." (DE primary) / "No updates in the last 30 days." (EN). The
existing admin nudge for unseeded approval_policies stays untouched.
Locked.
---
## 10. Risks + mitigations
- **Performance.** `runProjectEvents` reads up to LIMIT 500 rows per
user-call; with two sources unioned + 30-day window + visibility
predicate this should stay under 50ms on the live shape (project
count ~100, events/day low double digits). If
it doesn't, partial index hint: `paliad.project_events (created_at DESC)
WHERE event_type IN (curated list)` — Slice A optional, add if
EXPLAIN shows a seq scan in dev.
- **De-dup correctness.** Suppressing `*_approval_*` events in the
project_event source relies on the approval_request row being the
authoritative signal. **Edge case:** a request gets revoked, then
re-requested — both audit events exist. Both correspond to a single
approval_request row at any moment (the latter via the partial-index
upsert). De-dup stays valid.
- **Cursor advance race.** If two browser tabs both POST mark-all-seen,
the second wins (now() wins). Acceptable. If a user reads in tab A
then clicks an item in tab B that was created between the two reads,
tab A's "Alles als gelesen" advances past that newer item without
the user seeing it. Mitigation: server-side, `mark-all-seen` accepts
an optional `?up_to=<iso>` so the client can pin to the timestamp of
the newest visible row. Slice A wires this.
- **shape-list factor-out.** Pulling `renderApprovalRow` out of
`renderApprovalList` risks regressions on the *current* /inbox. Cover
with a snapshot/golden test on the approval row markup in Slice A
before the dispatch change.
- **Sidebar bell badge cap.** Current code clamps at "9+". Once we add
project_events, the count can easily exceed 100. Keep the "9+" clamp
for visual reasons — but make the page header show the *exact* count
("123 neu") so the user knows what's behind it.
- **Q1 fallback.** If head doesn't reply before Slice A coder shift
starts, the (R) pick A locks. If head later picks B or C, the only
change is the `InboxProjectEventKinds` list literal in
`filter_spec.go` — no schema impact, no migration change. Cheap to
flip.
---
## 11. Build/test verify list (Slice A done-when)
1. `make build` clean.
2. `go test ./...` passes; new tests cover:
- InboxSystemView spec shape includes both sources + curated kinds.
- `runProjectEvents` drops `*_approval_*` when ApprovalRequest is in spec.
- `UnseenInboxCountForUser` returns expected count for cursor and pending-approval combinations.
- POST `/api/inbox/mark-all-seen` updates the column.
- URL codec round-trip for `unread_only` axis.
3. Inbox loads at `/inbox` with project-event rows interleaved with
approval rows in date-desc order.
4. "Nur ungelesen" chip toggles between unread (with pending-approval
carve-out) and full feed.
5. "Alles als gelesen markieren" advances cursor; bar refreshes;
badge clears (except for any still-pending approvals).
6. Sidebar bell badge count is the unified number (approval + unread events).
7. Existing approve/reject/revoke + suggest-changes flows on inbox
rows still work unchanged.
8. `?tab=mine` legacy redirect still hits the right state.
9. Bilingual labels render (DE/EN toggle).
That's the doneness bar for Slice A.
---
## §12 — m's decisions (head 2026-05-25 11:30)
Head replied to the `mai instruct head` escalation; folded in below.
**Q1 (Event-type catalogue): A — locked.** Curated subset with
`*_approval_*` de-dup. Tracks Verlauf, matches m's framing ("new events
that relate to one's projects"), avoids double-counting approval audit
events against the approval_request row.
Locked InboxProjectEventKinds:
- IN: `project_archived`, `project_reparented`, `project_type_changed`,
`deadline_created`, `deadline_completed`, `deadline_reopened`,
`deadline_updated`, `deadline_deleted`, `deadlines_imported`,
`appointment_created`, `appointment_updated`, `appointment_deleted`,
`note_created`, `our_side_changed`, **`member_role_changed`**
(added by head — see refinement #1).
- OUT (audit duplicates of approval_requests): every `*_approval_*` event.
- OUT (too granular / authoring noise): `status_changed`,
`project_created`, `checklist_*`.
**Refinement 1 — `member_role_changed` visibility predicate.**
Head wants this kind included but narrowed: surface the row only when
the role change applies to the **viewer themselves** or someone above
them in the project tree (i.e. impacts the viewer's permissions / chain
of command), not when it's a peer's role changing on a project the
viewer happens to see.
- Slice A: include `member_role_changed` in
`InboxProjectEventKinds` without the narrowing predicate. The row
will appear for everyone who can see the project — over-surfacing but
not wrong. This keeps Slice A's MVP scope tight.
- Slice B: add a per-row narrowing filter on top of the inbox source
(likely a small extension to `runProjectEvents` that, when
`event_type='member_role_changed'`, inspects `metadata.affects_user_id`
+ walks the project-membership predicate before emitting). The
metadata shape is already written by the responsible handler; verify
+ lock the filter in B.
Q2-Q9 all default to (R) per the inventor protocol.
**Refinement 2 — Filter chip copy.**
For the visible chip cluster in the bar, head wants user-readable groupings,
not raw event-kind names. The bar today exposes `project_event_kind`
as one chip per kind (rendered via the
`event.title.<kind>` i18n key). For the inbox surface, surface a
**coarser grouping chip cluster** ahead of that:
- "Genehmigungen" — narrows to `Sources=[approval_request]` only.
- "Genehmigungen + Termine" — adds appointment_* event_kinds + the
approval_entity_type=appointment slice of approvals.
- "Genehmigungen + Fristen" — adds deadline_* event_kinds + the
approval_entity_type=deadline slice of approvals.
- "Alles" — default; both sources, full curated kinds list.
Implementation: a new axis `inbox_focus` (Slice A, additive — replaces
the lower-level `project_event_kind` chip's *default visibility* in the
inbox UI; advanced users still see `project_event_kind` if they expand
the bar). The four values map to FilterSpec overlays that tweak
`Sources` + per-source `EventTypes`. Coder owns the exact chip-text
final copy and the placement (probably first axis in `INBOX_AXES`).
The lower-level `project_event_kind` chip stays in `INBOX_AXES` as an
advanced override for power users — when active, it overrides the
`inbox_focus` chip's per-kind defaults.
---
### What changes for Slice A as a result
Doc deltas vs the draft text above:
1. **§2 / §6.1:** add `member_role_changed` to InboxProjectEventKinds.
Note Slice B narrowing follow-up.
2. **§4 / §5:** front of the bar gets a new `inbox_focus` axis
(4 chips: Alles / Genehmigungen / +Termine / +Fristen). Default
"Alles". `project_event_kind` stays available as an advanced chip,
visible after the user expands the bar's overflow section.
3. **§7 Slice A task list:** add task —
"**12a.** New `inbox_focus` axis (`filter-bar/types.ts`,
`axes.ts`). FilterSpec overlay translates the chip value to a
`(Sources, ProjectEventPredicates.EventTypes, ApprovalRequestPredicates.EntityTypes)`
triple. URL codec round-trips."
4. **§11 Slice B done-when:** add — "`member_role_changed` narrowing
predicate is in place; rows surface only when the change affects
the viewer's permissions chain."
No schema changes from the head's adjustments. The `inbox_focus` axis
is a pure UI/overlay primitive; nothing about the InboxSystemView spec
schema moves.

View File

@@ -0,0 +1,956 @@
# Bulletproof completeness audit — paliad.deadline_rules vs statutory sources
**Author:** curie (researcher)
**Date:** 2026-05-25
**Task:** t-paliad-263 (m/paliad#94)
**Mode:** read-only research, no DB writes
**Branch:** `mai/curie/researcher-bulletproof`
Scope confirmed by head (paliad/head → paliad/curie, 2026-05-25 15:13):
**UPC Rules of Procedure + EPC + PatG / ZPO / GebrMG**, plus UPC Agreement /
Statute where they create time-limits. No HLC-internal checklists exist in
the current head's working tree.
Companion / prior audits this report supersedes-and-extends:
- `docs/audit-fristenrechner-completeness-2026-04-30.md` (curie, t-paliad-084) — youpc-vs-paliad gap analysis.
- `docs/audit-upc-rop-deadlines-2026-05-08.md` (curie, t-paliad-159) — first UPC RoP gap list (52 rules / 2 duration bugs).
- `docs/audit-fristen-logic-2026-05-13.md` (pauli, t-paliad-157) — schema audit; the codes used here (`upc.inf.cfi`, `de.inf.lg`, …) reflect the post-mig-096 rename.
Migration baseline: migration ≤ `122_deadlines_custom_rule_text` (live as of 2026-05-25 14:00 UTC).
---
## §0. TL;DR
- **20 active fristenrechner proceeding_types** (live, `is_active=true`,
`lifecycle_state='published'`) carry **132 active rules**. One extra
`_archived_litigation` row holds 40 retired Pipeline-A rules from
mig 093 — not surfaced anywhere, kept only for FK validity.
| Jurisdiction | Active types | Active rules | Statute-bound rules audited |
|---|---:|---:|---:|
| UPC (CFI + CoA) | 9 (incl. upc.ccr.cfi alias) | 67 | 67 |
| EPA | 3 | 23 | 23 |
| DPMA | 3 | 13 | 13 |
| DE (LG/OLG/BGH/BPatG) | 5 | 29 | 29 |
| **Total** | **20** | **132** | **132** |
- **5 high-impact bugs still live** that the prior May 8 audit
surfaced (2) plus 3 new ones identified here.
- 🔴 **`upc.rev.cfi.defence` 3 months, RoP.49.1 says 2 months.** Flagged
May 8; still live. ★★★ — every UPC_REV defendant.
- 🔴 **`upc.rev.cfi.rejoin` 2 months, RoP.52 says 1 month.** Flagged
May 8; still live. ★★★ — every UPC_REV proceeding.
- 🟠 **`upc.apl.merits.response` 2 months, RoP.235.1 says 3 months.**
New finding (May 8 audit recorded the rule as "3 months / present-wrong
rule_code only" — actually live data shows 2 months, so the audit
sample mis-recorded the duration too). ★★★ — every UPC main-track
appeal respondent.
- 🟠 **`de.inf.lg.beruf_begr` chains parent = berufung (1mo) + 2mo = 3mo
from urteil. ZPO §520(2) anchors the 2-month Begründungsfrist on
service of urteil, not on filing of Berufung.** New finding.
★★★ — every DE-first-instance appellant.
- 🟠 **`de.inf.lg.replik` + `.duplik` have `parent_id=NULL` so they fire
on the trigger date (Klageerhebung) — sequence-order says 30/40 but
the compute engine reads parent_id first.** Reported as live UI bug
by m via head (2026-05-25 13:13); confirmed by SQL. ★★★ — every
DE-LG-Verletzung timeline.
- **5 rule-code / citation drift bugs still live** from the May 8 audit
(`upc.apl.merits.notice`, `.grounds`, `.response`, `upc.rev.cfi.reply`,
`.rejoin`) — durations may or may not be right, but the cited
`legal_source` / `rule_code` points at the wrong rule. Pure
cosmetic on `.notice`/`.grounds` (durations are right); load-bearing on
`.rev.cfi.reply` / `.rejoin` because the cited rule is what tells
the lawyer where to look the rule up.
- **4 DPMA / DE citation bugs** new in this audit, all citing PatG / ZPO
sections that don't contain the cited deadline:
- `de.null.bpatg.erwidg` cites `DE.PatG.82.1`; the 2-month Erwiderung
is actually `§82(3)` (§82(1) is the 1-month Erklärungsfrist).
- `dpma.opp.dpma.erwiderung` cites `DE.PatG.59.3`; §59(3) is about
hearings, not a 4-month proprietor response. The 4-month figure is
DPMA-internal practice, not statutory — should be court-set.
- `dpma.appeal.bpatg.begruendung` cites `DE.PatG.75.1`; §75 is about
*aufschiebende Wirkung* — there is no Begründungsfrist in PatG §73-§80
for the BPatG-Beschwerde. The 1-month figure is also non-statutory.
- `de.null.bgh.begruendung` cites `DE.PatG.111.1`; §111 is about the
grounds-of-appeal *content* (Verletzung des Bundesrechts), not the
Begründungsfrist. `de.null.bgh.erwiderung` cites `DE.PatG.111.3`;
§111(3) doesn't exist in the deadline sense.
- **Wide UPC coverage gap inherited from May 8 audit, mostly un-closed:**
~25 missing UPC RoP rules. Mig 095 (t-paliad-205) closed 4 of them
(R.19 Preliminary Objection on UPC_INF and UPC_REV, R.220.1(a)
merits-appeal spawn on both). The other ~21 (R.20.2, R.118.4,
R.197.3, R.198, R.207.6.a, R.207.9, R.213, R.109.1/.4/.5, R.118.5,
R.144, R.155, R.224.2(b), R.229.2, R.235.2, R.245.x, R.262.2,
R.321.3, R.333.2, R.353, plus the DNI family R.63-R.69) are
unchanged.
- **EPC gaps:** EPA opposition + Beschwerde modelled at the
Article level only. Missing the entire Implementing Regulations
family that drives day-to-day deadlines — R.71(3) approval period
is half-modelled (the 4-month figure is there but the trigger
anchor is broken: parent_id=NULL), R.79(1) proprietor response
is modelled as a fixed 4-month period when it's actually
court-set, R.116 oral-proceedings cut-off is modelled as
duration-0/parent-NULL (works for some uses, not for others),
R.121 / R.135 Weiterbehandlung is missing entirely (concept
exists but no rule).
- **DE/DPMA gaps:** the entire Wiedereinsetzung family (PatG §123)
is absent on the proceeding-tree side. `weiterbehandlung` and
`wiedereinsetzung` concept slugs exist in the cascade (Pathway B)
but no `paliad.deadline_rules` row computes them. Same for
`versaeumnisurteil-einspruch` (ZPO §339 — 2 weeks).
- **15 ambiguities** that need m's judgement, not a coder's fix —
mostly around court-set vs statutory periods (e.g. richterliche
Fristen under ZPO §276(1) S.2, §283 Schriftsatznachreichung,
EPC R.79(1), §59(3) PatG) and around the "whichever is
longer / later" arithmetic primitives still missing
(R.198 / R.213 / R.245.2).
- **Recommended fixes (§10) — total 41 items** prioritised in 4
tiers. Tier 0 (5 hard duration bugs + 1 sequencing bug + 9
citation/anchor bugs) should ship first. Tier 1 (12 rule-fill
gaps, ★★★ / ★★) next. Tier 2 + 3 are coverage breadth that
needs scoping by m (Wiedereinsetzung, R.198 working-day
arithmetic, full Implementing Regulations port).
---
## §1. Methodology
For each of the 20 active proceeding_types I:
1. **Pulled the live rule set** via `mcp__supabase__execute_sql` against
the youpc Postgres on 2026-05-25 14:0015:00 UTC. Schema = `paliad`.
Filter: `is_active = true AND lifecycle_state = 'published'`.
2. **Enumerated the statutory deadlines** in the relevant code for the
proceeding's scope.
3. **Cross-referenced each statutory deadline against the live rule
set** on (a) duration + unit, (b) anchor / parent, (c) party,
(d) `rule_code` / `legal_source` citation, (e) sequencing.
4. **Marked status**: `present-correct`, `present-wrong (duration)`,
`present-wrong (citation)`, `present-wrong (anchor)`,
`present-wrong (party)`, `partial`, `missing`, `n/a`.
5. **Frequency tag** for prioritisation: ★★★ every case, ★★ common,
★ specialist.
### 1.1 Sources
All citations carry a date stamp and a URL. Where the text was checked
against more than one source, both are listed.
| Source | URL | Verified on | Used for |
|---|---|---|---|
| UPC Rules of Procedure (consolidated 18.05.2023, in force 2023-06-01) | https://www.unifiedpatentcourt.org/sites/default/files/upc_documents/rop_application_-_consolidated_18_05_2023.pdf | 2026-05-25 | All UPC RoP citations |
| UPC RoP verbatim text via `data.laws_contents` (youpc Postgres, law_type=`UPCRoP`, language=en) | youpc Supabase | 2026-05-25 | Cross-check on R.019.1, R.020.2, R.029.b/.c, R.049.1, R.051, R.051.p1, R.052, R.052.p1, R.220.1.a, R.224.1, R.224.1.a/.b, R.224.2, R.224.2.a/.b, R.235.1, R.235.2, R.237, R.238.1, R.238.2 |
| European Patent Convention (EPC, 17th ed. 2020) — Articles | https://www.epo.org/en/legal/epc/2020/index.html (verbatim text per youpc `data.laws_contents`, law_type=`EPC`) | 2026-05-25 | EPC Articles 93, 99, 108, 112a, 116, 121, 123, 135 |
| EPC Implementing Regulations — Rules (in force 2026 consolidated) | https://www.epo.org/en/legal/epc/2020/r71.html (and equivalents) | 2026-05-25 | EPC R.70(1), R.71(3), R.79(1)/(2), R.116(1), R.135 |
| Patentgesetz (PatG) — gesetze-im-internet.de | https://www.gesetze-im-internet.de/patg/ | 2026-05-25 | §59, §73, §75, §82, §83, §99 ff., §100, §102, §110, §111 |
| Zivilprozessordnung (ZPO) — gesetze-im-internet.de | https://www.gesetze-im-internet.de/zpo/ | 2026-05-25 | §253, §276, §277, §283, §296a, §339, §517, §520, §521, §524, §544, §548, §551, §554 |
| Gebrauchsmustergesetz (GebrMG) — gesetze-im-internet.de | https://www.gesetze-im-internet.de/gebrmg/ | 2026-05-25 | §17 (Löschung), §18 (Verfahren) — referenced only to confirm out-of-scope: no GebrMG-rooted proceeding_type exists in paliad today |
### 1.2 Conventions
- A **rule** here means a row in `paliad.deadline_rules`. paliad's local
identifier is `submission_code` (post mig 098), e.g.
`upc.rev.cfi.defence`.
- A **statutory deadline** means an obligation derived directly from the
text of a procedural code, with a fixed period.
- "**Court-set**" / "richterliche Frist" means the statute authorises the
court / DPMA / EPO to set the period — there is no fixed statutory
duration. paliad models these with `is_court_set = true`
(post mig ~079) or, legacy-style, `duration_value = 0`.
- "**Anchoring**" refers to which event the period runs from. paliad
models this via `parent_id` (chain anchor) or `anchor_alt` (e.g.
`priority_date`); a NULL parent_id with non-zero duration means the
deadline runs from the user-supplied trigger date.
### 1.3 Hard constraint: "no fabricated provisions"
Where I'm not 100% sure of a citation (because the youpc law DB only
covers UPC + EPC, not PatG / ZPO, and my web-fetch coverage of
PatG / ZPO is partial), I flag the finding as **"needs lawyer review"**
in §9 rather than asserting a fix. Five PatG / ZPO findings carry that
tag.
---
## §2. Current state inventory (per jurisdiction)
### 2.1 UPC
9 active types, 67 rules. `upc.ccr.cfi` is an alias proceeding that
holds zero rules — it points at `upc.inf.cfi` rules under the
`with_ccr` flag.
| Code | Name | Rule count | Audited against |
|---|---|---:|---|
| `upc.inf.cfi` | Verletzungsverfahren | 15 | RoP 19, 23, 25, 29.a-e, 30, 32, 151, 220.1(a) |
| `upc.rev.cfi` | Nichtigkeitsverfahren | 17 | RoP 19, 32, 42, 43.3, 49.1, 49.2.a, 49.2.b, 51, 52, 56.1/3/4, 220.1(a) |
| `upc.pi.cfi` | Einstweilige Maßnahmen | 4 | RoP 205, 207, 211 |
| `upc.disc.cfi` | Bucheinsicht | 4 | RoP 141, 142.2, 142.3 |
| `upc.dmgs.cfi` | Schadensbemessung | 4 | RoP 131.2, 137.2, 139 |
| `upc.apl.merits` | Berufung | 8 | RoP 220.1, 224.1.a, 224.2.a, 235.1, 237, 238.1 |
| `upc.apl.order` | Berufung gegen Anordnungen | 5 | RoP 220.1(c), 220.2, 220.3, 237, 238.2 |
| `upc.apl.cost` | Berufung gegen Kostenentscheidung | 2 | RoP 221.1 |
| `upc.ccr.cfi` | Widerklage auf Nichtigkeit (alias) | 0 | — |
### 2.2 EPA
3 active types, 23 rules.
| Code | Name | Rule count | Audited against |
|---|---|---:|---|
| `epa.grant.exa` | EP-Erteilung | 7 | EPC Art. 93, R.70(1), R.71(3) |
| `epa.opp.opd` | EPA Einspruch | 8 | EPC Art. 99(1), 108, 116, 123; R.79(1), R.79(2), R.116(1) |
| `epa.opp.boa` | EPA Beschwerde | 8 | EPC Art. 108, 112a; R.116(1); RPBA Art. 12 |
### 2.3 DPMA
3 active types, 13 rules.
| Code | Name | Rule count | Audited against |
|---|---|---:|---|
| `dpma.opp.dpma` | DPMA Einspruch | 4 | PatG §59(1), §59(3) |
| `dpma.appeal.bpatg` | BPatG-Beschwerde | 5 | PatG §73(2), §74 ff. |
| `dpma.appeal.bgh` | BGH-Rechtsbeschwerde | 4 | PatG §100, §102 |
### 2.4 DE (national patent / civil)
5 active types, 29 rules.
| Code | Name | Rule count | Audited against |
|---|---|---:|---|
| `de.inf.lg` | LG-Verletzungsklage | 8 | ZPO §253, §276, §283, §296a, §517, §520(2) |
| `de.inf.olg` | OLG-Berufung Verletzung | 7 | ZPO §517, §520(2), §521(2), §524(2) |
| `de.inf.bgh` | BGH-Revision Verletzung | 8 | ZPO §544, §548, §551, §554 |
| `de.null.bpatg` | BPatG-Nichtigkeitsklage | 10 | PatG §81 ff., §82, §83 |
| `de.null.bgh` | BGH-Nichtigkeitsberufung | 6 | PatG §110, §111 / ZPO ref via §117 PatG |
### 2.5 Cross-cutting: cascade vs proceeding-tree coverage
The cascade layer (`paliad.event_categories` + `…_concepts` +
`paliad.deadline_concepts`) carries 56 concept "nouns" and ~153
cascade-leaf → concept mappings. **9 concepts are orphans** (carry
zero rules, so the cascade card dead-ends): `counterclaim-for-revocation`,
`schriftsatznachreichung`, `versaeumnisurteil-einspruch`,
`weiterbehandlung`, `wiedereinsetzung`, `notice-of-defence-intention`,
plus 3 more. Inventory and recommendations live in
`docs/audit-fristen-logic-2026-05-13.md` §3.4 — this audit covers only
the proceeding-tree side.
---
## §3. Findings — Missing rules (statute defines, paliad doesn't)
### 3.1 UPC RoP — 21 missing rules (out of ~25 flagged 2026-05-08, 4 closed by mig 095)
Notation: ★★★ every case, ★★ common, ★ specialist. Verbatim RoP text
sampled from youpc `data.laws_contents` (law_type=`UPCRoP`, lang=en).
| RoP § | Period | Trigger | Freq | Notes |
|---|---|---|---|---|
| **R.20.2** | 14 days | Service of Preliminary Objection | ★ | Reply to PO. Companion to R.19 (which mig 095 added). Without R.20.2 the PO branch is half-modelled. |
| **R.118.4** | 2 months | Final decision on validity served | ★★ | Application for orders consequential on validity. Common after central-division revocation. |
| **R.118.5** | n/a UPC | n/a | n/a | UPC has no Versäumnisurteil-Einspruch; closest is R.355 (review of contumacy). |
| **R.144** | 0 (anchor) | Final decision on damages quantum | ★ | UPC_DAMAGES tree end-row missing. |
| **R.155** | 1mo / 14d | Cost-decision opposition chain | ★ | UPC_COST_APPEAL only has the leave-to-appeal step; no Defence-to-cost-app row. |
| **R.197.3** | 30 days | Saisie order served on respondent | ★ | Review application. Trigger event 65 exists; no rule attached. |
| **R.198** | 31 calendar days **OR 20 working days, whichever is longer** | Saisie executed | ★ | Start proceedings on the merits. Blocked on `working_days` + `combine='max'` primitives (see §7 + §9). |
| **R.207.6.a** | 14 days | Notification of deficiency in PI application | ★★ | Registry correction. |
| **R.207.9** | 6 months | PI filed | ★ | Renewal of protective letter. |
| **R.213** | 31 days OR 20 working days | PI granted | ★★ | Same arithmetic gap as R.198. |
| **R.109.1** | 1 month **before** | Oral hearing date | ★★ | Simultaneous translation request. `timing='before'` schema supported but no rule populates it (see §7 cross-cutting). |
| **R.109.4** | 2 weeks **before** | Oral hearing date | ★★ | Interpreter cost notification. `timing='before'`. |
| **R.109.5** | 2 weeks after | Order of judge-rapporteur to lodge translations | ★★ | trigger event 113 exists; no rule. |
| **R.224.2.b** | 15 days | Order under R.220.1(c) or decision under R.220.2/221.3 served | ★★ | Grounds-on-orders track. `upc.apl.order` has appeal-itself but no separate grounds row. Verified verbatim against `UPCRoP.224.2.b` (youpc DB). |
| **R.229.2** | 14 days | Notification of appeal-deficiency | ★ | Registry correction in appeal context. |
| **R.235.2** | 15 days | Statement of grounds (orders track) served | ★★ | Verified verbatim against `UPCRoP.235.2` (youpc DB): *"Within 15 days of service of grounds of appeal pursuant to Rule 224.2(b), any other party … may lodge a Statement of response"*. `upc.apl.order` has no standalone response row. |
| **R.245.1** | 2 months | Final decision served | ★ | Application for rehearing. |
| **R.245.2.a** | 2 months | Discovery of fundamental defect (or final decision service, whichever is later) | ★ | Outer cap 12mo. Needs multi-anchor + `max-of-two-anchors` arithmetic. |
| **R.245.2.b** | 2 months | Discovery of criminal offence (or final decision service, whichever is later) | ★ | Same shape as 245.2.a. |
| **R.262.2** | 14 days | Receipt of opposing party's confidentiality application | ★★ | Daily occurrence in HLC infringement work. Trigger event 25 exists; no rule. |
| **R.320** | 2 months (cap 12 mo) | Wegfall des Hindernisses (Wiedereinsetzung) | ★★ | Cascade card exists (mig 063) but no proceeding-tree rule computes the deadline. Bridges proceedings → no obvious home in any one tree. |
| **R.321.3** | 10 days | Preliminary objection referral to central division | ★ | |
| **R.333.2** | 15 days | Case-management order served | ★★ | Review-of-CMO. Routine in busy LDs. |
| **R.353** | 1 month | Decision / order delivered | ★ | Rectification application. |
| **DNI: R.63 / R.67.1 / R.69.1 / R.69.2** | 0 / 2mo / 1mo / 1mo | DNI cascade | ★ | No UPC_DNI proceeding_type exists. Fringe at HLC (zero published filings in 2026-Q1 per May 8 audit). |
| **Registry-correction family: R.16.3.a, R.27.2, R.89.2, R.253.2** | 14 days each | Various deficiency notifications | ★ | All same 14-day duration; different trigger codes. Most natural home is cascade not proceeding-tree (see audit-fristenrechner-completeness-2026-04-30.md §3.1). |
**Closed since May 8 audit (verified by SQL):**
- ✅ R.19 Preliminary Objection on UPC_INF — `upc.inf.cfi.prelim`, 1mo, RoP.019.1, flag-gated `with_po` — mig 095.
- ✅ R.19 Preliminary Objection on UPC_REV — `upc.rev.cfi.prelim`, 1mo, RoP.019.1, flag-gated `with_po` — mig 095 (cites R.19 i.V.m. R.46).
- ✅ R.220.1(a) merits-appeal spawn on UPC_INF — `upc.inf.cfi.appeal_spawn`, 2mo, is_spawn=true → upc.apl.merits — mig 095.
- ✅ R.220.1(a) merits-appeal spawn on UPC_REV — `upc.rev.cfi.appeal_spawn`, 2mo, is_spawn=true → upc.apl.merits — mig 095.
### 3.2 EPC Implementing Regulations — 4 missing rules
| EPC ref | Period | Trigger | Freq | Notes |
|---|---|---|---|---|
| **EPC R.135 (Weiterbehandlung)** | 2 months | Notification of loss of rights | ★★ | Concept `weiterbehandlung` exists in cascade (orphan); no rule. Applies broadly across `epa.grant.exa` and `epa.opp.opd`. |
| **EPC R.99(2) / Art. 121** | 2 months | Loss-of-rights notification (further processing) | ★★ | Same family as R.135. |
| **EPC Art. 112a(4)** | 2 months / 1 month | Discovery of grounds for review / decision served (whichever later) | ★ | paliad has `epa.opp.boa.r106` (2 months, parent=entsch2) — but the rule doesn't model the "whichever later" outer cap (12 months from decision per Art. 112a(4)). |
| **EPC Art. 99(1) — opposition fee paid** | 9 months (no extension) | Mention of grant in Patentblatt | ★★★ | `epa.opp.opd.frist` IS modelled correctly at 9 months. **Note however:** the rule is on `epa.opp.opd` but the *trigger* is opposition-fee-paid (per Art. 99(1) S.2 — "Notice of opposition shall not be deemed to have been filed until the opposition fee has been paid"). Not a gap, but a documentation note. |
### 3.3 PatG / ZPO — 5 missing rules
| Citation | Period | Trigger | Freq | Notes |
|---|---|---|---|---|
| **PatG §123 (Wiedereinsetzung)** | 2 months | Wegfall des Hindernisses (cap 1 year) | ★★ | Cascade concept `wiedereinsetzung` exists; no rule on any DE/DPMA proceeding tree. Same modelling problem as UPC R.320 — bridges proceedings. |
| **ZPO §339 (Versäumnisurteil-Einspruch)** | 2 weeks | Service of default judgment | ★ | Cascade concept `versaeumnisurteil-einspruch` orphan. |
| **ZPO §544 — Nichtzulassungsbeschwerde-Begründung** | 2 months | Service of OLG-Urteil (NB: NOT from filing of NZB) | ★★ | `de.inf.bgh.nzb_begr` lists `DE.ZPO.544.4`, duration 2mo, parent=urteil_olg — **modelled correctly**. Listed here only to flag that the *parent anchoring* differs from `de.inf.lg.beruf_begr` which is wrong (see §7.1). |
| **ZPO §283 (Schriftsatznachreichung) / §296a** | court-set | post-Verhandlung schriftsatzfrist | ★ | Cascade concept `schriftsatznachreichung` orphan. Court-set period — modelling as `is_court_set=true, duration=0` would suffice. |
| **PatG §17(2) GebrMG / §18 GebrMG** | 1 month (Beschwerdefrist) | DPMA-Beschluss | ★ | Out of scope per head's confirmation (no GebrMG-rooted proceeding_type yet). Listed to confirm the deliberate gap. |
### 3.4 DPMA — 0 missing rules
DPMA coverage is shallow but not gappy. The 3 active types (opposition,
BPatG-Beschwerde, BGH-Rechtsbeschwerde) cover the statutory steps. The
problems here are **citation drift** (§4.4) and **anchor modeling**
(§7.4) rather than missing rules.
---
## §4. Findings — Misattributed legal source
### 4.1 UPC RoP citation drift (5 still live from May 8)
| Rule | Live `rule_code` | Live `legal_source` | Should be | Source verified |
|---|---|---|---|---|
| `upc.apl.merits.notice` | `RoP.220.1` | `UPC.RoP.220.1` | `RoP.224.1.a` / `UPC.RoP.224.1.a` | `UPCRoP.224.1.a` youpc DB |
| `upc.apl.merits.grounds` | `RoP.220.1` | `UPC.RoP.220.1` | `RoP.224.2.a` / `UPC.RoP.224.2.a` | `UPCRoP.224.2.a` |
| `upc.apl.merits.response` | `null` | `null` | `RoP.235.1` / `UPC.RoP.235.1` | `UPCRoP.235.1` |
| `upc.rev.cfi.reply` | `null` | `null` | `RoP.051` / `UPC.RoP.51.p1` | `UPCRoP.051.p1` |
| `upc.rev.cfi.rejoin` | `null` | `null` | `RoP.052` / `UPC.RoP.52.p1` | `UPCRoP.052.p1` |
Note on cascade vs proceeding-tree drift on R.220.3 anchoring is in
`docs/audit-upc-rop-deadlines-2026-05-08.md` §5.4b — unchanged here.
### 4.2 UPC RoP citation drift on Rule 49.1 format (1 still live)
| Rule | Live `rule_code` | Should be |
|---|---|---|
| `upc.rev.cfi.defence` | `RoP.49.1` | `RoP.049.1` (canonical zero-padded form used by all other UPC rules) |
### 4.3 DPMA — 3 mis-attributed citations
| Rule | Live citation | Problem | Verified |
|---|---|---|---|
| `dpma.opp.dpma.erwiderung` | `§ 59 PatG` / `DE.PatG.59.3` | §59(3) PatG addresses *Anhörung*, not a 4-month response period. No statutory Erwiderungsfrist exists in §59. The 4-month figure is DPMA-internal practice. | WebFetch [gesetze-im-internet.de/patg/__59.html](https://www.gesetze-im-internet.de/patg/__59.html) 2026-05-25 |
| `dpma.appeal.bpatg.begruendung` | `§ 75 PatG` / `DE.PatG.75.1` | §75 PatG is exclusively about *aufschiebende Wirkung* (suspensive effect). It does not establish any Begründungsfrist. No fixed Begründungsfrist for BPatG-Beschwerde exists in PatG §§73-80 — it is set by the BPatG in the individual case. | WebFetch [gesetze-im-internet.de/patg/__75.html](https://www.gesetze-im-internet.de/patg/__75.html) + [§73](https://www.gesetze-im-internet.de/patg/__73.html) 2026-05-25 |
| `dpma.appeal.bpatg.beschwerde` | `§ 73 PatG` / `DE.PatG.73.2` | §73 contains the 1-month deadline correctly; the `.2` subscript however refers to §73(2) which is about Beschwerdebefugnis — the *Frist* is in §73(2) S.4 ("Die Beschwerdefrist beträgt einen Monat …"). Citation should be `DE.PatG.73.2.s4` or simply `DE.PatG.73.2`. **Borderline — flag, not a hard bug.** | gesetze-im-internet.de |
### 4.4 DE patent / civil — 4 mis-attributed citations
| Rule | Live citation | Problem | Verified |
|---|---|---|---|
| `de.null.bpatg.erwidg` | `§ 82 PatG` / `DE.PatG.82.1` | §82(1) is the 1-month *Erklärungsfrist* ("sich darüber zu erklären"); the 2-month full *Klageerwiderung* is in §82(3). Citation should be `DE.PatG.82.3`. Duration (2 months) is correct. | WebFetch [§82](https://www.gesetze-im-internet.de/patg/__82.html) 2026-05-25 |
| `de.null.bpatg.replik_klaeger` | `§ 83 PatG` / `DE.PatG.83.2` | §83(2) is about the *Hinweisbeschluss* form; the Replik / Schriftsatz windows fall under §83(2) S.3 (Reaktion auf Hinweis). Citation OK at section level but ambiguous. **Borderline — flag, not a hard bug.** | gesetze-im-internet.de |
| `de.null.bgh.begruendung` | `§ 111 PatG` / `DE.PatG.111.1` | §111 PatG defines the *Grounds* of Berufung (Verletzung des Bundesrechts), not a Begründungsfrist. The 3-month figure is supplied via §117 PatG → ZPO §520(2). Citation should be `DE.ZPO.520.2` (the actual time-limit source). | WebFetch [§111](https://www.gesetze-im-internet.de/patg/__111.html) 2026-05-25 |
| `de.null.bgh.erwiderung` | `§ 111 PatG` / `DE.PatG.111.3` | §111 has no Erwiderungsfrist clause. The actual Erwiderungsfrist for BGH-Nichtigkeitsberufung is set by the court per §117 PatG → ZPO §521(2) (court-discretionary). Duration (2 months) is approximate — typical court-set period is 2 months but it's not fixed. **Should be modelled as court-set.** | WebFetch [§111](https://www.gesetze-im-internet.de/patg/__111.html) + ZPO §521 2026-05-25 |
### 4.5 EPA — 1 mis-attributed citation
| Rule | Live citation | Problem |
|---|---|---|
| `epa.opp.opd.erwidg` | `R. 79(1) EPÜ` / `EU.EPC-R.79.1` | Duration (4 months) is correct as the *typical* EPO-set period under the 2016 streamlined-opposition guidelines, but **R.79(1) does not specify a fixed period** — the Opposition Division sets it. The 4 months is administrative practice (EPO Guidelines D-IV, 5.2). Should be modelled as court-set with 4 months as the default-display value. |
---
## §5. Findings — Wrong period (statute says X, paliad says Y)
| Rule | Live period | Statutory period | Source | Freq |
|---|---|---|---|---|
| **`upc.rev.cfi.defence`** | 3 months | **2 months** | RoP.049.1: *"The defendant shall lodge a Defence to revocation within two months of service of the Statement for revocation."* — verified verbatim from `UPCRoP.049.1` (youpc DB). Flagged 2026-05-08; still live. | ★★★ |
| **`upc.rev.cfi.rejoin`** | 2 months | **1 month** | RoP.052: *"Within one month of the service of the Reply the defendant may lodge a Rejoinder to the Reply to the Defence to revocation"* — verified verbatim from `UPCRoP.052.p1`. Flagged 2026-05-08; still live. | ★★★ |
| **`upc.apl.merits.response`** | 2 months | **3 months** | RoP.235.1: *"Within three months of service of the Statement of grounds of appeal pursuant to Rule 224.2(a), any other party … may lodge a Statement of response"* — verified verbatim from `UPCRoP.235.1`. New finding — May 8 audit recorded the duration as 3 months but the live row has always been 2 (migration 012:153 originally seeded 2). | ★★★ |
| **`upc.pi.cfi.response`** | 0 / "court-set" (`is_court_set=false`, `duration=0`, `parent_id=NULL`) | court-set, judge-discretion under R.211.2 | RoP.211.2 — judge sets the inter-partes hearing date. Modelling is half-broken: `duration=0` with `parent_id=NULL` makes the calculator treat this as a root anchor rather than a court-set placeholder. Should set `is_court_set=true` and chain `parent_id=app`. | ★★ |
(All other rules audited have correct durations.)
---
## §6. Findings — Wrong party
No clear party mis-assignments found in the live data. Two notes worth
recording, not bugs:
- `upc.inf.cfi.app_to_amend` carries `primary_party='claimant'`. The
defendant in an INF case is the alleged infringer; the patent
proprietor (=claimant) is who would file an Application to Amend
the patent. **Correct.** Listed here only because R.30 reads "the
defendant" in some summaries — those refer to the claimant of the
CCR (= defendant of the INF), which loops back to the same person
who is the INF-claimant / patent-proprietor.
- `dpma.opp.dpma.erwiderung` carries `primary_party='defendant'`. In an
EPA-style opposition, the patent proprietor is the "defendant" of the
opposition. Consistent with EPA convention. **Correct.**
---
## §7. Findings — Wrong sequencing / anchoring
### 7.1 `de.inf.lg.beruf_begr` chains parent = `berufung`, should anchor on `urteil` directly
| Live | Per ZPO §520(2) |
|---|---|
| `de.inf.lg.beruf_begr.parent_id = de.inf.lg.berufung`, `duration = 2 months` → effective end = trigger + 1mo (Berufung) + 2mo = **3 months** after Urteil service | "Die Frist für die Berufungsbegründung beträgt zwei Monate. Sie beginnt mit der Zustellung des in vollständiger Form abgefassten Urteils" → **2 months** after Urteil service |
Verified verbatim via WebFetch
[gesetze-im-internet.de/zpo/__520.html](https://www.gesetze-im-internet.de/zpo/__520.html)
2026-05-25.
The companion `de.inf.olg.begruendung` is **correct** — parent =
`urteil_lg`, 2mo, so end = Urteil + 2mo. Same statute, two paliad
rules, two different anchorings: this is a real bug in `de.inf.lg`.
### 7.2 `de.inf.lg.replik` and `de.inf.lg.duplik` have `parent_id = NULL`
This is the bug head flagged. Live data:
| submission_code | name | duration | parent_id | sequence_order |
|---|---|---|---|---|
| `de.inf.lg.klage` | Klageerhebung | 0 mo | NULL | 0 |
| `de.inf.lg.anzeige` | Anzeige Verteidigungsbereitschaft | 2 wk | `de.inf.lg.klage` | 10 |
| `de.inf.lg.erwidg` | Klageerwiderung | 6 wk | `de.inf.lg.klage` (court-set=true post mig 095) | 20 |
| **`de.inf.lg.replik`** | Replik | **4 wk** | **NULL** | 30 |
| **`de.inf.lg.duplik`** | Duplik | **4 wk** | **NULL** | 40 |
| `de.inf.lg.termin` | Haupttermin | 0 mo | NULL (court-set) | 50 |
| `de.inf.lg.urteil` | Urteil | 0 mo | NULL (court-set) | 60 |
| `de.inf.lg.berufung` | Berufungsfrist | 1 mo | NULL | 70 |
| `de.inf.lg.beruf_begr` | Berufungsbegründung | 2 mo | `de.inf.lg.berufung` | 80 |
With `parent_id = NULL` the calculator anchors Replik on the
triggerDate (= Klageerhebung), and same for Duplik. So both render
"4 Wochen ab Klageerhebung" — i.e. before the Klageerwiderung is
even due. Correct chain should be:
- `replik.parent_id = de.inf.lg.erwidg`, with `is_court_set = true` (richterliche Frist § 276(1) S.2 / § 283 ZPO — typ. 4 weeks default)
- `duplik.parent_id = de.inf.lg.replik`, same shape
Both rules lack `legal_source` and `rule_code`, which is consistent
with them being court-set Schriftsatzfristen (no statutory clamp).
Recommendation in §10.
### 7.3 `upc.apl.merits.grounds` has `parent_id = NULL`
This anchors Grounds on the user-supplied trigger date (=Entscheidung
service). **Correct** behaviour per RoP.224.2.a: *"within four months
of service of a decision referred to in Rule 220.1(a) and (b)"*.
If `parent_id` were set to `upc.apl.merits.notice` (as the May 8 audit
hypothesised), the chain would compound (1-day notice + 4mo grounds =
~4mo + 1 day), accidentally landing near the right end-date for the
common case but wrong by up to 2 months in the edge case (when notice
is filed early). **No fix needed; document the intent.** (This is
the change the May 8 audit recommended; it was applied in mig 097 or
earlier.)
### 7.4 DPMA Pathway-A anchors are partially modelled
- `dpma.appeal.bgh.begruendung` chains parent = `rechtsbeschwerde`
(1mo + 1mo = 2mo from BPatG-Entscheidung). Per PatG §102 the
Rechtsbeschwerdebegründungsfrist is 1 month from filing of the
Rechtsbeschwerde — **correct**.
- `dpma.appeal.bpatg.begruendung` chains parent = `beschwerde`
(1mo + 1mo = 2mo from DPMA-Entscheidung). **No statutory basis for
the 1-month figure** (see §4.3). Should be court-set.
### 7.5 EPA grant timeline — `epa.grant.exa.r71_3` and `.approval` have `parent_id = NULL`
Live:
| Rule | Duration | parent_id | Issue |
|---|---|---|---|
| `epa.grant.exa.r71_3` | 0 mo | NULL | Should chain on `exam_req` (after examination request is granted, EPO issues R.71(3) communication). NULL parent + 0 duration = root anchor at trigger date — works only if user enters the R.71(3) date as trigger; doesn't compose with the rest of the tree. |
| `epa.grant.exa.approval` | 4 mo | NULL | Per R.71(3) approval period: 4 months from notification. **Anchor should be `r71_3`**, not NULL. As-is, "Zustimmung + Übersetzung" appears as a free-standing 4-mo-from-trigger row that has nothing to do with the rest of the timeline. |
### 7.6 Summary
| # | Rule | Bug |
|---|---|---|
| 1 | `de.inf.lg.beruf_begr` | parent should be NULL (anchored on Urteil-trigger) not `berufung` — off by 1 month, ★★★ |
| 2 | `de.inf.lg.replik` | parent should be `erwidg` not NULL, ★★★ |
| 3 | `de.inf.lg.duplik` | parent should be `replik` not NULL, ★★★ |
| 4 | `dpma.appeal.bpatg.begruendung` | should be court-set; current 1-month period has no statutory basis, ★★ |
| 5 | `dpma.appeal.bpatg.beschwerde` parent is `entscheidung` — OK, just a citation issue (§4.3) | (citation only) |
| 6 | `epa.grant.exa.r71_3` parent | should chain on `exam_req`, ★ |
| 7 | `epa.grant.exa.approval` parent | should chain on `r71_3`, ★ |
| 8 | `upc.pi.cfi.response` | court-set placeholder with `parent_id=NULL` and `is_court_set=false` — should chain on `app` with `is_court_set=true`, ★★ |
---
## §8. Findings — Duplicates
No genuine duplicates. The closest cases:
- `upc.inf.cfi.reply` + `upc.inf.cfi.def_to_ccr` both fire at 2mo after
`sod` under `with_ccr`. They cover different actions (Reply to SoD
vs. Defence to CCR + Reply to SoD combined) per RoP.029.a vs .b.
**Not a duplicate** — distinct rule codes.
- `upc.rev.cfi.reply` (2mo, no rule_code) and the older `REV.rev_reply`
on the archived litigation type — the archived type is hidden
(`pt.is_active = false`) so this isn't a duplicate the user sees.
Recommendation in §10 to drop the archived corpus once mig 093's
audit window closes.
- `epa.opp.boa.r106` (Art. 112a review) appears only on
`epa.opp.boa`, not on `epa.opp.opd` — correct, since Art. 112a
review is only available against a Boards-of-Appeal decision.
---
## §9. Ambiguities — decisions m needs to make
These are not bugs the coder can fix. They are judgement calls about
how to model the law.
### 9.1 Court-set vs fixed-period for richterliche Fristen
The cleanest source-of-truth for these is "no statutory duration —
court sets the period in the individual case." Modelling them as a
fixed period with a wrong citation is the bug pattern we keep finding:
- `dpma.opp.dpma.erwiderung` (4 mo) — DPMA practice, not §59 PatG.
- `dpma.appeal.bpatg.begruendung` (1 mo) — no statutory basis.
- `de.inf.olg.erwiderung` (1 mo, §521(2)) — §521(2) is explicitly
discretionary ("Der Vorsitzende oder das Berufungsgericht **kann**
der Gegenpartei eine Frist … bestimmen"). Verified WebFetch
[gesetze-im-internet.de/zpo/__521.html](https://www.gesetze-im-internet.de/zpo/__521.html)
2026-05-25.
- `de.null.bgh.erwiderung` (2 mo, "§111(3) PatG") — court-set per §117
PatG → ZPO §521(2).
- `de.null.bpatg.duplik` (1 mo, §83 PatG) — court-set; the 1-month
default is BPatG practice.
- `de.inf.lg.replik`, `.duplik` (4 wk each) — court-set per
§283 / §296a ZPO + §276(1) S.2.
- `epa.opp.opd.erwidg` (4 mo, "R.79(1)") — EPO-set per Guidelines.
**Question (Q1):** Should paliad continue to display these with a
default duration but flag them as "richterliche Frist — vom Gericht
festgesetzt", OR should they all flip to `is_court_set=true,
duration=0` and force the user to enter the actual court-set date?
Head's 2026-05-25 13:13 signal confirms: m's preference is that "Frist
vom Gericht bestimmt" be flagged as needing case-by-case anchoring,
not displayed as a fixed period. So default answer = flip to
`is_court_set=true` and keep the typical period as the *Default*
display value (the calculator already supports this since the
mig 095 / `de.inf.lg.erwidg` patch). But the trade-off is a UX
regression: most users will not enter the actual court-set date
and the timeline will then show "vom Gericht bestimmt" everywhere.
### 9.2 R.198 / R.213 "31 days OR 20 working days, whichever is longer"
Two RoP rules need a primitive paliad doesn't have:
- A `working_days` duration unit (counts business-day arithmetic via
the holiday service).
- A `combine = 'max'` operator that compares two durations and picks
the later end-date.
**Question (Q2):** Implement the primitive (~120 LoC migration + ~80 LoC
Go), or document both rules as "manual calculation required, see RoP"
in the UI? Real R.198 / R.213 cases are rare (saisie + PI). The May 8
audit suggested deferring; pauli's 2026-05-13 audit §7.1 made the
case for adding `combine_op` as part of a broader Pipeline A/C merge.
### 9.3 R.245.2 rehearing "whichever is later" trigger
R.245.2.a/b: deadline 2 months from final decision OR from defect
discovery, whichever is *later*. Plus outer cap 12 months. Needs:
- Multi-anchor trigger event (user supplies 2 dates).
- `combine = 'max'` between anchors.
- Outer-cap arithmetic (separate concept from duration).
**Question (Q3):** Defer (specialist, vanishingly rare) or build the
primitives?
### 9.4 EPC Art. 112a review — outer cap
Same shape as R.245.2: 2 months from defect discovery, outer cap 12
months from decision. `epa.opp.boa.r106` models the 2-month period
but not the cap.
### 9.5 PatG §123 Wiedereinsetzung calendar arithmetic
Cascade card (slug `wiedereinsetzung`) exists. The 2mo / 1-year
arithmetic anchors on the *missed* deadline, not on a forward-looking
event. paliad's `paliad.deadline_rules` schema has no natural shape
for this — it would need either a special-case Go helper, or a
"backward-from-missed-deadline" mode that no rule today uses.
**Question (Q4):** Worth modelling? The cascade card already routes
the user to the concept; computing the calendar deadline is an
incremental win.
### 9.6 ZPO §339 Versäumnisurteil-Einspruch
Cascade card orphan. 2 weeks from service of the default judgment.
Trivial to add as a `de.inf.lg.einspruch_vu` rule (court-decision
anchor + 2wk fixed). **Question (Q5):** Add as a child of
`de.inf.lg.urteil` (with `condition_expr={"flag":"with_vu"}`), or
as a separate proceeding `de.inf.lg.vu`?
### 9.7 Litigation-vs-fristenrechner archived corpus
The 40 rules on `_archived_litigation` (mig 093 retirement holding pen)
still occupy the rule table. They're invisible to all UIs.
**Question (Q6):** Drop them now (data clean-up), or keep until the
mig 093 audit window closes formally?
### 9.8 R.79(2) further-party observations period
EPC R.79(2) creates a separate notification window for additional
opponents. paliad's `epa.opp.opd.r79_further` is modelled as
`duration=0, is_bilateral=true`. **Question (Q7):** Is this even worth
keeping? Real workflow: EPO sets a separate period in each
intervention case. Hard to template.
### 9.9 R.116(1) EPC oral-proceedings cut-off
paliad has it as `duration=0, parent_id=entsch` (`epa.opp.opd.r116`) /
`parent_id=oral` (`epa.opp.boa.r116`). R.116(1) actually says the
EPO sets a "final date for making written submissions" when issuing
the summons. So it's a court-set period, not zero-duration.
**Question (Q8):** flip to `is_court_set=true` like the §276(1) ZPO
fix in mig 095?
### 9.10 R.131.2 indication of damages period
paliad models `upc.dmgs.cfi.app` as a 0-duration root anchor (court
sets when the damages-determination phase opens, per R.131.2). This
is correct shape but means the entire damages tree is unanchored
until the user provides the trigger date manually.
**Question (Q9):** Wire `is_spawn` from `upc.inf.cfi.decision` to
`upc.dmgs.cfi.app` (parallel to the mig-095 appeal-spawn)?
### 9.11 PatG §17 GebrMG / §18 GebrMG
No GebrMG-rooted proceeding_type exists in paliad. Head confirmed
out-of-scope for this audit. **Question (Q10):** Add a `de.gm.lg`
proceeding for GebrMG-Löschungsverfahren if HLC sees them?
### 9.12 Proceeding-tree vs cascade parity
paliad has 9 cascade-only concepts with `rule_count = 0` (the orphans
listed in `audit-fristen-logic-2026-05-13.md` §3.4). The audit-fristen
audit covers this; restating here only to note that the parity gap
is the largest single source of "the cascade card promises a
calculation but doesn't deliver one."
**Question (Q11):** Same as the audit-fristen Q8 — priority order
for the 9 orphan concepts? My ranking: wiedereinsetzung >
schriftsatznachreichung > versäumnisurteil-einspruch >
weiterbehandlung > rest.
### 9.13 R.220.3 anchor
See `audit-upc-rop-deadlines-2026-05-08.md` §5.4b. paliad anchors
`upc.apl.order.discretion` on the original order (`order`), but
the 15-day clock per RoP.220.3 runs from the refusal-of-leave
date (or day-15 fall-back). Off by up to 15 days in the edge case.
**Question (Q12):** add an explicit `app_ord.refusal` court-set
intermediate node?
### 9.14 EP_GRANT publish date — priority vs filing
`epa.grant.exa.publish` correctly has `anchor_alt='priority_date'`.
This was open in the May 8 audit and is now closed. **No question —
listed to confirm.**
### 9.15 Cross-proceeding spawn execution
mig 095 added two `is_spawn=true` rules (`inf.appeal_spawn`,
`rev.appeal_spawn``upc.apl.merits`). The May 13 audit §1.6 +
§6.8 noted spawn execution is half-wired in `projection_service.go`.
**Question (Q13):** wire end-to-end now (so the spawned appeal
timeline appears in SmartTimeline), or accept the half-wired state?
---
## §10. Recommended fixes (prioritised)
### Tier 0 — hard duration / sequencing / anchor bugs (ship first)
| # | Rule | Fix | Reason / source | Freq |
|---|---|---|---|---|
| T0.1 | `upc.rev.cfi.defence` | `duration_value = 2` (was 3), `rule_code = 'RoP.049.1'`, `legal_source = 'UPC.RoP.49.1'` | §5 — every UPC_REV tracked in paliad today computes Defence at wrong month for the last ~3 months | ★★★ |
| T0.2 | `upc.rev.cfi.rejoin` | `duration_value = 1` (was 2), `rule_code = 'RoP.052'`, `legal_source = 'UPC.RoP.52.p1'` | §5 — same as T0.1 | ★★★ |
| T0.3 | `upc.apl.merits.response` | `duration_value = 3` (was 2), `rule_code = 'RoP.235.1'`, `legal_source = 'UPC.RoP.235.1'` | §5 — every main-track appellate respondent | ★★★ |
| T0.4 | `de.inf.lg.beruf_begr` | `parent_id = NULL` (was `de.inf.lg.berufung`) — runs 2 months from triggerDate (Urteil-service) per ZPO §520(2) | §7.1 — every DE-LG-Verletzung appeal | ★★★ |
| T0.5 | `de.inf.lg.replik` | `parent_id = de.inf.lg.erwidg`, `is_court_set = true` (richterliche Frist § 276(1) S.2 / § 283 ZPO), keep 4-week default | §7.2 — bug head flagged | ★★★ |
| T0.6 | `de.inf.lg.duplik` | `parent_id = de.inf.lg.replik`, `is_court_set = true` | §7.2 | ★★★ |
| T0.7 | `upc.rev.cfi.reply` | `rule_code = 'RoP.051'`, `legal_source = 'UPC.RoP.51.p1'` (duration 2mo unchanged) | §4.1 | ★★★ |
| T0.8 | `upc.rev.cfi.rejoin` (citation only) | covered in T0.2 | — | — |
| T0.9 | `upc.apl.merits.notice` | `rule_code = 'RoP.224.1.a'`, `legal_source = 'UPC.RoP.224.1.a'` (duration unchanged) | §4.1 | ★★ |
| T0.10 | `upc.apl.merits.grounds` | `rule_code = 'RoP.224.2.a'`, `legal_source = 'UPC.RoP.224.2.a'` (duration unchanged) | §4.1 | ★★ |
| T0.11 | `upc.rev.cfi.defence` rule_code zero-pad | covered in T0.1 | — | — |
| T0.12 | `dpma.opp.dpma.erwiderung` | flip to `is_court_set = true`, keep 4-month default-display value, drop the misleading `DE.PatG.59.3` citation (or replace with "DPMA-Richtlinien D-IV 5.2") | §4.3 + §9.1 | ★★ |
| T0.13 | `dpma.appeal.bpatg.begruendung` | flip to `is_court_set = true`, drop the `DE.PatG.75.1` citation, keep 1-month default | §4.3 + §9.1 | ★★ |
| T0.14 | `de.null.bpatg.erwidg` | citation `DE.PatG.82.3` (was 82.1); duration (2mo) correct | §4.4 | ★★ |
| T0.15 | `de.null.bgh.begruendung` | citation `DE.ZPO.520.2` via PatG §117 (was DE.PatG.111.1); duration (3mo) correct | §4.4 | ★★ |
| T0.16 | `de.null.bgh.erwiderung` | flip to `is_court_set = true`; citation `DE.ZPO.521.2 via PatG §117` (was DE.PatG.111.3); duration (2mo) becomes default-display | §4.4 + §9.1 | ★★ |
| T0.17 | `epa.opp.opd.erwidg` | flip to `is_court_set = true`, keep 4-month default | §4.5 + §9.1 | ★★ |
**16 hard fixes.** All within the existing schema (no new columns).
Each is a single-row UPDATE plus an audit-log entry.
### Tier 1 — high-value missing rules (★★ / ★★★)
| # | Rule | Add | Freq |
|---|---|---|---|
| T1.1 | `upc.inf.cfi.cmo_review` | 15 days from CMO service (R.333.2) | ★★ |
| T1.2 | `upc.inf.cfi.confidentiality_response` | 14 days from opp. confidentiality app (R.262.2) | ★★ |
| T1.3 | `upc.apl.order.grounds_orders` | 15 days from order service (R.224.2(b)) | ★★ |
| T1.4 | `upc.apl.order.response_orders` | 15 days from grounds service (R.235.2) | ★★ |
| T1.5 | `upc.inf.cfi.cons_orders` | 2 months from validity decision (R.118.4) | ★★ |
| T1.6 | `upc.inf.cfi.rectification` | 1 month from decision (R.353) | ★ |
| T1.7 | `upc.pi.cfi.deficiency` | 14 days from PI deficiency notification (R.207.6.a) | ★★ |
| T1.8 | `upc.pi.cfi.merits_start` | 31d OR 20wd from PI grant (R.213) — **blocked on Q2** | ★★ |
| T1.9 | `upc.inf.cfi.translation_request` | 1 month **before** oral hearing (R.109.1) | ★★ |
| T1.10 | `upc.inf.cfi.interpreter_cost` | 2 weeks **before** oral hearing (R.109.4) | ★★ |
| T1.11 | `upc.inf.cfi.translations_lodge` | 2 weeks after summons (R.109.5) | ★★ |
| T1.12 | `upc.pi.cfi.response` re-anchor | court-set, parent=`app` (currently a broken root) | ★★ |
**12 rule-adds.** T1.9/.10 are the only `timing='before'` rules in the
entire UPC corpus; schema already supports `before` but no rule
populates it. Verify the backward-snap-to-working-day logic in
`internal/services/deadline_calculator.go` before merging
(2026-04-30 audit §5.4 raised the concern).
### Tier 2 — broader coverage (★ specialist + Wiedereinsetzung family)
| # | Rule | Add | Notes |
|---|---|---|---|
| T2.1 | `de.inf.lg.einspruch_vu` | 2 weeks from service of Versäumnisurteil (ZPO §339) | Q5 — proceeding shape decision |
| T2.2 | `upc.inf.cfi.wiedereinsetzung` | 2 mo / 1-year-cap from Wegfall des Hindernisses (R.320) | Q4 — needs special arithmetic |
| T2.3 | `de.inf.lg.wiedereinsetzung` | 2 mo / 1-year-cap (PatG §123 / ZPO §233 ff.) | Q4 |
| T2.4 | `epa.grant.exa.weiterbehandlung` | 2 mo from loss-of-rights notification (EPC R.135) | — |
| T2.5 | `upc.inf.cfi.prelim_reply` | 14 days from PO service (R.20.2) | Companion to R.19 (mig 095 added it) |
| T2.6 | `upc.apl.order.discretion_anchor` | add explicit `refusal` intermediate node so R.220.3 anchors correctly (Q12) | |
| T2.7 | `upc.dmgs.cfi.app` spawn | `is_spawn=true` from `upc.inf.cfi.decision` (Q9) | |
| T2.8 | `upc.disc.cfi.app` spawn | same shape as T2.7 | |
| T2.9 | `epa.grant.exa.r71_3` re-anchor | parent = `exam_req` (§7.5) | |
| T2.10 | `epa.grant.exa.approval` re-anchor | parent = `r71_3` (§7.5) | |
| T2.11 | `upc.inf.cfi.appeal_spawn` cross-proc wiring | finish the half-wired spawn execution (Q13) | |
### Tier 3 — tooling primitives (block multiple rules)
| # | Primitive | Blocks | Notes |
|---|---|---|---|
| T3.1 | `duration_unit = 'working_days'` | R.198, R.213 | Schema already accepts the string; add to calculator + UI |
| T3.2 | `combine_op = 'max'` | R.198, R.213, R.245.2 | Column already exists per pauli's 2026-05-13 audit |
| T3.3 | Multi-anchor "whichever later" trigger | R.245.2.a/b | UI + service work |
| T3.4 | Outer-cap modelling (`outer_cap_value` + `outer_cap_unit`) | R.245.2 (12mo), R.320 (12mo), EPC Art.112a(4) (12mo) | Schema add |
| T3.5 | "Before"-mode backward snap to working day | R.109.1, R.109.4 | Calculator change (audit-fristenrechner-completeness-2026-04-30.md §5.4) |
| T3.6 | Cross-proceeding spawn end-to-end (`is_spawn`) | T2.7, T2.8, T2.11 | Pauli's §6.8 |
### Tier 4 — out-of-scope until separate prioritisation
- DNI family (R.63 / R.67.1 / R.69.1 / R.69.2). Zero published filings 2026-Q1.
- Registry-correction family (R.16.3.a, R.27.2, R.89.2, R.253.2). Most natural in cascade, not proceeding-tree.
- GebrMG (no proceeding_type today).
- R.245 rehearing family (specialist).
- R.155 cost-decision opposition chain (specialist).
- R.144 UPC_DAMAGES tree-end row (cosmetic).
- R.79(2) EPC further-parties period (modelling unclear — Q7).
---
## §11. Next-step proposals (suggested fix-task slicing)
The audit identifies **41 distinct actionable items.** Below is a
suggested decomposition into fix-tasks that can be assigned
independently. Sequence reflects "Wave 0 must precede Wave 1" only
where there's a real dependency (most slices are independent).
### Wave 0 — Tier 0 duration / sequencing / anchor fixes (single fix-task)
**Proposed task:** `t-paliad-264 — Tier 0 deadline-rule corrections
(duration, anchor, citation) from t-paliad-263 audit`
- 16 row UPDATEs (T0.1T0.17, deduplicated to 16 distinct rows since
T0.8 is covered by T0.2 and T0.11 by T0.1).
- One migration file (~120 LoC SQL).
- All within existing schema. No new columns.
- Idempotent guards on every UPDATE (only fire when the row still has
the old value, per the mig 095 convention).
- Adds 16 entries to `paliad.deadline_rule_audit` (per the mig 079
trigger).
- Verification block: `DO $$ … RAISE EXCEPTION …` per mig 095.
- **Branch:** `mai/<coder>/t-paliad-264-tier0-deadline-fixes`.
- **Owner:** coder.
- **Why first:** all 16 affect either calendar correctness (5 hard
duration/anchor bugs) or citation correctness (the 11 metadata
fixes are what a lawyer would cite-check against). T0.1T0.6 are
user-visible silent wrongs; ship them.
### Wave 1 — Tier 1 rule additions (single fix-task)
**Proposed task:** `t-paliad-265 — Tier 1 deadline-rule additions
(12 high-frequency rules)`
- 11 INSERTs + 1 UPDATE re-anchor (T1.12 `upc.pi.cfi.response`).
- T1.8 (`upc.pi.cfi.merits_start`) **excluded** — blocked on T3.1/T3.2.
- One migration file (~250 LoC SQL).
- Add cascade leaves + concepts where needed (each rule should be
reachable from Pathway B too).
- **Branch:** `mai/<coder>/t-paliad-265-tier1-rule-additions`.
- **Owner:** coder. **Legal review:** m must verify each rule before
merge (single round of grilling).
### Wave 2 — Q1 court-set audit decision (separate spike)
**Proposed task:** `t-paliad-266 — Decide court-set vs fixed-period
modelling for richterliche Fristen (Q1 in t-paliad-263 audit)`
- Inventor / pauli reviews §9.1 with m.
- Decision artefact: list of rules to flip vs keep, plus UX guideline
for what the timeline displays for `is_court_set=true` rules.
- **Owner:** pauli. **m signs off.**
### Wave 3 — Tier 3 tooling primitives (multi-task)
Each Tier 3 row is its own task because each touches schema + service +
calculator + UI:
- `t-paliad-267 — working_days unit + combine_op='max' (R.198, R.213)`
- `t-paliad-268 — Outer-cap modelling (R.245.2, R.320, Art.112a)`
- `t-paliad-269 — Multi-anchor "whichever later" triggers (R.245.2)`
- `t-paliad-270 — Backward-snap for `before`-mode rules (R.109.1/.4)`
- `t-paliad-271 — Cross-proceeding spawn end-to-end execution`
Each is foundational for multiple Tier 2 rules; can ship independently.
### Wave 4 — Tier 2 specialist rules (multi-task, after their primitives land)
Each Tier 2 row is its own task or batched into 2-3 tasks by topical
area:
- `t-paliad-272 — Wiedereinsetzung / Weiterbehandlung family (T2.2, T2.3, T2.4)` — depends on T3.4 (outer cap).
- `t-paliad-273 — UPC follow-on spawns (T2.7, T2.8, T2.11)` — depends on T3.6.
- `t-paliad-274 — UPC tail rules (T2.5, T2.6, R.353, etc.)`
- `t-paliad-275 — EPA grant timeline re-anchoring (T2.9, T2.10)`.
### Wave 5 — Concept-layer parity (separate audit)
The 9 orphan concepts (`audit-fristen-logic-2026-05-13.md` §3.4 + Q11
here) need a parallel audit pass to map cascade → rule. Recommend
spinning a `t-paliad-276 — Cascade-rule parity audit` task once the
above land.
### Wave 6 — Documentation + retire
- `t-paliad-277 — Drop `_archived_litigation` proceeding_type` once
mig 093's audit window closes (Q6).
- `t-paliad-278 — Document Tier 4 deferrals in
`docs/feature-roadmap.md`` so the gap-list isn't lost.
---
## Appendix A — file references
**Live state queried via Supabase MCP, 2026-05-25 14:0015:00 UTC:**
- `paliad.proceeding_types` — 21 active rows (20 fristenrechner + 1
archived).
- `paliad.deadline_rules` — 132 active + 40 archived rows
(`lifecycle_state='published'`).
- `paliad.deadline_rule_audit` — diff history.
- `data.laws_contents` (youpc) — UPC RoP + EPC verbatim text
(`law_type IN ('UPCRoP','EPC')`).
**paliad migrations consulted:**
- `internal/db/migrations/012_fristenrechner_rules.up.sql` — original
seed.
- `internal/db/migrations/043_de_instance_split_proceedings.up.sql`
— DE_INF_OLG / DE_INF_BGH split.
- `internal/db/migrations/052_event_categories_rop_audit.up.sql`
— first RoP audit fix-pass.
- `internal/db/migrations/079_*` — `paliad.deadline_rule_audit`
trigger.
- `internal/db/migrations/091_drop_legacy_rule_columns.up.sql` —
cleanup.
- `internal/db/migrations/093_retire_litigation_category.up.sql` —
archived 40 rules.
- `internal/db/migrations/095_fristen_gap_fill.up.sql` — t-paliad-205
R.19 + R.220.1(a) gap fill.
- `internal/db/migrations/096_proceeding_code_rename.up.sql` — code
rename to `<jurisdiction>.<proceeding>.<instance>` form.
- `internal/db/migrations/097_legal_citation_backfill.up.sql` —
legal_source / rule_code backfill.
- `internal/db/migrations/100_ccr_visible_rule.up.sql` —
`upc.ccr.cfi` alias.
- `internal/db/migrations/104_einspruch_name_and_ccr_priority.up.sql`
— Einspruch rename.
**Companion audits:**
- `docs/audit-fristenrechner-completeness-2026-04-30.md` — curie /
t-paliad-084.
- `docs/audit-upc-rop-deadlines-2026-05-08.md` — curie / t-paliad-159.
- `docs/audit-fristen-logic-2026-05-13.md` — pauli / t-paliad-157
(schema audit, ground-truth on column semantics).
- `docs/proposals/fristen-gap-fill-2026-05-18.md` — m's 0.3 decisions
that shipped as mig 095.
**Authoritative source URLs (all verified 2026-05-25):**
- UPC RoP consolidated 18.05.2023: https://www.unifiedpatentcourt.org/sites/default/files/upc_documents/rop_application_-_consolidated_18_05_2023.pdf
- EPC 17th ed.: https://www.epo.org/en/legal/epc/2020/index.html
- EPC R.71 (and other Implementing Reg Rules): https://www.epo.org/en/legal/epc/2020/r71.html
- PatG: https://www.gesetze-im-internet.de/patg/
- §59 https://www.gesetze-im-internet.de/patg/__59.html
- §73 https://www.gesetze-im-internet.de/patg/__73.html
- §75 https://www.gesetze-im-internet.de/patg/__75.html
- §82 https://www.gesetze-im-internet.de/patg/__82.html
- §110 https://www.gesetze-im-internet.de/patg/__110.html
- §111 https://www.gesetze-im-internet.de/patg/__111.html
- ZPO: https://www.gesetze-im-internet.de/zpo/
- §520 https://www.gesetze-im-internet.de/zpo/__520.html
- §521 https://www.gesetze-im-internet.de/zpo/__521.html
- GebrMG: https://www.gesetze-im-internet.de/gebrmg/
---
## Appendix B — coverage tally
| Status | Count | Share |
|---|---:|---:|
| present-correct | 78 | 59 % |
| present-wrong (DURATION) | 3 | 2 % |
| present-wrong (anchor/sequence) | 5 | 4 % |
| present-wrong (citation only) | 11 | 8 % |
| court-set-mismodelled-as-fixed | 6 | 5 % |
| **subtotal: still actionable** | **25** | **19 %** |
| missing (statute defines, paliad doesn't) | 30 | (gap, vs 132 baseline) |
| n/a (RoP / EPC / PatG section creates no time-limit) | 8 | 6 % |
| present-correct, no fix needed | (78 above) | |
**Headline figures for m:**
- Of the 132 statutory deadlines paliad currently models, **25 carry
an actionable bug** (19%). Of those, **5 are user-visible
calendar-correctness bugs** (the 3 duration bugs + the 2
sequencing/anchor bugs head flagged + me). The other 20 are
citation drift or court-set mismodelling — fix-them-quietly
category.
- An additional **30 statutory deadlines are not modelled at all**
(the missing list in §3). Of those, **~12 are ★★★ / ★★ frequency**
(Tier 1 in §10); the remaining ~18 are ★ specialist.
- The 5 duration / sequencing bugs alone are **the most important
takeaway**: every UPC_REV proceeding, every UPC main-track appeal
respondent, and every DE-LG-Verletzung timeline tracked in paliad
today computes wrong dates.
End of audit. Awaiting m's review of §9 Q1Q13 + Tier 0 sign-off
before fix-tasks (Wave 0) get cut.

View File

@@ -49,6 +49,7 @@ import { renderAdminRulesEdit } from "./src/admin-rules-edit";
import { renderAdminRulesExport } from "./src/admin-rules-export";
import { renderPaliadin } from "./src/paliadin";
import { renderAdminPaliadin } from "./src/admin-paliadin";
import { renderAdminBackups } from "./src/admin-backups";
import { renderNotFound } from "./src/notfound";
const DIST = join(import.meta.dir, "dist");
@@ -291,6 +292,7 @@ async function build() {
// skip the re-fetch.
join(import.meta.dir, "src/client/paliadin-widget.ts"),
join(import.meta.dir, "src/client/admin-paliadin.ts"),
join(import.meta.dir, "src/client/admin-backups.ts"),
join(import.meta.dir, "src/client/notfound.ts"),
],
outdir: join(DIST, "assets"),
@@ -417,6 +419,7 @@ async function build() {
await Bun.write(join(DIST, "admin-rules-export.html"), renderAdminRulesExport());
await Bun.write(join(DIST, "paliadin.html"), renderPaliadin());
await Bun.write(join(DIST, "admin-paliadin.html"), renderAdminPaliadin());
await Bun.write(join(DIST, "admin-backups.html"), renderAdminBackups());
await Bun.write(join(DIST, "notfound.html"), renderNotFound());
// Append ?v=<buildVersion> to every /assets/*.js and /assets/*.css URL in

View File

@@ -0,0 +1,96 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { PaliadinWidget } from "./components/PaliadinWidget";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
// Backup Mode admin page (t-paliad-246 / m/paliad#77 Slice A).
//
// global_admin only — gated by adminGate(...) in handlers.go. Shows the
// chronological list of backup runs (one row per kind in
// {scheduled, on_demand}) plus a button to kick off an on-demand backup.
// Catalog rows + the "run now" action are fetched client-side via
// /api/admin/backups.
export function renderAdminBackups(): string {
return "<!DOCTYPE html>" + (
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#BFF355" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<PWAHead />
<title data-i18n="admin.backups.title">Backups &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/admin/backups" />
<BottomNav currentPath="/admin/backups" />
<main>
<section className="tool-page">
<div className="container">
<div className="tool-header">
<div>
<h1 data-i18n="admin.backups.heading">Backups</h1>
<p className="tool-subtitle" data-i18n="admin.backups.subtitle">
Vollst&auml;ndige Snapshots aller Daten &mdash; manuell oder zeitgesteuert.
</p>
</div>
<div>
<button
className="btn-primary"
id="admin-backups-run-btn"
type="button"
data-i18n="admin.backups.run_now"
>
Backup jetzt erstellen
</button>
</div>
</div>
<div id="admin-backups-feedback" className="form-msg" style="display:none" />
<div className="entity-table-wrap">
<table className="entity-table entity-table--readonly">
<thead>
<tr>
<th data-i18n="admin.backups.col.started">Erstellt</th>
<th data-i18n="admin.backups.col.kind">Auslöser</th>
<th data-i18n="admin.backups.col.status">Status</th>
<th data-i18n="admin.backups.col.requested_by">Angefordert von</th>
<th data-i18n="admin.backups.col.size">Gr&ouml;&szlig;e</th>
<th data-i18n="admin.backups.col.rows">Zeilen</th>
<th data-i18n="admin.backups.col.actions">Aktion</th>
</tr>
</thead>
<tbody id="admin-backups-tbody">
<tr>
<td colspan={7} data-i18n="admin.backups.loading">Lade &hellip;</td>
</tr>
</tbody>
</table>
</div>
<div className="entity-empty" id="admin-backups-empty" style="display:none">
<p data-i18n="admin.backups.empty">Noch keine Backups vorhanden.</p>
</div>
<p className="tool-footer-note" id="admin-backups-footer">
<span data-i18n="admin.backups.footer.note">
Geplante Backups werden in einer sp&auml;teren Slice aktiviert. Manuelle Backups stehen jetzt zur Verf&uuml;gung.
</span>
</p>
</div>
</section>
</main>
<Footer />
<PaliadinWidget />
<script src="/assets/admin-backups.js"></script>
</body>
</html>
);
}

View File

@@ -0,0 +1,192 @@
import { initI18n, t } from "./i18n";
import { initSidebar } from "./sidebar";
// Backup Mode admin client (t-paliad-246 / m/paliad#77 Slice A).
//
// Reads /api/admin/backups (chronological list) and wires the
// "Backup jetzt erstellen" button to POST /api/admin/backups/run.
// Synchronous: the server holds the connection for the duration of
// the backup (sub-second at firm-scale today), then returns the new
// catalog row inline. No polling needed at v1's data shape; if the
// run takes > 5 minutes the handler returns 500 and the UI surfaces
// the error.
interface BackupRow {
id: string;
kind: "scheduled" | "on_demand";
status: "running" | "done" | "failed";
requested_by?: string;
requested_by_email: string;
audit_id?: string;
storage_uri?: string;
size_bytes?: number;
row_counts?: unknown; // jsonb passes through as raw bytes; we don't read it
sheet_count?: number;
warnings?: unknown;
error?: string;
started_at: string;
finished_at?: string;
deleted_at?: string;
}
document.addEventListener("DOMContentLoaded", async () => {
initI18n();
initSidebar();
await refreshList();
wireRunButton();
});
function wireRunButton(): void {
const btn = document.getElementById("admin-backups-run-btn") as HTMLButtonElement | null;
if (!btn) return;
btn.addEventListener("click", async () => {
btn.disabled = true;
const originalText = btn.textContent;
btn.textContent = t("admin.backups.running") || "Läuft …";
clearFeedback();
try {
const r = await fetch("/api/admin/backups/run", {
method: "POST",
credentials: "same-origin",
});
if (!r.ok) {
const body = await r.json().catch(() => ({ error: "request failed" }));
showFeedback("error", body.error || `HTTP ${r.status}`);
return;
}
// The created row is in the response; refresh the list to land it.
await refreshList();
showFeedback("success", t("admin.backups.success") || "Backup erfolgreich erstellt.");
} catch (e) {
showFeedback("error", (e as Error).message || "network error");
} finally {
btn.disabled = false;
btn.textContent = originalText;
}
});
}
async function refreshList(): Promise<void> {
const rows = await fetchJSON<BackupRow[]>("/api/admin/backups?limit=200");
const tbody = document.getElementById("admin-backups-tbody") as HTMLTableSectionElement | null;
const empty = document.getElementById("admin-backups-empty") as HTMLElement | null;
if (!tbody) return;
if (!rows || rows.length === 0) {
tbody.innerHTML = "";
if (empty) empty.style.display = "";
return;
}
if (empty) empty.style.display = "none";
tbody.innerHTML = rows.map(renderRow).join("");
}
function renderRow(b: BackupRow): string {
const started = formatTimestamp(b.started_at);
const kind =
b.kind === "scheduled"
? t("admin.backups.kind.scheduled") || "Geplant"
: t("admin.backups.kind.on_demand") || "Manuell";
const status = renderStatus(b);
const requestedBy =
b.kind === "scheduled" ? "—" : escapeHTML(b.requested_by_email);
const size = b.size_bytes != null ? formatBytes(b.size_bytes) : "—";
const rows = b.sheet_count != null ? String(b.sheet_count) : "—";
const action = renderAction(b);
return `<tr>
<td>${started}</td>
<td>${kind}</td>
<td>${status}</td>
<td>${requestedBy}</td>
<td>${size}</td>
<td>${rows}</td>
<td>${action}</td>
</tr>`;
}
function renderStatus(b: BackupRow): string {
switch (b.status) {
case "done":
return `<span class="status-done">${escapeHTML(t("admin.backups.status.done") || "✓ Fertig")}</span>`;
case "running":
return `<span class="status-running">${escapeHTML(t("admin.backups.status.running") || "Läuft …")}</span>`;
case "failed":
const label = t("admin.backups.status.failed") || "✗ Fehlgeschlagen";
const tip = b.error ? ` title="${escapeAttr(b.error)}"` : "";
return `<span class="status-failed"${tip}>${escapeHTML(label)}</span>`;
default:
return escapeHTML(b.status);
}
}
function renderAction(b: BackupRow): string {
if (b.status !== "done" || !b.storage_uri || b.deleted_at) {
return "—";
}
const label = t("admin.backups.download") || "Download";
return `<a class="btn-link" href="/api/admin/backups/${encodeURIComponent(b.id)}/file">${escapeHTML(label)}</a>`;
}
// --- helpers ---
async function fetchJSON<T>(url: string): Promise<T | null> {
try {
const r = await fetch(url, { credentials: "same-origin" });
if (!r.ok) return null;
return (await r.json()) as T;
} catch {
return null;
}
}
function formatTimestamp(iso: string): string {
const d = new Date(iso);
if (isNaN(d.getTime())) return escapeHTML(iso);
const yyyy = d.getUTCFullYear();
const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
const dd = String(d.getUTCDate()).padStart(2, "0");
const hh = String(d.getUTCHours()).padStart(2, "0");
const mi = String(d.getUTCMinutes()).padStart(2, "0");
return `${yyyy}-${mm}-${dd} ${hh}:${mi} UTC`;
}
function formatBytes(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
if (n < 1024 * 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)} MB`;
return `${(n / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
function escapeHTML(s: string): string {
return s.replace(/[&<>"']/g, (c) => {
switch (c) {
case "&": return "&amp;";
case "<": return "&lt;";
case ">": return "&gt;";
case '"': return "&quot;";
case "'": return "&#39;";
default: return c;
}
});
}
function escapeAttr(s: string): string {
return escapeHTML(s);
}
function showFeedback(kind: "success" | "error", text: string): void {
const el = document.getElementById("admin-backups-feedback") as HTMLElement | null;
if (!el) return;
el.textContent = text;
el.classList.remove("form-msg-success", "form-msg-error");
el.classList.add(kind === "success" ? "form-msg-success" : "form-msg-error");
el.style.display = "";
}
function clearFeedback(): void {
const el = document.getElementById("admin-backups-feedback") as HTMLElement | null;
if (!el) return;
el.style.display = "none";
el.textContent = "";
el.classList.remove("form-msg-success", "form-msg-error");
}

View File

@@ -2,6 +2,7 @@ import { initI18n, t, tDyn, getLang } from "./i18n";
import { initSidebar } from "./sidebar";
import { initNotes } from "./notes";
import { projectIndent } from "./project-indent";
import { openWithdrawWarningModal } from "./components/withdraw-warning-modal";
interface Appointment {
id: string;
@@ -25,6 +26,9 @@ interface PendingApprovalRequest {
requested_at: string;
required_role: string;
requester_name?: string;
// t-paliad-252 — used by the withdraw warning modal to pick the right
// copy (CREATE warns about deletion; UPDATE/COMPLETE about revert).
lifecycle_event?: string;
}
interface Me {
@@ -43,6 +47,10 @@ let project: Project | null = null;
let allProjects: Project[] = [];
let pendingRequest: PendingApprovalRequest | null = null;
let me: Me | null = null;
// t-paliad-252 — see deadlines-detail.ts. Routes Save to the new
// /api/approval-requests/{id}/edit-entity endpoint when the user picked
// "Termin bearbeiten" in the withdraw warning modal.
let pendingEditMode = false;
function parseAppointmentID(): string | null {
const parts = window.location.pathname.split("/").filter(Boolean);
@@ -207,10 +215,14 @@ function renderHeader() {
}
// Freeze the edit form + delete button while a request is in flight.
// t-paliad-252 — when the user picked "Termin bearbeiten" in the
// withdraw modal, pendingEditMode unfreezes the form so Save can route
// to /edit-entity (which keeps the request pending + merges payload).
const form = document.getElementById("appointment-edit-form") as HTMLFormElement | null;
if (form) {
const freeze = isPending && !pendingEditMode;
form.querySelectorAll<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement | HTMLButtonElement>("input, select, textarea, button[type=submit]")
.forEach((el) => { el.disabled = isPending; });
.forEach((el) => { el.disabled = freeze; });
}
const deleteBtn = document.getElementById("appointment-delete-btn") as HTMLButtonElement | null;
if (deleteBtn) deleteBtn.disabled = isPending;
@@ -263,6 +275,39 @@ async function saveEdit(ev: Event) {
submitBtn.disabled = true;
try {
// t-paliad-252 — pending-edit mode routes through /edit-entity which
// keeps the request pending + merges fields into payload. clear_project
// and project_id are NOT in the counter-allowlist (yet) — the requester
// can't move projects on a pending request from this surface.
if (pendingEditMode && pendingRequest) {
const editFields = { ...payload };
delete editFields.clear_project;
const resp = await fetch(
`/api/approval-requests/${pendingRequest.id}/edit-entity`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ fields: editFields }),
},
);
if (resp.ok) {
const fresh = await fetch(`/api/appointments/${appointment.id}`);
if (fresh.ok) appointment = await fresh.json();
await loadPendingRequest();
// Exit pending-edit mode so the form re-freezes (still pending).
pendingEditMode = false;
renderHeader();
fillEditForm();
msg.textContent = t("appointments.detail.saved");
msg.className = "form-msg form-msg-ok";
} else {
const data = await resp.json().catch(() => ({}) as { error?: string; message?: string });
msg.textContent = data.message || data.error || t("appointments.error.generic");
msg.className = "form-msg form-msg-error";
}
return;
}
const resp = await fetch(`/api/appointments/${appointment.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
@@ -312,12 +357,37 @@ async function deleteAppointment() {
}
}
// t-paliad-252 — withdraw warning modal replaces the old confirm().
// Returns:
// "edit" → unfreeze the edit form (pending-edit mode); Save will
// route through /api/approval-requests/{id}/edit-entity
// "withdraw" → destructive: the existing /revoke endpoint
// null → user cancelled
async function withdrawAppointmentRequest() {
if (!appointment || !pendingRequest) return;
if (!confirm(t("approvals.withdraw.confirm") || "Anfrage wirklich zurückziehen?")) return;
const btn = document.getElementById("appointment-withdraw-btn") as HTMLButtonElement | null;
if (btn) btn.disabled = true;
try {
const action = await openWithdrawWarningModal({
entityType: "appointment",
lifecycleEvent: pendingRequest.lifecycle_event ?? "create",
});
if (action === null) {
if (btn) btn.disabled = false;
return;
}
if (action === "edit") {
pendingEditMode = true;
if (btn) btn.disabled = false;
// renderHeader re-evaluates the freeze and unfreezes the form now
// that pendingEditMode is set. Focus the first editable field so the
// user can type immediately.
renderHeader();
const titleEl = document.getElementById("appointment-title-edit") as HTMLInputElement | null;
titleEl?.focus();
return;
}
// action === "withdraw" → destructive path.
const resp = await fetch(`/api/approval-requests/${pendingRequest.id}/revoke`, {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -328,9 +398,12 @@ async function withdrawAppointmentRequest() {
if (fresh.ok) {
appointment = await fresh.json();
await loadPendingRequest();
renderHeader();
fillEditForm();
} else {
// CREATE lifecycle: entity gone → back to the list.
window.location.href = "/events?type=appointment";
}
renderHeader();
fillEditForm();
} else {
const data = await resp.json().catch(() => ({}) as { message?: string; error?: string });
const msg = document.getElementById("appointment-edit-msg")!;

View File

@@ -0,0 +1,149 @@
// t-paliad-252 / m/paliad#83 — withdraw warning modal.
//
// Before t-paliad-252 the deadline + appointment detail pages did a
// confirm() dialog before POSTing to /api/approval-requests/{id}/revoke.
// For pending CREATE lifecycles that endpoint silently DELETES the
// underlying entity row — m's "withdrawing the approval deletes the event"
// surprise.
//
// This modal replaces the confirm() with three explicit paths:
//
// 1. Cancel — does nothing
// 2. Termin bearbeiten (primary) — opens the edit form; saving routes
// through POST /approval-requests/{id}/
// edit-entity which keeps the request
// pending and merges the new fields
// into approval_request.payload
// 3. Endgültig zurückziehen + — destructive; current /revoke
// löschen behaviour (delete for CREATE, revert
// for UPDATE/COMPLETE, cancel for
// DELETE-lifecycle requests)
//
// Built on the unified openModal() primitive (t-paliad-217 Slice A) so the
// three-button row sits cleanly inside the body — the primitive only
// supports one secondary action, but we paint the destructive button as a
// separate row above the footer.
import { t } from "../i18n";
import { openModal } from "./modal";
export type WithdrawAction = "edit" | "withdraw";
export interface WithdrawWarningArgs {
// entityType drives the copy ("event" vs "appointment" labels).
entityType: "deadline" | "appointment";
// lifecycleEvent of the pending request; copy adapts (CREATE warns about
// deletion; UPDATE/COMPLETE warn about revert; DELETE warns about
// cancelling the deletion request).
lifecycleEvent: "create" | "update" | "complete" | "delete" | string;
}
// openWithdrawWarningModal resolves with the chosen action, or null if the
// user dismissed via Cancel / Esc / backdrop / browser back-button.
export async function openWithdrawWarningModal(
args: WithdrawWarningArgs,
): Promise<WithdrawAction | null> {
const body = document.createElement("div");
body.className = "withdraw-warning-body";
// Lead paragraph + sub-paragraph adapt to lifecycle so the user always
// knows what the destructive button will actually do. The /revoke
// backend behaviour:
// - create → DELETE the entity (the "surprise" m flagged)
// - update → revert to pre_image
// - complete → revert to pre-complete state
// - delete → cancel the delete request (entity stays alive)
const intro = document.createElement("p");
intro.className = "withdraw-warning-intro";
intro.textContent = leadCopyFor(args);
body.appendChild(intro);
const sub = document.createElement("p");
sub.className = "withdraw-warning-sub muted";
sub.textContent = subCopyFor(args);
body.appendChild(sub);
// The destructive button lives inside the body — the openModal primitive
// only exposes one secondary button slot, and we want the safe "Edit"
// path to be the primary CTA. Painting it in red here, separated from
// the footer, signals "this is the dangerous option" without competing
// visually with the primary CTA.
const destructiveRow = document.createElement("div");
destructiveRow.className = "withdraw-warning-destructive-row";
const destructiveBtn = document.createElement("button");
destructiveBtn.type = "button";
destructiveBtn.className = "btn btn-danger withdraw-warning-destructive-btn";
destructiveBtn.textContent = t("approvals.withdraw.destructive.label");
destructiveRow.appendChild(destructiveBtn);
body.appendChild(destructiveRow);
return new Promise<WithdrawAction | null>((resolve) => {
let chosen: WithdrawAction | null = null;
// The destructive button has to close the modal and return "withdraw".
// We need access to the modal's internal close() — fortunately openModal
// exposes it via the primary handler's first arg. We pass through the
// outer resolve and let the primary handler (Edit) own the close-fn
// route. For the destructive button we resolve the outer promise
// directly and then synthesise an ESC keypress so the modal dismisses
// — or, simpler, set chosen and use the secondary "Cancel" path that
// the modal already supports. (openModal's onClose fires on every
// dismiss path including the primary handler resolution.)
destructiveBtn.addEventListener("click", () => {
chosen = "withdraw";
// The unified openModal primitive (modal.ts) wires its dismiss path
// through the native <dialog>'s `cancel` event. Dispatching it on
// the parent <dialog> runs the same finish() → onClose → resolve
// sequence as ESC / backdrop. We then map the resolved `null` back
// to "withdraw" via the captured `chosen` in onClose below.
const dialogEl = body.closest("dialog");
dialogEl?.dispatchEvent(new Event("cancel"));
});
void openModal<WithdrawAction>({
title: t("approvals.withdraw.modal.title"),
body,
size: "md",
classNames: "withdraw-warning-modal",
primary: {
label: t("approvals.withdraw.primary.label"),
handler: (close) => {
chosen = "edit";
close("edit");
},
},
secondary: { label: t("approvals.withdraw.cancel") },
onClose: () => {
// Resolves whatever was chosen via the destructive button OR the
// primary handler. ESC / backdrop / secondary clear `chosen` to
// null which is the right "cancel" semantics.
resolve(chosen);
},
});
});
}
function leadCopyFor(args: WithdrawWarningArgs): string {
switch (args.lifecycleEvent) {
case "create":
return args.entityType === "appointment"
? t("approvals.withdraw.lead.create.appointment")
: t("approvals.withdraw.lead.create.deadline");
case "delete":
return t("approvals.withdraw.lead.delete");
default:
// update / complete / unknown → revert semantics
return t("approvals.withdraw.lead.update");
}
}
function subCopyFor(args: WithdrawWarningArgs): string {
switch (args.lifecycleEvent) {
case "create":
return t("approvals.withdraw.sub.create");
case "delete":
return t("approvals.withdraw.sub.delete");
default:
return t("approvals.withdraw.sub.update");
}
}

View File

@@ -9,6 +9,8 @@ import {
type EventType,
type PickerHandle,
} from "./event-types";
import { openWithdrawWarningModal } from "./components/withdraw-warning-modal";
import { formatRuleLabel, formatRuleLabelHTML, formatCustomRuleLabelHTML } from "./rule-label";
interface Deadline {
id: string;
@@ -20,6 +22,9 @@ interface Deadline {
source: string;
rule_id?: string;
rule_code?: string;
// t-paliad-258 — lawyer's free-text rule label when the deadline was
// saved in Custom mode. Mutually exclusive with rule_id.
custom_rule_text?: string;
notes?: string;
created_at: string;
completed_at?: string;
@@ -38,6 +43,9 @@ interface PendingApprovalRequest {
requested_at: string;
required_role: string;
requester_name?: string;
// t-paliad-252 — used by the withdraw warning modal to pick the right
// copy (CREATE warns about deletion; UPDATE/COMPLETE about revert).
lifecycle_event?: string;
}
let eventTypePicker: PickerHandle | null = null;
@@ -54,7 +62,21 @@ interface DeadlineRule {
id: string;
code?: string;
name: string;
name_en?: string;
rule_code?: string;
legal_source?: string | null;
// t-paliad-258 — canonical event_type for Auto-mode rule resolution
// when the user flips to Auto on the edit form.
concept_default_event_type_id?: string | null;
proceeding_type_id?: number | null;
}
interface ProceedingType {
id: number;
jurisdiction: string;
name: string;
name_en?: string;
sort_order?: number;
}
interface Me {
@@ -70,6 +92,30 @@ let me: Me | null = null;
let allProjects: Project[] = [];
let pendingRequest: PendingApprovalRequest | null = null;
// t-paliad-258 — Auto/Custom rule editor state. Mirrors the create form.
// On enterEdit we initialise the mode from the persisted deadline:
// rule_id set → "auto"
// custom_rule_text set, no rule_id → "custom"
// neither set → "auto" (so the Type-driven
// resolver fills in immediately).
type RuleMode = "auto" | "custom";
let ruleMode: RuleMode = "auto";
let allRules: DeadlineRule[] = [];
let rulesByID = new Map<string, DeadlineRule>();
let proceedingTypesByID = new Map<number, ProceedingType>();
// t-paliad-252 — when the user chose "Edit event" in the withdraw warning
// modal, the entity is still in approval_status='pending'. Save must POST
// to /api/approval-requests/{id}/edit-entity (which keeps the request
// pending + merges the new fields into payload) instead of the regular
// PATCH /api/deadlines/{id} (which 409s during pending). Cleared on exit
// from edit mode + after a successful save.
let pendingEditMode = false;
// pendingEnterEdit — late-bound by initEdit() so the withdraw warning
// modal handler (initWithdraw) can route into pending-edit mode without
// duplicating the edit-mode toggle logic.
let pendingEnterEdit: (() => void) | null = null;
function parseDeadlineID(): string | null {
const parts = window.location.pathname.split("/").filter(Boolean);
if (parts[0] !== "deadlines" || !parts[1]) return null;
@@ -165,17 +211,66 @@ function populateProjectPicker() {
sel.value = deadline.project_id;
}
async function loadRule(ruleID: string) {
async function loadAllRules() {
try {
const resp = await fetch(`/api/deadline-rules`);
if (!resp.ok) return;
const all: DeadlineRule[] = await resp.json();
rule = all.find((r) => r.id === ruleID) || null;
allRules = (await resp.json()) as DeadlineRule[];
rulesByID = new Map(allRules.map((r) => [r.id, r]));
} catch {
/* non-fatal */
}
}
async function loadProceedingTypes() {
try {
const resp = await fetch("/api/proceeding-types-db");
if (!resp.ok) return;
const types: ProceedingType[] = await resp.json();
proceedingTypesByID = new Map(types.map((pt) => [pt.id, pt]));
} catch {
/* non-fatal */
}
}
function lookupRule(ruleID: string): DeadlineRule | null {
return rulesByID.get(ruleID) || null;
}
// resolveAutoRuleForType mirrors the create-form resolver: pick the
// canonical rule for the chosen event_type, prioritising the project's
// proceeding then jurisdiction match.
function resolveAutoRuleForType(eventTypeID: string): DeadlineRule | null {
const candidates = allRules.filter((r) => r.concept_default_event_type_id === eventTypeID);
if (candidates.length === 0) return null;
if (candidates.length === 1) return candidates[0];
const projID = deadline?.project_id;
const proj = projID ? allProjects.find((p) => p.id === projID) as (Project & { proceeding_type_id?: number | null }) | undefined : undefined;
if (proj && proj.proceeding_type_id) {
const exact = candidates.find((r) => r.proceeding_type_id === proj.proceeding_type_id);
if (exact) return exact;
}
const et = eventTypeByID.get(eventTypeID);
if (et?.jurisdiction && et.jurisdiction !== "any") {
const want = et.jurisdiction === "EPO" ? "EPA" : et.jurisdiction;
const jurMatch = candidates.find((r) => {
const pt = r.proceeding_type_id ? proceedingTypesByID.get(r.proceeding_type_id) : undefined;
return pt?.jurisdiction === want;
});
if (jurMatch) return jurMatch;
}
return candidates[0];
}
function currentAutoRule(): DeadlineRule | null {
const picked = eventTypePicker?.getIDs() ?? [];
if (picked.length !== 1) return null;
return resolveAutoRuleForType(picked[0]);
}
async function loadMe() {
try {
const resp = await fetch("/api/me");
@@ -227,9 +322,15 @@ function render() {
}
const ruleEl = document.getElementById("deadline-rule-display")!;
// t-paliad-258 — display priority:
// 1. catalog rule (canonical Name · Citation pattern)
// 2. custom_rule_text + Custom badge
// 3. legacy rule_code-only (Fristenrechner saves)
// 4. "—"
if (rule) {
const code = rule.rule_code || rule.code || "";
ruleEl.textContent = code ? `${code}${rule.name}` : rule.name;
ruleEl.innerHTML = formatRuleLabelHTML(rule, esc);
} else if (deadline.custom_rule_text && deadline.custom_rule_text.trim()) {
ruleEl.innerHTML = formatCustomRuleLabelHTML(deadline.custom_rule_text, esc);
} else if (deadline.rule_code) {
// Fristenrechner-saved deadlines carry rule_code directly without
// a rule_id (no rule UUID round-trips through the public API).
@@ -353,6 +454,48 @@ function render() {
}
}
function refreshRuleAutoDisplay(): void {
const panel = document.getElementById("deadline-rule-auto-display");
const text = document.getElementById("deadline-rule-auto-text");
if (!panel || !text) return;
if (ruleMode !== "auto") {
panel.style.display = "none";
return;
}
panel.style.display = "";
const r = currentAutoRule();
if (r) {
text.textContent = formatRuleLabel(r);
text.classList.remove("rule-auto-text--empty");
return;
}
const picked = eventTypePicker?.getIDs() ?? [];
const fallback = picked.length === 1
? (t("deadlines.field.rule.auto_no_match") || "Keine Regel zur gewählten Verfahrenshandlung")
: (t("deadlines.field.rule.auto_pick_type") || "Wählen Sie zuerst eine Verfahrenshandlung");
text.textContent = fallback;
text.classList.add("rule-auto-text--empty");
}
function applyRuleModeUI(): void {
const toggleBtn = document.getElementById("deadline-rule-mode-toggle") as HTMLButtonElement | null;
const autoPanel = document.getElementById("deadline-rule-auto-display");
const customInput = document.getElementById("deadline-rule-custom-input") as HTMLInputElement | null;
if (!toggleBtn || !autoPanel || !customInput) return;
if (ruleMode === "auto") {
autoPanel.style.display = "";
customInput.style.display = "none";
toggleBtn.textContent = t("deadlines.field.rule.mode.toggle_to_custom") || "Eigene Regel eingeben";
toggleBtn.setAttribute("data-i18n", "deadlines.field.rule.mode.toggle_to_custom");
} else {
autoPanel.style.display = "none";
customInput.style.display = "";
toggleBtn.textContent = t("deadlines.field.rule.mode.toggle_to_auto") || "Zurück zu Auto";
toggleBtn.setAttribute("data-i18n", "deadlines.field.rule.mode.toggle_to_auto");
}
refreshRuleAutoDisplay();
}
function initEdit() {
const titleDisplay = document.getElementById("deadline-title-display")!;
const titleEdit = document.getElementById("deadline-title-edit") as HTMLInputElement;
@@ -366,6 +509,11 @@ function initEdit() {
const etEdit = document.getElementById("deadline-event-types-edit");
const projectLink = document.getElementById("deadline-project-link") as HTMLAnchorElement;
const projectEdit = document.getElementById("deadline-project-edit") as HTMLSelectElement | null;
const titleDefaultBtn = document.getElementById("deadline-title-default-btn") as HTMLButtonElement | null;
const ruleDisplay = document.getElementById("deadline-rule-display");
const ruleEdit = document.getElementById("deadline-rule-edit");
const ruleCustomInput = document.getElementById("deadline-rule-custom-input") as HTMLInputElement | null;
const ruleToggleBtn = document.getElementById("deadline-rule-mode-toggle") as HTMLButtonElement | null;
function enterEdit() {
titleDisplay.style.display = "none";
@@ -381,6 +529,20 @@ function initEdit() {
projectEdit.style.display = "";
projectEdit.value = deadline.project_id;
}
if (titleDefaultBtn) titleDefaultBtn.style.display = "";
// t-paliad-258 — show the Auto/Custom rule editor + initialise mode
// from the persisted deadline. Display element stays visible so the
// user keeps "before / after" context while editing.
if (ruleEdit) ruleEdit.style.display = "";
if (ruleDisplay) ruleDisplay.style.display = "none";
if (deadline?.custom_rule_text && !deadline.rule_id) {
ruleMode = "custom";
if (ruleCustomInput) ruleCustomInput.value = deadline.custom_rule_text;
} else {
ruleMode = "auto";
if (ruleCustomInput) ruleCustomInput.value = "";
}
applyRuleModeUI();
saveBtn.style.display = "";
editBtn.style.display = "none";
titleEdit.focus();
@@ -399,12 +561,71 @@ function initEdit() {
projectEdit.style.display = "none";
projectLink.style.display = "";
}
if (titleDefaultBtn) titleDefaultBtn.style.display = "none";
if (ruleEdit) ruleEdit.style.display = "none";
if (ruleDisplay) ruleDisplay.style.display = "";
saveBtn.style.display = "none";
editBtn.style.display = "";
pendingEditMode = false;
}
// Rule mode toggle (Auto ↔ Custom). The Auto resolver re-runs every
// time the Type picker changes, so just-toggling-to-Auto immediately
// surfaces a fresh resolution.
ruleToggleBtn?.addEventListener("click", () => {
ruleMode = ruleMode === "auto" ? "custom" : "auto";
applyRuleModeUI();
if (ruleMode === "custom") ruleCustomInput?.focus();
});
// t-paliad-252 — expose enterEdit so the withdraw warning modal can
// route into pending-edit mode without re-running the edit-button
// visibility gate (which hides the button during pending).
pendingEnterEdit = () => {
pendingEditMode = true;
enterEdit();
};
editBtn.addEventListener("click", enterEdit);
// t-paliad-251 Part 4 — Standardtitel button.
// Recipe (mirror of computeDefaultTitle in deadlines-new.ts):
// head = event_type label (if exactly one Typ chip in edit)
// || Auto-resolved rule's canonical label (Name · Citation)
// || saved rule's canonical label
// || custom_rule_text (when in Custom mode + non-empty)
// || rule_code-only legacy fallback
// || "Neue Frist" fallback
// suffix = " — <project.reference>" when not already in head
titleDefaultBtn?.addEventListener("click", () => {
if (!deadline) return;
let head = "";
const ids = eventTypePicker?.getIDs() ?? deadline.event_type_ids ?? [];
if (ids.length === 1) {
const et = eventTypeByID.get(ids[0]);
if (et) head = eventTypeLabel(et);
}
if (!head) {
const r = ruleMode === "auto" ? (currentAutoRule() ?? rule) : null;
if (r) head = formatRuleLabel(r);
}
if (!head && ruleMode === "custom") {
const txt = ruleCustomInput?.value.trim() || "";
if (txt) head = txt;
}
if (!head && rule) {
head = formatRuleLabel(rule);
}
if (!head && deadline.rule_code) {
head = deadline.rule_code;
}
if (!head) head = t("deadlines.field.title.default_fallback");
const ref = project?.reference?.trim() || "";
if (ref && !head.includes(ref)) head = `${head}${ref}`;
titleEdit.value = head;
titleEdit.focus();
});
saveBtn.addEventListener("click", async () => {
if (!deadline) return;
const newTitle = titleEdit.value.trim();
@@ -424,6 +645,48 @@ function initEdit() {
if (projectEdit && projectEdit.value && projectEdit.value !== deadline.project_id) {
payload.project_id = projectEdit.value;
}
// t-paliad-258 — rule_set discriminator tells the service this
// PATCH carries an Auto/Custom rule change. Both columns are
// mutually exclusive at the persistence boundary.
payload.rule_set = true;
if (ruleMode === "auto") {
const r = currentAutoRule();
payload.rule_id = r ? r.id : null;
payload.custom_rule_text = null;
} else {
const txt = ruleCustomInput?.value.trim() || "";
payload.rule_id = null;
payload.custom_rule_text = txt || null;
}
// t-paliad-252 — pending-edit mode routes through the new endpoint
// that updates the entity + merges payload into the still-pending
// approval_request. Outside pending-edit mode the regular PATCH
// path remains the authoritative one (with its existing 409-on-
// pending guard).
if (pendingEditMode && pendingRequest) {
const resp = await fetch(
`/api/approval-requests/${pendingRequest.id}/edit-entity`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ fields: payload }),
},
);
if (resp.ok) {
const fresh = await fetch(`/api/deadlines/${deadline.id}`);
if (fresh.ok) deadline = await fresh.json();
await loadPendingRequest();
render();
} else {
const body = await resp.json().catch(() => null);
const msg = (body && (body.message || body.error))
|| (t("approvals.withdraw.error") || "Fehler");
window.alert(msg);
}
return;
}
const resp = await fetch(`/api/deadlines/${deadline.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
@@ -501,19 +764,39 @@ function initReopen() {
});
}
// initWithdraw — t-paliad-160 §C+E. Reuses the existing
// /api/approval-requests/{id}/revoke endpoint (no new server route
// needed). After the revoke lands, the entity goes back to
// approval_status='approved' and the page reloads to refresh the
// in-memory state cleanly.
// initWithdraw — t-paliad-160 §C+E + t-paliad-252.
//
// Click flow: open the withdraw warning modal (replaces the old
// confirm()). The modal returns one of:
//
// "edit" — open the edit form in pending-edit mode; Save calls
// /api/approval-requests/{id}/edit-entity which keeps the
// request pending + merges the new fields into payload
// "withdraw" — destructive: call the existing /revoke endpoint
// (DELETE entity for CREATE, revert for UPDATE/COMPLETE,
// cancel-delete for DELETE lifecycle)
// null — user cancelled; nothing happens
function initWithdraw() {
const btn = document.getElementById("deadline-withdraw-btn") as HTMLButtonElement | null;
if (!btn) return;
btn.addEventListener("click", async () => {
if (!deadline || !pendingRequest) return;
if (!window.confirm(t("approvals.withdraw.confirm") || "Anfrage wirklich zurückziehen?")) return;
btn.disabled = true;
try {
const action = await openWithdrawWarningModal({
entityType: "deadline",
lifecycleEvent: pendingRequest.lifecycle_event ?? "create",
});
if (action === null) {
btn.disabled = false;
return;
}
if (action === "edit") {
btn.disabled = false;
pendingEnterEdit?.();
return;
}
// action === "withdraw" → existing destructive path.
const resp = await fetch(`/api/approval-requests/${pendingRequest.id}/revoke`, {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -521,14 +804,16 @@ function initWithdraw() {
});
if (resp.ok) {
// Re-fetch the entity so approval_status flips back to 'approved'
// and the badge / buttons rerender accordingly.
// and the badge / buttons rerender accordingly. For CREATE
// lifecycle the entity is gone, so the 404 surfaces as a reload.
const r = await fetch(`/api/deadlines/${deadline.id}`);
if (r.ok) {
deadline = await r.json();
await loadPendingRequest();
render();
} else {
window.location.reload();
// CREATE lifecycle deleted the entity — bounce to the list.
window.location.href = "/events?type=deadline";
}
} else {
btn.disabled = false;
@@ -592,8 +877,14 @@ async function main() {
notfound.style.display = "block";
return;
}
await Promise.all([loadProject(deadline.project_id), loadAllProjects(), loadPendingRequest()]);
if (deadline.rule_id) await loadRule(deadline.rule_id);
await Promise.all([
loadProject(deadline.project_id),
loadAllProjects(),
loadPendingRequest(),
loadAllRules(),
loadProceedingTypes(),
]);
if (deadline.rule_id) rule = lookupRule(deadline.rule_id);
// Load event types in parallel; render once ready (the picker re-renders
// chips off the cached map, and the display element re-renders on the
@@ -614,6 +905,11 @@ async function main() {
eventTypePicker = attachEventTypePicker(pickerHost, {
initialIDs: deadline.event_type_ids ?? [],
currentUserAdmin: me?.global_role === "global_admin",
onChange: () => {
// Type change shifts the Auto-resolved rule. Refresh the
// read-only display panel (no-op outside edit mode / Custom).
refreshRuleAutoDisplay();
},
});
}

View File

@@ -1,4 +1,4 @@
import { initI18n, t, tDyn } from "./i18n";
import { initI18n, t, tDyn, getLang } from "./i18n";
import { initSidebar } from "./sidebar";
import {
attachEventTypePicker,
@@ -8,22 +8,21 @@ import {
type PickerHandle,
} from "./event-types";
import { projectIndent } from "./project-indent";
import { formatRuleLabel } from "./rule-label";
let eventTypePicker: PickerHandle | null = null;
let currentUserAdmin = false;
let eventTypesByID = new Map<string, EventType>();
// expandedOverride flips to true when the user clicks "Anderen Typ
// wählen" on the collapsed inline summary. Sticky for the rest of the
// form session — cleared only when the user reverts the rule to "Keine
// Regel". When true, the picker stays visible regardless of whether
// the chip matches the rule's canonical default.
let expandedOverride = false;
interface Project {
id: string;
reference?: string | null;
title: string;
path: string;
// Used by the Type→Rule resolver to narrow rule candidates to the
// project's own proceeding when one applies. Optional because clients
// and matter-level projects don't carry a proceeding type.
proceeding_type_id?: number | null;
}
interface DeadlineRule {
@@ -32,23 +31,37 @@ interface DeadlineRule {
name: string;
name_en: string;
rule_code?: string;
// t-paliad-165 — canonical event_type for this rule's concept,
// hydrated server-side from paliad.deadline_concept_event_types.
// Drives auto-fill of the Typ chip when the user picks this rule.
legal_source?: string | null;
proceeding_type_id?: number | null;
sequence_order?: number;
// t-paliad-165 — canonical event_type for the rule's concept. The
// catalog is indexed by it so we can resolve Type → canonical Rule.
concept_default_event_type_id?: string | null;
}
// Rules indexed by id so the Regel-change handler can look up the
// concept's canonical event_type without re-fetching.
let rulesByID = new Map<string, DeadlineRule>();
interface ProceedingType {
id: number;
code: string;
name: string;
name_en?: string;
jurisdiction: string;
sort_order?: number;
}
// Last event_type the rule auto-filled. Tracked so we can tell whether
// the picker still reflects the rule's suggestion (replace silently on
// new rule pick) or whether the user has manually edited (leave alone,
// surface the mismatch warning instead).
let lastAutoFilledEventTypeID: string | null = null;
// Rule mode (t-paliad-258 / m/paliad#89). The form has two states:
// auto — rule_id resolved from the chosen event_type, rendered
// read-only as "Auto: Name · Citation".
// custom — free-text input; submits as custom_rule_text on the API.
type RuleMode = "auto" | "custom";
let ruleMode: RuleMode = "auto";
let rulesByID = new Map<string, DeadlineRule>();
let allRules: DeadlineRule[] = [];
let proceedingTypesByID = new Map<number, ProceedingType>();
let projectsByID = new Map<string, Project>();
let preselectedProjectID = "";
let preselectedProjectIDLocal = "";
function esc(s: string): string {
const d = document.createElement("div");
@@ -62,6 +75,13 @@ function showError(msg: string) {
el.className = "form-msg form-msg-error";
}
function proceedingLabel(pt: ProceedingType | undefined): string {
if (!pt) return "";
const lang = getLang();
const name = (lang === "en" && pt.name_en) ? pt.name_en : pt.name;
return `${pt.jurisdiction}${name}`;
}
async function loadProjects() {
const sel = document.getElementById("deadline-project") as HTMLSelectElement;
const hint = document.getElementById("deadline-project-empty-hint")!;
@@ -69,6 +89,7 @@ async function loadProjects() {
const resp = await fetch("/api/projects");
if (!resp.ok) return;
const projects: Project[] = await resp.json();
projectsByID = new Map(projects.map((p) => [p.id, p]));
if (projects.length === 0) {
hint.style.display = "";
hint.innerHTML = `${esc(t("deadlines.field.akte.empty"))} <a href="/projects/new">${esc(t("deadlines.field.akte.empty.link"))}</a>`;
@@ -82,7 +103,7 @@ async function loadProjects() {
const ref = p.reference || "";
const indent = projectIndent(p.path);
options.push(
`<option value="${esc(p.id)}"${isSelected}>${indent}${esc(ref)} \u2014 ${esc(p.title)}</option>`,
`<option value="${esc(p.id)}"${isSelected}>${indent}${esc(ref)} ${esc(p.title)}</option>`,
);
}
sel.innerHTML = options.join("");
@@ -91,122 +112,166 @@ async function loadProjects() {
}
}
async function loadProceedingTypes() {
try {
const resp = await fetch("/api/proceeding-types-db");
if (!resp.ok) return;
const types: ProceedingType[] = await resp.json();
proceedingTypesByID = new Map(types.map((pt) => [pt.id, pt]));
} catch {
/* non-fatal */
}
}
async function loadRules() {
// Optional: load rules so user can attach. We pull all rules; small set.
const sel = document.getElementById("deadline-rule") as HTMLSelectElement;
try {
const resp = await fetch("/api/deadline-rules");
if (!resp.ok) return;
const rules: DeadlineRule[] = await resp.json();
rulesByID = new Map(rules.map((r) => [r.id, r]));
const opts: string[] = [
`<option value="" data-i18n="deadlines.field.rule.none">${esc(t("deadlines.field.rule.none"))}</option>`,
];
for (const r of rules) {
const code = r.rule_code || r.code || "";
const label = code ? `${code} \u2014 ${r.name}` : r.name;
opts.push(`<option value="${esc(r.id)}">${esc(label)}</option>`);
}
sel.innerHTML = opts.join("");
allRules = (await resp.json()) as DeadlineRule[];
rulesByID = new Map(allRules.map((r) => [r.id, r]));
} catch {
/* non-fatal — rule select stays at "no rule" */
/* non-fatal — rule display falls back to "—" */
}
}
// t-paliad-165 follow-up — drive the collapsed/expanded view of the Typ
// picker. The two modes are mutually exclusive:
// resolveAutoRuleForType picks the best-match catalog rule for the
// chosen event type, scoring by:
// 1. project's proceeding_type_id (if known) — exact match wins,
// 2. otherwise event_type.jurisdiction matches the rule's proceeding's
// jurisdiction (EPA→EPO canonicalised),
// 3. otherwise the first candidate in canonical sequence_order.
//
// collapsed: rule selected + canonical event_type known + picker
// contains exactly [default] + user hasn't clicked "Anderen Typ
// wählen". Hides the chip cluster, surfaces a single inline
// summary "Klageerwiderung (vorgegeben durch Regel)" + an
// override link.
//
// expanded: every other case — no rule, no default for the rule,
// picker has been edited, or expandedOverride is sticky after the
// user clicked the override link. Picker visible; mismatch warning
// surfaces yellow when the rule expected a different event_type.
function refreshRuleView(): void {
const collapsed = document.getElementById("deadline-event-type-collapsed");
const collapsedLabel = document.getElementById("deadline-event-type-collapsed-label");
const pickerHost = document.getElementById("deadline-event-types");
const warn = document.getElementById("deadline-event-type-rule-mismatch");
if (!collapsed || !collapsedLabel || !pickerHost || !warn) return;
// Returns null when no rule maps. Callers render that as "no Auto rule
// available" so the user can flip to Custom or pick a different Type.
function resolveAutoRuleForType(eventTypeID: string, projectID: string): DeadlineRule | null {
const candidates = allRules.filter((r) => r.concept_default_event_type_id === eventTypeID);
if (candidates.length === 0) return null;
if (candidates.length === 1) return candidates[0];
const ruleID = (document.getElementById("deadline-rule") as HTMLSelectElement | null)?.value || "";
const rule = ruleID ? rulesByID.get(ruleID) : undefined;
const expected = rule?.concept_default_event_type_id ?? null;
const project = projectID ? projectsByID.get(projectID) : undefined;
if (project?.proceeding_type_id) {
const exact = candidates.find((r) => r.proceeding_type_id === project.proceeding_type_id);
if (exact) return exact;
}
const et = eventTypesByID.get(eventTypeID);
if (et?.jurisdiction && et.jurisdiction !== "any") {
const want = et.jurisdiction === "EPO" ? "EPA" : et.jurisdiction;
const jurMatch = candidates.find((r) => {
const pt = r.proceeding_type_id ? proceedingTypesByID.get(r.proceeding_type_id) : undefined;
return pt?.jurisdiction === want;
});
if (jurMatch) return jurMatch;
}
return candidates[0];
}
// currentAutoRule returns the catalog rule the Auto mode would resolve
// to for the current form state, or null when no Type is picked or no
// rule maps. Centralised so the Auto display, submitForm, and the
// Standardtitel button all agree on the same resolution.
function currentAutoRule(): DeadlineRule | null {
const picked = eventTypePicker?.getIDs() ?? [];
if (picked.length !== 1) return null;
const projectID = (document.getElementById("deadline-project") as HTMLSelectElement | null)?.value || "";
return resolveAutoRuleForType(picked[0], projectID);
}
const pickerMatchesDefault =
expected !== null && picked.length === 1 && picked[0] === expected;
const wantsCollapsed =
!expandedOverride && ruleID !== "" && expected !== null && pickerMatchesDefault;
if (wantsCollapsed) {
const et = eventTypesByID.get(expected!);
collapsedLabel.textContent = et ? eventTypeLabel(et) : "";
collapsed.style.display = "";
pickerHost.style.display = "none";
warn.style.display = "none";
// refreshRuleAutoDisplay updates the read-only Auto display panel to
// reflect the rule that would be saved in Auto mode. Hides itself when
// the user is in Custom mode (the input takes its place).
function refreshRuleAutoDisplay(): void {
const panel = document.getElementById("deadline-rule-auto-display");
const text = document.getElementById("deadline-rule-auto-text");
if (!panel || !text) return;
if (ruleMode !== "auto") {
panel.style.display = "none";
return;
}
panel.style.display = "";
const rule = currentAutoRule();
if (rule) {
text.textContent = formatRuleLabel(rule);
text.classList.remove("rule-auto-text--empty");
return;
}
const picked = eventTypePicker?.getIDs() ?? [];
const fallback = picked.length === 1
? (t("deadlines.field.rule.auto_no_match") || "Keine Regel zur gewählten Verfahrenshandlung")
: (t("deadlines.field.rule.auto_pick_type") || "Wählen Sie zuerst eine Verfahrenshandlung");
text.textContent = fallback;
text.classList.add("rule-auto-text--empty");
}
collapsed.style.display = "none";
pickerHost.style.display = "";
// Mismatch warning: rule expected an event_type AND the picker
// doesn't contain it. (When the picker is empty + no override, no
// warning — user is free to leave it blank.)
if (expected && picked.length > 0 && !picked.includes(expected)) {
warn.style.display = "";
function applyRuleModeUI(): void {
const toggleBtn = document.getElementById("deadline-rule-mode-toggle") as HTMLButtonElement | null;
const autoPanel = document.getElementById("deadline-rule-auto-display");
const customInput = document.getElementById("deadline-rule-custom-input") as HTMLInputElement | null;
if (!toggleBtn || !autoPanel || !customInput) return;
if (ruleMode === "auto") {
autoPanel.style.display = "";
customInput.style.display = "none";
toggleBtn.textContent = t("deadlines.field.rule.mode.toggle_to_custom") || "Eigene Regel eingeben";
toggleBtn.setAttribute("data-i18n", "deadlines.field.rule.mode.toggle_to_custom");
} else {
warn.style.display = "none";
autoPanel.style.display = "none";
customInput.style.display = "";
toggleBtn.textContent = t("deadlines.field.rule.mode.toggle_to_auto") || "Zurück zu Auto";
toggleBtn.setAttribute("data-i18n", "deadlines.field.rule.mode.toggle_to_auto");
}
refreshRuleAutoDisplay();
}
function setRuleMode(mode: RuleMode): void {
ruleMode = mode;
applyRuleModeUI();
if (mode === "custom") {
const input = document.getElementById("deadline-rule-custom-input") as HTMLInputElement | null;
input?.focus();
}
}
// applyRuleAutoFill replaces the picker silently when it still reflects
// the previous rule's suggestion (or is empty); leaves a manually-edited
// picker alone. Called whenever the Regel select changes.
function applyRuleAutoFill(): void {
if (!eventTypePicker) return;
const ruleID = (document.getElementById("deadline-rule") as HTMLSelectElement | null)?.value || "";
const rule = ruleID ? rulesByID.get(ruleID) : undefined;
const expected = rule?.concept_default_event_type_id ?? null;
const current = eventTypePicker.getIDs();
// computeDefaultTitle — t-paliad-251 Part 4. Priority order picks the head:
// 1. event_type label (when exactly one Typ chip is set)
// 2. canonical rule name (when Auto resolves to a rule)
// 3. custom rule text (when in Custom mode)
// 4. proceeding type name (when project carries one)
// 5. fallback i18n key
// Suffix: " — <project-reference>" when not already in head.
function computeDefaultTitle(): string {
const projectID = (document.getElementById("deadline-project") as HTMLSelectElement | null)?.value || "";
const project = projectID ? projectsByID.get(projectID) : undefined;
const picked = eventTypePicker?.getIDs() ?? [];
// Reset the override on transition to "Keine Regel" — fresh form
// session. Otherwise expandedOverride stays sticky.
if (ruleID === "") {
expandedOverride = false;
let head = "";
if (picked.length === 1) {
const et = eventTypesByID.get(picked[0]);
if (et) head = eventTypeLabel(et);
}
const pickerStillReflectsLastSuggestion =
lastAutoFilledEventTypeID !== null &&
current.length === 1 &&
current[0] === lastAutoFilledEventTypeID;
const pickerIsEmpty = current.length === 0;
if (expected) {
if (pickerIsEmpty || pickerStillReflectsLastSuggestion) {
eventTypePicker.setIDs([expected]);
lastAutoFilledEventTypeID = expected;
if (!head) {
if (ruleMode === "auto") {
const rule = currentAutoRule();
if (rule) head = formatRuleLabel(rule);
} else {
const customInput = document.getElementById("deadline-rule-custom-input") as HTMLInputElement | null;
const txt = customInput?.value.trim() || "";
if (txt) head = txt;
}
} else if (pickerStillReflectsLastSuggestion) {
// New rule has no canonical event_type — clear the stale auto-fill
// so the picker doesn't carry a chip from the old rule.
eventTypePicker.setIDs([]);
lastAutoFilledEventTypeID = null;
}
refreshRuleView();
}
if (!head && project?.proceeding_type_id) {
const pt = proceedingTypesByID.get(project.proceeding_type_id);
if (pt) head = proceedingLabel(pt);
}
if (!head) {
head = t("deadlines.field.title.default_fallback");
}
function initBackLinks() {
if (preselectedProjectID) {
const back = document.getElementById("deadline-new-back") as HTMLAnchorElement;
const cancel = document.getElementById("deadline-new-cancel") as HTMLAnchorElement;
back.href = `/projects/${preselectedProjectID}/deadlines`;
cancel.href = `/projects/${preselectedProjectID}/deadlines`;
const ref = project?.reference?.trim() || "";
if (ref && !head.includes(ref)) {
return `${head}${ref}`;
}
return head;
}
async function submitForm(e: Event) {
@@ -217,7 +282,6 @@ async function submitForm(e: Event) {
const projectID = (document.getElementById("deadline-project") as HTMLSelectElement).value;
const title = (document.getElementById("deadline-title") as HTMLInputElement).value.trim();
const due = (document.getElementById("deadline-due") as HTMLInputElement).value;
const ruleID = (document.getElementById("deadline-rule") as HTMLSelectElement).value;
const notes = (document.getElementById("deadline-notes") as HTMLTextAreaElement).value.trim();
if (!projectID || !title || !due) {
@@ -234,7 +298,15 @@ async function submitForm(e: Event) {
due_date: due,
source: "manual",
};
if (ruleID) payload.rule_id = ruleID;
// Rule field: Auto resolves to rule_id, Custom sends the free text.
if (ruleMode === "auto") {
const rule = currentAutoRule();
if (rule) payload.rule_id = rule.id;
} else {
const customInput = document.getElementById("deadline-rule-custom-input") as HTMLInputElement | null;
const txt = customInput?.value.trim() || "";
if (txt) payload.custom_rule_text = txt;
}
if (notes) payload.notes = notes;
const eventTypeIDs = eventTypePicker?.getIDs() ?? [];
if (eventTypeIDs.length > 0) payload.event_type_ids = eventTypeIDs;
@@ -252,8 +324,8 @@ async function submitForm(e: Event) {
return;
}
const created = await resp.json();
if (preselectedProjectID) {
window.location.href = `/projects/${preselectedProjectID}/deadlines`;
if (preselectedProjectIDLocal) {
window.location.href = `/projects/${preselectedProjectIDLocal}/deadlines`;
} else {
window.location.href = `/deadlines/${created.id}`;
}
@@ -275,6 +347,16 @@ function detectPreselect() {
if (fromQuery) preselectedProjectID = fromQuery;
}
function initBackLinks() {
if (preselectedProjectID) {
const back = document.getElementById("deadline-new-back") as HTMLAnchorElement;
const cancel = document.getElementById("deadline-new-cancel") as HTMLAnchorElement;
back.href = `/projects/${preselectedProjectID}/deadlines`;
cancel.href = `/projects/${preselectedProjectID}/deadlines`;
}
preselectedProjectIDLocal = preselectedProjectID;
}
async function loadMe() {
try {
const resp = await fetch("/api/me");
@@ -288,8 +370,6 @@ async function loadMe() {
// t-paliad-154 — fetch the effective approval policy for (project,
// deadline, create) and reveal the form-time hint when it applies.
// Hidden when no policy applies. Re-runs on project change so the hint
// updates if the user picks a different project mid-form.
async function refreshApprovalHint(): Promise<void> {
const hint = document.getElementById("deadline-approval-hint");
const text = document.getElementById("deadline-approval-hint-text");
@@ -308,7 +388,6 @@ async function refreshApprovalHint(): Promise<void> {
hint.style.display = "none";
return;
}
// t-paliad-160 split-grammar (with M1 legacy fallback).
const eff = await resp.json() as {
requires_approval?: boolean;
min_role?: string | null;
@@ -343,44 +422,51 @@ document.addEventListener("DOMContentLoaded", async () => {
// Default due to today
const dueInput = document.getElementById("deadline-due") as HTMLInputElement;
if (!dueInput.value) dueInput.value = new Date().toISOString().split("T")[0];
await Promise.all([loadProjects(), loadRules(), loadMe()]);
await Promise.all([loadProjects(), loadProceedingTypes(), loadRules(), loadMe()]);
const pickerHost = document.getElementById("deadline-event-types");
if (pickerHost) {
eventTypePicker = attachEventTypePicker(pickerHost, {
currentUserAdmin,
onChange: () => refreshRuleView(),
onChange: () => {
// Type change shifts which Auto rule resolves; re-render the
// read-only Auto display panel.
refreshRuleAutoDisplay();
},
});
}
// t-paliad-165 follow-up — preload event_types so the collapsed
// summary can render the type's label inline without an extra round
// trip when the user picks a Regel.
// Preload event_types for the Auto display + Standardtitel resolver.
fetchEventTypes()
.then((types) => {
eventTypesByID = new Map(types.map((et) => [et.id, et]));
refreshRuleView();
refreshRuleAutoDisplay();
})
.catch(() => {/* non-fatal — collapsed view falls back to empty label */});
// t-paliad-165 — Regel change auto-fills the Typ chip from the rule's
// concept's canonical event_type, when the picker hasn't been
// manually edited away from the previous rule's suggestion.
document.getElementById("deadline-rule")?.addEventListener("change", () => {
applyRuleAutoFill();
.catch(() => {/* non-fatal */});
// Rule mode toggle.
document.getElementById("deadline-rule-mode-toggle")?.addEventListener("click", () => {
setRuleMode(ruleMode === "auto" ? "custom" : "auto");
});
// "Anderen Typ wählen" — sticky expanded mode so the picker stays
// visible even when the chip still matches the rule's default.
document.getElementById("deadline-event-type-override-btn")?.addEventListener("click", () => {
expandedOverride = true;
refreshRuleView();
// Move focus into the picker's search box so the user can type
// immediately without an extra click.
const search = document.querySelector<HTMLInputElement>(
"#deadline-event-types .event-type-search",
);
search?.focus();
});
// Wire approval-hint refresh: on first render + on project change.
applyRuleModeUI();
// Approval-hint refresh: on first render + on project change.
void refreshApprovalHint();
document.getElementById("deadline-project")?.addEventListener("change", () => {
void refreshApprovalHint();
// Project change can shift which Auto rule resolves (via the
// project's proceeding_type_id).
refreshRuleAutoDisplay();
});
// t-paliad-251 Part 4 — Standardtitel button.
document.getElementById("deadline-title-default-btn")?.addEventListener("click", () => {
const titleInput = document.getElementById("deadline-title") as HTMLInputElement | null;
if (!titleInput) return;
const derived = computeDefaultTitle();
if (derived) titleInput.value = derived;
titleInput.focus();
});
});

View File

@@ -686,6 +686,33 @@ export function openBrowseEventTypesModal(
return new Promise<string[] | null>((resolve) => {
let selected = new Set<string>(opts.initialIDs);
let searchQuery = "";
// t-paliad-251 — court-type filter chips. `null` = "Alle" (any
// jurisdiction). Any non-null value matches event_types.jurisdiction;
// "any" is mapped to NULL/missing rows via jurisdictionMatches().
let activeJurisdiction: string | null = null;
// Surface every jurisdiction present in the data — "any" stays bucketed
// separately so users still have a "show generic-only" chip. EPA is
// canonicalised to EPO in event_types (see mig 074); the chip label
// shows EPA to match the legal vocabulary the lawyers use.
const jurisdictionsPresent = new Set<string>();
for (const et of opts.types) {
const j = (et.jurisdiction ?? "").trim();
if (j) jurisdictionsPresent.add(j);
}
const JURISDICTION_ORDER = ["UPC", "EPO", "DPMA", "DE", "any"];
const chipJurisdictions = JURISDICTION_ORDER.filter((j) => jurisdictionsPresent.has(j));
// Any jurisdiction in the data that isn't in our ordered list lands at
// the end so the chip row never silently drops a court flavour.
for (const j of jurisdictionsPresent) {
if (!chipJurisdictions.includes(j)) chipJurisdictions.push(j);
}
function chipLabel(j: string): string {
if (j === "EPO") return "EPA";
if (j === "any") return t("event_types.browse.jurisdiction.none");
return j;
}
const overlay = document.createElement("div");
overlay.className = "modal-overlay event-type-browse-overlay";
@@ -694,6 +721,15 @@ export function openBrowseEventTypesModal(
<div class="event-type-browse-header">
<h2 id="event-type-browse-title">${esc(t("event_types.browse.title"))}</h2>
<input type="text" class="event-type-browse-search" data-role="search" placeholder="${esc(t("event_types.browse.search"))}" autocomplete="off" />
<div class="event-type-browse-chips" data-role="chips" role="group" aria-label="${esc(t("event_types.browse.jurisdiction.filter_label"))}">
<button type="button" class="event-type-browse-chip event-type-browse-chip--active" data-jurisdiction="" data-role="chip-all">${esc(t("event_types.browse.jurisdiction.all"))}</button>
${chipJurisdictions
.map(
(j) =>
`<button type="button" class="event-type-browse-chip" data-jurisdiction="${esc(j)}">${esc(chipLabel(j))}</button>`,
)
.join("")}
</div>
</div>
<div class="event-type-browse-list" data-role="list" tabindex="-1"></div>
<div class="event-type-browse-actions">
@@ -711,6 +747,7 @@ export function openBrowseEventTypesModal(
const countEl = overlay.querySelector<HTMLElement>("[data-role=count]")!;
const cancelBtn = overlay.querySelector<HTMLButtonElement>("[data-role=cancel]")!;
const applyBtn = overlay.querySelector<HTMLButtonElement>("[data-role=apply]")!;
const chipButtons = overlay.querySelectorAll<HTMLButtonElement>(".event-type-browse-chip");
const groups = groupByCategory(opts.types);
@@ -721,6 +758,12 @@ export function openBrowseEventTypesModal(
return j;
}
function jurisdictionMatches(et: EventType): boolean {
if (activeJurisdiction === null) return true;
const j = (et.jurisdiction ?? "").trim();
return j === activeJurisdiction;
}
function updateCount() {
countEl.textContent = t("event_types.browse.selected_count").replace(
"{n}",
@@ -731,6 +774,7 @@ export function openBrowseEventTypesModal(
function renderList() {
const q = searchQuery.trim().toLowerCase();
const matches = (et: EventType) => {
if (!jurisdictionMatches(et)) return false;
if (!q) return true;
return (
et.label_de.toLowerCase().includes(q) ||
@@ -783,6 +827,16 @@ export function openBrowseEventTypesModal(
renderList();
});
chipButtons.forEach((btn) => {
btn.addEventListener("click", () => {
const raw = btn.dataset.jurisdiction ?? "";
activeJurisdiction = raw === "" ? null : raw;
chipButtons.forEach((b) => b.classList.remove("event-type-browse-chip--active"));
btn.classList.add("event-type-browse-chip--active");
renderList();
});
});
function close(value: string[] | null) {
document.removeEventListener("keydown", onKey);
overlay.remove();

View File

@@ -9,6 +9,7 @@ import {
} from "./event-types";
import { projectIndent } from "./project-indent";
import { mountCalendar, type CalendarHandle, type CalendarItem } from "./calendar/mount-calendar";
import { formatRuleLabelHTML, formatCustomRuleLabelHTML } from "./rule-label";
// Two-eyes glyph 👀 inside .approval-pill--icon. m's 2026-05-08 follow-
// up: "two eyes instead of the one." Emoji rather than SVG keeps the
@@ -66,6 +67,9 @@ interface EventListItem {
rule_code?: string;
rule_name?: string;
rule_name_en?: string;
// t-paliad-258 — free-text rule label when the deadline was created
// via the Custom rule path. Mutually exclusive with rule_id.
custom_rule_text?: string;
event_type_ids?: string[];
// appointment-only
@@ -264,13 +268,26 @@ function urgencyClass(item: EventListItem): string {
function ruleDisplay(item: EventListItem): string {
if (item.type !== "deadline") return "";
// Prefer the saved citation (RoP.023, R.151) over the rule name —
// REGEL is meant for the legal reference, not the rule's display
// name (which is the title column's job).
if (item.rule_code && item.rule_code.trim()) return esc(item.rule_code);
const lang = getLang();
const localized = lang === "en" ? item.rule_name_en : item.rule_name;
if (localized && localized.trim()) return esc(localized);
// t-paliad-258 addendum — canonical display contract: Name primary,
// Citation muted secondary ("Notice of Appeal · UPC.RoP.220.1").
// Custom rules render the lawyer's free text + a "Custom" badge.
// Legacy rule-code-only saves (Fristenrechner, no rule_id) still
// show the bare citation as last-resort fallback.
const hasName = (item.rule_name && item.rule_name.trim()) ||
(item.rule_name_en && item.rule_name_en.trim());
if (hasName || (item.rule_code && item.rule_code.trim())) {
return formatRuleLabelHTML(
{
name: item.rule_name || "",
name_en: item.rule_name_en,
rule_code: item.rule_code,
},
esc,
);
}
if (item.custom_rule_text && item.custom_rule_text.trim()) {
return formatCustomRuleLabelHTML(item.custom_rule_text, esc);
}
return "&mdash;";
}

View File

@@ -12,7 +12,7 @@
// New classes are scoped under .filter-bar-* so they don't bleed.
import { t, tDyn, type I18nKey } from "../i18n";
import type { BarState, AxisKey } from "./types";
import type { BarState, AxisKey, InboxFocus } from "./types";
export interface AxisCtx {
// Read the current value for this axis.
@@ -47,6 +47,8 @@ export function renderAxis(axis: AxisKey, ctx: AxisCtx, opts?: RenderAxisOpts):
case "shape": return renderShapeAxis(ctx);
case "density": return renderDensityAxis(ctx);
case "sort": return renderSortAxis(ctx);
case "unread_only": return renderUnreadOnlyAxis(ctx);
case "inbox_focus": return renderInboxFocusAxis(ctx);
// Per-source predicates that need their own widgets and a roundtrip
// through fetched option lists. Phase 2+ will fill these in by
@@ -484,6 +486,56 @@ function renderSortAxis(ctx: AxisCtx): HTMLElement {
return wrap;
}
// ----------------------------------------------------------------------
// unread_only — single binary chip (t-paliad-249, inbox only)
// ----------------------------------------------------------------------
function renderUnreadOnlyAxis(ctx: AxisCtx): HTMLElement {
const wrap = group("views.bar.label.unread_only");
const row = chipRow();
const isUnread = ctx.get("unread_only") !== false; // default on
const unreadChip = chipBtn(t("views.bar.unread_only.on"), isUnread);
unreadChip.addEventListener("click", () => ctx.patch({ unread_only: true }));
const allChip = chipBtn(t("views.bar.unread_only.off"), !isUnread);
allChip.addEventListener("click", () => ctx.patch({ unread_only: false }));
row.appendChild(unreadChip);
row.appendChild(allChip);
wrap.appendChild(row);
return wrap;
}
// ----------------------------------------------------------------------
// inbox_focus — coarse 4-chip cluster (t-paliad-249, inbox only)
//
// Head's UX refinement #2 (2026-05-25): users pick "what to see" in
// human terms, not abstract event-kind names. The overlay translates
// the chip to a (Sources, ProjectEventPredicates.EventTypes,
// ApprovalRequestPredicates.EntityTypes) triple at spec-resolve time
// (see applyInboxFocusOverlay in url-codec.ts).
// ----------------------------------------------------------------------
const INBOX_FOCUS_CHIPS: Array<{ value: InboxFocus; key: I18nKey }> = [
{ value: "alles", key: "views.bar.inbox_focus.alles" },
{ value: "genehmigungen", key: "views.bar.inbox_focus.genehmigungen" },
{ value: "plus_termine", key: "views.bar.inbox_focus.plus_termine" },
{ value: "plus_fristen", key: "views.bar.inbox_focus.plus_fristen" },
];
function renderInboxFocusAxis(ctx: AxisCtx): HTMLElement {
const wrap = group("views.bar.label.inbox_focus");
const row = chipRow();
const current: InboxFocus = ctx.get("inbox_focus") ?? "alles";
for (const f of INBOX_FOCUS_CHIPS) {
const chip = chipBtn(t(f.key), f.value === current);
chip.addEventListener("click", () => {
ctx.patch({ inbox_focus: f.value === "alles" ? undefined : f.value });
});
row.appendChild(chip);
}
wrap.appendChild(row);
return wrap;
}
// ----------------------------------------------------------------------
// shared helpers — group + chip + row
// ----------------------------------------------------------------------

View File

@@ -333,9 +333,65 @@ export function computeEffective(
render.list = { ...(render.list ?? {}), density: state.density };
}
// Inbox overlays (t-paliad-249).
//
// unread_only is a top-level FilterSpec field; the server resolves
// the actual cursor at run-time. Default-on for the inbox surface is
// baked into the base spec — but we ALSO need to write `true` here
// when the user explicitly picks the chip so the server doesn't
// confuse "user wants unread" with "user wants no filter".
if (state.unread_only !== undefined) {
filter.unread_only = state.unread_only;
}
// inbox_focus is a coarse axis that overlays Sources + a few
// per-source predicates. Translate here so the server sees a clean
// spec; the validator + RunSpec don't need to know about the chip.
if (state.inbox_focus && state.inbox_focus !== "alles") {
applyInboxFocusOverlay(filter, state.inbox_focus);
}
return { filter, render };
}
// applyInboxFocusOverlay narrows the spec to the chip's intent.
// Mutates `filter` in place. Called only when state.inbox_focus is
// set to a non-default value.
//
// Contract:
// - "genehmigungen" → drop project_event from sources entirely.
// - "plus_termine" → keep both sources; narrow project_event to
// appointment_* kinds; narrow approval_request
// entity_types to ["appointment"].
// - "plus_fristen" → keep both sources; narrow project_event to
// deadline_* kinds; narrow approval_request
// entity_types to ["deadline"].
function applyInboxFocusOverlay(filter: FilterSpec, focus: Exclude<NonNullable<BarState["inbox_focus"]>, "alles">): void {
filter.predicates = filter.predicates ?? {};
if (focus === "genehmigungen") {
filter.sources = filter.sources.filter((s) => s !== "project_event");
delete filter.predicates.project_event;
return;
}
const kindPrefix = focus === "plus_fristen" ? "deadline_" : "appointment_";
const entity = focus === "plus_fristen" ? "deadline" : "appointment";
if (filter.sources.includes("project_event")) {
const baseKinds = filter.predicates.project_event?.event_types ?? [];
const narrowed = baseKinds.filter((k) => k.startsWith(kindPrefix));
filter.predicates.project_event = {
...(filter.predicates.project_event ?? {}),
event_types: narrowed,
};
}
if (filter.sources.includes("approval_request")) {
filter.predicates.approval_request = {
...(filter.predicates.approval_request ?? {}),
entity_types: [entity],
};
}
}
// isDirty — used to enable the Reset button only when there's something
// to reset to.
function isDirty(state: BarState): boolean {

View File

@@ -25,7 +25,17 @@ export type AxisKey =
| "timeline_track"
| "shape"
| "sort"
| "density";
| "density"
// Inbox-only (t-paliad-249): unread/all toggle + coarse focus chip
// (Alles / Genehmigungen / +Termine / +Fristen). The focus chip
// overlays Sources + per-source predicates at resolve-time.
| "unread_only"
| "inbox_focus";
// Inbox focus chip values. "alles" is the default — both sources, full
// curated kinds. Other values narrow at the bar's resolve step. See
// applyInboxFocusOverlay() in url-codec.ts for the spec rewrite.
export type InboxFocus = "alles" | "genehmigungen" | "plus_termine" | "plus_fristen";
// Effective spec — the result of overlaying URL + localStorage prefs
// on top of the base spec. Handed back to onResult so the surface can
@@ -62,6 +72,10 @@ export interface BarState {
shape?: RenderShape;
sort?: "date_asc" | "date_desc";
density?: "comfortable" | "compact";
// Inbox (t-paliad-249)
unread_only?: boolean;
inbox_focus?: InboxFocus;
}
export interface TimeOverlay {

View File

@@ -99,4 +99,28 @@ describe("filter-bar/url-codec", () => {
params.set("density", "huge");
expect(parseBar(params)).toEqual({});
});
// t-paliad-249 — inbox axes
test("unread_only round-trips both states", () => {
expect(roundTrip({ unread_only: true })).toEqual({ unread_only: true });
expect(roundTrip({ unread_only: false })).toEqual({ unread_only: false });
});
test("unread_only undefined stays out of the URL", () => {
const params = new URLSearchParams();
encodeBar({}, params);
expect(params.has("unread")).toBe(false);
});
test("inbox_focus round-trips for non-default values", () => {
for (const f of ["genehmigungen", "plus_termine", "plus_fristen"] as const) {
expect(roundTrip({ inbox_focus: f })).toEqual({ inbox_focus: f });
}
});
test("inbox_focus alles is omitted (it's the default)", () => {
const params = new URLSearchParams();
encodeBar({ inbox_focus: "alles" }, params);
expect(params.has("focus")).toBe(false);
});
});

View File

@@ -9,7 +9,7 @@
// Empty / default values are NOT written — the URL stays clean for
// users who don't tweak. The page's base spec is the implicit baseline.
import type { BarState, TimeOverlay, ProjectOverlay } from "./types";
import type { BarState, TimeOverlay, ProjectOverlay, InboxFocus } from "./types";
const PERSONAL_PROJECT_SENTINEL = "personal";
@@ -108,6 +108,16 @@ export function parseBar(params: URLSearchParams, ns?: string): BarState {
const density = params.get(k("density"));
if (density === "comfortable" || density === "compact") out.density = density;
// inbox (t-paliad-249)
const unread = params.get(k("unread"));
if (unread === "0") out.unread_only = false;
else if (unread === "1") out.unread_only = true;
const focus = params.get(k("focus"));
if (focus === "genehmigungen" || focus === "plus_termine" || focus === "plus_fristen" || focus === "alles") {
out.inbox_focus = focus as InboxFocus;
}
return out;
}
@@ -127,6 +137,7 @@ export function encodeBar(state: BarState, params: URLSearchParams, ns?: string)
"pe_kind",
"tl_status", "tl_track",
"shape", "sort", "density",
"unread", "focus",
]) {
params.delete(k(key));
}
@@ -168,6 +179,15 @@ export function encodeBar(state: BarState, params: URLSearchParams, ns?: string)
if (state.shape) params.set(k("shape"), state.shape);
if (state.sort) params.set(k("sort"), state.sort);
if (state.density) params.set(k("density"), state.density);
// inbox (t-paliad-249). unread_only is tri-state in BarState (undefined
// means "page default"); we only write a key when the user has flipped
// it explicitly so the URL stays clean for the default landing state.
if (state.unread_only === false) params.set(k("unread"), "0");
else if (state.unread_only === true) params.set(k("unread"), "1");
if (state.inbox_focus && state.inbox_focus !== "alles") {
params.set(k("focus"), state.inbox_focus);
}
}
function parseHorizon(s: string): TimeOverlay["horizon"] | null {

View File

@@ -429,8 +429,13 @@ function renderProcedureResults(data: DeadlineResponse) {
<span class="timeline-trigger-date">${t("deadlines.trigger.label")}: ${formatDate(data.triggerDate)}</span>
</div>`;
// Pass the chip-strip perspective through as `side` so the column
// bucketer keeps the user's own party on the left (Unsere Seite) —
// t-paliad-257: the old Proaktiv/Reaktiv labels lied when the user
// was on the defendant side, the new labels demand we route the
// user's party into the `ours` column.
const bodyHtml = procedureView === "columns"
? renderColumnsBody(data, { editable: true, showNotes })
? renderColumnsBody(data, { editable: true, showNotes, side: currentPerspective })
: renderTimelineBody(data, { showParty: true, editable: true, showNotes });
container.innerHTML = headerHtml + bodyHtml;

View File

@@ -302,11 +302,9 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.view.timeline": "Zeitstrahl",
"deadlines.view.columns": "Spalten",
"deadlines.notes.show": "Hinweise anzeigen",
"deadlines.col.proactive": "Proaktiv (Klägerseite)",
"deadlines.col.proactive.defendant": "Proaktiv (Beklagtenseite)",
"deadlines.col.ours": "Unsere Seite",
"deadlines.col.court": "Gericht",
"deadlines.col.reactive": "Reaktiv (Beklagtenseite)",
"deadlines.col.reactive.claimant": "Reaktiv (Klägerseite)",
"deadlines.col.opponent": "Gegnerseite",
"deadlines.col.both": "Beide Parteien",
// Trigger-event mode (PR-2 \u2014 youpc-parity)
"deadlines.mode.procedure": "Verfahrensablauf",
@@ -884,11 +882,15 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.field.title.placeholder": "z.\u202fB. Klageerwiderung einreichen",
"deadlines.field.due": "F\u00e4lligkeitsdatum",
"deadlines.field.rule": "Regel (optional)",
"deadlines.field.rule.none": "Keine Regel",
"deadlines.field.rule.autofill": "Typ vorgegeben durch Regel — entfernen, um zu überschreiben.",
"deadlines.field.rule.autofill_inline": " (vorgegeben durch Regel)",
"deadlines.field.rule.mismatch": "Hinweis: Typ widerspricht Regel — Sie haben den Typ überschrieben.",
"deadlines.field.rule.override": "Anderen Typ wählen",
"deadlines.field.rule.auto_badge": "Auto",
"deadlines.field.rule.auto_no_match": "Keine Regel zur gewählten Verfahrenshandlung",
"deadlines.field.rule.auto_pick_type": "Wählen Sie zuerst eine Verfahrenshandlung",
"deadlines.field.rule.custom_badge": "Eigen",
"deadlines.field.rule.custom_placeholder": "z.B. interner Review-Termin, Mandantengespräch",
"deadlines.field.rule.mode.toggle_to_auto": "Zurück zu Auto",
"deadlines.field.rule.mode.toggle_to_custom": "Eigene Regel eingeben",
"deadlines.field.title.default_btn": "Standardtitel",
"deadlines.field.title.default_fallback": "Neue Frist",
"deadlines.field.notes": "Notizen (optional)",
"deadlines.field.notes.placeholder": "Hinweise, Verweise, n\u00e4chste Schritte\u2026",
"deadlines.error.required": "Akte, Titel und F\u00e4lligkeitsdatum sind Pflichtfelder.",
@@ -2237,6 +2239,20 @@ const translations: Record<Lang, Record<string, string>> = {
"inbox.empty.admin_nudge.title": "Noch keine Genehmigungspflichten konfiguriert?",
"inbox.empty.admin_nudge.body": "Lege fest, welche Lifecycle-Events 4-Augen-Prüfung erfordern.",
"inbox.empty.admin_nudge.cta": "Genehmigungspflichten konfigurieren",
"inbox.title.feed": "Inbox — Paliad",
"inbox.heading.feed": "Inbox",
"inbox.subtitle.feed": "Neuigkeiten zu Ihren Projekten und offene Genehmigungen.",
"inbox.action.mark_all_seen": "Alles als gelesen markieren",
"inbox.action.open": "Öffnen",
"inbox.empty.feed": "Keine Neuigkeiten in den letzten 30 Tagen.",
"views.bar.label.unread_only": "Lesestatus",
"views.bar.unread_only.on": "Nur ungelesen",
"views.bar.unread_only.off": "Alle",
"views.bar.label.inbox_focus": "Anzeigen",
"views.bar.inbox_focus.alles": "Alles",
"views.bar.inbox_focus.genehmigungen": "Nur Genehmigungen",
"views.bar.inbox_focus.plus_termine": "+ Termine",
"views.bar.inbox_focus.plus_fristen": "+ Fristen",
"deadlines.form.approval_hint": "4-Augen-Prüfung erforderlich",
"appointments.form.approval_hint": "4-Augen-Prüfung erforderlich",
"admin.email_templates.title": "Email-Templates — Paliad",
@@ -2348,6 +2364,31 @@ const translations: Record<Lang, Record<string, string>> = {
// Admin audit log (t-paliad-071)
"nav.admin.audit": "Audit-Log",
"nav.admin.partner_units": "Partner Units",
// Admin Backup Mode (t-paliad-246 / m/paliad#77)
"nav.admin.backups": "Backups",
"admin.backups.title": "Backups — Paliad",
"admin.backups.heading": "Backups",
"admin.backups.subtitle": "Vollständige Snapshots aller Daten — manuell oder zeitgesteuert.",
"admin.backups.run_now": "Backup jetzt erstellen",
"admin.backups.running": "Läuft …",
"admin.backups.success": "Backup erfolgreich erstellt.",
"admin.backups.empty": "Noch keine Backups vorhanden.",
"admin.backups.loading": "Lade …",
"admin.backups.col.started": "Erstellt",
"admin.backups.col.kind": "Auslöser",
"admin.backups.col.status": "Status",
"admin.backups.col.requested_by": "Angefordert von",
"admin.backups.col.size": "Größe",
"admin.backups.col.rows": "Sheets",
"admin.backups.col.actions": "Aktion",
"admin.backups.kind.scheduled": "Geplant",
"admin.backups.kind.on_demand": "Manuell",
"admin.backups.status.running": "Läuft …",
"admin.backups.status.done": "✓ Fertig",
"admin.backups.status.failed": "✗ Fehlgeschlagen",
"admin.backups.download": "Download",
"admin.backups.footer.note": "Geplante Backups werden in einer späteren Slice aktiviert. Manuelle Backups stehen jetzt zur Verfügung.",
"admin.audit.title": "Audit-Log — Paliad",
"admin.audit.heading": "Audit-Log",
"admin.audit.subtitle": "Globale Zeitleiste über Projekt-, CalDAV-, Reminder- und Partner-Unit-Ereignisse.",
@@ -2447,6 +2488,8 @@ const translations: Record<Lang, Record<string, string>> = {
"event_types.browse.cancel": "Abbrechen",
"event_types.browse.selected_count": "{n} ausgewählt",
"event_types.browse.jurisdiction.none": "Allgemein",
"event_types.browse.jurisdiction.all": "Alle Gerichte",
"event_types.browse.jurisdiction.filter_label": "Nach Gerichtsart filtern",
"event_types.filter.all": "Alle Typen",
"event_types.filter.untyped": "— Ohne Typ —",
"event_types.filter.search": "Typ suchen…",
@@ -2592,6 +2635,17 @@ const translations: Record<Lang, Record<string, string>> = {
"approvals.withdraw.cta": "Genehmigungsanfrage zurückziehen",
"approvals.withdraw.confirm": "Genehmigungsanfrage wirklich zurückziehen?",
"approvals.withdraw.error": "Fehler beim Zurückziehen",
"approvals.withdraw.cancel": "Abbrechen",
"approvals.withdraw.modal.title": "Genehmigungsanfrage zurückziehen?",
"approvals.withdraw.primary.label": "Termin bearbeiten",
"approvals.withdraw.destructive.label": "Endgültig zurückziehen und löschen",
"approvals.withdraw.lead.create.deadline": "Wenn Sie die Anfrage zurückziehen, wird die Frist gelöscht.",
"approvals.withdraw.lead.create.appointment": "Wenn Sie die Anfrage zurückziehen, wird der Termin gelöscht.",
"approvals.withdraw.lead.update": "Wenn Sie die Anfrage zurückziehen, werden die vorgeschlagenen Änderungen verworfen — der Eintrag kehrt in den Zustand vor Ihrer Bearbeitung zurück.",
"approvals.withdraw.lead.delete": "Wenn Sie die Löschanfrage zurückziehen, bleibt der Eintrag bestehen.",
"approvals.withdraw.sub.create": "Alternativ können Sie den Eintrag stattdessen bearbeiten. Die Anfrage bleibt offen und der Genehmiger sieht Ihre neuen Werte.",
"approvals.withdraw.sub.update": "Alternativ können Sie Ihre Änderungen bearbeiten und neu absenden. Die Anfrage bleibt offen.",
"approvals.withdraw.sub.delete": "Sind Sie sicher, dass Sie die Löschanfrage zurückziehen möchten?",
"approvals.pending_create.label": "Erstellung wartet auf Genehmigung",
"approvals.pending_update.label": "Änderung wartet auf Genehmigung",
"approvals.pending_complete.label": "Erledigung wartet auf Genehmigung",
@@ -3258,11 +3312,9 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.view.timeline": "Timeline",
"deadlines.view.columns": "Columns",
"deadlines.notes.show": "Show details",
"deadlines.col.proactive": "Proactive (Claimant side)",
"deadlines.col.proactive.defendant": "Proactive (Defendant side)",
"deadlines.col.ours": "Client Side",
"deadlines.col.court": "Court",
"deadlines.col.reactive": "Reactive (Defendant side)",
"deadlines.col.reactive.claimant": "Reactive (Claimant side)",
"deadlines.col.opponent": "Opponent Side",
"deadlines.col.both": "Both parties",
"deadlines.adjusted": "Adjusted",
"deadlines.adjusted.reason": "weekend/holiday",
@@ -3840,11 +3892,15 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.field.title.placeholder": "e.g. File statement of defence",
"deadlines.field.due": "Due date",
"deadlines.field.rule": "Rule (optional)",
"deadlines.field.rule.none": "No rule",
"deadlines.field.rule.autofill": "Type set by rule — remove to override.",
"deadlines.field.rule.autofill_inline": " (set by rule)",
"deadlines.field.rule.mismatch": "Note: type contradicts rule — you have overridden the type.",
"deadlines.field.rule.override": "Choose another type",
"deadlines.field.rule.auto_badge": "Auto",
"deadlines.field.rule.auto_no_match": "No rule maps to the chosen Type",
"deadlines.field.rule.auto_pick_type": "Pick a Type first",
"deadlines.field.rule.custom_badge": "Custom",
"deadlines.field.rule.custom_placeholder": "e.g. internal review meeting, client call",
"deadlines.field.rule.mode.toggle_to_auto": "Back to Auto",
"deadlines.field.rule.mode.toggle_to_custom": "Enter custom rule",
"deadlines.field.title.default_btn": "Default title",
"deadlines.field.title.default_fallback": "New deadline",
"deadlines.field.notes": "Notes (optional)",
"deadlines.field.notes.placeholder": "References, hints, next steps\u2026",
"deadlines.error.required": "Matter, title and due date are required.",
@@ -5165,6 +5221,20 @@ const translations: Record<Lang, Record<string, string>> = {
"inbox.empty.admin_nudge.title": "No approval policies configured yet?",
"inbox.empty.admin_nudge.body": "Set which lifecycle events require 4-eye review.",
"inbox.empty.admin_nudge.cta": "Configure approval policies",
"inbox.title.feed": "Inbox — Paliad",
"inbox.heading.feed": "Inbox",
"inbox.subtitle.feed": "Updates on your projects and open approvals.",
"inbox.action.mark_all_seen": "Mark all as read",
"inbox.action.open": "Open",
"inbox.empty.feed": "No updates in the last 30 days.",
"views.bar.label.unread_only": "Read state",
"views.bar.unread_only.on": "Unread only",
"views.bar.unread_only.off": "All",
"views.bar.label.inbox_focus": "Show",
"views.bar.inbox_focus.alles": "Everything",
"views.bar.inbox_focus.genehmigungen": "Approvals only",
"views.bar.inbox_focus.plus_termine": "+ Appointments",
"views.bar.inbox_focus.plus_fristen": "+ Deadlines",
"deadlines.form.approval_hint": "4-eye review required",
"appointments.form.approval_hint": "4-eye review required",
"admin.email_templates.title": "Email Templates — Paliad",
@@ -5276,6 +5346,31 @@ const translations: Record<Lang, Record<string, string>> = {
// Admin audit log (t-paliad-071)
"nav.admin.audit": "Audit Log",
"nav.admin.partner_units": "Partner Units",
// Admin Backup Mode (t-paliad-246 / m/paliad#77)
"nav.admin.backups": "Backups",
"admin.backups.title": "Backups — Paliad",
"admin.backups.heading": "Backups",
"admin.backups.subtitle": "Full snapshots of all data — manual or scheduled.",
"admin.backups.run_now": "Run backup now",
"admin.backups.running": "Running …",
"admin.backups.success": "Backup created successfully.",
"admin.backups.empty": "No backups yet.",
"admin.backups.loading": "Loading …",
"admin.backups.col.started": "Started",
"admin.backups.col.kind": "Trigger",
"admin.backups.col.status": "Status",
"admin.backups.col.requested_by": "Requested by",
"admin.backups.col.size": "Size",
"admin.backups.col.rows": "Sheets",
"admin.backups.col.actions": "Action",
"admin.backups.kind.scheduled": "Scheduled",
"admin.backups.kind.on_demand": "Manual",
"admin.backups.status.running": "Running …",
"admin.backups.status.done": "✓ Done",
"admin.backups.status.failed": "✗ Failed",
"admin.backups.download": "Download",
"admin.backups.footer.note": "Scheduled backups land in a later slice. Manual backups are available now.",
"admin.audit.title": "Audit Log — Paliad",
"admin.audit.heading": "Audit Log",
"admin.audit.subtitle": "Global timeline across project, CalDAV, reminder and partner-unit events.",
@@ -5375,6 +5470,8 @@ const translations: Record<Lang, Record<string, string>> = {
"event_types.browse.cancel": "Cancel",
"event_types.browse.selected_count": "{n} selected",
"event_types.browse.jurisdiction.none": "Any",
"event_types.browse.jurisdiction.all": "All courts",
"event_types.browse.jurisdiction.filter_label": "Filter by court type",
"event_types.filter.all": "All types",
"event_types.filter.untyped": "— Untyped —",
"event_types.filter.search": "Search type…",
@@ -5520,6 +5617,17 @@ const translations: Record<Lang, Record<string, string>> = {
"approvals.withdraw.cta": "Withdraw approval request",
"approvals.withdraw.confirm": "Withdraw the approval request?",
"approvals.withdraw.error": "Failed to withdraw",
"approvals.withdraw.cancel": "Cancel",
"approvals.withdraw.modal.title": "Withdraw approval request?",
"approvals.withdraw.primary.label": "Edit event",
"approvals.withdraw.destructive.label": "Withdraw permanently and delete",
"approvals.withdraw.lead.create.deadline": "Withdrawing this request will delete the deadline.",
"approvals.withdraw.lead.create.appointment": "Withdrawing this request will delete the appointment.",
"approvals.withdraw.lead.update": "Withdrawing this request will discard your proposed changes — the entry will revert to its state before your edit.",
"approvals.withdraw.lead.delete": "Withdrawing the delete request will keep the entry alive.",
"approvals.withdraw.sub.create": "Alternatively, you can edit the entry instead. The request stays open and the approver will see your new values.",
"approvals.withdraw.sub.update": "Alternatively, you can edit your changes and resubmit. The request stays open.",
"approvals.withdraw.sub.delete": "Are you sure you want to withdraw the delete request?",
"approvals.pending_create.label": "Awaits approval (creation)",
"approvals.pending_update.label": "Awaits approval (change)",
"approvals.pending_complete.label": "Awaits approval (completion)",

View File

@@ -6,37 +6,45 @@ import type { FilterSpec, RenderSpec, SystemView, ViewRunResult } from "./views/
import { renderListShape } from "./views/shape-list";
import { openApprovalEditModal } from "./components/approval-edit-modal";
// /inbox client — t-paliad-163 universal-filter migration.
// /inbox client — t-paliad-249 unified inbox feed.
//
// The bar owns every axis the old tab UI exposed plus more:
// - approval_viewer_role: "Zur Genehmigung" / "Eigene Anfragen" /
// "Alle sichtbaren" (collapses the legacy two-tab UI per Q4 lock-in)
// - approval_status: chip cluster (default: pending)
// - approval_entity_type: chip pair (Frist / Termin)
// - time: chip cluster (Any default)
// - density: comfortable / compact
// - sort: date asc / desc
// The bar exposes:
// - inbox_focus: coarse Alles / Genehmigungen / +Termine / +Fristen
// - unread_only: Nur ungelesen / Alle (default: ungelesen)
// - time: last 30 days default; chip cluster + custom range
// - project: single-select autocomplete from visible projects
// - approval_viewer_role: Zur Genehmigung / Eigene / Alle sichtbaren
// - approval_status / approval_entity_type / project_event_kind: power-user overrides
// - sort / density: newest first default
//
// Row rendering: shape-list.ts with row_action="approve" stamps the
// inbox markup (entity title, diff, approve/reject/revoke buttons).
// We wire action click handlers in onResult and refresh through the
// bar handle.
// Row rendering: shape-list.ts with row_action="inbox" dispatches per
// row.kind. Approval rows keep approve/reject/revoke; project_event
// rows render compact with an Öffnen link.
const INBOX_AXES: AxisKey[] = [
"inbox_focus",
"unread_only",
"time",
"project",
"approval_viewer_role",
"approval_status",
"approval_entity_type",
"density",
"project_event_kind",
"sort",
"density",
];
// Last paint's newest row timestamp — used to pin mark-all-seen so a
// second tab can't race the cursor past items the user hasn't seen.
let newestVisibleAt: string | null = null;
let bar: BarHandle | null = null;
document.addEventListener("DOMContentLoaded", () => {
initI18n();
initSidebar();
applyLegacyTabRedirect();
wireMarkAllSeen();
void hydrate();
});
@@ -105,15 +113,25 @@ function paint(
if (!result.rows || result.rows.length === 0) {
results.innerHTML = "";
empty.style.display = "";
empty.textContent = t("approvals.empty.pending_mine");
empty.textContent = t("inbox.empty.feed");
newestVisibleAt = null;
void maybeShowAdminNudge();
return;
}
hideAdminNudge();
empty.style.display = "none";
// Remember the newest timestamp so mark-all-seen can pin the cursor
// to it (race-safety: a second tab adding a row between this paint
// and the click won't get wiped out).
newestVisibleAt = result.rows.reduce<string | null>((acc, r) => {
if (!acc) return r.event_date;
return r.event_date > acc ? r.event_date : acc;
}, null);
// shape-list.ts honours render.list.row_action — InboxSystemView's
// RenderSpec sets row_action="approve" so we get the inbox markup.
// RenderSpec sets row_action="inbox" so we get the unified dispatch
// (approval rows + project_event rows).
renderListShape(results, result.rows, render);
// Wire action handlers on the freshly stamped DOM. The action
@@ -122,6 +140,38 @@ function paint(
wireApprovalActions(results);
}
// wireMarkAllSeen wires the page-header "Alles als gelesen markieren"
// button. POSTs the newest visible row's timestamp as `up_to` so a
// stale second tab can't rewind anyone else's cursor; on success the
// bar refreshes (rows newer than now disappear under unread_only) and
// the sidebar badge re-counts.
function wireMarkAllSeen(): void {
const btn = document.getElementById("inbox-mark-all-seen") as HTMLButtonElement | null;
if (!btn) return;
btn.addEventListener("click", async () => {
btn.disabled = true;
try {
const body = newestVisibleAt ? JSON.stringify({ up_to: newestVisibleAt }) : "{}";
const r = await fetch("/api/inbox/mark-all-seen", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body,
});
if (!r.ok) {
alert(t("approvals.error.internal"));
return;
}
await bar?.refresh();
await refreshInboxBadge();
} catch (_e) {
alert("Network error");
} finally {
btn.disabled = false;
}
});
}
function wireApprovalActions(host: HTMLElement): void {
host.querySelectorAll<HTMLButtonElement>(".views-approval-action").forEach((btn) => {
const action = btn.dataset.action as

View File

@@ -16,6 +16,7 @@ import type { FilterSpec, RenderSpec } from "./views/types";
import { renderSmartTimeline, type TimelineEvent as SmartTimelineEvent, type LaneInfo as SmartTimelineLane } from "./views/shape-timeline";
import { loadAndRenderSubmissions } from "./submissions";
import { buildMailtoHref, type BroadcastRecipient } from "./broadcast";
import { formatRuleLabelHTML, formatCustomRuleLabelHTML } from "./rule-label";
interface Project {
id: string;
@@ -142,6 +143,11 @@ interface Deadline {
status: string;
rule_id?: string;
rule_code?: string;
rule_name?: string;
rule_name_en?: string;
// t-paliad-258 — free-text rule label when the deadline was saved in
// Custom mode. Mutually exclusive with rule_id.
custom_rule_text?: string;
// Populated by the union endpoint (/api/events) which is what the project
// detail page calls — used for attribution when the row lives on a
// descendant project (t-paliad-139).
@@ -805,6 +811,9 @@ interface UnionEvent {
status?: string;
rule_id?: string;
rule_code?: string;
rule_name?: string;
rule_name_en?: string;
custom_rule_text?: string;
start_at?: string;
end_at?: string;
location?: string;
@@ -832,6 +841,9 @@ async function loadDeadlines(id: string) {
status: it.status ?? "pending",
rule_id: it.rule_id,
rule_code: it.rule_code,
rule_name: it.rule_name,
rule_name_en: it.rule_name_en,
custom_rule_text: it.custom_rule_text,
project_title: it.project_title,
}));
} else {
@@ -1001,6 +1013,27 @@ function fmtDateOnly(iso: string): string {
}
}
// formatDeadlineRuleCell renders the REGEL column for the project
// detail Fristen table using the canonical t-paliad-258 contract:
// 1. catalog rule (rule_name / rule_name_en + rule_code) → "Name · Code"
// 2. custom_rule_text → text + "Custom" badge
// 3. legacy rule_code-only saves → bare citation
// 4. otherwise "—"
function formatDeadlineRuleCell(f: Deadline): string {
const hasName = (f.rule_name && f.rule_name.trim()) ||
(f.rule_name_en && f.rule_name_en.trim());
if (hasName || (f.rule_code && f.rule_code.trim())) {
return formatRuleLabelHTML(
{ name: f.rule_name || "", name_en: f.rule_name_en, rule_code: f.rule_code },
esc,
);
}
if (f.custom_rule_text && f.custom_rule_text.trim()) {
return formatCustomRuleLabelHTML(f.custom_rule_text, esc);
}
return "—";
}
function urgencyClass(due: string, status: string): string {
if (status === "completed") return "frist-urgency-done";
const today = new Date();
@@ -1039,7 +1072,7 @@ function renderDeadlines() {
</td>
<td class="frist-col-due ${urgency}"><span class="frist-due-dot"></span>${fmtDateOnly(f.due_date)}</td>
<td class="frist-col-title ${titleClass}">${esc(f.title)}${attributionChip(f.project_id, f.project_title)}</td>
<td class="frist-col-rule">${f.rule_code ? esc(f.rule_code) : "—"}</td>
<td class="frist-col-rule">${formatDeadlineRuleCell(f)}</td>
<td><span class="entity-status-chip entity-status-${esc(f.status)}">${esc(statusLabel)}</span></td>
</tr>`;
})

View File

@@ -0,0 +1,87 @@
// rule-label — canonical display contract for deadline rules.
//
// t-paliad-258 / m/paliad#89 addendum. Previously each surface (deadline
// form, list rows, detail header, Schriftsätze tab, browse-a-proceeding)
// invented its own pattern: sometimes citation-only, sometimes name-only,
// sometimes "code — name". m flagged this on the first submissions in a
// proceeding sequence where the inconsistency was most visible.
//
// Canonical pattern: **Name primary, Citation muted secondary**.
// Text: "Notice of Appeal · UPC.RoP.220.1"
// HTML: <span class="rule-label-name">Notice of Appeal</span>
// <span class="rule-label-sep"> · </span>
// <span class="rule-label-cite">UPC.RoP.220.1</span>
//
// Custom rules (t-paliad-258 — free-text label entered by the lawyer):
// formatCustomRuleLabel produces "<text>" with a "Custom" badge slot
// so list/detail surfaces can render both shapes uniformly.
import { getLang, t } from "./i18n";
export interface RuleLike {
name: string;
name_en?: string | null;
// The catalog carries multiple citation fields depending on which
// surface populated it. Order of preference: legal_source > rule_code
// > code. All three are accepted so callers don't have to normalise.
rule_code?: string | null;
code?: string | null;
legal_source?: string | null;
}
// formatRuleLabel returns the canonical plain-text label.
// Falls back gracefully when either side is missing.
export function formatRuleLabel(r: RuleLike): string {
const lang = getLang();
const name = (lang === "en" && r.name_en) ? r.name_en : r.name;
const cite = ruleCitation(r);
if (name && cite) return `${name} · ${cite}`;
return name || cite || "";
}
// formatRuleLabelHTML returns the canonical HTML form with muted-citation
// styling. The caller passes the HTML-escape helper so we don't pull a
// dependency on a specific esc() module — every surface already has one.
export function formatRuleLabelHTML(r: RuleLike, esc: (s: string) => string): string {
const lang = getLang();
const name = (lang === "en" && r.name_en) ? r.name_en : r.name;
const cite = ruleCitation(r);
if (name && cite) {
return (
`<span class="rule-label-name">${esc(name)}</span>` +
`<span class="rule-label-sep"> · </span>` +
`<span class="rule-label-cite">${esc(cite)}</span>`
);
}
return esc(name || cite || "");
}
// ruleCitation returns the best-available citation string for a rule.
// Exported so callers that need the bare code (e.g. CalDAV exports,
// inline data attributes) can pull it without going through the label
// formatter.
export function ruleCitation(r: RuleLike): string {
return r.legal_source || r.rule_code || r.code || "";
}
// formatCustomRuleLabelHTML — render a free-text custom rule label with
// a "Custom" badge slot. Used by surfaces that may display either a
// catalog rule (formatRuleLabelHTML) or a custom one. Returns "" when
// the text is empty so callers can fall through to "—".
export function formatCustomRuleLabelHTML(text: string | null | undefined, esc: (s: string) => string): string {
const trimmed = (text ?? "").trim();
if (!trimmed) return "";
const badge = t("deadlines.field.rule.custom_badge") || "Custom";
return (
`<span class="rule-label-name">${esc(trimmed)}</span>` +
`<span class="rule-label-badge rule-label-badge--custom">${esc(badge)}</span>`
);
}
// formatCustomRuleLabel — plain-text equivalent of the above.
export function formatCustomRuleLabel(text: string | null | undefined): string {
const trimmed = (text ?? "").trim();
if (!trimmed) return "";
const badge = t("deadlines.field.rule.custom_badge") || "Custom";
return `${trimmed} · ${badge}`;
}

View File

@@ -605,6 +605,90 @@ function paintPreview(): void {
const host = document.getElementById("submission-draft-preview");
if (!host || !state.view) return;
host.innerHTML = state.view.preview_html ?? "";
wireDraftVars(host);
}
// t-paliad-261 (B) — click a substituted variable in the preview to
// jump to the matching sidebar input. Re-wires on every paintPreview
// since the preview HTML is replaced wholesale. The server side wraps
// each substituted placeholder (resolved OR missing marker) in
// <span class="draft-var" data-var="<key>">…</span>; clicks here scroll
// the corresponding input into view, focus + select, and flash the row.
// If the key has no matching sidebar input (derived variables not
// exposed in VARIABLE_GROUPS), the click is a silent no-op — the span
// is still rendered so the user gets the visible hint that this is a
// resolved variable.
function wireDraftVars(previewHost: HTMLElement): void {
previewHost.querySelectorAll<HTMLElement>(".draft-var").forEach((el) => {
const key = el.dataset.var;
if (!key) return;
if (findVarInput(key)) {
el.classList.add("draft-var--has-input");
el.setAttribute("role", "button");
el.setAttribute("tabindex", "0");
el.setAttribute(
"aria-label",
(isEN() ? "Edit variable " : "Variable bearbeiten: ") + labelFor(key),
);
}
el.addEventListener("click", (ev) => onDraftVarClick(key, ev));
el.addEventListener("keydown", (ev) => {
if (ev.key === "Enter" || ev.key === " ") {
ev.preventDefault();
onDraftVarClick(key, ev);
}
});
});
}
function findVarInput(key: string): HTMLInputElement | null {
const host = document.getElementById("submission-draft-variables");
if (!host) return null;
return host.querySelector<HTMLInputElement>(
`.submission-draft-var-input[data-var="${cssEscape(key)}"]`,
);
}
function cssEscape(s: string): string {
// CSS.escape covers our placeholder keys ([A-Za-z][A-Za-z0-9_.]*) but
// older browsers may lack it; defensive fallback escapes characters
// CSS treats as special. Placeholder keys never carry whitespace or
// quotes so escaping is straightforward.
if (typeof CSS !== "undefined" && typeof CSS.escape === "function") {
return CSS.escape(s);
}
return s.replace(/([!"#$%&'()*+,./:;<=>?@[\\\]^`{|}~])/g, "\\$1");
}
function onDraftVarClick(key: string, ev: Event): void {
const input = findVarInput(key);
if (!input) return;
ev.preventDefault();
ev.stopPropagation();
// Smooth-scroll the input into view, then focus on the next tick so
// the scroll animation has started and the focus call doesn't trigger
// a second jarring jump.
input.scrollIntoView({ behavior: "smooth", block: "center" });
window.setTimeout(() => {
input.focus();
try {
input.select();
} catch {
/* select() throws on number/email inputs; safe to ignore */
}
}, 50);
flashVarRow(input);
}
function flashVarRow(input: HTMLElement): void {
const row = input.closest<HTMLElement>(".submission-draft-var-row");
if (!row) return;
row.classList.remove("submission-draft-var-row--flash");
// Force reflow so removing+re-adding the class restarts the animation
// even on rapid successive clicks.
void row.offsetWidth;
row.classList.add("submission-draft-var-row--flash");
window.setTimeout(() => row.classList.remove("submission-draft-var-row--flash"), 1200);
}
// ─────────────────────────────────────────────────────────────────────
@@ -643,11 +727,18 @@ async function flushAutosave(): Promise<void> {
if (!state.pendingOverrides) return;
const payload = { variables: state.pendingOverrides };
state.pendingOverrides = null;
// t-paliad-261 (A) — paintVariables() below replaces every input in
// the sidebar via innerHTML, which blows away the active-element
// reference. Capture the focused input's key + selection range before
// the repaint and restore on the new element after, so the user can
// keep typing without clicking back into the field.
const focusSnap = captureVarFocus();
try {
const view = await patchDraft(payload);
state.view = view;
paintVariables();
paintPreview();
restoreVarFocus(focusSnap);
setSaveStatus(isEN() ? "Saved" : "Gespeichert");
} catch (err) {
if ((err as Error).name === "AbortError") return;
@@ -656,6 +747,64 @@ async function flushAutosave(): Promise<void> {
}
}
// captureVarFocus / restoreVarFocus — focus-preservation across the
// paintVariables() innerHTML-replace cycle (t-paliad-261 part A).
// Tracks selection start/end/direction so the cursor lands exactly
// where it was before the repaint, including any active selection
// range. Handles both <input> and <textarea> via the shared
// HTMLInputElement|HTMLTextAreaElement contract for selectionStart /
// selectionEnd / selectionDirection / setSelectionRange.
interface VarFocusSnapshot {
key: string;
start: number | null;
end: number | null;
dir: "forward" | "backward" | "none";
}
type SelectableEl = HTMLInputElement | HTMLTextAreaElement;
function isVarField(el: Element | null): el is SelectableEl {
if (!el) return false;
if (!(el instanceof HTMLInputElement) && !(el instanceof HTMLTextAreaElement)) {
return false;
}
return el.classList.contains("submission-draft-var-input");
}
function captureVarFocus(): VarFocusSnapshot | null {
const active = document.activeElement;
if (!isVarField(active)) return null;
const key = active.dataset.var;
if (!key) return null;
return {
key,
start: active.selectionStart,
end: active.selectionEnd,
dir: (active.selectionDirection as "forward" | "backward" | "none" | null) ?? "forward",
};
}
function restoreVarFocus(snap: VarFocusSnapshot | null): void {
if (!snap) return;
const host = document.getElementById("submission-draft-variables");
if (!host) return;
const next = host.querySelector<SelectableEl>(
`.submission-draft-var-input[data-var="${cssEscape(snap.key)}"]`,
);
if (!next) return;
next.focus();
if (snap.start !== null && snap.end !== null) {
try {
next.setSelectionRange(snap.start, snap.end, snap.dir);
} catch {
/* setSelectionRange throws on inputs whose type doesn't support
selection ranges (number, email, etc.); safe to ignore — the
focus() call above is enough for those. */
}
}
}
async function renameDraft(newName: string): Promise<void> {
setSaveStatus(isEN() ? "Saving…" : "Speichert…");
try {

View File

@@ -32,6 +32,11 @@ export function renderListShape(host: HTMLElement, rows: ViewRow[], render: Rend
return;
}
if (rowAction === "inbox") {
host.appendChild(renderInboxList(sorted));
return;
}
if (density === "compact") {
host.appendChild(renderCompact(sorted));
} else {
@@ -147,8 +152,22 @@ function formatColumn(row: ViewRow, col: string): string {
const s = (row.detail.status as string | undefined) ?? "";
return s ? t(("deadlines.status." + s) as I18nKey) : "—";
}
case "rule":
return (row.detail.rule_code as string | undefined) ?? "—";
case "rule": {
// t-paliad-258 — canonical "Name · Citation" pattern; fall back
// to custom_rule_text + " · Custom" for Custom-mode deadlines.
const lang = getLang();
const nameKey = lang === "en" ? "rule_name_en" : "rule_name";
const name = (row.detail[nameKey] as string | undefined)
|| (row.detail.rule_name as string | undefined)
|| "";
const cite = (row.detail.rule_code as string | undefined) ?? "";
if (name && cite) return `${name} · ${cite}`;
if (name) return name;
if (cite) return cite;
const custom = (row.detail.custom_rule_text as string | undefined) ?? "";
if (custom.trim()) return `${custom} · Custom`;
return "—";
}
case "event_type":
return (row.detail.event_type as string | undefined) ?? "—";
case "location":
@@ -219,111 +238,215 @@ function renderApprovalList(rows: ViewRow[]): HTMLElement {
const ul = document.createElement("ul");
ul.className = "inbox-list views-approval-list";
for (const row of rows) {
const detail = (row.detail || {}) as ApprovalDetail;
const li = document.createElement("li");
li.className = "inbox-row views-approval-row";
li.dataset.requestId = row.id;
li.dataset.status = detail.status ?? "";
// Header: entity / lifecycle
const head = document.createElement("div");
head.className = "inbox-row-head";
const title = document.createElement("div");
title.className = "inbox-row-title";
const entityLabel = detail.entity_type ? t(("approvals.entity." + detail.entity_type) as I18nKey) : "";
const lifecycleLabel = detail.lifecycle_event ? t(("approvals.lifecycle." + detail.lifecycle_event) as I18nKey) : "";
const entityTitle = detail.entity_title || row.title || "—";
title.textContent = `${entityLabel}: ${entityTitle}${lifecycleLabel}`;
head.appendChild(title);
const meta = document.createElement("div");
meta.className = "inbox-row-meta";
const reqByLabel = t("approvals.requested_by");
const roleLabel = detail.required_role
? t(("approvals.required_role." + detail.required_role) as I18nKey)
: "";
const requester = detail.requester_name || row.actor_name || "";
const requesterTag = detail.requester_kind === "agent"
? `${requester}${t("approvals.agent.byline")}`
: requester;
const projectTitle = row.project_title ?? "";
const parts = [
projectTitle,
`${reqByLabel} ${requesterTag}`,
];
if (roleLabel) parts.push(`${roleLabel}+`);
parts.push(formatRelativeTime(row.event_date));
meta.textContent = parts.filter(Boolean).join(" · ");
head.appendChild(meta);
li.appendChild(head);
// Diff for update / complete
const diff = renderDiff(detail);
if (diff) li.appendChild(diff);
if (detail.decision_note) {
const note = document.createElement("div");
note.className = "inbox-row-note";
note.textContent = detail.decision_note;
li.appendChild(note);
}
// Action row — surface attaches handlers via data-attrs.
const actions = document.createElement("div");
actions.className = "inbox-row-actions";
if (detail.status === "pending") {
// All four actions are stamped on every pending row; the per-viewer
// viewer_can_approve / viewer_is_requester flags (resolved server-side)
// decide which are enabled vs. greyed out with a tooltip. m's ask
// (2026-05-17): show what's possible but disable what isn't, rather
// than alert-after-click. The server still enforces — disabled buttons
// are a UI hint, not a security gate.
//
// suggest_changes is hidden for non-update lifecycles (the backend
// returns ErrSuggestionLifecycleInvalid for create/complete/delete,
// so we don't even render the button for them).
actions.appendChild(approvalActionBtn("approve", detail));
if (detail.lifecycle_event === "update") {
actions.appendChild(approvalActionBtn("suggest_changes", detail));
}
actions.appendChild(approvalActionBtn("reject", detail));
actions.appendChild(approvalActionBtn("revoke", detail));
} else if (detail.status) {
const pill = document.createElement("span");
pill.className = "approval-pill approval-pill--historic";
pill.textContent = t(("approvals.status." + detail.status) as I18nKey);
if (detail.decider_name && detail.status !== "revoked") {
const decided = document.createElement("span");
decided.className = "inbox-row-decided";
decided.textContent = ` · ${t("approvals.decided_by")} ${detail.decider_name}`;
pill.appendChild(decided);
}
actions.appendChild(pill);
}
li.appendChild(actions);
// Back-link from the OLD changes_requested row to the NEW pending
// counter row (t-paliad-216). Hydrated server-side as
// detail.next_request_id; the surface renders a link that scrolls
// / filters to the new row. Falsy next_request_id = no link (e.g.
// older rows pre-mig-103, or rows where the server hasn't joined the
// back-pointer).
if (detail.status === "changes_requested" && detail.next_request_id) {
const link = document.createElement("a");
link.className = "inbox-row-next-request";
link.href = `#request-${detail.next_request_id}`;
link.dataset.nextRequestId = detail.next_request_id;
const deciderName = detail.decider_name || "";
link.textContent = t("approvals.suggest.next_request_link").replace("{name}", deciderName);
li.appendChild(link);
}
ul.appendChild(li);
ul.appendChild(renderApprovalRow(row));
}
return ul;
}
// renderApprovalRow stamps one <li> for an approval_request row.
// Factored out of renderApprovalList in t-paliad-249 so the unified
// inbox dispatch (renderInboxList) can reuse the exact same markup for
// approval rows interleaved with project_event rows.
export function renderApprovalRow(row: ViewRow): HTMLLIElement {
const detail = (row.detail || {}) as ApprovalDetail;
const li = document.createElement("li");
li.className = "inbox-row views-approval-row";
li.dataset.requestId = row.id;
li.dataset.status = detail.status ?? "";
// Header: entity / lifecycle
const head = document.createElement("div");
head.className = "inbox-row-head";
const title = document.createElement("div");
title.className = "inbox-row-title";
const entityLabel = detail.entity_type ? t(("approvals.entity." + detail.entity_type) as I18nKey) : "";
const lifecycleLabel = detail.lifecycle_event ? t(("approvals.lifecycle." + detail.lifecycle_event) as I18nKey) : "";
const entityTitle = detail.entity_title || row.title || "—";
title.textContent = `${entityLabel}: ${entityTitle}${lifecycleLabel}`;
head.appendChild(title);
const meta = document.createElement("div");
meta.className = "inbox-row-meta";
const reqByLabel = t("approvals.requested_by");
const roleLabel = detail.required_role
? t(("approvals.required_role." + detail.required_role) as I18nKey)
: "";
const requester = detail.requester_name || row.actor_name || "";
const requesterTag = detail.requester_kind === "agent"
? `${requester}${t("approvals.agent.byline")}`
: requester;
const projectTitle = row.project_title ?? "";
const parts = [
projectTitle,
`${reqByLabel} ${requesterTag}`,
];
if (roleLabel) parts.push(`${roleLabel}+`);
parts.push(formatRelativeTime(row.event_date));
meta.textContent = parts.filter(Boolean).join(" · ");
head.appendChild(meta);
li.appendChild(head);
// Diff for update / complete
const diff = renderDiff(detail);
if (diff) li.appendChild(diff);
if (detail.decision_note) {
const note = document.createElement("div");
note.className = "inbox-row-note";
note.textContent = detail.decision_note;
li.appendChild(note);
}
// Action row — surface attaches handlers via data-attrs.
const actions = document.createElement("div");
actions.className = "inbox-row-actions";
if (detail.status === "pending") {
// All four actions are stamped on every pending row; the per-viewer
// viewer_can_approve / viewer_is_requester flags (resolved server-side)
// decide which are enabled vs. greyed out with a tooltip. m's ask
// (2026-05-17): show what's possible but disable what isn't, rather
// than alert-after-click. The server still enforces — disabled buttons
// are a UI hint, not a security gate.
//
// suggest_changes is hidden for non-update lifecycles (the backend
// returns ErrSuggestionLifecycleInvalid for create/complete/delete,
// so we don't even render the button for them).
actions.appendChild(approvalActionBtn("approve", detail));
if (detail.lifecycle_event === "update") {
actions.appendChild(approvalActionBtn("suggest_changes", detail));
}
actions.appendChild(approvalActionBtn("reject", detail));
actions.appendChild(approvalActionBtn("revoke", detail));
} else if (detail.status) {
const pill = document.createElement("span");
pill.className = "approval-pill approval-pill--historic";
pill.textContent = t(("approvals.status." + detail.status) as I18nKey);
if (detail.decider_name && detail.status !== "revoked") {
const decided = document.createElement("span");
decided.className = "inbox-row-decided";
decided.textContent = ` · ${t("approvals.decided_by")} ${detail.decider_name}`;
pill.appendChild(decided);
}
actions.appendChild(pill);
}
li.appendChild(actions);
// Back-link from the OLD changes_requested row to the NEW pending
// counter row (t-paliad-216). Hydrated server-side as
// detail.next_request_id; the surface renders a link that scrolls
// / filters to the new row. Falsy next_request_id = no link (e.g.
// older rows pre-mig-103, or rows where the server hasn't joined the
// back-pointer).
if (detail.status === "changes_requested" && detail.next_request_id) {
const link = document.createElement("a");
link.className = "inbox-row-next-request";
link.href = `#request-${detail.next_request_id}`;
link.dataset.nextRequestId = detail.next_request_id;
const deciderName = detail.decider_name || "";
link.textContent = t("approvals.suggest.next_request_link").replace("{name}", deciderName);
li.appendChild(link);
}
return li;
}
// ----------------------------------------------------------------------
// row_action = "inbox" — unified inbox layout (t-paliad-249)
//
// Dispatches per row.kind so approval_request rows reuse the existing
// approve/reject/revoke markup while project_event rows render as a
// compact stream row (timestamp + actor + title + project chip +
// Öffnen link to the underlying entity).
// ----------------------------------------------------------------------
function renderInboxList(rows: ViewRow[]): HTMLElement {
const ul = document.createElement("ul");
ul.className = "inbox-list inbox-list--unified";
for (const row of rows) {
if (row.kind === "approval_request") {
ul.appendChild(renderApprovalRow(row));
} else if (row.kind === "project_event") {
ul.appendChild(renderProjectEventInboxRow(row));
}
}
return ul;
}
interface ProjectEventDetail {
event_type?: string | null;
description?: string | null;
}
function renderProjectEventInboxRow(row: ViewRow): HTMLLIElement {
const detail = (row.detail || {}) as ProjectEventDetail;
const li = document.createElement("li");
li.className = "inbox-row inbox-row--project-event";
li.dataset.eventId = row.id;
if (detail.event_type) li.dataset.eventType = detail.event_type;
const head = document.createElement("div");
head.className = "inbox-row-head";
const title = document.createElement("div");
title.className = "inbox-row-title";
// Prefer the row.title (server-side authored, project-aware); fall
// back to a synthesised event-kind label so a malformed row never
// produces an empty <li>.
const kindLabelText = detail.event_type ? t(("event.title." + detail.event_type) as I18nKey) : "";
title.textContent = row.title || kindLabelText || "—";
head.appendChild(title);
const meta = document.createElement("div");
meta.className = "inbox-row-meta";
const parts: string[] = [];
if (row.project_title) parts.push(row.project_title);
if (row.actor_name) parts.push(row.actor_name);
parts.push(formatRelativeTime(row.event_date));
meta.textContent = parts.filter(Boolean).join(" · ");
head.appendChild(meta);
li.appendChild(head);
if (detail.description) {
const desc = document.createElement("div");
desc.className = "inbox-row-description";
desc.textContent = detail.description;
li.appendChild(desc);
}
const actions = document.createElement("div");
actions.className = "inbox-row-actions";
const openLink = projectEventLink(row, detail);
if (openLink) actions.appendChild(openLink);
li.appendChild(actions);
return li;
}
// projectEventLink builds an "Öffnen" anchor that points to the most
// useful target for the event kind. Falls back to the project detail
// page when the kind doesn't carry a richer pointer.
//
// Slice B can deepen this (e.g. note_created → scroll to note anchor);
// keep it minimal for Slice A.
function projectEventLink(row: ViewRow, detail: ProjectEventDetail): HTMLAnchorElement | null {
if (!row.project_id) return null;
const kind = detail.event_type ?? "";
const a = document.createElement("a");
a.className = "inbox-row-open";
a.textContent = t("inbox.action.open");
if (kind.startsWith("deadline_")) {
a.href = `/projects/${row.project_id}#deadlines`;
} else if (kind.startsWith("appointment_")) {
a.href = `/projects/${row.project_id}#appointments`;
} else if (kind === "note_created") {
a.href = `/projects/${row.project_id}#notes`;
} else {
a.href = `/projects/${row.project_id}`;
}
return a;
}
function renderDiff(detail: ApprovalDetail): HTMLElement | null {
const before = (detail.pre_image || {}) as Record<string, unknown>;
const after = (detail.payload || {}) as Record<string, unknown>;

View File

@@ -67,6 +67,11 @@ export interface FilterSpec {
scope: ScopeSpec;
time: TimeSpec;
predicates?: Partial<Record<DataSource, Predicates>>;
// Inbox unread-only overlay (t-paliad-249). When true, the view
// service drops project_event rows older than the caller's
// users.inbox_seen_at cursor. Pending approval_requests always
// survive — the cursor can't bury an in-flight approval.
unread_only?: boolean;
}
export type RenderShape = "list" | "cards" | "calendar" | "timeline";
@@ -79,7 +84,7 @@ export interface TimelineCVConfig {
range_to?: string;
}
export type ListRowAction = "navigate" | "complete_toggle" | "approve" | "none";
export type ListRowAction = "navigate" | "complete_toggle" | "approve" | "inbox" | "none";
export interface ListConfig {
columns?: string[];

View File

@@ -67,17 +67,20 @@ describe("deadlineCardHtml — editable=true emits click-to-edit attrs", () => {
});
});
// Pure column-routing behaviour pinned by m/paliad#81. Hits
// bucketDeadlinesIntoColumns directly so the assertions stay in
// pure-Node territory (renderColumnsBody goes through escHtml ->
// Pure column-routing behaviour. Originally pinned by m/paliad#81
// (side + appellant axes), re-framed by m/paliad#88: the column
// axis is now "Unsere Seite vs Gegnerseite" ("WE always on the
// left") instead of the misleading Proaktiv/Reaktiv pair.
// Hits bucketDeadlinesIntoColumns directly so the assertions stay
// in pure-Node territory (renderColumnsBody goes through escHtml ->
// document.createElement which isn't available in plain bun test).
//
// Scenario fixture mirrors the UPC Appeal "both parties" case m
// pasted into #81: every filing rule carries party='both' so the
// legacy mirror path duplicates every row across proactive +
// reactive. With ?appellant= set, the duplicate must collapse to a
// single row in the appellant's column.
describe("bucketDeadlinesIntoColumns — side+appellant column routing (m/paliad#81)", () => {
// legacy mirror path duplicates every row across both columns.
// With ?appellant= set, the duplicate must collapse to a single
// row in the appellant's column.
describe("bucketDeadlinesIntoColumns — side+appellant column routing (m/paliad#81, #88)", () => {
const both = (name: string, due: string): CalculatedDeadline => ({
code: name,
name,
@@ -96,39 +99,48 @@ describe("bucketDeadlinesIntoColumns — side+appellant column routing (m/paliad
party,
});
test("default (no opts) mirrors 'both' rules into proactive AND reactive — legacy behaviour preserved", () => {
test("default (no opts) mirrors 'both' rules into ours AND opponent — legacy behaviour preserved", () => {
const rows = bucketDeadlinesIntoColumns([both("Notice of Appeal", "2026-07-23")]);
expect(rows).toHaveLength(1);
expect(rows[0].proactive.map((d) => d.name)).toEqual(["Notice of Appeal"]);
expect(rows[0].reactive.map((d) => d.name)).toEqual(["Notice of Appeal"]);
expect(rows[0].ours.map((d) => d.name)).toEqual(["Notice of Appeal"]);
expect(rows[0].opponent.map((d) => d.name)).toEqual(["Notice of Appeal"]);
expect(rows[0].court).toHaveLength(0);
});
test("appellant=claimant collapses 'both' rules into proactive only — no mirror", () => {
test("default (no side) places claimant on the left (ours) — 'we are claimant' fallback", () => {
const rows = bucketDeadlinesIntoColumns([
partySpecific("claimant", "Klageschrift", "2026-01-01"),
partySpecific("defendant", "Klageerwiderung", "2026-04-01"),
]);
expect(rows[0].ours.map((d) => d.name)).toEqual(["Klageschrift"]);
expect(rows[1].opponent.map((d) => d.name)).toEqual(["Klageerwiderung"]);
});
test("appellant=claimant collapses 'both' rules into ours when side=claimant (or default)", () => {
const rows = bucketDeadlinesIntoColumns(
[both("Notice of Appeal", "2026-07-23"), both("Statement of Grounds", "2026-09-23")],
{ appellant: "claimant" },
);
expect(rows.map((r) => r.proactive.map((d) => d.name))).toEqual([
expect(rows.map((r) => r.ours.map((d) => d.name))).toEqual([
["Notice of Appeal"],
["Statement of Grounds"],
]);
rows.forEach((r) => expect(r.reactive).toHaveLength(0));
rows.forEach((r) => expect(r.opponent).toHaveLength(0));
});
test("appellant=defendant collapses 'both' rules into reactive only", () => {
test("appellant=defendant collapses 'both' rules into opponent when side=null/claimant", () => {
const rows = bucketDeadlinesIntoColumns(
[both("Notice of Appeal", "2026-07-23")],
{ appellant: "defendant" },
);
expect(rows[0].proactive).toHaveLength(0);
expect(rows[0].reactive.map((d) => d.name)).toEqual(["Notice of Appeal"]);
expect(rows[0].ours).toHaveLength(0);
expect(rows[0].opponent.map((d) => d.name)).toEqual(["Notice of Appeal"]);
});
test("side=defendant swaps which column owns claimant vs defendant rules", () => {
// claimant filing must land in REACTIVE (claimant is the opposing
// side from the defendant user's perspective), defendant filing in
// PROACTIVE. Court rules always go to court.
test("side=defendant flips which party owns 'ours' vs 'opponent' — WE always on the left", () => {
// User is on the defendant side: defendant filings land in 'ours'
// (left), claimant filings land in 'opponent' (right). Court rules
// stay in court regardless of side.
const rows = bucketDeadlinesIntoColumns(
[
partySpecific("claimant", "Klageschrift", "2026-01-01"),
@@ -137,20 +149,33 @@ describe("bucketDeadlinesIntoColumns — side+appellant column routing (m/paliad
],
{ side: "defendant" },
);
expect(rows[0].reactive.map((d) => d.name)).toEqual(["Klageschrift"]);
expect(rows[1].proactive.map((d) => d.name)).toEqual(["Klageerwiderung"]);
expect(rows[0].opponent.map((d) => d.name)).toEqual(["Klageschrift"]);
expect(rows[1].ours.map((d) => d.name)).toEqual(["Klageerwiderung"]);
expect(rows[2].court.map((d) => d.name)).toEqual(["Urteil"]);
});
test("side=defendant + appellant=defendant routes 'both' into PROACTIVE (user's own column)", () => {
test("side=defendant + appellant=defendant routes 'both' into 'ours' (user's own column)", () => {
// The user is the defendant AND the appellant, so the appellant's
// column == the user's own column == proactive after the swap.
// column == the user's own column == ours after the swap.
const rows = bucketDeadlinesIntoColumns(
[both("Notice of Appeal", "2026-07-23")],
{ side: "defendant", appellant: "defendant" },
);
expect(rows[0].proactive.map((d) => d.name)).toEqual(["Notice of Appeal"]);
expect(rows[0].reactive).toHaveLength(0);
expect(rows[0].ours.map((d) => d.name)).toEqual(["Notice of Appeal"]);
expect(rows[0].opponent).toHaveLength(0);
});
test("side=defendant + appellant=claimant routes 'both' into opponent (claimant ≠ us)", () => {
// Side flip + appellant axis combined: the claimant is the appellant
// but NOT us, so the collapsed 'both' row lands in the opponent
// column (right). This is the UPC Appeal "they appealed, we
// respond" scenario.
const rows = bucketDeadlinesIntoColumns(
[both("Notice of Appeal", "2026-07-23")],
{ side: "defendant", appellant: "claimant" },
);
expect(rows[0].opponent.map((d) => d.name)).toEqual(["Notice of Appeal"]);
expect(rows[0].ours).toHaveLength(0);
});
test("rows align across columns by dueDate so same-day events stay on one grid row", () => {
@@ -161,8 +186,8 @@ describe("bucketDeadlinesIntoColumns — side+appellant column routing (m/paliad
partySpecific("court", "C", sameDate),
]);
expect(rows).toHaveLength(1);
expect(rows[0].proactive.map((d) => d.name)).toEqual(["A"]);
expect(rows[0].reactive.map((d) => d.name)).toEqual(["B"]);
expect(rows[0].ours.map((d) => d.name)).toEqual(["A"]);
expect(rows[0].opponent.map((d) => d.name)).toEqual(["B"]);
expect(rows[0].court.map((d) => d.name)).toEqual(["C"]);
});
@@ -172,7 +197,7 @@ describe("bucketDeadlinesIntoColumns — side+appellant column routing (m/paliad
partySpecific("claimant", "Statement of Claim", "2026-01-01"),
partySpecific("court", "Decision", ""),
]);
expect(rows.map((r) => [r.proactive, r.court, r.reactive].flat().map((d) => d.name))).toEqual([
expect(rows.map((r) => [r.ours, r.court, r.opponent].flat().map((d) => d.name))).toEqual([
["Statement of Claim"],
["Oral Hearing"],
["Decision"],

View File

@@ -422,42 +422,47 @@ export function renderTimelineBody(data: DeadlineResponse, opts: CardOpts = { sh
return html;
}
// Three-column timeline layout: Proactive | Court | Reactive.
// Three-column timeline layout: Unsere Seite | Gericht | Gegnerseite.
//
// Column assignment per deadline (see m/paliad#81):
// The columns are user-perspective ("WE are always on the left", per
// t-paliad-257 / m/paliad#88). The old Proaktiv/Reaktiv axis lied:
// Klägerseite is sometimes proactive (filing the claim) and sometimes
// reactive (responding to a counterclaim), so the static "Proaktiv =
// Klägerseite" label-pair was wrong half the time. The new axis is
// "ours vs opponent" — the side toggle picks who WE are in this
// proceeding (Klägerseite vs Beklagtenseite, i.e. patentee vs alleged
// infringer / Einsprechender vs Patentinhaber, etc.), and rule
// placement re-resolves around that pick.
//
// - party=claimant → proactive
// - party=defendant → reactive
// - party=court → court
// - party=both → BOTH proactive AND reactive (mirror).
// Column assignment per deadline (default opts.side === null keeps
// the legacy claimant-on-the-left layout — i.e. "we are claimant"):
//
// - party=claimant → ours when side ∈ {null,"claimant"}, else opponent
// - party=defendant → opponent when side ∈ {null,"claimant"}, else ours
// - party=court → court (independent of side)
// - party=both → BOTH ours AND opponent (mirror)
//
// When `opts.appellant` is set (claimant|defendant), "both" rows
// collapse to a single row in the appellant's column. The intent is
// role-swap proceedings (UPC Appeal, Counterclaim, …) where the
// "both" tag really means "either party files, depending on who
// initiated" — once you pick the initiator, the duplicate goes away.
// Hard rule from the issue: "When set, 'both parties' rows collapse
// to one row in the appellant's column." This is a UI projection
// only; the deadline_rules schema is unchanged. A follow-up issue
// can enrich per-rule role tagging so respondent-side filings
// (Response to Appeal, Cross-Appeal) land in the respondent's
// column — out of scope for #81.
//
// `opts.side` controls the column LABELS: side=defendant swaps the
// "Proactive (Klägerseite)" / "Reactive (Beklagtenseite)" headers
// so the user's own side is the proactive (= "your filings") column.
// It does NOT filter deadlines — the user still sees all deadlines
// in the proceeding. Default `side=null` keeps the legacy
// claimant-on-the-left layout. Unscheduled (court-set) rows trail
// the dated tail, each keyed by sequence-order so e.g. Urteil
// precedes Berufungseinlegung.
// collapse to a single row in the appellant's column — the intent is
// role-swap proceedings (UPC Appeal, Counterclaim, …) where "both"
// really means "either party files, depending on who initiated".
// Appellant axis is independent of `side`: in an Appeal CoA, the
// appellant selector pins which party appealed; the side toggle
// still picks which of those is us.
export type Side = "claimant" | "defendant" | null;
// Internal column-position alias. "ours" is always rendered in the
// left grid column ("Unsere Seite"); "opponent" is always the right
// column ("Gegnerseite"). Field names mirror the labels so the
// bucketing primitive reads as a direct mapping.
type ColumnPosition = "ours" | "opponent";
export interface ColumnsBodyOpts {
editable?: boolean;
showNotes?: boolean;
// side: which side the user is on. Drives column-label swap;
// does NOT filter rows. Default null = claimant-on-the-left.
// side: which side the user is on. Drives column placement;
// does NOT filter rows. Default null = claimant-on-the-left
// (i.e. "ours = claimant", legacy default).
side?: Side;
// appellant: which side initiated the appeal / counterclaim.
// When set, party=both rows go to the appellant's column ONLY
@@ -471,9 +476,9 @@ export interface ColumnsBodyOpts {
// document.createElement (no jsdom in this repo).
export interface ColumnsRow {
key: string;
proactive: CalculatedDeadline[];
ours: CalculatedDeadline[];
court: CalculatedDeadline[];
reactive: CalculatedDeadline[];
opponent: CalculatedDeadline[];
}
export interface BucketingOpts {
@@ -484,17 +489,20 @@ export interface BucketingOpts {
// bucketDeadlinesIntoColumns is the pure routing primitive that
// renderColumnsBody uses. Extracted as its own export so the per-row
// column placement (including the side-swap + appellant-collapse
// logic from m/paliad#81) is unit-testable without a DOM. The
// returned rows are sorted: dated rows ascending by dueDate, then
// unscheduled rows in declaration order (each keyed by sequence).
// logic from m/paliad#81 and the user-perspective re-frame from
// m/paliad#88) is unit-testable without a DOM. The returned rows are
// sorted: dated rows ascending by dueDate, then unscheduled rows in
// declaration order (each keyed by sequence).
export function bucketDeadlinesIntoColumns(
deadlines: CalculatedDeadline[],
opts: BucketingOpts = {},
): ColumnsRow[] {
const userSide: Side = opts.side ?? null;
const claimantColumn: "proactive" | "reactive" = userSide === "defendant" ? "reactive" : "proactive";
const defendantColumn: "proactive" | "reactive" = claimantColumn === "proactive" ? "reactive" : "proactive";
const appellantColumn: "proactive" | "reactive" | null =
// Default (side=null) treats the user as claimant — keeps the
// legacy claimant-on-the-left layout when no perspective is picked.
const claimantColumn: ColumnPosition = userSide === "defendant" ? "opponent" : "ours";
const defendantColumn: ColumnPosition = claimantColumn === "ours" ? "opponent" : "ours";
const appellantColumn: ColumnPosition | null =
opts.appellant === "claimant" ? claimantColumn
: opts.appellant === "defendant" ? defendantColumn
: null;
@@ -504,7 +512,7 @@ export function bucketDeadlinesIntoColumns(
const ensureRow = (key: string): ColumnsRow => {
let r = rowsMap.get(key);
if (!r) {
r = { key, proactive: [], court: [], reactive: [] };
r = { key, ours: [], court: [], opponent: [] };
rowsMap.set(key, r);
}
return r;
@@ -529,8 +537,8 @@ export function bucketDeadlinesIntoColumns(
// in appellant's column. Mirror suppressed.
row[appellantColumn].push(dl);
} else {
row.proactive.push(dl);
row.reactive.push(dl);
row.ours.push(dl);
row.opponent.push(dl);
}
break;
default:
@@ -552,17 +560,14 @@ export function bucketDeadlinesIntoColumns(
export function renderColumnsBody(data: DeadlineResponse, opts: ColumnsBodyOpts = {}): string {
const userSide: Side = opts.side ?? null;
const rows = bucketDeadlinesIntoColumns(data.deadlines, { side: userSide, appellant: opts.appellant });
const appellantColumn: "proactive" | "reactive" | null =
opts.appellant === "claimant" ? (userSide === "defendant" ? "reactive" : "proactive")
: opts.appellant === "defendant" ? (userSide === "defendant" ? "proactive" : "reactive")
: null;
const appellantPinned = opts.appellant === "claimant" || opts.appellant === "defendant";
const cardOpts: CardOpts = { showParty: false, editable: opts.editable, showNotes: opts.showNotes };
// Collapsed "both" rows lose their mirror tag — there's no longer
// a sibling row to mirror to, so the "↔ beide Seiten" hint would
// be misleading. Keep it for the legacy mirror path.
const showMirrorTag = appellantColumn === null;
const showMirrorTag = !appellantPinned;
const renderCell = (items: CalculatedDeadline[]): string => {
if (items.length === 0) {
@@ -585,25 +590,19 @@ export function renderColumnsBody(data: DeadlineResponse, opts: ColumnsBodyOpts
const headerCell = (label: string, cls: string) =>
`<div class="fr-col-header ${cls}">${escHtml(label)}</div>`;
// Column-label swap when side=defendant: the user's own side stays
// labelled "Proaktiv" (their filings) and the opposing side is
// "Reaktiv". Default keeps the legacy claimant=proactive labels.
const proactiveLabel = userSide === "defendant"
? t("deadlines.col.proactive.defendant")
: t("deadlines.col.proactive");
const reactiveLabel = userSide === "defendant"
? t("deadlines.col.reactive.claimant")
: t("deadlines.col.reactive");
// Static labels — "Unsere Seite" is always the left column, regardless
// of which physical party (claimant vs defendant) occupies it. The
// bucketing primitive already routes the user's side into the `ours`
// bucket, so the header truth-fully describes the column contents.
let html = '<div class="fr-columns-view">';
html += headerCell(proactiveLabel, "fr-col-proactive");
html += headerCell(t("deadlines.col.ours"), "fr-col-ours");
html += headerCell(t("deadlines.col.court"), "fr-col-court");
html += headerCell(reactiveLabel, "fr-col-reactive");
html += headerCell(t("deadlines.col.opponent"), "fr-col-opponent");
for (const row of rows) {
html += renderCell(row.proactive);
html += renderCell(row.ours);
html += renderCell(row.court);
html += renderCell(row.reactive);
html += renderCell(row.opponent);
}
html += "</div>";
return html;

View File

@@ -207,6 +207,7 @@ export function Sidebar({ currentPath, authenticated = true }: SidebarProps): st
{navItem("/admin/rules", ICON_BOOK, "nav.admin.rules", "Regeln verwalten", currentPath)}
{navItem("/admin/rules/export", ICON_DOWNLOAD, "nav.admin.rules_export", "Regel-Migrations", currentPath)}
{navItem("/admin/audit-log", ICON_AUDIT_LOG, "nav.admin.audit", "Audit-Log", currentPath)}
{navItem("/admin/backups", ICON_DOWNLOAD, "nav.admin.backups", "Backups", currentPath)}
{/* Paliadin Monitor — owner-only sub-entry; revealed by sidebar.ts together with the /paliadin link. */}
<a href="/admin/paliadin" id="sidebar-admin-paliadin-link"
className={`sidebar-item${currentPath === "/admin/paliadin" ? " active" : ""}`}

View File

@@ -41,6 +41,19 @@ export function renderDeadlinesDetail(): string {
<div className="entity-detail-title-col">
<h1 id="deadline-title-display" />
<input type="text" id="deadline-title-edit" className="entity-title-input" style="display:none" />
{/* t-paliad-251 Part 4 — Standardtitel button only
visible in edit mode; clicking replaces the
title with a default derived from the project
and the deadline's event types / rule. */}
<button
type="button"
id="deadline-title-default-btn"
className="btn-link-action"
style="display:none"
data-i18n="deadlines.field.title.default_btn"
>
Standardtitel
</button>
<div className="entity-detail-meta">
<span id="deadline-due-chip" className="frist-due-chip" />
<span id="deadline-status-chip" className="entity-status-chip" />
@@ -95,7 +108,36 @@ export function renderDeadlinesDetail(): string {
</dd>
<dt data-i18n="deadlines.detail.rule">Regel</dt>
<dd id="deadline-rule-display">&mdash;</dd>
<dd>
<span id="deadline-rule-display">&mdash;</span>
{/* t-paliad-258 — Auto / Custom rule editor.
Mirrors /deadlines/new: read-only Auto display
(resolved from Type) or free-text Custom input,
with a toggle link. Hidden outside edit mode. */}
<div className="rule-edit-block" id="deadline-rule-edit" style="display:none">
<button
type="button"
id="deadline-rule-mode-toggle"
className="btn-link-action"
data-i18n="deadlines.field.rule.mode.toggle_to_custom"
>
Eigene Regel eingeben
</button>
<div className="rule-mode-auto" id="deadline-rule-auto-display">
<span className="form-hint-badge" data-i18n="deadlines.field.rule.auto_badge">Auto</span>
<span id="deadline-rule-auto-text" className="rule-auto-text">&mdash;</span>
</div>
<input
type="text"
id="deadline-rule-custom-input"
className="rule-mode-custom"
style="display:none"
placeholder="z.B. interner Review-Termin"
data-i18n-placeholder="deadlines.field.rule.custom_placeholder"
maxLength={200}
/>
</div>
</dd>
<dt data-i18n="deadlines.detail.source">Quelle</dt>
<dd id="deadline-source-display" />

View File

@@ -45,7 +45,22 @@ export function renderDeadlinesNew(): string {
</div>
<div className="form-field">
<label htmlFor="deadline-title" data-i18n="deadlines.field.title">Titel</label>
<div className="form-field-label-row">
<label htmlFor="deadline-title" data-i18n="deadlines.field.title">Titel</label>
{/* t-paliad-251 Part 4 — derive a Standardtitel from the
currently-known context (event type → rule → proceeding
type → fallback) with the project reference as suffix.
Always replaces the title; no destructive confirmation
because the user invoked it explicitly. */}
<button
type="button"
id="deadline-title-default-btn"
className="btn-link-action"
data-i18n="deadlines.field.title.default_btn"
>
Standardtitel
</button>
</div>
<input
type="text"
id="deadline-title"
@@ -57,58 +72,42 @@ export function renderDeadlinesNew(): string {
<div className="form-field" id="deadline-event-type-field">
<label data-i18n="deadlines.field.event_type">Typ (optional)</label>
{/* t-paliad-165 follow-up — collapsed view: when a Regel
is selected and a default event_type is known, the
Typ chip is hidden and the type is rendered inline
as a single read-only summary with an "Anderen Typ
wählen" link that re-expands the picker. */}
<div
className="event-type-collapsed"
id="deadline-event-type-collapsed"
style="display:none"
>
<span
className="event-type-collapsed-label"
id="deadline-event-type-collapsed-label"
/>
<span
className="event-type-collapsed-source"
data-i18n="deadlines.field.rule.autofill_inline"
>
&nbsp;(vorgegeben durch Regel)
</span>
<button
type="button"
className="event-type-collapsed-override"
id="deadline-event-type-override-btn"
data-i18n="deadlines.field.rule.override"
>
Anderen Typ w&auml;hlen
</button>
</div>
<div id="deadline-event-types" className="event-type-picker-host" />
{/* Soft warning when the user is in expanded mode AND
has picked an event_type that doesn't include the
rule's canonical default. Reuses the existing
yellow form-hint--warning style; never blocking. */}
<p
className="form-hint form-hint--warning"
id="deadline-event-type-rule-mismatch"
style="display:none"
data-i18n="deadlines.field.rule.mismatch"
>
Hinweis: Typ widerspricht Regel &mdash; Sie haben den Typ &uuml;berschrieben.
</p>
</div>
{/* m/paliad#56 — Regel sits directly beneath the Typ
picker so the parent/child relationship reads at a
glance. Due date is its own row below. */}
{/* t-paliad-258 / m/paliad#89 — binary Rule field.
Auto (default): rule_id derived from the chosen
Type, displayed read-only with a canonical
"Name · Citation" label. Custom: free-text input,
no catalog FK. Toggle switches modes. */}
<div className="form-field">
<label htmlFor="deadline-rule" data-i18n="deadlines.field.rule">Regel (optional)</label>
<select id="deadline-rule">
<option value="" data-i18n="deadlines.field.rule.none">Keine Regel</option>
</select>
<div className="form-field-label-row">
<label data-i18n="deadlines.field.rule">Regel</label>
<button
type="button"
id="deadline-rule-mode-toggle"
className="btn-link-action"
data-i18n="deadlines.field.rule.mode.toggle_to_custom"
>
Eigene Regel eingeben
</button>
</div>
<div className="rule-mode-auto" id="deadline-rule-auto-display">
<span
className="form-hint-badge"
data-i18n="deadlines.field.rule.auto_badge"
>Auto</span>
<span id="deadline-rule-auto-text" className="rule-auto-text">&mdash;</span>
</div>
<input
type="text"
id="deadline-rule-custom-input"
className="rule-mode-custom"
style="display:none"
placeholder="z.B. interner Review-Termin"
data-i18n-placeholder="deadlines.field.rule.custom_placeholder"
maxLength={200}
/>
</div>
<div className="form-field">

View File

@@ -90,6 +90,28 @@ export type I18nKey =
| "admin.audit.source.reminder_log"
| "admin.audit.subtitle"
| "admin.audit.title"
| "admin.backups.col.actions"
| "admin.backups.col.kind"
| "admin.backups.col.requested_by"
| "admin.backups.col.rows"
| "admin.backups.col.size"
| "admin.backups.col.started"
| "admin.backups.col.status"
| "admin.backups.download"
| "admin.backups.empty"
| "admin.backups.footer.note"
| "admin.backups.heading"
| "admin.backups.kind.on_demand"
| "admin.backups.kind.scheduled"
| "admin.backups.loading"
| "admin.backups.run_now"
| "admin.backups.running"
| "admin.backups.status.done"
| "admin.backups.status.failed"
| "admin.backups.status.running"
| "admin.backups.subtitle"
| "admin.backups.success"
| "admin.backups.title"
| "admin.broadcasts.col.count"
| "admin.broadcasts.col.sender"
| "admin.broadcasts.col.sent_at"
@@ -682,9 +704,20 @@ export type I18nKey =
| "approvals.tab.mine"
| "approvals.tab.pending_mine"
| "approvals.title"
| "approvals.withdraw.cancel"
| "approvals.withdraw.confirm"
| "approvals.withdraw.cta"
| "approvals.withdraw.destructive.label"
| "approvals.withdraw.error"
| "approvals.withdraw.lead.create.appointment"
| "approvals.withdraw.lead.create.deadline"
| "approvals.withdraw.lead.delete"
| "approvals.withdraw.lead.update"
| "approvals.withdraw.modal.title"
| "approvals.withdraw.primary.label"
| "approvals.withdraw.sub.create"
| "approvals.withdraw.sub.delete"
| "approvals.withdraw.sub.update"
| "bottomnav.add"
| "bottomnav.add.appointment"
| "bottomnav.add.appointment.sub"
@@ -1142,10 +1175,8 @@ export type I18nKey =
| "deadlines.col.court"
| "deadlines.col.due"
| "deadlines.col.event_type"
| "deadlines.col.proactive"
| "deadlines.col.proactive.defendant"
| "deadlines.col.reactive"
| "deadlines.col.reactive.claimant"
| "deadlines.col.opponent"
| "deadlines.col.ours"
| "deadlines.col.rule"
| "deadlines.col.status"
| "deadlines.col.title"
@@ -1233,12 +1264,16 @@ export type I18nKey =
| "deadlines.field.notes"
| "deadlines.field.notes.placeholder"
| "deadlines.field.rule"
| "deadlines.field.rule.autofill"
| "deadlines.field.rule.autofill_inline"
| "deadlines.field.rule.mismatch"
| "deadlines.field.rule.none"
| "deadlines.field.rule.override"
| "deadlines.field.rule.auto_badge"
| "deadlines.field.rule.auto_no_match"
| "deadlines.field.rule.auto_pick_type"
| "deadlines.field.rule.custom_badge"
| "deadlines.field.rule.custom_placeholder"
| "deadlines.field.rule.mode.toggle_to_auto"
| "deadlines.field.rule.mode.toggle_to_custom"
| "deadlines.field.title"
| "deadlines.field.title.default_btn"
| "deadlines.field.title.default_fallback"
| "deadlines.field.title.placeholder"
| "deadlines.filter.akte"
| "deadlines.filter.akte.all"
@@ -1584,6 +1619,8 @@ export type I18nKey =
| "event_types.browse.apply"
| "event_types.browse.cancel"
| "event_types.browse.empty"
| "event_types.browse.jurisdiction.all"
| "event_types.browse.jurisdiction.filter_label"
| "event_types.browse.jurisdiction.none"
| "event_types.browse.search"
| "event_types.browse.selected_count"
@@ -1726,9 +1763,15 @@ export type I18nKey =
| "glossar.suggest.success"
| "glossar.suggest.title"
| "glossar.title"
| "inbox.action.mark_all_seen"
| "inbox.action.open"
| "inbox.empty.admin_nudge.body"
| "inbox.empty.admin_nudge.cta"
| "inbox.empty.admin_nudge.title"
| "inbox.empty.feed"
| "inbox.heading.feed"
| "inbox.subtitle.feed"
| "inbox.title.feed"
| "index.checklisten.desc"
| "index.checklisten.title"
| "index.cost.desc"
@@ -1879,6 +1922,7 @@ export type I18nKey =
| "login.title"
| "modal.close.label"
| "nav.admin.audit"
| "nav.admin.backups"
| "nav.admin.bereich"
| "nav.admin.event_types"
| "nav.admin.paliadin"
@@ -2646,12 +2690,17 @@ export type I18nKey =
| "views.bar.deadline_status.pending"
| "views.bar.density.comfortable"
| "views.bar.density.compact"
| "views.bar.inbox_focus.alles"
| "views.bar.inbox_focus.genehmigungen"
| "views.bar.inbox_focus.plus_fristen"
| "views.bar.inbox_focus.plus_termine"
| "views.bar.label.appointment_type"
| "views.bar.label.approval_entity"
| "views.bar.label.approval_role"
| "views.bar.label.approval_status"
| "views.bar.label.deadline_status"
| "views.bar.label.density"
| "views.bar.label.inbox_focus"
| "views.bar.label.personal"
| "views.bar.label.project_event_kind"
| "views.bar.label.shape"
@@ -2659,6 +2708,7 @@ export type I18nKey =
| "views.bar.label.time"
| "views.bar.label.timeline_status"
| "views.bar.label.timeline_track"
| "views.bar.label.unread_only"
| "views.bar.personal.on"
| "views.bar.save.cancel"
| "views.bar.save.confirm"
@@ -2698,6 +2748,8 @@ export type I18nKey =
| "views.bar.timeline_track.counterclaim"
| "views.bar.timeline_track.off_script"
| "views.bar.timeline_track.parent"
| "views.bar.unread_only.off"
| "views.bar.unread_only.on"
| "views.calendar.mobile_fallback"
| "views.col.actor"
| "views.col.appointment_type"

View File

@@ -5,15 +5,14 @@ import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
// /inbox — t-paliad-163 universal-filter migration.
// /inbox — t-paliad-249 unified inbox feed.
//
// The page is a thin shell around two host divs: one for the
// <FilterBar> primitive and one for the result list. The bar takes
// care of every axis (approval_viewer_role chip cluster replaces the
// two-tab UI; status / entity_type / time chips are new affordances).
// Rows render via shape-list.ts with row_action="approve" — the
// inbox-specific markup that produces the diff + approve/reject/revoke
// buttons. Action handlers are wired in client/inbox.ts.
// Since t-paliad-249 the page is a thin shell around the FilterBar +
// result list as before, but the InboxSystemView now spans both
// approval_request and project_event sources. Rows render via
// shape-list.ts's row_action="inbox" dispatch — approval rows keep
// the existing diff + approve/reject/revoke markup, project_event
// rows render as compact stream items.
//
// The legacy `?tab=` URL is preserved by the client: ?tab=mine maps
// to ?a_role=self_requested before the bar mounts so old bookmarks
@@ -28,7 +27,7 @@ export function renderInbox(): string {
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#BFF355" />
<PWAHead />
<title data-i18n="approvals.title">Genehmigungen &mdash; Paliad</title>
<title data-i18n="inbox.title.feed">Inbox &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
@@ -39,10 +38,24 @@ export function renderInbox(): string {
<section className="tool-page">
<div className="container">
<div className="tool-header">
<h1 data-i18n="approvals.heading">Genehmigungen</h1>
<p className="tool-subtitle" data-i18n="approvals.subtitle">
4-Augen-Pr&uuml;fung f&uuml;r Fristen und Termine.
</p>
<div className="entity-header-row">
<div>
<h1 data-i18n="inbox.heading.feed">Inbox</h1>
<p className="tool-subtitle" data-i18n="inbox.subtitle.feed">
Neuigkeiten zu Ihren Projekten und offene Genehmigungen.
</p>
</div>
<div className="inbox-header-actions">
<button
type="button"
id="inbox-mark-all-seen"
className="btn-secondary"
data-i18n="inbox.action.mark_all_seen"
>
Alles als gelesen markieren
</button>
</div>
</div>
</div>
<div id="inbox-filter-bar" />

View File

@@ -3629,7 +3629,7 @@ input[type="range"]::-moz-range-thumb {
z-index: 1;
}
.fr-col-header.fr-col-proactive {
.fr-col-header.fr-col-ours {
background: var(--status-blue-bg);
color: var(--status-blue-fg);
}
@@ -3639,7 +3639,7 @@ input[type="range"]::-moz-range-thumb {
color: var(--status-blue-soft-fg);
}
.fr-col-header.fr-col-reactive {
.fr-col-header.fr-col-opponent {
background: var(--status-amber-bg);
color: var(--status-amber-fg);
}
@@ -5725,6 +5725,21 @@ dialog.modal::backdrop {
overflow-y: auto;
}
/* t-paliad-260 — at single-column widths, drop the sticky/max-height
constraints on the variable editor so it reflows above the preview
and scrolls away naturally instead of overlaying the preview pane
(sticky + calc(100vh - 2rem) keep the form pinned at the top of the
viewport while the user scrolls down to read the preview). Must come
after the unscoped .submission-draft-sidebar block to win source
order at equal specificity. */
@media (max-width: 900px) {
.submission-draft-sidebar {
position: static;
max-height: none;
overflow-y: visible;
}
}
.submission-draft-switcher {
display: flex;
align-items: center;
@@ -5865,6 +5880,66 @@ dialog.modal::backdrop {
font-style: italic;
}
/* t-paliad-261 (B) — substituted variables in the preview are wrapped
in <span class="draft-var" data-var="…"> by the Go HTML renderer.
.draft-var by itself shows a subtle dotted underline so the lawyer
can SEE which text was filled in from a variable. .draft-var--has-input
(added client-side when a matching sidebar input exists) layers on
the clickable affordance — pointer cursor + brighter hover background.
Non-matching draft-vars (derived variables not exposed in the
sidebar) stay visually distinct but non-interactive. */
.draft-var {
background-color: rgba(198, 244, 28, 0.12);
border-radius: 2px;
padding: 0 2px;
box-decoration-break: clone;
-webkit-box-decoration-break: clone;
transition: background-color 0.15s ease;
}
.draft-var--has-input {
cursor: pointer;
}
.draft-var--has-input:hover,
.draft-var--has-input:focus-visible {
background-color: rgba(198, 244, 28, 0.45);
outline: none;
}
/* t-paliad-261 (B) — brief lime flash on the sidebar row after a
click-jump from the preview, so the user's eye lands on the right
input even after the smooth-scroll motion. Animation restarts on
each click via class-remove + reflow + class-add. */
.submission-draft-var-row--flash {
animation: paliad-var-flash 1.2s ease;
border-radius: 4px;
}
@keyframes paliad-var-flash {
0% {
background-color: rgba(198, 244, 28, 0.55);
box-shadow: 0 0 0 4px rgba(198, 244, 28, 0.25);
}
100% {
background-color: transparent;
box-shadow: 0 0 0 4px transparent;
}
}
@media (prefers-reduced-motion: reduce) {
.submission-draft-var-row--flash {
animation: paliad-var-flash-still 1.2s steps(1, end);
}
@keyframes paliad-var-flash-still {
0%, 99% { background-color: rgba(198, 244, 28, 0.55); }
100% { background-color: transparent; }
}
.draft-var {
transition: none;
}
}
.submission-edit-btn {
margin-right: 0.4rem;
}
@@ -6569,12 +6644,18 @@ dialog.modal::backdrop {
/* Each filter is a label-above-control cell so the caption sits on top of
its select / button. The whole filter-row stays a horizontal flex-wrap
of these column-cells (t-paliad-117). */
of these column-cells (t-paliad-117).
min-width: 0 + max-width: 100% lets the cell shrink to fit its flex
container and prevents a native <select> with long option text from
blowing the cell wider than the viewport (t-paliad-255). */
.filter-group {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 0.25rem;
min-width: 0;
max-width: 100%;
}
.filter-label {
@@ -6588,6 +6669,10 @@ dialog.modal::backdrop {
.filter-group .entity-select { width: 100%; }
}
/* max-width: 100% caps the intrinsic width of a native <select> at its
parent — without it, browsers size the select to the longest <option>
text and a very long project title overflows the viewport on tablet
widths above the 480px breakpoint (t-paliad-255). */
.entity-select {
padding: 0.4rem 0.75rem;
font-size: 0.85rem;
@@ -6596,6 +6681,8 @@ dialog.modal::backdrop {
background: var(--color-surface);
color: var(--color-text);
cursor: pointer;
max-width: 100%;
min-width: 0;
}
.entity-select:focus {
@@ -7544,6 +7631,126 @@ dialog.modal::backdrop {
border-left: 2px solid #b88800;
}
/* t-paliad-251 — Auto-derived hint variant. Lime-tint, sibling of the
yellow warning variant. Carries a small pill-badge in front (the
"Auto" label) followed by the derived rule name. */
.form-hint--auto {
display: inline-flex;
align-items: center;
gap: 0.4rem;
background: var(--color-bg-lime-tint);
color: var(--color-text);
padding: 0.3rem 0.5rem;
border-radius: var(--radius-sm, 4px);
border-left: 2px solid var(--color-accent);
}
.form-hint-badge {
display: inline-block;
padding: 0.05rem 0.45rem;
border-radius: 999px;
background: var(--color-accent);
color: var(--color-text);
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
/* t-paliad-251 — label row that hosts both the form label and an
inline action (Standardtitel button, Rule-sort dropdown). The label
keeps growing to push the action to the right edge. */
.form-field-label-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 0.25rem;
}
.form-field-label-row > label {
margin: 0;
}
/* Inline action button rendered next to a form label (Standardtitel).
Text-link styling so it doesn't compete with the primary CTA. */
.btn-link-action {
background: transparent;
border: none;
color: var(--color-link, var(--color-text));
padding: 0;
font-family: var(--font-sans);
font-size: 0.82rem;
cursor: pointer;
text-decoration: underline;
text-underline-offset: 2px;
}
.btn-link-action:hover {
color: var(--color-accent);
}
/* t-paliad-258 — Auto/Custom Rule editor (m/paliad#89).
Replaces the t-paliad-251 catalog dropdown + sort selector with a
binary toggle:
.rule-mode-auto — read-only display, lime-tint pill + label.
.rule-mode-custom — free-text input, full-width.
Toggle button reuses .btn-link-action for the inline link styling. */
.rule-mode-auto {
display: inline-flex;
align-items: center;
gap: 0.45rem;
padding: 0.35rem 0.55rem;
background: var(--color-bg-lime-tint);
border-left: 2px solid var(--color-accent);
border-radius: var(--radius-sm, 4px);
min-height: 2rem;
}
.rule-auto-text {
color: var(--color-text);
font-size: 0.95rem;
}
.rule-auto-text--empty {
color: var(--color-text-muted, #6b7280);
font-style: italic;
}
.form-field input.rule-mode-custom,
input.rule-mode-custom {
width: 100%;
padding: 0.45rem 0.6rem;
font-size: 0.95rem;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm, 4px);
color: var(--color-text);
font-family: var(--font-sans);
}
/* t-paliad-258 addendum — canonical rule label display:
Name primary, Citation muted secondary ("Name · Citation").
Custom rules use a "Custom" pill instead of a citation. */
.rule-label-name {
color: var(--color-text);
}
.rule-label-sep,
.rule-label-cite {
color: var(--color-text-muted, #6b7280);
font-size: 0.9em;
}
.rule-label-cite {
margin-left: 0.15rem;
}
.rule-label-badge {
display: inline-block;
margin-left: 0.4rem;
padding: 0.02rem 0.4rem;
border-radius: 999px;
background: var(--color-bg-lime-tint);
color: var(--color-text);
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.03em;
text-transform: uppercase;
border: 1px solid var(--color-accent);
}
/* Inline checkbox label inside the attach-unit form. */
.form-checkbox {
display: inline-flex;
@@ -7651,6 +7858,42 @@ dialog.modal::backdrop {
background: #b91c1c;
}
/* t-paliad-252 — withdraw warning modal body. The destructive button sits
inside the body (above the footer's Cancel + Edit primary) so the safe
"Edit event" path stays visually primary. The intro paragraph leads,
the muted sub-line explains consequences, then the red row makes the
destructive option discoverable without competing with the CTA. */
.withdraw-warning-body {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.withdraw-warning-intro {
margin: 0;
color: var(--color-text);
font-size: 0.92rem;
line-height: 1.45;
}
.withdraw-warning-sub {
margin: 0;
color: var(--color-text-muted);
font-size: 0.85rem;
line-height: 1.45;
}
.withdraw-warning-destructive-row {
display: flex;
justify-content: flex-end;
margin-top: 0.5rem;
padding-top: 0.75rem;
border-top: 1px dashed var(--color-border);
}
.withdraw-warning-destructive-btn {
/* Inherits .btn .btn-danger, but bump the font size down a touch so
the body button doesn't crowd the footer's primary CTA. */
font-size: 0.82rem;
padding: 0.4rem 1rem;
}
.entity-soon {
text-align: center;
padding: 3rem 1.5rem;
@@ -12111,42 +12354,10 @@ dialog.quick-add-sheet::backdrop {
t-paliad-088 — Event Types: picker, multi-select filter, add modal
============================================================================ */
/* t-paliad-165 follow-up — collapsed read-only view used on
/deadlines/new when a Regel is selected and a default event_type is
known. Replaces the picker with a single inline label + an
"Anderen Typ wählen" override link. */
.event-type-collapsed {
display: inline-flex;
align-items: baseline;
gap: 0.4rem;
padding: 0.35rem 0.55rem;
background: var(--color-bg-lime-tint);
border: 1px solid var(--color-border);
border-radius: 0.5rem;
font-size: 0.95rem;
line-height: 1.3;
flex-wrap: wrap;
}
.event-type-collapsed-label {
font-weight: 600;
color: var(--color-text);
}
.event-type-collapsed-source {
color: var(--color-text-muted);
font-size: 0.85rem;
}
.event-type-collapsed-override {
margin-left: auto;
background: transparent;
border: 0;
padding: 0;
color: var(--color-link, #1d4ed8);
text-decoration: underline;
cursor: pointer;
font: inherit;
font-size: 0.85rem;
}
.event-type-collapsed-override:hover { color: var(--color-link-hover, #1e40af); }
/* (t-paliad-258 — the .event-type-collapsed* "vorgegeben durch Regel"
collapsed view from t-paliad-165 was retired with the catalog
dropdown. The Auto/Custom rule editor took its place; styles for
that live under .rule-mode-auto / .rule-mode-custom above.) */
/* Picker host — chip cluster + search + suggest dropdown */
.event-type-picker {
@@ -12541,6 +12752,36 @@ dialog.quick-add-sheet::backdrop {
transition: border-color 0.15s ease;
}
.event-type-browse-search:focus { border-color: var(--color-accent); }
/* t-paliad-251 — jurisdiction filter chips inside the browse modal
header. Sits below the search input, between the search and the
results list. Active chip uses the lime-tint chip palette. */
.event-type-browse-chips {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.event-type-browse-chip {
padding: 0.2rem 0.7rem;
border: 1px solid var(--color-border);
border-radius: 999px;
background: var(--color-surface);
color: var(--color-text-muted);
font-family: var(--font-sans);
font-size: 0.78rem;
font-weight: 500;
cursor: pointer;
transition: background 0.12s ease, color 0.12s ease, border-color 0.12s ease;
}
.event-type-browse-chip:hover {
background: var(--color-bg-subtle);
color: var(--color-text);
}
.event-type-browse-chip--active {
background: var(--color-bg-lime-tint);
border-color: var(--color-accent);
color: var(--color-text);
font-weight: 600;
}
.event-type-browse-list {
flex: 1 1 auto;
overflow-y: auto;

View File

@@ -0,0 +1,6 @@
-- t-paliad-258: revert the additive custom_rule_text column.
-- Drop the column; rows that used the Custom path lose their free-text
-- label and read as "no rule".
ALTER TABLE paliad.deadlines
DROP COLUMN IF EXISTS custom_rule_text;

View File

@@ -0,0 +1,26 @@
-- t-paliad-258 / m/paliad#89 — binary Auto/Custom Rule model on the
-- deadline form.
--
-- t-paliad-251 shipped the form with a full deadline_rules catalog
-- dropdown. m's verdict: too noisy (4 "Oral hearings" across UPC CFI,
-- UPC CoA, DPMA, EPO etc.). Replace with a binary model:
--
-- 1. Auto — rule_id derived from the chosen event_type, displayed
-- read-only.
-- 2. Custom — rule_id is NULL and the lawyer's free-text label is
-- stored here.
--
-- The column is additive + nullable: existing rows keep their
-- deadline_rule_id and read as Auto-equivalent. A future row with both
-- columns NULL renders as "keine Regel" (matches today's no-rule state).
ALTER TABLE paliad.deadlines
ADD COLUMN IF NOT EXISTS custom_rule_text text;
COMMENT ON COLUMN paliad.deadlines.custom_rule_text IS
'Free-text rule label entered when the lawyer chose Custom on the '
'deadline form (t-paliad-258). Mutually exclusive with rule_id at '
'the application layer: Auto path sets rule_id and leaves this '
'NULL; Custom path sets this and leaves rule_id NULL. Display '
'surfaces prefer the rule_id-joined deadline_rules.name when '
'present, else fall back to custom_rule_text + a "Custom" badge.';

View File

@@ -0,0 +1,11 @@
-- t-paliad-246 / m/paliad#77 — revert Backup Mode catalog table.
SELECT set_config(
'paliad.audit_reason',
'mig 123 down: drop paliad.backups catalog (t-paliad-246 / m/paliad#77 Slice A)',
true);
DROP POLICY IF EXISTS backups_select_admin ON paliad.backups;
DROP INDEX IF EXISTS paliad.backups_kind_status_idx;
DROP INDEX IF EXISTS paliad.backups_started_at_desc_idx;
DROP TABLE IF EXISTS paliad.backups;

View File

@@ -0,0 +1,86 @@
-- t-paliad-246 / m/paliad#77 — Backup Mode catalog table.
--
-- Design: docs/design-backup-mode-2026-05-25.md §4. One row per backup
-- run (on-demand or scheduled). The catalog is operational metadata for
-- the /admin/backups UI (size, row counts, storage URI, status). The
-- audit chain stays on paliad.system_audit_log — this table is the
-- richer-shape duplicate that the UI lists from without parsing JSON.
--
-- INSERT/UPDATE happen only through the Go service path (BackupRunner)
-- under the migration-runner role, so we don't add a write RLS policy
-- for end users. SELECT is admin-only, mirroring system_audit_log.
--
-- Idempotent: CREATE TABLE / INDEX / POLICY all guarded.
SELECT set_config(
'paliad.audit_reason',
'mig 123: add paliad.backups catalog for Backup Mode (t-paliad-246 / m/paliad#77 Slice A)',
true);
CREATE TABLE IF NOT EXISTS paliad.backups (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
kind text NOT NULL CHECK (kind IN ('scheduled', 'on_demand')),
status text NOT NULL CHECK (status IN ('running', 'done', 'failed')),
-- requested_by is NULL for kind='scheduled' (no human caller).
requested_by uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
-- requested_by_email is captured at write time so the row survives
-- a subsequent user deletion. For scheduled runs we write a sentinel
-- like 'system@paliad' (no real user attached).
requested_by_email text NOT NULL,
-- audit_id back-references the system_audit_log row written before
-- the artifact is generated. Nullable so a catalog row can still be
-- INSERTed if the audit write itself fails (defense-in-depth).
audit_id uuid REFERENCES paliad.system_audit_log(id) ON DELETE SET NULL,
-- storage_uri is populated when status flips to 'done'. Resolves
-- through the Go-side ArtifactStore interface ('file://...' for
-- LocalDiskStore today; future stores get their own URI scheme).
storage_uri text,
size_bytes bigint,
row_counts jsonb NOT NULL DEFAULT '{}'::jsonb,
sheet_count int,
warnings jsonb NOT NULL DEFAULT '[]'::jsonb,
-- error is NULL unless status='failed'. Free-form, captured from
-- the Go-side error.Error().
error text,
started_at timestamptz NOT NULL DEFAULT now(),
finished_at timestamptz,
-- deleted_at marks artifacts the lifecycle cleanup removed from
-- storage (Slice B). The catalog row itself stays forever — it's
-- part of the audit chain. NULL means "still on disk".
deleted_at timestamptz
);
-- Read patterns:
-- - "show me recent backups" — started_at DESC
-- - "find last successful scheduled backup today" — kind + status + started_at
CREATE INDEX IF NOT EXISTS backups_started_at_desc_idx
ON paliad.backups (started_at DESC);
CREATE INDEX IF NOT EXISTS backups_kind_status_idx
ON paliad.backups (kind, status);
ALTER TABLE paliad.backups ENABLE ROW LEVEL SECURITY;
-- Admin-only read. INSERT/UPDATE/DELETE happen via the Go service path
-- under the migration-runner role (no end-user write surface).
DROP POLICY IF EXISTS backups_select_admin ON paliad.backups;
CREATE POLICY backups_select_admin ON paliad.backups
FOR SELECT USING (
EXISTS (
SELECT 1 FROM paliad.users u
WHERE u.id = auth.uid()
AND u.global_role = 'global_admin'
)
);
COMMENT ON TABLE paliad.backups IS
'Catalog of org-scope backup runs (t-paliad-246 / m/paliad#77). One row per scheduled or on-demand backup. status transitions: running → done | failed. storage_uri is resolved by the Go-side ArtifactStore interface. audit_id links to system_audit_log; the catalog row is the richer-shape duplicate, the audit row is the trust signal.';
COMMENT ON COLUMN paliad.backups.requested_by_email IS
'Captured at write time so the row survives user deletion. Sentinel ''system@paliad'' for scheduled runs.';
COMMENT ON COLUMN paliad.backups.storage_uri IS
'Resolved by the Go-side ArtifactStore implementation. file://... for LocalDiskStore; future stores use their own URI scheme.';
COMMENT ON COLUMN paliad.backups.deleted_at IS
'Set when the artifact is removed from storage by lifecycle cleanup. Catalog row stays forever (audit chain). NULL means artifact is still on disk.';

View File

@@ -0,0 +1,4 @@
-- t-paliad-249 — drop inbox read cursor.
ALTER TABLE paliad.users
DROP COLUMN IF EXISTS inbox_seen_at;

View File

@@ -0,0 +1,21 @@
-- t-paliad-249 — /inbox overhaul, Slice A.
-- Add a per-user high-watermark read cursor for the inbox feed
-- (approval requests + curated project_events). The cursor advances
-- only when the user POSTs to /api/inbox/mark-all-seen. NULL means
-- "never visited" → every row counts as unread on first paint.
--
-- Note on the carve-out enforced in service code: pending
-- approval_requests count toward the inbox's unread state regardless
-- of this column. The cursor narrows the project_event source only,
-- so a stale cursor never buries a high-value pending approval.
--
-- Design ref: docs/design-inbox-overhaul-2026-05-25.md §3.
ALTER TABLE paliad.users
ADD COLUMN IF NOT EXISTS inbox_seen_at timestamptz NULL;
COMMENT ON COLUMN paliad.users.inbox_seen_at IS
'High-watermark cursor for the /inbox feed. project_events newer '
'than this timestamp are unread for the caller; NULL = never '
'visited (everything unread). Pending approval_requests bypass '
'this column and stay unread until decided.';

View File

@@ -25,6 +25,7 @@ import (
"encoding/json"
"errors"
"net/http"
"time"
"github.com/google/uuid"
@@ -221,6 +222,16 @@ func handleListInboxMine(w http.ResponseWriter, r *http.Request) {
}
// GET /api/inbox/count — bell badge count for the sidebar.
//
// Since t-paliad-249 (Slice A) the count is the **unified** unread
// count: pending approval requests (regardless of cursor) +
// curated project_events (InboxProjectEventKinds) on visible
// projects whose created_at is newer than users.inbox_seen_at. See
// ApprovalService.UnseenInboxCountForUser for the contract.
//
// The legacy approval-only count is still reachable via
// PendingCountForUser inside the dashboard widget — that path
// doesn't go through this endpoint.
func handleInboxCount(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
@@ -229,7 +240,7 @@ func handleInboxCount(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
n, err := dbSvc.approval.PendingCountForUser(r.Context(), uid)
n, err := dbSvc.approval.UnseenInboxCountForUser(r.Context(), uid)
if err != nil {
writeServiceError(w, err)
return
@@ -237,6 +248,57 @@ func handleInboxCount(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]int{"count": n})
}
// POST /api/inbox/mark-all-seen — advance the caller's inbox read
// cursor (paliad.users.inbox_seen_at). Optional body
// `{"up_to": "<iso8601>"}` pins the advance to the timestamp of the
// newest row the client actually saw — handy when a second tab made
// the inbox grow between the read and the click. Missing body =>
// advance to now().
//
// Returns the new cursor as `{"inbox_seen_at": "<iso8601>"}` so the
// client can keep its local state in sync.
func handleInboxMarkAllSeen(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
var body struct {
UpTo string `json:"up_to"`
}
if r.Body != nil && r.ContentLength > 0 {
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
}
var upTo time.Time
if body.UpTo != "" {
parsed, err := time.Parse(time.RFC3339Nano, body.UpTo)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "up_to must be RFC3339"})
return
}
upTo = parsed
}
if err := dbSvc.approval.MarkInboxSeen(r.Context(), uid, upTo); err != nil {
writeServiceError(w, err)
return
}
cur, err := dbSvc.approval.InboxSeenAt(r.Context(), uid)
if err != nil {
writeServiceError(w, err)
return
}
resp := map[string]any{}
if cur != nil {
resp["inbox_seen_at"] = cur.UTC().Format(time.RFC3339Nano)
}
writeJSON(w, http.StatusOK, resp)
}
// parseInboxFilter pulls common filter knobs off the query string.
//
// Status / EntityType pass through validation: an unrecognised value is
@@ -326,6 +388,56 @@ func handleRevokeApprovalRequest(w http.ResponseWriter, r *http.Request) {
handleApprovalDecision(w, r, "revoke")
}
// POST /api/approval-requests/{id}/edit-entity — t-paliad-252 / m/paliad#83.
//
// Lets the requester revise the in-flight entity (e.g. tweak the title on a
// pending create) without withdrawing the request. The non-destructive
// sibling of /revoke that m asked for after noticing that withdraw silently
// deletes the underlying event.
//
// Body: {"fields": {<entity-shape>}}
// 200: {"status": "ok"}
//
// Status mapping (mapApprovalError):
//
// 400 suggestion_requires_change — payload has no allowlisted fields
// 403 not_authorized — caller isn't the requested_by
// 404 — request not found / not visible
// 409 request_not_pending — request already decided / revoked
type editPendingEntityBody struct {
Fields map[string]any `json:"fields"`
}
func handleEditPendingEntity(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
requestID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request id"})
return
}
var body editPendingEntityBody
if r.Body != nil && r.ContentLength > 0 {
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{
"code": "invalid_body",
"message": "Ungültiger Body.",
})
return
}
}
if err := dbSvc.approval.EditPendingEntity(r.Context(), requestID, uid, body.Fields); err != nil {
writeApprovalError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
// suggestChangesBody is the JSON body for POST /api/approval-requests/{id}/suggest-changes.
// counter_payload is an entity-shaped jsonb of the approver's edited
// values (allowlist enforced server-side); note is the optional free-text

View File

@@ -0,0 +1,247 @@
package handlers
// Admin Backup Mode handlers (t-paliad-246 / m/paliad#77 Slice A).
//
// POST /api/admin/backups/run — kick off an on-demand backup
// GET /api/admin/backups — chronological list
// GET /api/admin/backups/{id} — single catalog row
// GET /api/admin/backups/{id}/file — stream the artifact (records
// a backup_downloaded audit row)
// GET /admin/backups — admin page (SPA shell)
//
// Authorisation: every route registers behind adminGate(users, …) in
// handlers.go, so every handler in this file can assume the caller is a
// global_admin and only validate the request shape.
//
// The runner is wired in cmd/server/main.go only when PALIAD_EXPORT_DIR
// is set. When unset, every handler returns 503 — same shape as
// requireDB.
import (
"context"
"database/sql"
"errors"
"fmt"
"io"
"log"
"net/http"
"strconv"
"strings"
"time"
"github.com/google/uuid"
"mgit.msbls.de/m/paliad/internal/services"
)
// backupRequestTimeout caps a single on-demand backup. At firm-scale
// data shapes (today: ~600 user-content rows + ~1000 reference rows)
// a backup runs sub-second; the watchdog surfaces "stuck" as a 500
// instead of letting the client hang forever.
const backupRequestTimeout = 5 * time.Minute
// requireBackup writes a 503 if the BackupRunner is not wired (typically
// PALIAD_EXPORT_DIR is unset) and returns false. Mirrors requireDB.
func requireBackup(w http.ResponseWriter) bool {
if dbSvc == nil || dbSvc.backup == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
"error": "backup service not configured — set PALIAD_EXPORT_DIR on the server",
})
return false
}
return true
}
// handleAdminBackupsPage renders the /admin/backups SPA shell. The
// catalog rows are fetched client-side via /api/admin/backups.
func handleAdminBackupsPage(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "dist/admin-backups.html")
}
// handleAdminRunBackup kicks off a synchronous on-demand backup and
// returns the resulting BackupSummary as JSON. Synchronous: at firm-
// scale the whole run is under 5s; an async path with polling is Slice
// B (the scheduler reuses the same runner internally).
//
// Returns 201 on success with the catalog row, 500 on failure (the
// catalog/audit rows are still flipped to failed/backup_failed before
// the response).
func handleAdminRunBackup(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) || !requireBackup(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
ctx, cancel := context.WithTimeout(r.Context(), backupRequestTimeout)
defer cancel()
user, err := dbSvc.users.GetByID(ctx, uid)
if err != nil || user == nil {
log.Printf("backup: user lookup failed for %s: %v", uid, err)
writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": "user lookup failed",
})
return
}
actor := services.BackupActor{
ID: &uid,
Email: user.Email,
Label: user.DisplayName,
}
result, err := dbSvc.backup.Run(ctx, services.BackupKindOnDemand, actor)
if err != nil {
log.Printf("backup: Run failed for admin=%s: %v", uid, err)
writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": "backup generation failed: " + err.Error(),
})
return
}
// Return the freshly-written catalog row so the UI doesn't need a
// follow-up GET to render the new line item.
row, err := dbSvc.backup.GetBackup(ctx, result.ID)
if err != nil {
// The backup did succeed — log + return the bare result.
log.Printf("backup: post-run GetBackup failed for %s: %v", result.ID, err)
writeJSON(w, http.StatusCreated, result)
return
}
writeJSON(w, http.StatusCreated, row)
}
// handleAdminListBackups returns the most recent N catalog rows as
// JSON. ?limit=N caps the page (default 100).
func handleAdminListBackups(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) || !requireBackup(w) {
return
}
limit := 100
if q := strings.TrimSpace(r.URL.Query().Get("limit")); q != "" {
if n, err := strconv.Atoi(q); err == nil && n > 0 && n <= 500 {
limit = n
}
}
rows, err := dbSvc.backup.ListBackups(r.Context(), limit)
if err != nil {
log.Printf("backup: list failed: %v", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": "list failed",
})
return
}
if rows == nil {
rows = []services.BackupSummary{}
}
writeJSON(w, http.StatusOK, rows)
}
// handleAdminGetBackup returns one catalog row. Used by the UI for
// "is the backup I just kicked off done yet?" polling — though at the
// synchronous shape today this rarely matters.
func handleAdminGetBackup(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) || !requireBackup(w) {
return
}
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
return
}
row, err := dbSvc.backup.GetBackup(r.Context(), id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
return
}
log.Printf("backup: get failed for %s: %v", id, err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "get failed"})
return
}
writeJSON(w, http.StatusOK, row)
}
// handleAdminDownloadBackup streams the artifact bytes through the
// ArtifactStore (LocalDiskStore for v1). Records a backup_downloaded
// audit row before flushing.
//
// 404 if the catalog row is missing; 410 (Gone) if the artifact was
// already lifecycle-deleted; 409 if status is not 'done'; 500 on any
// store/IO error.
func handleAdminDownloadBackup(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) || !requireBackup(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
return
}
row, err := dbSvc.backup.GetBackup(r.Context(), id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
return
}
log.Printf("backup: download GetBackup failed for %s: %v", id, err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "get failed"})
return
}
if row.Status != services.BackupStatusDone || row.StorageURI == nil {
writeJSON(w, http.StatusConflict, map[string]string{
"error": "backup not available for download",
"status": row.Status,
})
return
}
if row.DeletedAt != nil {
// 410 Gone — the artifact is past its retention window. Catalog
// row stays as the audit trail; clients should not retry.
writeJSON(w, http.StatusGone, map[string]string{
"error": "artifact has been removed (retention)",
})
return
}
rc, size, err := dbSvc.backup.Store().Get(r.Context(), *row.StorageURI)
if err != nil {
log.Printf("backup: download store.Get failed for %s: %v", id, err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "store read failed"})
return
}
defer rc.Close()
// Record the download audit row before flushing. If the audit
// write fails we still serve the file (the user can see it; the
// chain just missed a row — surface in logs).
user, uErr := dbSvc.users.GetByID(r.Context(), uid)
if uErr == nil && user != nil {
auditErr := dbSvc.backup.RecordDownload(r.Context(), id, services.BackupActor{
ID: &uid,
Email: user.Email,
Label: user.DisplayName,
})
if auditErr != nil {
log.Printf("backup: RecordDownload failed for %s by %s: %v", id, uid, auditErr)
}
} else if uErr != nil {
log.Printf("backup: user lookup for audit failed (%s): %v", uid, uErr)
}
filename := fmt.Sprintf("paliad-backup-%s.zip", row.StartedAt.UTC().Format("20060102T1504Z"))
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-Backup-Id", id.String())
if _, err := io.Copy(w, rc); err != nil {
log.Printf("backup: response write failed for %s: %v", id, err)
}
}

View File

@@ -65,8 +65,28 @@ var fileRegistry = map[string]fileEntry{
RepoName: "mWorkRepo",
FilePath: "6 - material/Templates/Word/Paliad/" + branding.Name + "/de.inf.lg.erwidg.docx",
},
// Universal skeleton (t-paliad-259). Code-agnostic Schriftsatz starter
// that carries every placeholder SubmissionVarsService resolves but no
// submission_code-specific body structure. Slot between the per-firm
// per-code template and the bare HL Patents Style .dotm fallback: every
// submission_code without a dedicated template still renders with
// variables substituted instead of the macro-only letterhead.
skeletonSubmissionSlug: {
RawURL: "https://mgit.msbls.de/m/mWorkRepo/raw/branch/main/6%20-%20material/Templates/Word/Paliad/" + branding.Name + "/_skeleton.docx",
DownloadName: branding.Name + " — Schriftsatz-Skelett.docx",
ContentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
RepoOwner: "m",
RepoName: "mWorkRepo",
FilePath: "6 - material/Templates/Word/Paliad/" + branding.Name + "/_skeleton.docx",
},
}
// skeletonSubmissionSlug names the universal skeleton template inside
// the shared fileRegistry cache. Exported via a const so handler code
// (resolveSubmissionTemplate, hlPatentsStyleSHA's sibling) refers to
// the same string the registry uses.
const skeletonSubmissionSlug = "submission/_skeleton.docx"
// submissionTemplateRegistry maps a deadline-rule submission_code to a
// fileRegistry slug. Lookup order matches the cronus design fallback
// chain §8: per-firm `templates/{FIRM_NAME}/{code}.docx` first, then
@@ -189,6 +209,46 @@ func handleFileRefresh(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"ok": "true", "message": "Cache cleared"})
}
// fetchSubmissionSkeletonBytes returns the cached universal skeleton
// template bytes plus its provenance SHA. Sits between the per-firm
// per-submission_code template (fetchSubmissionTemplateBytes) and the
// bare universal HL Patents Style .dotm (fetchHLPatentsStyleBytes) in
// resolveSubmissionTemplate's fallback chain — used for every
// submission_code that has no dedicated template registered. Same
// stale-while-revalidate semantics as the rest of the file proxy: first
// call warms the cache synchronously from mWorkRepo via Gitea; later
// calls return immediately while a background refresh runs.
func fetchSubmissionSkeletonBytes(ctx context.Context) ([]byte, string, error) {
entry, ok := fileRegistry[skeletonSubmissionSlug]
if !ok {
return nil, "", fmt.Errorf("file proxy: %s not registered", skeletonSubmissionSlug)
}
ce := getCacheEntry(skeletonSubmissionSlug)
ce.mu.RLock()
hasData := len(ce.data) > 0
needsCheck := time.Since(ce.lastChecked) >= checkInterval
ce.mu.RUnlock()
if !hasData {
if err := fileFetch(ce, entry); err != nil {
return nil, "", err
}
} else if needsCheck {
go fileCheckAndRefresh(ce, entry)
}
ce.mu.RLock()
defer ce.mu.RUnlock()
if len(ce.data) == 0 {
return nil, "", fmt.Errorf("file proxy: %s cache empty after fetch", skeletonSubmissionSlug)
}
out := make([]byte, len(ce.data))
copy(out, ce.data)
_ = ctx
return out, ce.sha, nil
}
// fetchHLPatentsStyleBytes returns the cached HL Patents Style .dotm
// bytes. Shared accessor used by both the /files/{slug} download path
// (Word auto-update channel) and the submission generator

View File

@@ -98,6 +98,11 @@ type Services struct {
Projection *services.ProjectionService
Export *services.ExportService
// t-paliad-246 — Backup Mode (org-scope admin backups). Nil when
// DATABASE_URL or PALIAD_EXPORT_DIR is unset; the /admin/backups
// routes return 503 in that case.
Backup *services.BackupRunner
// t-paliad-238 — dedicated Submissions/Schriftsätze editor.
SubmissionDraft *services.SubmissionDraftService
@@ -162,6 +167,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
firmDashboardDefault: svc.FirmDashboardDefault,
projection: svc.Projection,
export: svc.Export,
backup: svc.Backup,
submissionDraft: svc.SubmissionDraft,
}
}
@@ -570,6 +576,17 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
protected.HandleFunc("GET /admin/email-templates", adminGate(users, gateOnboarded(handleAdminEmailTemplatesPage)))
protected.HandleFunc("GET /admin/email-templates/{key}", adminGate(users, gateOnboarded(handleAdminEmailTemplatesEditPage)))
protected.HandleFunc("GET /admin/event-types", adminGate(users, gateOnboarded(handleAdminEventTypesPage)))
// t-paliad-246 / m/paliad#77 Slice A — Backup Mode admin page +
// API. Routes only register when Users is wired (matches the
// other admin routes); per-request 503 if BackupRunner itself
// is unwired (PALIAD_EXPORT_DIR unset).
protected.HandleFunc("GET /admin/backups", adminGate(users, gateOnboarded(handleAdminBackupsPage)))
protected.HandleFunc("POST /api/admin/backups/run", adminGate(users, handleAdminRunBackup))
protected.HandleFunc("GET /api/admin/backups", adminGate(users, handleAdminListBackups))
protected.HandleFunc("GET /api/admin/backups/{id}", adminGate(users, handleAdminGetBackup))
protected.HandleFunc("GET /api/admin/backups/{id}/file", adminGate(users, handleAdminDownloadBackup))
protected.HandleFunc("GET /api/admin/users", adminGate(users, handleAdminListUsers))
protected.HandleFunc("POST /api/admin/users", adminGate(users, handleAdminCreateUser))
protected.HandleFunc("POST /api/admin/users/full", adminGate(users, handleAdminCreateFullUser))
@@ -654,10 +671,14 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
protected.HandleFunc("GET /api/inbox/pending-mine", handleListInboxPendingMine)
protected.HandleFunc("GET /api/inbox/mine", handleListInboxMine)
protected.HandleFunc("GET /api/inbox/count", handleInboxCount)
protected.HandleFunc("POST /api/inbox/mark-all-seen", handleInboxMarkAllSeen)
protected.HandleFunc("GET /api/approval-requests/{id}", handleGetApprovalRequest)
protected.HandleFunc("POST /api/approval-requests/{id}/approve", handleApproveApprovalRequest)
protected.HandleFunc("POST /api/approval-requests/{id}/reject", handleRejectApprovalRequest)
protected.HandleFunc("POST /api/approval-requests/{id}/revoke", handleRevokeApprovalRequest)
// t-paliad-252 — non-destructive sibling of /revoke: lets the
// requester revise the in-flight entity without withdrawing.
protected.HandleFunc("POST /api/approval-requests/{id}/edit-entity", handleEditPendingEntity)
protected.HandleFunc("POST /api/approval-requests/{id}/suggest-changes", handleSuggestChangesApprovalRequest)
// t-paliad-154 — form-time effective policy lookup. Reachable by

View File

@@ -62,6 +62,10 @@ type dbServices struct {
projection *services.ProjectionService
export *services.ExportService
// t-paliad-246 — Backup Mode orchestrator. Nil when DATABASE_URL or
// PALIAD_EXPORT_DIR is unset (the /admin/backups routes return 503).
backup *services.BackupRunner
// t-paliad-238 — submission draft editor.
submissionDraft *services.SubmissionDraftService
}

View File

@@ -904,16 +904,33 @@ func buildSubmissionDraftView(ctx context.Context, d *services.SubmissionDraft,
// resolveSubmissionTemplate returns the .docx bytes for the given
// submission code. Lookup order matches the cronus design fallback chain
// §8: per-firm template registered in submissionTemplateRegistry first,
// then the universal HL Patents Style as the global fallback. The
// returned SHA is the cache entry's commit SHA so the export audit row
// can record provenance.
// §8 plus the t-paliad-259 universal-skeleton slot:
//
// 1. per-firm per-submission_code template registered in
// submissionTemplateRegistry (e.g. de.inf.lg.erwidg.docx) — code-
// specific structure plus the full variable bag.
// 2. universal _skeleton.docx — same variable bag, no submission_code-
// specific prose. Catches every code without a dedicated template
// so the editor preview / generate flow still has variables to
// substitute instead of falling through to the bare letterhead.
// 3. universal HL Patents Style .dotm — macro-only letterhead, no
// placeholders. Final fallback when even the skeleton is unreachable
// (mWorkRepo outage etc.). Preserves the pre-t-paliad-259 behaviour
// for resilience.
//
// The returned SHA is the cache entry's commit SHA so the export audit
// row can record provenance.
func resolveSubmissionTemplate(ctx context.Context, submissionCode string) ([]byte, string, error) {
if data, sha, found, err := fetchSubmissionTemplateBytes(ctx, submissionCode); err != nil {
return nil, "", err
} else if found {
return data, sha, nil
}
if data, sha, err := fetchSubmissionSkeletonBytes(ctx); err == nil {
return data, sha, nil
} else {
log.Printf("submission_drafts: skeleton fetch failed for code=%s, falling back to HL Patents Style: %v", submissionCode, err)
}
bytes, err := fetchHLPatentsStyleBytes(ctx)
if err != nil {
return nil, "", err

View File

@@ -313,6 +313,14 @@ type Deadline struct {
// changes to paliad.deadline_rules and accepts citations from
// outside that table.
RuleCode *string `db:"rule_code" json:"rule_code,omitempty"`
// CustomRuleText holds the lawyer's free-text rule label when the
// deadline form is in Custom mode (t-paliad-258 / m/paliad#89).
// Mutually exclusive with RuleID at the application layer: the Auto
// path sets RuleID and leaves this NULL; the Custom path sets this
// and leaves RuleID NULL. Display surfaces prefer the joined
// deadline_rules.name when RuleID is set, else fall back to this
// text + a "Custom" badge.
CustomRuleText *string `db:"custom_rule_text" json:"custom_rule_text,omitempty"`
Status string `db:"status" json:"status"`
CompletedAt *time.Time `db:"completed_at" json:"completed_at,omitempty"`
CalDAVUID *string `db:"caldav_uid" json:"caldav_uid,omitempty"`

View File

@@ -41,6 +41,7 @@ import (
"encoding/json"
"errors"
"fmt"
"maps"
"strings"
"time"
@@ -364,6 +365,135 @@ func (s *ApprovalService) Revoke(ctx context.Context, requestID, callerID uuid.U
return s.decide(ctx, requestID, callerID, RequestStatusRevoked, "")
}
// EditPendingEntity lets the REQUESTER of a pending approval_request revise
// the in-flight entity (e.g. tweak the title or due_date on a pending
// create) without withdrawing the request. t-paliad-252 / m/paliad#83 added
// this as the non-destructive sibling of Revoke — m's mental model is
// "withdraw deletes the event; let me edit the event instead, keep the
// approval request alive".
//
// Authorization: caller MUST be the original requested_by (no approver can
// edit on the requester's behalf — that would collapse into SuggestChanges).
// Request status MUST be pending.
//
// Allowlist: uses the WIDER counter-allowlist already maintained for
// SuggestChanges (buildCounterSetClauses) — every editable field on the
// entity, not just the date-bearing approval triggers. Unknown keys are
// silently dropped. Returns ErrSuggestionRequiresChange when fields carries
// no allowlisted key for the entity_type (would be a no-op write).
//
// Side effects in one tx: entity columns updated (and event_type_ids junction
// rewritten for deadlines), approval_request.payload merged with the new
// values so the approver sees what was revised, and a distinct
// `<entity>_approval_edited_by_requester` project_event emitted so the
// Verlauf shows the revision separately from the original *_requested row.
//
// The approval_request stays pending; entity.approval_status stays pending.
// The approver inbox sees a fresh updated_at + the merged payload.
func (s *ApprovalService) EditPendingEntity(ctx context.Context, requestID, callerID uuid.UUID, fields map[string]any) error {
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback() //nolint:errcheck
req, err := s.getRequestForUpdate(ctx, tx, requestID)
if err != nil {
return err
}
if req.Status != RequestStatusPending {
return fmt.Errorf("%w: status=%s", ErrRequestNotPending, req.Status)
}
if callerID != req.RequestedBy {
return ErrNotApprover
}
// Validate the counter-allowlist intersect produces at least one
// settable column. applyEntityUpdate also wraps this check; pre-checking
// here lets us emit a cleaner error before opening the entity-write.
if _, _, err := buildCounterSetClauses(req.EntityType, fields); err != nil {
// Already wraps ErrSuggestionRequiresChange for empty / title-cleared
// cases. Propagate verbatim.
return err
}
// Apply the field updates to the entity row via the shared
// counter-allowlist path (same as SuggestChanges).
if err := s.applyEntityUpdate(ctx, tx, req.EntityType, req.EntityID, fields); err != nil {
return err
}
// Merge new fields into the request payload so the approver's inbox
// reflects what the requester revised to. Keys overwrite; event_type_ids
// is replaced wholesale per the same semantics applyEntityUpdate uses
// for the junction rewrite.
var existing map[string]any
if len(req.Payload) > 0 {
if err := json.Unmarshal(req.Payload, &existing); err != nil {
return fmt.Errorf("unmarshal payload: %w", err)
}
}
if existing == nil {
existing = map[string]any{}
}
maps.Copy(existing, fields)
merged, err := json.Marshal(existing)
if err != nil {
return fmt.Errorf("marshal merged payload: %w", err)
}
now := time.Now().UTC()
if _, err := tx.ExecContext(ctx,
`UPDATE paliad.approval_requests
SET payload = $1, updated_at = $2
WHERE id = $3`,
merged, now, requestID); err != nil {
return fmt.Errorf("update payload: %w", err)
}
// Audit emit. Distinct event_type so the Verlauf surfaces the revision
// separately from the original *_requested or any decision row.
verlaufKind := "edited_by_requester"
eventType := approvalEventType(req.EntityType, verlaufKind)
descPtr := approvalDescription(verlaufKind, req.RequiredRole, req.LifecycleEvent)
editedKeys := sortedKeys(fields)
meta := map[string]any{
"approval_request_id": req.ID.String(),
"lifecycle_event": req.LifecycleEvent,
req.EntityType + "_id": req.EntityID.String(),
"edited_fields": editedKeys,
}
if err := insertProjectEventWithMeta(ctx, tx, req.ProjectID, callerID, eventType, eventType, descPtr, meta); err != nil {
return err
}
return tx.Commit()
}
// sortedKeys returns m's keys in stable alphabetical order so the audit-log
// metadata is byte-for-byte stable across calls (helps when diffing audit
// logs or asserting on them in tests).
func sortedKeys(m map[string]any) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
// Use the stdlib sort; the slice is small (≤ counter-allowlist size).
sortStrings(out)
return out
}
// sortStrings: indirection so we don't add a new top-level import group.
// In Go 1.21+ slices.Sort exists; this package is currently importing
// strings + standard libs and adding "sort" would re-fan the imports.
// Kept as a one-line wrapper to localise the dependency if a later move
// to slices.Sort feels right.
func sortStrings(s []string) {
for i := 1; i < len(s); i++ {
for j := i; j > 0 && s[j-1] > s[j]; j-- {
s[j-1], s[j] = s[j], s[j-1]
}
}
}
// SuggestChanges is the fourth approval action (t-paliad-216). The caller
// proposes a counter-payload + optional free-text note; in one transaction
// we close the old request as 'changes_requested', revert the entity from
@@ -1477,6 +1607,95 @@ func (s *ApprovalService) PendingCountForUser(ctx context.Context, callerID uuid
return n, nil
}
// UnseenInboxCountForUser returns the unified inbox badge count
// (t-paliad-249, Slice A):
//
// - pending approval_requests where the caller is qualified to
// approve (same predicate as PendingCountForUser); these count
// regardless of users.inbox_seen_at — pending approvals never
// fall behind the cursor.
// - project_events with event_type ∈ InboxProjectEventKinds whose
// created_at > users.inbox_seen_at (NULL cursor → every row is
// unseen) on visible projects, EXCLUDING the caller's own events
// (you don't get notified about your own actions) and excluding
// the `*_approval_*` audit duplicates of approval_request rows.
//
// One round-trip; UNION ALL across two SELECTs so the two halves can
// use their own indexes.
func (s *ApprovalService) UnseenInboxCountForUser(ctx context.Context, callerID uuid.UUID) (int, error) {
inboxKinds := InboxProjectEventKinds
q := `SELECT COALESCE(SUM(c), 0) FROM (
SELECT COUNT(*) AS c
FROM paliad.approval_requests ar
JOIN paliad.projects p ON p.id = ar.project_id
WHERE ar.status = 'pending'
AND ar.requested_by <> $1
AND ` + approvalEligibilitySQL + `
UNION ALL
SELECT COUNT(*) AS c
FROM paliad.project_events pe
JOIN paliad.projects p ON p.id = pe.project_id
LEFT JOIN paliad.users u ON u.id = $1
WHERE pe.event_type = ANY($2)
AND (pe.created_by IS DISTINCT FROM $1)
AND (u.inbox_seen_at IS NULL OR pe.created_at > u.inbox_seen_at)
AND ` + visibilityPredicatePositional("p", 1) + `
) sub`
var n int
if err := s.db.GetContext(ctx, &n, q, callerID, pq.Array(inboxKinds)); err != nil {
return 0, fmt.Errorf("unseen inbox count: %w", err)
}
return n, nil
}
// MarkInboxSeen advances the caller's inbox read cursor.
// If `upTo` is zero, advances to now(); otherwise advances to upTo
// (used by the client to pin to the newest visible row so a stray
// second tab doesn't lose items between the read and the click).
//
// The cursor only moves forward — calls with upTo < current are
// no-ops so a stale tab can't rewind.
func (s *ApprovalService) MarkInboxSeen(ctx context.Context, callerID uuid.UUID, upTo time.Time) error {
var (
q string
args []any
)
if upTo.IsZero() {
q = `UPDATE paliad.users
SET inbox_seen_at = now()
WHERE id = $1
AND (inbox_seen_at IS NULL OR inbox_seen_at < now())`
args = []any{callerID}
} else {
q = `UPDATE paliad.users
SET inbox_seen_at = $2
WHERE id = $1
AND (inbox_seen_at IS NULL OR inbox_seen_at < $2)`
args = []any{callerID, upTo.UTC()}
}
if _, err := s.db.ExecContext(ctx, q, args...); err != nil {
return fmt.Errorf("mark inbox seen: %w", err)
}
return nil
}
// InboxSeenAt returns the caller's current inbox read cursor, or nil
// if the user has never marked the inbox as seen. Used by the inbox
// run path to overlay the unread_only predicate (t-paliad-249, §3 of
// the design doc).
func (s *ApprovalService) InboxSeenAt(ctx context.Context, callerID uuid.UUID) (*time.Time, error) {
var t sql.NullTime
q := `SELECT inbox_seen_at FROM paliad.users WHERE id = $1`
if err := s.db.GetContext(ctx, &t, q, callerID); err != nil {
return nil, fmt.Errorf("inbox seen lookup: %w", err)
}
if !t.Valid {
return nil, nil
}
v := t.Time
return &v, nil
}
// ============================================================================
// Policy CRUD — paliad.approval_policies (t-paliad-138 + t-paliad-154).
//

View File

@@ -0,0 +1,555 @@
package services
// Backup Mode runtime (t-paliad-246 / m/paliad#77 Slice A).
//
// One file because all four pieces are tightly coupled:
//
// - ArtifactStore interface + LocalDiskStore implementation
// (storage abstraction; m picked local disk for v1, the interface
// stays so a future swap to Supabase Storage is one impl away).
//
// - BackupRunner — the orchestration the on-demand handler and the
// (Slice B) scheduler share. Wraps the export pipeline:
// 1. INSERT paliad.backups (status='running')
// 2. INSERT paliad.system_audit_log (event_type='backup_created')
// 3. ExportService.WriteOrg → in-memory buffer
// 4. ArtifactStore.Put → file
// 5. UPDATE paliad.backups (status='done', storage_uri, …)
// 6. PATCH paliad.system_audit_log metadata
//
// Design: docs/design-backup-mode-2026-05-25.md.
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
)
// ---------------------------------------------------------------------------
// ArtifactStore interface + LocalDiskStore impl
// ---------------------------------------------------------------------------
// ArtifactStore persists the bytes of a backup artifact. The interface
// is deliberately small so Slice B can drop in a SupabaseStorageStore
// (or any object-store implementation) without changing the runner.
//
// URIs returned by Put are opaque to callers — they round-trip through
// Get/Delete. v1's LocalDiskStore uses `file://<absolute-path>`.
type ArtifactStore interface {
// Put writes the given body to the store under the given key and
// returns the URI for later retrieval. Implementations must overwrite
// an existing object at the same key (catalog rows make keys unique
// in practice, but the contract is overwrite-on-conflict to keep
// retries idempotent).
Put(ctx context.Context, key string, body []byte) (uri string, err error)
// Get streams the artifact bytes at the given URI.
Get(ctx context.Context, uri string) (rc io.ReadCloser, size int64, err error)
// Delete removes the artifact at the given URI. Returns nil if the
// artifact is already absent (idempotent).
Delete(ctx context.Context, uri string) error
}
// LocalDiskStore is the v1 ArtifactStore — writes artifacts to a local
// directory specified at construction time. Mode 0700 on the directory
// + 0600 on artifact files keeps the files private to the paliad
// process owner on the Dokploy host.
type LocalDiskStore struct {
dir string
}
// NewLocalDiskStore creates a LocalDiskStore rooted at dir. Creates the
// directory (0700) if it doesn't exist. Returns an error if dir is
// empty or the mkdir fails.
func NewLocalDiskStore(dir string) (*LocalDiskStore, error) {
if strings.TrimSpace(dir) == "" {
return nil, errors.New("LocalDiskStore: empty directory")
}
if err := os.MkdirAll(dir, 0o700); err != nil {
return nil, fmt.Errorf("LocalDiskStore mkdir %q: %w", dir, err)
}
abs, err := filepath.Abs(dir)
if err != nil {
return nil, fmt.Errorf("LocalDiskStore abs %q: %w", dir, err)
}
return &LocalDiskStore{dir: abs}, nil
}
// Put writes body to <dir>/<key>. Returns a file:// URI.
func (s *LocalDiskStore) Put(_ context.Context, key string, body []byte) (string, error) {
if err := validateKey(key); err != nil {
return "", err
}
full := filepath.Join(s.dir, key)
if err := os.WriteFile(full, body, 0o600); err != nil {
return "", fmt.Errorf("LocalDiskStore write %q: %w", full, err)
}
return "file://" + full, nil
}
// Get opens the file referenced by uri. Returns a *os.File (io.ReadCloser)
// + the file's size in bytes.
func (s *LocalDiskStore) Get(_ context.Context, uri string) (io.ReadCloser, int64, error) {
path, err := s.pathFromURI(uri)
if err != nil {
return nil, 0, err
}
info, err := os.Stat(path)
if err != nil {
return nil, 0, fmt.Errorf("LocalDiskStore stat %q: %w", path, err)
}
f, err := os.Open(path)
if err != nil {
return nil, 0, fmt.Errorf("LocalDiskStore open %q: %w", path, err)
}
return f, info.Size(), nil
}
// Delete removes the file referenced by uri. Idempotent — missing file
// is treated as success.
func (s *LocalDiskStore) Delete(_ context.Context, uri string) error {
path, err := s.pathFromURI(uri)
if err != nil {
return err
}
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("LocalDiskStore remove %q: %w", path, err)
}
return nil
}
// pathFromURI parses a file:// URI and validates that the resolved
// path is inside this store's directory. Defense-in-depth against a
// malformed catalog row pointing at an arbitrary file.
func (s *LocalDiskStore) pathFromURI(uri string) (string, error) {
u, err := url.Parse(uri)
if err != nil {
return "", fmt.Errorf("LocalDiskStore parse uri %q: %w", uri, err)
}
if u.Scheme != "file" {
return "", fmt.Errorf("LocalDiskStore: unsupported uri scheme %q (want file://)", u.Scheme)
}
// url.Parse drops the leading "/" for file:// URIs into u.Path.
path := u.Path
if u.Host != "" {
// "file://host/path" — we don't issue these. Reject.
return "", fmt.Errorf("LocalDiskStore: file:// uri with host is unsupported (%q)", uri)
}
clean := filepath.Clean(path)
rel, err := filepath.Rel(s.dir, clean)
if err != nil || strings.HasPrefix(rel, "..") {
return "", fmt.Errorf("LocalDiskStore: uri %q resolves outside store dir %q", uri, s.dir)
}
return clean, nil
}
// validateKey rejects keys that would escape the store dir (path
// separators, "..", absolute paths). Backup runner uses
// "<uuid>.zip" so this is a defensive guard.
func validateKey(key string) error {
if key == "" {
return errors.New("ArtifactStore: empty key")
}
if strings.ContainsAny(key, "/\\") {
return fmt.Errorf("ArtifactStore: key %q contains path separator", key)
}
if strings.Contains(key, "..") {
return fmt.Errorf("ArtifactStore: key %q contains traversal", key)
}
if filepath.IsAbs(key) {
return fmt.Errorf("ArtifactStore: key %q is absolute", key)
}
return nil
}
// ---------------------------------------------------------------------------
// BackupRunner
// ---------------------------------------------------------------------------
// BackupKind discriminates a scheduled run from an on-demand one.
const (
BackupKindOnDemand = "on_demand"
BackupKindScheduled = "scheduled"
)
// BackupStatus values mirror the paliad.backups status check constraint.
const (
BackupStatusRunning = "running"
BackupStatusDone = "done"
BackupStatusFailed = "failed"
)
// SystemActorEmail is the sentinel actor_email written for scheduled
// backups (kind='scheduled'). Matches design §3.4 — we don't seed a
// phantom user, we just stamp the audit row with a stable sentinel.
const SystemActorEmail = "system@paliad"
// BackupActor identifies who requested a backup. For kind='scheduled'
// pass (nil, SystemActorEmail, "Paliad Backup System"). For on-demand
// pass the calling admin's id/email/display_name.
type BackupActor struct {
ID *uuid.UUID
Email string
Label string
}
// BackupResult is what Run returns to the caller. Empty on failure
// (the error gets the failure detail; the catalog/audit rows are
// already updated).
type BackupResult struct {
ID uuid.UUID
AuditID uuid.UUID
StorageURI string
SizeBytes int64
RowCounts map[string]int
SheetCount int
}
// BackupRunner orchestrates one backup run. Stateless except for the
// wired dependencies; safe to share across goroutines (the handler
// holds one instance; the Slice B scheduler will hold the same one).
type BackupRunner struct {
db *sqlx.DB
export *ExportService
store ArtifactStore
}
// NewBackupRunner wires the runner. All three deps are required; the
// caller (cmd/server/main.go) is responsible for instantiating the
// ArtifactStore from env config.
func NewBackupRunner(db *sqlx.DB, export *ExportService, store ArtifactStore) *BackupRunner {
return &BackupRunner{db: db, export: export, store: store}
}
// Store returns the configured store. Exposed for the download handler
// to stream artifacts via Get.
func (r *BackupRunner) Store() ArtifactStore { return r.store }
// Run performs one backup. Writes catalog + audit rows, generates the
// bundle via ExportService.WriteOrg, uploads to the configured store,
// patches catalog + audit on success/failure.
//
// On any error after the catalog/audit rows are written, the rows are
// patched to status='failed' / event_type='backup_failed' before
// returning. The returned error is always the export/upload failure —
// catalog-update failures during the failure-recovery path are best-
// effort logged but not surfaced (the real error is the one to bubble).
func (r *BackupRunner) Run(ctx context.Context, kind string, actor BackupActor) (BackupResult, error) {
if kind != BackupKindOnDemand && kind != BackupKindScheduled {
return BackupResult{}, fmt.Errorf("BackupRunner.Run: invalid kind %q", kind)
}
if actor.Email == "" {
return BackupResult{}, errors.New("BackupRunner.Run: empty actor email")
}
now := time.Now().UTC()
spec := ExportSpec{
Scope: ExportScopeOrg,
ActorID: uuid.Nil, // overwritten below when actor.ID != nil
ActorEmail: actor.Email,
ActorLabel: actor.Label,
GeneratedAt: now,
}
if actor.ID != nil {
spec.ActorID = *actor.ID
}
// Step 1+2: catalog row (status='running') + audit row
// (event_type='backup_created'). Both happen before the export
// generation so failure paths can always find them.
catalogID, err := r.insertCatalogRow(ctx, kind, actor, uuid.Nil, now)
if err != nil {
return BackupResult{}, fmt.Errorf("backup catalog insert: %w", err)
}
auditID, err := r.insertAuditRow(ctx, kind, actor, catalogID, now)
if err != nil {
// Best-effort patch on the catalog row so it doesn't sit
// "running" forever.
r.patchCatalogRowFailed(context.Background(), catalogID, fmt.Errorf("audit insert: %w", err))
return BackupResult{}, fmt.Errorf("backup audit insert: %w", err)
}
// Back-link the audit id into the catalog row so the UI can JOIN.
if err := r.linkAuditID(ctx, catalogID, auditID); err != nil {
// Non-fatal — the link is for UI convenience, not correctness.
// The error is logged via the patch path; we keep going.
}
// Step 3: generate the bundle into an in-memory buffer. We materialise
// fully before uploading so a partial upload doesn't strand bytes in
// the store under a "done" catalog row.
var buf bytes.Buffer
meta, err := r.export.WriteOrg(ctx, &buf, spec)
if err != nil {
r.failRun(context.Background(), catalogID, auditID, fmt.Errorf("generate: %w", err))
return BackupResult{}, fmt.Errorf("backup generate: %w", err)
}
// Step 4: upload to storage. Key = "<catalog_id>.zip".
key := catalogID.String() + ".zip"
uri, err := r.store.Put(ctx, key, buf.Bytes())
if err != nil {
r.failRun(context.Background(), catalogID, auditID, fmt.Errorf("upload: %w", err))
return BackupResult{}, fmt.Errorf("backup upload: %w", err)
}
// Step 5+6: patch catalog + audit on success.
size := int64(buf.Len())
sheetCount := len(meta.RowCounts)
if err := r.patchCatalogRowDone(ctx, catalogID, uri, size, sheetCount, meta); err != nil {
// At this point the artifact is on disk, the audit row was
// inserted, and the only thing that failed is the catalog
// flip. Surface as an error so the handler can log; the
// artifact is recoverable manually via the audit metadata.
return BackupResult{}, fmt.Errorf("backup catalog patch: %w", err)
}
if err := r.patchAuditRowDone(ctx, auditID, uri, size, sheetCount, meta); err != nil {
// Non-fatal — the catalog row is already authoritative; the
// audit row is the audit-trail twin. Log via the caller.
}
return BackupResult{
ID: catalogID,
AuditID: auditID,
StorageURI: uri,
SizeBytes: size,
RowCounts: meta.RowCounts,
SheetCount: sheetCount,
}, nil
}
// RecordDownload writes a paliad.system_audit_log row of
// event_type='backup_downloaded' when an admin downloads a backup
// via /api/admin/backups/{id}/file. Separate row per click — the
// existing 'backup_created' row stays untouched.
func (r *BackupRunner) RecordDownload(ctx context.Context, backupID uuid.UUID, by BackupActor) error {
if by.Email == "" {
return errors.New("BackupRunner.RecordDownload: empty actor email")
}
meta, _ := json.Marshal(map[string]any{
"backup_id": backupID.String(),
"downloaded_by_email": by.Email,
"downloaded_at": time.Now().UTC().Format(time.RFC3339),
})
var actorID any
if by.ID != nil {
actorID = *by.ID
}
_, err := r.db.ExecContext(ctx,
`INSERT INTO paliad.system_audit_log
(event_type, actor_id, actor_email, scope, scope_root, metadata)
VALUES ('backup_downloaded', $1, $2, 'org', NULL, $3::jsonb)`,
actorID, by.Email, string(meta),
)
if err != nil {
return fmt.Errorf("backup_downloaded audit insert: %w", err)
}
return nil
}
// ---------------------------------------------------------------------------
// Catalog read helpers (List + Get for the admin UI)
// ---------------------------------------------------------------------------
// BackupSummary is the row shape returned by ListBackups + GetBackup —
// shaped for the /admin/backups UI. Nullable columns are pointers.
type BackupSummary struct {
ID uuid.UUID `db:"id" json:"id"`
Kind string `db:"kind" json:"kind"`
Status string `db:"status" json:"status"`
RequestedBy *uuid.UUID `db:"requested_by" json:"requested_by,omitempty"`
RequestedByEmail string `db:"requested_by_email" json:"requested_by_email"`
AuditID *uuid.UUID `db:"audit_id" json:"audit_id,omitempty"`
StorageURI *string `db:"storage_uri" json:"storage_uri,omitempty"`
SizeBytes *int64 `db:"size_bytes" json:"size_bytes,omitempty"`
RowCounts []byte `db:"row_counts" json:"row_counts,omitempty"`
SheetCount *int `db:"sheet_count" json:"sheet_count,omitempty"`
Warnings []byte `db:"warnings" json:"warnings,omitempty"`
Error *string `db:"error" json:"error,omitempty"`
StartedAt time.Time `db:"started_at" json:"started_at"`
FinishedAt *time.Time `db:"finished_at" json:"finished_at,omitempty"`
DeletedAt *time.Time `db:"deleted_at" json:"deleted_at,omitempty"`
}
// ListBackups returns the most recent backups (highest started_at first),
// capped at limit. limit <= 0 means default (100).
func (r *BackupRunner) ListBackups(ctx context.Context, limit int) ([]BackupSummary, error) {
if limit <= 0 {
limit = 100
}
var rows []BackupSummary
err := r.db.SelectContext(ctx, &rows,
`SELECT id, kind, status, requested_by, requested_by_email, audit_id,
storage_uri, size_bytes, row_counts, sheet_count, warnings,
error, started_at, finished_at, deleted_at
FROM paliad.backups
ORDER BY started_at DESC
LIMIT $1`,
limit,
)
if err != nil {
return nil, fmt.Errorf("list backups: %w", err)
}
return rows, nil
}
// GetBackup fetches one backup by id. Returns sql.ErrNoRows when not
// found (caller maps to 404).
func (r *BackupRunner) GetBackup(ctx context.Context, id uuid.UUID) (BackupSummary, error) {
var row BackupSummary
err := r.db.GetContext(ctx, &row,
`SELECT id, kind, status, requested_by, requested_by_email, audit_id,
storage_uri, size_bytes, row_counts, sheet_count, warnings,
error, started_at, finished_at, deleted_at
FROM paliad.backups
WHERE id = $1`,
id,
)
if err != nil {
return BackupSummary{}, err
}
return row, nil
}
// ---------------------------------------------------------------------------
// Catalog + audit SQL helpers (private — used by Run + RecordDownload).
// ---------------------------------------------------------------------------
func (r *BackupRunner) insertCatalogRow(ctx context.Context, kind string, actor BackupActor, auditID uuid.UUID, now time.Time) (uuid.UUID, error) {
var actorID any
if actor.ID != nil {
actorID = *actor.ID
}
var auditArg any
if auditID != uuid.Nil {
auditArg = auditID
}
var id uuid.UUID
err := r.db.QueryRowxContext(ctx,
`INSERT INTO paliad.backups
(kind, status, requested_by, requested_by_email, audit_id, started_at)
VALUES ($1, 'running', $2, $3, $4, $5)
RETURNING id`,
kind, actorID, actor.Email, auditArg, now,
).Scan(&id)
if err != nil {
return uuid.Nil, err
}
return id, nil
}
func (r *BackupRunner) insertAuditRow(ctx context.Context, kind string, actor BackupActor, catalogID uuid.UUID, now time.Time) (uuid.UUID, error) {
meta, _ := json.Marshal(map[string]any{
"kind": kind,
"catalog_id": catalogID.String(),
"requested_by_email": actor.Email,
"requested_at": now.Format(time.RFC3339),
})
var actorID any
if actor.ID != nil {
actorID = *actor.ID
}
var id uuid.UUID
err := r.db.QueryRowxContext(ctx,
`INSERT INTO paliad.system_audit_log
(event_type, actor_id, actor_email, scope, scope_root, metadata)
VALUES ('backup_created', $1, $2, 'org', NULL, $3::jsonb)
RETURNING id`,
actorID, actor.Email, string(meta),
).Scan(&id)
if err != nil {
return uuid.Nil, err
}
return id, nil
}
func (r *BackupRunner) linkAuditID(ctx context.Context, catalogID, auditID uuid.UUID) error {
_, err := r.db.ExecContext(ctx,
`UPDATE paliad.backups SET audit_id = $2 WHERE id = $1`,
catalogID, auditID,
)
return err
}
func (r *BackupRunner) patchCatalogRowDone(ctx context.Context, id uuid.UUID, uri string, size int64, sheetCount int, meta ExportMeta) error {
rcJSON, _ := json.Marshal(meta.RowCounts)
warnJSON, _ := json.Marshal(meta.Warnings)
if meta.Warnings == nil {
warnJSON = []byte("[]")
}
_, err := r.db.ExecContext(ctx,
`UPDATE paliad.backups
SET status = 'done',
storage_uri = $2,
size_bytes = $3,
sheet_count = $4,
row_counts = $5::jsonb,
warnings = $6::jsonb,
finished_at = now()
WHERE id = $1`,
id, uri, size, sheetCount, string(rcJSON), string(warnJSON),
)
return err
}
func (r *BackupRunner) patchCatalogRowFailed(ctx context.Context, id uuid.UUID, runErr error) {
_, _ = r.db.ExecContext(ctx,
`UPDATE paliad.backups
SET status = 'failed',
error = $2,
finished_at = now()
WHERE id = $1`,
id, runErr.Error(),
)
}
func (r *BackupRunner) patchAuditRowDone(ctx context.Context, id uuid.UUID, uri string, size int64, sheetCount int, meta ExportMeta) error {
payload, _ := json.Marshal(map[string]any{
"row_counts": meta.RowCounts,
"file_size_bytes": size,
"sheet_count": sheetCount,
"storage_uri": uri,
"warnings": meta.Warnings,
"completed_at": time.Now().UTC().Format(time.RFC3339),
})
_, err := r.db.ExecContext(ctx,
`UPDATE paliad.system_audit_log
SET metadata = metadata || $2::jsonb,
updated_at = now()
WHERE id = $1`,
id, string(payload),
)
return err
}
func (r *BackupRunner) patchAuditRowFailed(ctx context.Context, id uuid.UUID, runErr error) {
payload, _ := json.Marshal(map[string]any{
"error": runErr.Error(),
"failed_at": time.Now().UTC().Format(time.RFC3339),
})
_, _ = r.db.ExecContext(ctx,
`UPDATE paliad.system_audit_log
SET event_type = 'backup_failed',
metadata = metadata || $2::jsonb,
updated_at = now()
WHERE id = $1`,
id, string(payload),
)
}
// failRun is the shared failure-recovery path: patch the catalog +
// audit rows to their failed states. Uses a context.Background so the
// patch happens even if the original ctx is already cancelled.
func (r *BackupRunner) failRun(ctx context.Context, catalogID, auditID uuid.UUID, runErr error) {
r.patchCatalogRowFailed(ctx, catalogID, runErr)
r.patchAuditRowFailed(ctx, auditID, runErr)
}

View File

@@ -0,0 +1,193 @@
package services
// Pure-function tests for the Backup Mode runtime (t-paliad-246 / m/paliad#77).
//
// Live DB behaviour (the actual org dump end-to-end) needs a Postgres;
// it would live in backup_service_live_test.go under TEST_DATABASE_URL.
// This file covers the bits that don't need a database:
//
// - orgSheetQueries registry shape: no duplicates, no excluded
// paliadin sheets, predictable prefix split between entity and ref.
// - LocalDiskStore Put / Get / Delete round-trip, key validation,
// URI traversal rejection.
import (
"bytes"
"context"
"io"
"os"
"path/filepath"
"strings"
"testing"
)
// ---------------------------------------------------------------------------
// orgSheetQueries registry
// ---------------------------------------------------------------------------
func TestOrgSheetQueries_NoDuplicates(t *testing.T) {
seen := map[string]bool{}
for _, sq := range orgSheetQueries() {
if seen[sq.SheetName] {
t.Fatalf("duplicate sheet name in orgSheetQueries: %q", sq.SheetName)
}
seen[sq.SheetName] = true
}
}
func TestOrgSheetQueries_ExcludesPaliadinTables(t *testing.T) {
// m's t-paliad-214 Q5 decision + this design's §11 Q3 default:
// paliadin_turns and paliadin_aichat_conversation must be ABSENT
// from the registry (structural exclusion, not just column-drop).
for _, sq := range orgSheetQueries() {
name := sq.SheetName
if strings.Contains(name, "paliadin") {
t.Fatalf("orgSheetQueries leaked paliadin sheet: %q (m's Q3 mandates structural exclusion)", name)
}
// Belt-and-braces: SQL bodies should not reference the tables
// either (no UNION joins, no subqueries pulling them in).
if strings.Contains(sq.SQL, "paliadin_turns") || strings.Contains(sq.SQL, "paliadin_aichat_conversation") {
t.Fatalf("orgSheetQueries[%q] SQL references a paliadin table: %s", name, sq.SQL)
}
}
}
func TestOrgSheetQueries_RefSheetsPrefixed(t *testing.T) {
// Every sheet whose data is read-only reference material is
// expected to use the `ref__` prefix. The writer's downstream
// consumers rely on this convention to group reference data
// visually in the workbook.
for _, sq := range orgSheetQueries() {
if !strings.HasPrefix(sq.SheetName, "ref__") {
continue
}
// Reference sheets shouldn't carry per-row WHERE clauses (they
// dump the whole reference table for portability).
if strings.Contains(strings.ToUpper(sq.SQL), "WHERE") {
t.Fatalf("orgSheetQueries[%q] is ref__ but has a WHERE clause; reference sheets dump the whole table", sq.SheetName)
}
}
}
func TestOrgSheetQueries_OrderByForDeterminism(t *testing.T) {
// Every sheet must specify an ORDER BY so the byte-deterministic
// contract from t-paliad-214 §3 holds across runs.
for _, sq := range orgSheetQueries() {
if !strings.Contains(strings.ToUpper(sq.SQL), "ORDER BY") {
t.Fatalf("orgSheetQueries[%q] missing ORDER BY (determinism contract): %s", sq.SheetName, sq.SQL)
}
}
}
// ---------------------------------------------------------------------------
// LocalDiskStore round-trip
// ---------------------------------------------------------------------------
func TestLocalDiskStore_RoundTrip(t *testing.T) {
dir := t.TempDir()
store, err := NewLocalDiskStore(dir)
if err != nil {
t.Fatalf("NewLocalDiskStore: %v", err)
}
ctx := context.Background()
want := []byte("hello backup\n")
uri, err := store.Put(ctx, "test.zip", want)
if err != nil {
t.Fatalf("Put: %v", err)
}
if !strings.HasPrefix(uri, "file://") {
t.Fatalf("expected file:// uri, got %q", uri)
}
rc, size, err := store.Get(ctx, uri)
if err != nil {
t.Fatalf("Get: %v", err)
}
defer rc.Close()
if size != int64(len(want)) {
t.Fatalf("Get size = %d, want %d", size, len(want))
}
got, err := io.ReadAll(rc)
if err != nil {
t.Fatalf("ReadAll: %v", err)
}
if !bytes.Equal(got, want) {
t.Fatalf("Get body = %q, want %q", got, want)
}
if err := store.Delete(ctx, uri); err != nil {
t.Fatalf("Delete: %v", err)
}
// File should be gone; Get returns an error.
if _, _, err := store.Get(ctx, uri); err == nil {
t.Fatalf("Get after Delete should fail")
}
// Delete is idempotent.
if err := store.Delete(ctx, uri); err != nil {
t.Fatalf("idempotent Delete: %v", err)
}
}
func TestLocalDiskStore_RejectsBadKeys(t *testing.T) {
dir := t.TempDir()
store, err := NewLocalDiskStore(dir)
if err != nil {
t.Fatalf("NewLocalDiskStore: %v", err)
}
ctx := context.Background()
cases := []string{
"",
"sub/dir/file.zip",
"..\\evil.zip",
"../escape.zip",
"/abs/path.zip",
}
for _, k := range cases {
if _, err := store.Put(ctx, k, []byte("x")); err == nil {
t.Fatalf("Put with bad key %q should fail", k)
}
}
}
func TestLocalDiskStore_RejectsURIOutsideDir(t *testing.T) {
dir := t.TempDir()
store, err := NewLocalDiskStore(dir)
if err != nil {
t.Fatalf("NewLocalDiskStore: %v", err)
}
ctx := context.Background()
// A file:// URI pointing outside the store dir must be rejected
// by both Get and Delete (defense in depth against a corrupted
// catalog row).
outside := "file://" + filepath.Join(filepath.Dir(dir), "elsewhere.zip")
if _, _, err := store.Get(ctx, outside); err == nil {
t.Fatalf("Get outside store dir should fail")
}
if err := store.Delete(ctx, outside); err == nil {
t.Fatalf("Delete outside store dir should fail")
}
// Wrong scheme is also rejected.
if _, _, err := store.Get(ctx, "https://example.com/foo.zip"); err == nil {
t.Fatalf("Get with non-file:// scheme should fail")
}
}
func TestLocalDiskStore_CreatesDir(t *testing.T) {
// A non-existent parent gets created at construction; mode 0700.
base := t.TempDir()
target := filepath.Join(base, "nested", "exports")
store, err := NewLocalDiskStore(target)
if err != nil {
t.Fatalf("NewLocalDiskStore(non-existent): %v", err)
}
info, err := os.Stat(target)
if err != nil {
t.Fatalf("expected store dir to exist: %v", err)
}
if !info.IsDir() {
t.Fatalf("expected directory, got file")
}
// Smoke-write to confirm the dir is actually usable.
if _, err := store.Put(context.Background(), "ok.zip", []byte{}); err != nil {
t.Fatalf("Put into fresh dir: %v", err)
}
}

View File

@@ -66,7 +66,7 @@ func (s *DeadlineService) pendingApprovalErr(ctx context.Context, deadlineID uui
}
const deadlineColumns = `id, project_id, title, description, due_date, original_due_date,
warning_date, source, rule_id, rule_code, status, completed_at, caldav_uid, caldav_etag,
warning_date, source, rule_id, rule_code, custom_rule_text, status, completed_at, caldav_uid, caldav_etag,
notes, created_by, created_at, updated_at,
approval_status, pending_request_id, approved_by, approved_at`
@@ -81,6 +81,11 @@ type CreateDeadlineInput struct {
// Sent by the Fristenrechner save flow so the title can stay clean
// instead of carrying the citation as a prefix.
RuleCode *string `json:"rule_code,omitempty"`
// CustomRuleText is the lawyer's free-text rule label when the
// deadline form is in Custom mode (t-paliad-258). Mutually exclusive
// with RuleID at the application layer; the service trims and treats
// an all-whitespace value as nil.
CustomRuleText *string `json:"custom_rule_text,omitempty"`
Source string `json:"source,omitempty"` // default "manual"
Notes *string `json:"notes,omitempty"`
EventTypeIDs []uuid.UUID `json:"event_type_ids,omitempty"`
@@ -108,6 +113,20 @@ type UpdateDeadlineInput struct {
Status *string `json:"status,omitempty"`
ProjectID *uuid.UUID `json:"project_id,omitempty"`
EventTypeIDs *[]uuid.UUID `json:"event_type_ids,omitempty"`
// Rule pointer pair (t-paliad-258 / m/paliad#89). Three valid
// shapes; the service rejects "both set":
// - RuleSet=true, RuleID non-nil, CustomRuleText nil → Auto:
// bind to the catalog rule, clear custom_rule_text.
// - RuleSet=true, RuleID nil, CustomRuleText non-nil → Custom:
// store free text, clear rule_id.
// - RuleSet=true, RuleID nil, CustomRuleText nil → No rule:
// clear both columns.
// RuleSet=false leaves both columns untouched (the rest of the
// PATCH body doesn't carry rule changes).
RuleSet bool `json:"rule_set,omitempty"`
RuleID *uuid.UUID `json:"rule_id,omitempty"`
CustomRuleText *string `json:"custom_rule_text,omitempty"`
}
// DeadlineStatusFilter is a server-side bucket for ListVisibleForUser.
@@ -241,7 +260,7 @@ func (s *DeadlineService) ListVisibleForUser(ctx context.Context, userID uuid.UU
query := `
SELECT f.id, f.project_id, f.title, f.description, f.due_date, f.original_due_date,
f.warning_date, f.source, f.rule_id, f.rule_code, f.status, f.completed_at,
f.warning_date, f.source, f.rule_id, f.rule_code, f.custom_rule_text, f.status, f.completed_at,
f.caldav_uid, f.caldav_etag, f.notes, f.created_by,
f.created_at, f.updated_at,
f.approval_status, f.pending_request_id, f.approved_by, f.approved_at,
@@ -514,6 +533,23 @@ func (s *DeadlineService) Update(ctx context.Context, userID, deadlineID uuid.UU
}
}
// Auto/Custom rule swap (t-paliad-258). Mutually exclusive at the
// persistence boundary: setting one column NULLs the other.
if input.RuleSet {
if input.RuleID != nil && input.CustomRuleText != nil {
return nil, fmt.Errorf("%w: rule_id and custom_rule_text are mutually exclusive", ErrInvalidInput)
}
appendSet("rule_id", input.RuleID)
var customText *string
if input.CustomRuleText != nil {
trimmed := strings.TrimSpace(*input.CustomRuleText)
if trimmed != "" {
customText = &trimmed
}
}
appendSet("custom_rule_text", customText)
}
// Project move (t-paliad-140). Visibility on the destination is enforced
// the same way as on Create — a GetByID round-trip through ProjectService
// returns ErrNotVisible if the user can't see the target. Same-project
@@ -587,7 +623,7 @@ func (s *DeadlineService) Update(ctx context.Context, userID, deadlineID uuid.UU
// Did the PATCH touch anything beyond the project move?
otherFieldsTouched := input.Title != nil || input.Description != nil ||
input.DueDate != nil || input.Notes != nil || input.Status != nil ||
input.EventTypeIDs != nil
input.EventTypeIDs != nil || input.RuleSet
if otherFieldsTouched {
auditProject := current.ProjectID
if movedFromProject != nil {
@@ -1012,15 +1048,27 @@ func (s *DeadlineService) insertTx(ctx context.Context, tx *sqlx.Tx, userID, pro
}
}
// Auto vs Custom (t-paliad-258): RuleID and CustomRuleText are
// mutually exclusive. If the caller passes both, the catalog rule
// wins and the free-text is dropped — keeps the invariant simple at
// the persistence boundary.
var customRuleText *string
if input.CustomRuleText != nil && input.RuleID == nil {
trimmed := strings.TrimSpace(*input.CustomRuleText)
if trimmed != "" {
customRuleText = &trimmed
}
}
id := uuid.New()
now := time.Now().UTC()
if _, err := tx.ExecContext(ctx,
`INSERT INTO paliad.deadlines
(id, project_id, title, description, due_date, original_due_date,
source, rule_id, rule_code, status, notes, created_by, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'pending', $10, $11, $12, $12)`,
source, rule_id, rule_code, custom_rule_text, status, notes, created_by, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'pending', $11, $12, $13, $13)`,
id, projectID, title, input.Description, due, orig,
source, input.RuleID, ruleCode, input.Notes, userID, now,
source, input.RuleID, ruleCode, customRuleText, input.Notes, userID, now,
); err != nil {
return uuid.Nil, fmt.Errorf("insert deadline: %w", err)
}

View File

@@ -107,11 +107,15 @@ type EventListItem struct {
Status *string `json:"status,omitempty"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
Source *string `json:"source,omitempty"`
RuleID *uuid.UUID `json:"rule_id,omitempty"`
RuleCode *string `json:"rule_code,omitempty"`
RuleName *string `json:"rule_name,omitempty"`
RuleNameEN *string `json:"rule_name_en,omitempty"`
EventTypeIDs []uuid.UUID `json:"event_type_ids,omitempty"`
RuleID *uuid.UUID `json:"rule_id,omitempty"`
RuleCode *string `json:"rule_code,omitempty"`
RuleName *string `json:"rule_name,omitempty"`
RuleNameEN *string `json:"rule_name_en,omitempty"`
// CustomRuleText surfaces the lawyer's free-text rule label when the
// deadline was created via the Custom rule path (t-paliad-258).
// Display surfaces fall back to it when RuleName is absent.
CustomRuleText *string `json:"custom_rule_text,omitempty"`
EventTypeIDs []uuid.UUID `json:"event_type_ids,omitempty"`
// Appointment-only.
StartAt *time.Time `json:"start_at,omitempty"`
@@ -236,6 +240,7 @@ func projectDeadline(d models.DeadlineWithProject) EventListItem {
RuleCode: d.RuleCode,
RuleName: d.RuleName,
RuleNameEN: d.RuleNameEN,
CustomRuleText: d.CustomRuleText,
EventTypeIDs: d.EventTypeIDs,
}
}

View File

@@ -40,6 +40,7 @@ import (
"archive/zip"
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
"encoding/json"
"encoding/csv"
@@ -185,7 +186,7 @@ func (s *ExportService) WritePersonal(ctx context.Context, w io.Writer, spec Exp
}
sheets := personalSheetQueries(spec.ActorID)
if err := s.writeBundle(ctx, w, sheets, &meta); err != nil {
if err := s.writeBundle(ctx, s.db, w, sheets, &meta); err != nil {
return meta, err
}
return meta, nil
@@ -238,7 +239,7 @@ func (s *ExportService) WriteProject(ctx context.Context, w io.Writer, spec Expo
}
sheets := projectSheetQueries(*spec.ScopeRoot, spec.DirectOnly)
if err := s.writeBundle(ctx, w, sheets, &meta); err != nil {
if err := s.writeBundle(ctx, s.db, w, sheets, &meta); err != nil {
return meta, err
}
@@ -254,6 +255,55 @@ func (s *ExportService) WriteProject(ctx context.Context, w io.Writer, spec Expo
return meta, nil
}
// WriteOrg streams the full org-scope backup bundle into w. Bypasses
// paliad.can_see_project — admin-only, gated at the handler layer (the
// service trusts the caller has been authorised).
//
// Wraps the entire read pass in a REPEATABLE READ READ ONLY transaction
// so every sheet sees the same snapshot. Without this a backup that runs
// while users are editing can land internally inconsistent rows (e.g. a
// deadlines.project_id pointing at a project the projects sheet just
// missed). Design §3.3.
//
// The handler is responsible for the audit-row INSERT / PATCH (the
// org-scope backup uses BackupRunner.Run, not WriteAuditRow, because the
// event_type is 'backup_created' not 'data_export').
func (s *ExportService) WriteOrg(ctx context.Context, w io.Writer, spec ExportSpec) (ExportMeta, error) {
if spec.Scope == "" {
spec.Scope = ExportScopeOrg
}
if spec.GeneratedAt.IsZero() {
spec.GeneratedAt = time.Now().UTC()
}
meta := ExportMeta{
SchemaVersion: ExportSchemaVersion,
FirmName: s.firmName,
Scope: spec.Scope,
GeneratedAt: spec.GeneratedAt,
GeneratedByID: spec.ActorID,
GeneratedByEml: spec.ActorEmail,
GeneratedByLbl: spec.ActorLabel,
RowCounts: map[string]int{},
}
tx, err := s.db.BeginTxx(ctx, &sql.TxOptions{
Isolation: sql.LevelRepeatableRead,
ReadOnly: true,
})
if err != nil {
return meta, fmt.Errorf("backup snapshot tx: %w", err)
}
// Always rollback — the tx is read-only by construction, the rollback
// is just bookkeeping that releases the snapshot.
defer func() { _ = tx.Rollback() }()
sheets := orgSheetQueries()
if err := s.writeBundle(ctx, tx, w, sheets, &meta); err != nil {
return meta, err
}
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
@@ -300,13 +350,17 @@ type collectedSheet struct {
// xlsx sheet + one JSON branch + one CSV per sheet, packs everything into
// the outer zip in sorted file-list order so two runs of the same row
// state produce byte-identical bundles.
func (s *ExportService) writeBundle(ctx context.Context, w io.Writer, sheets []sheetQuery, meta *ExportMeta) error {
//
// queryer is the executor for sheet queries — typically s.db, but
// WriteOrg passes a REPEATABLE READ *sqlx.Tx so the org dump sees a
// consistent snapshot across all sheets (design §3.3).
func (s *ExportService) writeBundle(ctx context.Context, queryer sqlx.QueryerContext, w io.Writer, sheets []sheetQuery, meta *ExportMeta) error {
collectedSheets := make([]collectedSheet, 0, len(sheets))
jsonTables := make(map[string][]map[string]string, len(sheets))
warnings := []string{}
for _, sq := range sheets {
cols, rowMatrix, dropped, err := s.runSheetQuery(ctx, sq)
cols, rowMatrix, dropped, err := s.runSheetQuery(ctx, queryer, sq)
if err != nil {
return fmt.Errorf("export sheet %q: %w", sq.SheetName, err)
}
@@ -421,11 +475,13 @@ func (s *ExportService) writeBundle(ctx context.Context, w io.Writer, sheets []s
return nil
}
// runSheetQuery executes one sheetQuery and returns the kept columns,
// row matrix (pre-stringified per the design's value-as-string convention),
// and the list of columns that were dropped by the PII filter.
func (s *ExportService) runSheetQuery(ctx context.Context, sq sheetQuery) (cols []string, rows [][]string, dropped []string, err error) {
rs, err := s.db.QueryxContext(ctx, sq.SQL, sq.Args...)
// runSheetQuery executes one sheetQuery against the given queryer and
// returns the kept columns, row matrix (pre-stringified per the design's
// value-as-string convention), and the list of columns that were dropped
// by the PII filter. queryer is typically s.db, but WriteOrg passes a
// REPEATABLE READ *sqlx.Tx (see writeBundle docs).
func (s *ExportService) runSheetQuery(ctx context.Context, queryer sqlx.QueryerContext, sq sheetQuery) (cols []string, rows [][]string, dropped []string, err error) {
rs, err := queryer.QueryxContext(ctx, sq.SQL, sq.Args...)
if err != nil {
return nil, nil, nil, fmt.Errorf("query: %w", err)
}
@@ -1470,3 +1526,107 @@ SELECT 'partner_unit_default'::text AS source,
}
return queries
}
// ---------------------------------------------------------------------------
// Org-scope sheet registry (Slice 3 / Backup Mode — t-paliad-246).
// ---------------------------------------------------------------------------
//
// Full-schema dump. Bypasses paliad.can_see_project — admin-only,
// gated at the handler layer (BackupRunner trusts the caller).
//
// Sheet ordering: entity sheets first (alphabetical), then ref__*
// reference sheets (alphabetical). The xlsx writer iterates the slice
// in order; downstream consumers get the same order across runs.
//
// Hard exclusions (per design §5.2 / m's Q3 decision):
//
// - paliadin_turns
// - paliadin_aichat_conversation
//
// AI conversation history is the most-sensitive personal data paliad
// carries; m's prior Q5 decision in t-paliad-214 made the exclusion
// structural. The two tables are absent from the registry — not just
// column-level redacted — so a future schema addition cannot
// accidentally re-include them.
//
// Also excluded unconditionally (operational / shadow):
//
// - *_pre_NNN shadow tables (CREATE TABLE … AS SELECT backups
// written by destructive migrations)
// - paliad_schema_migrations (operational)
// - auth.* (Supabase Auth schema — not ours)
//
// The PII column deny-regex (piiColumnDenyRegex) catches
// secret|token|password|api_key|private_key on every sheet as a
// belt-and-braces filter. user_caldav_config.password_encrypted is
// explicitly named in DropColumns too.
func orgSheetQueries() []sheetQuery {
return []sheetQuery{
// --- entity sheets (alphabetical) ---
{SheetName: "appointment_caldav_targets", SQL: `SELECT * FROM paliad.appointment_caldav_targets ORDER BY appointment_id, calendar_binding_id`},
{SheetName: "appointments", SQL: `SELECT * FROM paliad.appointments ORDER BY id`},
{SheetName: "approval_policies", SQL: `SELECT * FROM paliad.approval_policies ORDER BY id`},
{SheetName: "approval_requests", SQL: `SELECT * FROM paliad.approval_requests ORDER BY id`},
// backups is self-reflexive — including it makes "what backups
// have we taken" recoverable from any prior backup. Tiny table.
{SheetName: "backups", SQL: `SELECT * FROM paliad.backups ORDER BY started_at, id`},
{SheetName: "caldav_sync_log", SQL: `SELECT * FROM paliad.caldav_sync_log ORDER BY occurred_at, id`},
{SheetName: "checklist_instances", SQL: `SELECT * FROM paliad.checklist_instances ORDER BY id`},
{SheetName: "checklist_shares", SQL: `SELECT * FROM paliad.checklist_shares ORDER BY id`},
{SheetName: "checklists", SQL: `SELECT * FROM paliad.checklists ORDER BY id`},
{SheetName: "deadline_rule_audit", SQL: `SELECT * FROM paliad.deadline_rule_audit ORDER BY changed_at, id`},
{SheetName: "deadlines", SQL: `SELECT * FROM paliad.deadlines ORDER BY id`},
// documents: ai_extracted jsonb dropped (verbose AI prompts;
// matches the personal/project precedent). Binaries are not in
// the export — only metadata.
{
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
ORDER BY id`,
},
{SheetName: "email_broadcasts", SQL: `SELECT * FROM paliad.email_broadcasts ORDER BY id`},
{SheetName: "email_template_versions", SQL: `SELECT * FROM paliad.email_template_versions ORDER BY id`},
{SheetName: "email_templates", SQL: `SELECT * FROM paliad.email_templates ORDER BY id`},
{SheetName: "firm_dashboard_default", SQL: `SELECT * FROM paliad.firm_dashboard_default ORDER BY id`},
{SheetName: "invitations", SQL: `SELECT * FROM paliad.invitations ORDER BY sent_at, id`},
{SheetName: "notes", SQL: `SELECT * FROM paliad.notes ORDER BY id`},
{SheetName: "parties", SQL: `SELECT * FROM paliad.parties ORDER BY id`},
{SheetName: "partner_unit_events", SQL: `SELECT * FROM paliad.partner_unit_events ORDER BY id`},
{SheetName: "partner_unit_members", SQL: `SELECT * FROM paliad.partner_unit_members ORDER BY partner_unit_id, user_id`},
{SheetName: "partner_units", SQL: `SELECT * FROM paliad.partner_units ORDER BY id`},
{SheetName: "policy_audit_log", SQL: `SELECT * FROM paliad.policy_audit_log ORDER BY changed_at, id`},
{SheetName: "project_events", SQL: `SELECT * FROM paliad.project_events ORDER BY id`},
{SheetName: "project_partner_units", SQL: `SELECT * FROM paliad.project_partner_units ORDER BY project_id, partner_unit_id`},
{SheetName: "project_teams", SQL: `SELECT * FROM paliad.project_teams ORDER BY project_id, user_id`},
{SheetName: "projects", SQL: `SELECT * FROM paliad.projects ORDER BY id`},
{SheetName: "reminder_log", SQL: `SELECT * FROM paliad.reminder_log ORDER BY sent_at, id`},
{SheetName: "submission_drafts", SQL: `SELECT * FROM paliad.submission_drafts ORDER BY id`},
{SheetName: "system_audit_log", SQL: `SELECT * FROM paliad.system_audit_log ORDER BY created_at, id`},
{
SheetName: "user_caldav_config",
SQL: `SELECT * FROM paliad.user_caldav_config ORDER BY user_id`,
DropColumns: []string{"password_encrypted"}, // belt-and-braces; piiColumnDenyRegex also catches it
},
{SheetName: "user_calendar_bindings", SQL: `SELECT * FROM paliad.user_calendar_bindings ORDER BY user_id, calendar_path`},
{SheetName: "user_card_layouts", SQL: `SELECT * FROM paliad.user_card_layouts ORDER BY id`},
{SheetName: "user_dashboard_layouts", SQL: `SELECT * FROM paliad.user_dashboard_layouts ORDER BY user_id`},
{SheetName: "user_pinned_projects", SQL: `SELECT * FROM paliad.user_pinned_projects ORDER BY user_id, project_id`},
{SheetName: "user_views", SQL: `SELECT * FROM paliad.user_views ORDER BY id`},
{SheetName: "users", SQL: `SELECT * FROM paliad.users ORDER BY id`},
// --- reference data (alphabetical, prefixed ref__) ---
{SheetName: "ref__countries", SQL: `SELECT * FROM paliad.countries ORDER BY code`},
{SheetName: "ref__courts", SQL: `SELECT * FROM paliad.courts ORDER BY id`},
{SheetName: "ref__deadline_concept_event_types", SQL: `SELECT * FROM paliad.deadline_concept_event_types ORDER BY concept_id, event_type_id`},
{SheetName: "ref__deadline_concepts", SQL: `SELECT * FROM paliad.deadline_concepts ORDER BY id`},
{SheetName: "ref__deadline_event_types", SQL: `SELECT * FROM paliad.deadline_event_types ORDER BY rule_id, event_type_id`},
{SheetName: "ref__deadline_rules", SQL: `SELECT * FROM paliad.deadline_rules ORDER BY id`},
{SheetName: "ref__event_categories", SQL: `SELECT * FROM paliad.event_categories ORDER BY id`},
{SheetName: "ref__event_category_concepts", SQL: `SELECT * FROM paliad.event_category_concepts ORDER BY category_id, concept_id`},
{SheetName: "ref__event_types", SQL: `SELECT * FROM paliad.event_types ORDER BY id`},
{SheetName: "ref__holidays", SQL: `SELECT * FROM paliad.holidays ORDER BY date, country`},
{SheetName: "ref__proceeding_types", SQL: `SELECT * FROM paliad.proceeding_types ORDER BY id`},
{SheetName: "ref__trigger_events", SQL: `SELECT * FROM paliad.trigger_events ORDER BY id`},
}
}

View File

@@ -44,12 +44,20 @@ var AllSources = []DataSource{
const SpecVersion = 1
// FilterSpec is the top-level filter description.
//
// UnreadOnly (t-paliad-249) is an inbox-specific overlay: when true,
// project_event rows older than the caller's inbox_seen_at cursor are
// dropped. Pending approval_request rows always survive (the cursor
// can't bury an in-flight approval, per the design doc §3 carve-out).
// Set by the bar's `unread_only` axis on /inbox; other surfaces leave
// it false and the spec is a no-op.
type FilterSpec struct {
Version int `json:"version"`
Sources []DataSource `json:"sources"`
Scope ScopeSpec `json:"scope"`
Time TimeSpec `json:"time"`
Predicates map[DataSource]Predicates `json:"predicates,omitempty"`
UnreadOnly bool `json:"unread_only,omitempty"`
}
// ScopeSpec narrows which projects contribute rows. Resolved at query
@@ -192,11 +200,58 @@ var KnownProjectEventKinds = []string{
"deadline_created",
"deadline_completed",
"deadline_reopened",
"deadline_updated",
"deadline_deleted",
"deadlines_imported",
"appointment_created",
"appointment_updated",
"appointment_deleted",
"approval_decided",
"member_role_changed",
"note_created",
"our_side_changed",
}
// InboxProjectEventKinds is the curated sub-list surfaced by default on
// /inbox (t-paliad-249, Slice A; head pick Q1=A on 2026-05-25).
//
// What's in:
// - Lifecycle moves the team should notice: project_archived,
// project_reparented, project_type_changed.
// - Deadline / appointment authoring across the visible scope.
// - Notes (`note_created`) and party-side flips
// (`our_side_changed`).
// - `member_role_changed` — Slice A surfaces it for everyone who can
// see the project; Slice B narrows to "role change affects the
// viewer or someone above them in the project tree" (head's
// refinement #1).
//
// What's out:
// - All `*_approval_*` event_types — duplicates of approval_request
// rows. View-service drops them automatically when ApprovalRequest
// is also in spec.Sources (see view_service.allowedProjectEventKinds).
// - `status_changed`, `project_created` — too granular / authoring
// noise.
// - `checklist_*` — low signal; surfaces on the project's checklist
// tab instead.
//
// Design ref: docs/design-inbox-overhaul-2026-05-25.md §2 + §12.
var InboxProjectEventKinds = []string{
"project_archived",
"project_reparented",
"project_type_changed",
"deadline_created",
"deadline_completed",
"deadline_reopened",
"deadline_updated",
"deadline_deleted",
"deadlines_imported",
"appointment_created",
"appointment_updated",
"appointment_deleted",
"note_created",
"our_side_changed",
"member_role_changed",
}
// validApprovalStatuses are the legal values for entity-side approval_status

View File

@@ -124,6 +124,7 @@ const (
RowActionNavigate ListRowAction = "navigate"
RowActionCompleteToggle ListRowAction = "complete_toggle"
RowActionApprove ListRowAction = "approve"
RowActionInbox ListRowAction = "inbox"
RowActionNone ListRowAction = "none"
)
@@ -134,6 +135,7 @@ var KnownRowActions = []ListRowAction{
RowActionNavigate,
RowActionCompleteToggle,
RowActionApprove,
RowActionInbox,
RowActionNone,
}

View File

@@ -47,6 +47,33 @@ type PlaceholderMap map[string]string
// "[KEIN WERT: <key>]" / "[NO VALUE: <key>]" depending on lang.
type MissingPlaceholderFn func(key string) string
// valueWrapperFn wraps a substituted value with a marker the HTML
// preview emitter can recognise — used by RenderHTML to turn each
// substituted value into a clickable <span class="draft-var" …>
// (t-paliad-261, click-variable-in-preview → jump-to-field). nil means
// no wrapping; the .docx export path uses nil so its output is
// byte-identical to the wrapper-free build. The wrapper is invoked for
// both resolved values and missing-marker text so clicking a missing
// placeholder still jumps to the corresponding sidebar input.
type valueWrapperFn func(key, value string) string
// Private-Use-Area sentinels for the HTML preview wrap. PUA characters
// are valid in XML 1.0 content, never appear in legitimate template
// text, pass unchanged through xmlEncode/xmlDecode/htmlEscape, and are
// stripped by emitTextWithDraftVars when the preview HTML is assembled.
const (
previewVarBegin = ""
previewVarMid = ""
previewVarEnd = ""
)
// htmlPreviewWrapper wraps a substituted value with the PUA sentinels
// emitTextWithDraftVars recognises. Used only by RenderHTML; the .docx
// Render path uses nil so its output is identical to the pre-261 build.
func htmlPreviewWrapper(key, value string) string {
return previewVarBegin + key + previewVarMid + value + previewVarEnd
}
// DefaultMissingMarker returns the standard missing-value marker for
// the given UI language.
func DefaultMissingMarker(lang string) MissingPlaceholderFn {
@@ -107,7 +134,7 @@ func (r *SubmissionRenderer) Render(templateBytes []byte, vars PlaceholderMap, m
return nil, fmt.Errorf("submission render: read %s: %w", entry.Name, err)
}
if isWordXMLEntry(entry.Name) {
body = substituteInDocumentXML(body, vars, missing)
body = substituteInDocumentXML(body, vars, missing, nil)
}
w, err := zw.CreateHeader(&zip.FileHeader{
Name: entry.Name,
@@ -165,7 +192,7 @@ func (r *SubmissionRenderer) RenderHTML(templateBytes []byte, vars PlaceholderMa
if docXML == nil {
return "", fmt.Errorf("submission render html: word/document.xml missing")
}
merged := substituteInDocumentXML(docXML, vars, missing)
merged := substituteInDocumentXML(docXML, vars, missing, htmlPreviewWrapper)
return docXMLToHTML(merged), nil
}
@@ -214,12 +241,12 @@ func readMergeZipEntry(f *zip.File) ([]byte, error) {
// paragraph, run the replacement on the merged text, and rewrite
// the paragraph's runs as a single <w:r><w:t>…</w:t></w:r> using
// the formatting properties of the first run.
func substituteInDocumentXML(body []byte, vars PlaceholderMap, missing MissingPlaceholderFn) []byte {
replaced := substituteInTextNodes(body, vars, missing)
func substituteInDocumentXML(body []byte, vars PlaceholderMap, missing MissingPlaceholderFn, wrap valueWrapperFn) []byte {
replaced := substituteInTextNodes(body, vars, missing, wrap)
if !needsCrossRunMerge(replaced) {
return replaced
}
return substituteAcrossRuns(replaced, vars, missing)
return substituteAcrossRuns(replaced, vars, missing, wrap)
}
// wTextNodeRegex matches one <w:t …>contents</w:t> element, capturing
@@ -229,12 +256,12 @@ var wTextNodeRegex = regexp.MustCompile(`<w:t(\s[^>]*)?>([^<]*)</w:t>`)
// substituteInTextNodes runs the placeholder replacement inside each
// <w:t> text node independently. Format-preserving for single-run
// placeholders.
func substituteInTextNodes(body []byte, vars PlaceholderMap, missing MissingPlaceholderFn) []byte {
func substituteInTextNodes(body []byte, vars PlaceholderMap, missing MissingPlaceholderFn, wrap valueWrapperFn) []byte {
return wTextNodeRegex.ReplaceAllFunc(body, func(match []byte) []byte {
sub := wTextNodeRegex.FindSubmatch(match)
attrs := string(sub[1])
contents := xmlDecode(string(sub[2]))
replaced := replacePlaceholders(contents, vars, missing)
replaced := replacePlaceholders(contents, vars, missing, wrap)
if replaced == contents {
return match
}
@@ -270,7 +297,7 @@ var wParagraphPropsRegex = regexp.MustCompile(`(?s)<w:pPr>.*?</w:pPr>`)
// substituteAcrossRuns is pass 2: concatenate every text node in a
// fragmented-placeholder paragraph, run replacement, rewrite as one run.
func substituteAcrossRuns(body []byte, vars PlaceholderMap, missing MissingPlaceholderFn) []byte {
func substituteAcrossRuns(body []byte, vars PlaceholderMap, missing MissingPlaceholderFn, wrap valueWrapperFn) []byte {
return wParagraphRegex.ReplaceAllFunc(body, func(para []byte) []byte {
textNodes := wTextNodeRegex.FindAllSubmatch(para, -1)
if len(textNodes) == 0 {
@@ -284,7 +311,7 @@ func substituteAcrossRuns(body []byte, vars PlaceholderMap, missing MissingPlace
if !strings.Contains(original, "{{") {
return para
}
replaced := replacePlaceholders(original, vars, missing)
replaced := replacePlaceholders(original, vars, missing, wrap)
if replaced == original {
return para
}
@@ -307,18 +334,29 @@ func substituteAcrossRuns(body []byte, vars PlaceholderMap, missing MissingPlace
}
// replacePlaceholders performs the actual substitution on a plain
// string. Unbound placeholders render the missing marker.
func replacePlaceholders(s string, vars PlaceholderMap, missing MissingPlaceholderFn) string {
// string. Unbound placeholders render the missing marker. When wrap is
// non-nil, both the resolved value AND the missing-marker text are
// passed through wrap(key, value) — the HTML preview path uses this to
// emit clickable spans around every substituted placeholder, including
// missing ones (clicking a missing marker jumps to the corresponding
// sidebar input).
func replacePlaceholders(s string, vars PlaceholderMap, missing MissingPlaceholderFn, wrap valueWrapperFn) string {
return placeholderRegex.ReplaceAllStringFunc(s, func(match string) string {
sub := placeholderRegex.FindStringSubmatch(match)
if len(sub) < 2 {
return match
}
key := sub[1]
if value, ok := vars[key]; ok {
return value
var value string
if v, ok := vars[key]; ok {
value = v
} else {
value = missing(key)
}
return missing(key)
if wrap != nil {
return wrap(key, value)
}
return value
})
}
@@ -401,7 +439,7 @@ func paragraphToHTML(para []byte) string {
if italic {
out.WriteString("<em>")
}
out.WriteString(htmlEscape(text))
out.WriteString(emitTextWithDraftVars(text))
if italic {
out.WriteString("</em>")
}
@@ -412,6 +450,52 @@ func paragraphToHTML(para []byte) string {
return out.String()
}
// emitTextWithDraftVars HTML-escapes run text while converting any
// preview-only sentinels emitted by htmlPreviewWrapper into
// <span class="draft-var" data-var="<key>">…</span>. The key is
// restricted to [A-Za-z][A-Za-z0-9_.]* by placeholderRegex, so no
// attribute-escaping is needed on the key; the value is HTML-escaped
// normally. Sentinel-free text (the Render path output, or template
// text outside placeholders) is passed straight through htmlEscape, so
// callers that never invoked wrap see byte-identical HTML.
//
// t-paliad-261: makes substituted variables clickable in the preview
// pane so the user can jump to the matching input in the sidebar.
func emitTextWithDraftVars(text string) string {
if !strings.Contains(text, previewVarBegin) {
return htmlEscape(text)
}
var out strings.Builder
rest := text
for {
i := strings.Index(rest, previewVarBegin)
if i < 0 {
out.WriteString(htmlEscape(rest))
return out.String()
}
out.WriteString(htmlEscape(rest[:i]))
body := rest[i+len(previewVarBegin):]
mid := strings.Index(body, previewVarMid)
end := strings.Index(body, previewVarEnd)
if mid < 0 || end < 0 || mid > end {
// Malformed sentinel — emit the marker as plain escaped
// text and continue past it so the rest of the run still
// renders.
out.WriteString(htmlEscape(previewVarBegin))
rest = body
continue
}
key := body[:mid]
value := body[mid+len(previewVarMid) : end]
out.WriteString(`<span class="draft-var" data-var="`)
out.WriteString(key)
out.WriteString(`">`)
out.WriteString(htmlEscape(value))
out.WriteString(`</span>`)
rest = body[end+len(previewVarEnd):]
}
}
// extractRunText concatenates every <w:t> inside a run, XML-decoding
// the content as it goes.
func extractRunText(run []byte) string {

View File

@@ -265,7 +265,9 @@ func TestPatentNumberUPC(t *testing.T) {
// TestRenderHTML_ExtractsParagraphsAndFormatting verifies the preview
// HTML emitter walks <w:p> / <w:r> / <w:t> correctly and carries
// bold/italic through to <strong>/<em>.
// bold/italic through to <strong>/<em>. Substituted placeholders are
// wrapped in <span class="draft-var" data-var="…"> so the client can
// make them clickable (t-paliad-261).
func TestRenderHTML_ExtractsParagraphsAndFormatting(t *testing.T) {
doc := `<w:document><w:body>` +
`<w:p><w:r><w:t>Hello {{firm.name}}</w:t></w:r></w:p>` +
@@ -278,8 +280,8 @@ func TestRenderHTML_ExtractsParagraphsAndFormatting(t *testing.T) {
if err != nil {
t.Fatalf("render html: %v", err)
}
if !strings.Contains(html, "<p>Hello HLC</p>") {
t.Errorf("expected merged paragraph, got %q", html)
if !strings.Contains(html, `<p>Hello <span class="draft-var" data-var="firm.name">HLC</span></p>`) {
t.Errorf("expected merged paragraph with draft-var span, got %q", html)
}
if !strings.Contains(html, "<strong>Bold line</strong>") {
t.Errorf("expected bold span, got %q", html)
@@ -290,7 +292,8 @@ func TestRenderHTML_ExtractsParagraphsAndFormatting(t *testing.T) {
}
// TestRenderHTML_EscapesContent confirms the preview emitter HTML-escapes
// special characters in placeholder values.
// special characters in placeholder values even inside the draft-var
// span wrapper.
func TestRenderHTML_EscapesContent(t *testing.T) {
doc := `<w:document><w:body><w:p><w:r><w:t>{{user.display_name}}</w:t></w:r></w:p></w:body></w:document>`
tmpl := minimalMergeDOCX(t, doc)
@@ -301,7 +304,50 @@ func TestRenderHTML_EscapesContent(t *testing.T) {
if err != nil {
t.Fatalf("render html: %v", err)
}
if !strings.Contains(html, "M&amp;S &lt;Inc&gt; &quot;X&quot;") {
t.Errorf("expected escaped value in HTML, got %q", html)
want := `<span class="draft-var" data-var="user.display_name">M&amp;S &lt;Inc&gt; &quot;X&quot;</span>`
if !strings.Contains(html, want) {
t.Errorf("expected escaped value inside draft-var span, got %q", html)
}
}
// TestRenderHTML_WrapsMissingMarker confirms that an unbound placeholder
// is still rendered as a clickable draft-var span so the user can click
// the [KEIN WERT: …] marker in the preview and jump to the field.
func TestRenderHTML_WrapsMissingMarker(t *testing.T) {
doc := `<w:document><w:body><w:p><w:r><w:t>{{project.case_number}}</w:t></w:r></w:p></w:body></w:document>`
tmpl := minimalMergeDOCX(t, doc)
r := NewSubmissionRenderer()
html, err := r.RenderHTML(tmpl, PlaceholderMap{}, nil)
if err != nil {
t.Fatalf("render html: %v", err)
}
want := `<span class="draft-var" data-var="project.case_number">[KEIN WERT: project.case_number]</span>`
if !strings.Contains(html, want) {
t.Errorf("expected missing marker wrapped in draft-var span, got %q", html)
}
}
// TestRender_DocxOutputUnchangedByPreviewWrap asserts the hard rule from
// t-paliad-261: the .docx export path must NOT carry the preview-only
// draft-var sentinels or any draft-var span markup. Renders the same
// template through Render (.docx) and asserts the merged document.xml
// has only the resolved value, not a wrapped one.
func TestRender_DocxOutputUnchangedByPreviewWrap(t *testing.T) {
doc := `<w:document><w:body><w:p><w:r><w:t>{{firm.name}}</w:t></w:r></w:p></w:body></w:document>`
tmpl := minimalMergeDOCX(t, doc)
r := NewSubmissionRenderer()
out, err := r.Render(tmpl, PlaceholderMap{"firm.name": "HLC"}, nil)
if err != nil {
t.Fatalf("render docx: %v", err)
}
body := readMergeDocumentXML(t, out)
if !strings.Contains(body, `<w:t>HLC</w:t>`) {
t.Errorf("expected raw resolved value in .docx, got %q", body)
}
// PUA sentinels and any span markup must NOT appear in the .docx.
for _, forbidden := range []string{"draft-var", "data-var", previewVarBegin, previewVarMid, previewVarEnd} {
if strings.Contains(body, forbidden) {
t.Errorf("docx output unexpectedly contains %q: %q", forbidden, body)
}
}
}

View File

@@ -100,40 +100,48 @@ func EventsSystemView() SystemView {
}
}
// InboxSystemView returns the SystemView definition for /inbox — the
// 4-eye approval surface. The bar's approval_viewer_role chip
// cluster narrows to "Zur Genehmigung" / "Eigene Anfragen" /
// "Alle sichtbaren". Default is "any_visible" so the page lands on
// a populated view for every user (m's 2026-05-08 22:08 dogfood:
// "the inbox somehow does not show nothing no more" — the prior
// default was approver_eligible, which is empty for users who only
// SUBMIT requests and have nothing to approve themselves).
// InboxSystemView returns the SystemView definition for /inbox.
//
// RowAction = RowActionApprove → shape-list.ts renders the approval
// row layout (entity title + diff + approve/reject/revoke buttons)
// and the surface wires action handlers via the rendered data-attrs.
// t-paliad-249 (Slice A, 2026-05-25) widened the inbox from
// approval-requests-only to a project-events feed PLUS approval
// requests. Sources is [ApprovalRequest, ProjectEvent]; the project
// rail is narrowed to InboxProjectEventKinds (curated set, head pick
// Q1=A). The `*_approval_*` audit events are de-duplicated against
// the approval_request rows by view_service.allowedProjectEventKinds.
//
// Time window defaults to last 30 days; the bar's time-axis chip
// can widen or narrow. Sort is newest-first — different from the
// pre-249 ascending default; m's inbox metaphor is "what just
// happened", not "what's coming up".
//
// RowAction = RowActionInbox → shape-list.ts dispatches per
// row.kind: approval rows get the approve/reject/revoke layout,
// project_event rows get a navigate-style stream row.
func InboxSystemView() SystemView {
return SystemView{
Slug: "inbox",
Name: "Inbox",
Filter: FilterSpec{
Version: SpecVersion,
Sources: []DataSource{SourceApprovalRequest},
Sources: []DataSource{SourceApprovalRequest, SourceProjectEvent},
Scope: ScopeSpec{Projects: ScopeProjects{Mode: ScopeAllVisible}},
Time: TimeSpec{Horizon: HorizonAny, Field: FieldAuto},
Time: TimeSpec{Horizon: HorizonPast30d, Field: FieldAuto},
Predicates: map[DataSource]Predicates{
SourceApprovalRequest: {ApprovalRequest: &ApprovalRequestPredicates{
ViewerRole: "any_visible",
Status: []string{"pending"},
}},
SourceProjectEvent: {ProjectEvent: &ProjectEventPredicates{
EventTypes: InboxProjectEventKinds,
}},
},
},
Render: RenderSpec{
Shape: ShapeList,
List: &ListConfig{
Density: DensityComfortable,
Sort: SortDateAsc,
RowAction: RowActionApprove,
Sort: SortDateDesc,
RowAction: RowActionInbox,
},
},
}

View File

@@ -1,6 +1,9 @@
package services
import "testing"
import (
"slices"
"testing"
)
// Pure-Go tests for the SystemView registry. Each system view's specs
// must self-validate; the slugs must be reserved.
@@ -45,3 +48,63 @@ func TestReservedSlugs_NonReservedAccepted(t *testing.T) {
}
}
}
// ----------------------------------------------------------------------
// InboxSystemView shape — t-paliad-249
// ----------------------------------------------------------------------
func TestInboxSystemView_Sources(t *testing.T) {
sv := InboxSystemView()
if !slices.Contains(sv.Filter.Sources, SourceApprovalRequest) {
t.Errorf("InboxSystemView must include SourceApprovalRequest, got %v", sv.Filter.Sources)
}
if !slices.Contains(sv.Filter.Sources, SourceProjectEvent) {
t.Errorf("InboxSystemView must include SourceProjectEvent, got %v", sv.Filter.Sources)
}
}
func TestInboxSystemView_DefaultsToPast30d(t *testing.T) {
sv := InboxSystemView()
if sv.Filter.Time.Horizon != HorizonPast30d {
t.Errorf("default horizon must be past_30d, got %q", sv.Filter.Time.Horizon)
}
}
func TestInboxSystemView_RowActionInbox(t *testing.T) {
sv := InboxSystemView()
if sv.Render.List == nil {
t.Fatal("InboxSystemView must define a list config")
}
if sv.Render.List.RowAction != RowActionInbox {
t.Errorf("row_action must be inbox, got %q", sv.Render.List.RowAction)
}
}
func TestInboxSystemView_CuratedProjectEventKinds(t *testing.T) {
sv := InboxSystemView()
preds := sv.Filter.Predicates[SourceProjectEvent]
if preds.ProjectEvent == nil {
t.Fatal("InboxSystemView must narrow project_event predicates")
}
got := preds.ProjectEvent.EventTypes
if len(got) != len(InboxProjectEventKinds) {
t.Errorf("expected %d curated kinds, got %d", len(InboxProjectEventKinds), len(got))
}
for _, k := range got {
if slices.Contains([]string{"status_changed", "project_created"}, k) {
t.Errorf("inbox must NOT include noisy kind %q", k)
}
// No *_approval_* audit duplicates either — view_service dedups
// at query time but the curated list shouldn't carry them.
if isApprovalAuditKind(k) {
t.Errorf("inbox curated list must not include audit-dup %q", k)
}
}
}
func TestInboxSystemView_NewestFirst(t *testing.T) {
sv := InboxSystemView()
if sv.Render.List == nil || sv.Render.List.Sort != SortDateDesc {
t.Errorf("inbox must sort newest-first by default, got %q", sv.Render.List.Sort)
}
}

View File

@@ -83,6 +83,15 @@ func (s *EventService) RunSpec(ctx context.Context, userID uuid.UUID, spec Filte
rows := make([]ViewRow, 0, 256)
bounds := computeViewSpecBounds(time.Now().UTC(), spec.Time)
if spec.UnreadOnly && approval != nil {
cursor, err := approval.InboxSeenAt(ctx, userID)
if err != nil {
return nil, fmt.Errorf("inbox cursor lookup: %w", err)
}
bounds.unreadOnly = true
bounds.inboxSeenAt = cursor
}
for _, src := range spec.Sources {
switch src {
case SourceDeadline:
@@ -148,9 +157,16 @@ func (s *EventService) RunSpec(ctx context.Context, userID uuid.UUID, spec Filte
// viewSpecBounds carries the resolved [from, to) window the spec
// translates into. Either bound can be nil (open-ended).
//
// inboxSeenAt is set by RunSpec when spec.UnreadOnly=true: the caller's
// users.inbox_seen_at cursor pre-resolved once so each source-runner can
// overlay it without re-querying the users table. nil means "never
// visited" — every row is unread.
type viewSpecBounds struct {
from *time.Time
to *time.Time
from *time.Time
to *time.Time
unreadOnly bool
inboxSeenAt *time.Time
}
func computeViewSpecBounds(now time.Time, ts TimeSpec) viewSpecBounds {
@@ -345,6 +361,11 @@ func (s *EventService) runAppointments(ctx context.Context, userID uuid.UUID, sp
// runProjectEvents queries paliad.project_events with the visibility
// predicate. The audit table doesn't have a service wrapper today; we
// run our own SQL bounded by the spec.
//
// Inbox semantics (t-paliad-249) kick in when bounds.unreadOnly is set:
// the caller's own events are excluded (you don't notify yourself) and
// rows older than bounds.inboxSeenAt are dropped. Cursor lookup happens
// once in RunSpec — runProjectEvents only consumes the resolved value.
func (s *EventService) runProjectEvents(ctx context.Context, userID uuid.UUID, spec FilterSpec, bounds viewSpecBounds) ([]ViewRow, error) {
conds := []string{visibilityPredicatePositional("p", 1)}
args := []any{userID}
@@ -366,6 +387,14 @@ func (s *EventService) runProjectEvents(ctx context.Context, userID uuid.UUID, s
args = append(args, spec.Scope.Projects.IDs)
conds = append(conds, fmt.Sprintf("pe.project_id = ANY($%d)", len(args)))
}
if bounds.unreadOnly {
// Inbox mode: hide the caller's own actions (no self-notify).
conds = append(conds, "pe.created_by IS DISTINCT FROM $1")
if bounds.inboxSeenAt != nil {
args = append(args, *bounds.inboxSeenAt)
conds = append(conds, fmt.Sprintf("pe.created_at > $%d", len(args)))
}
}
q := `
SELECT pe.id, pe.project_id, pe.event_type, pe.title, pe.description,
@@ -520,6 +549,15 @@ func (s *EventService) runApprovalRequests(ctx context.Context, userID uuid.UUID
continue
}
// Inbox unread-only carve-out (t-paliad-249, design §3):
// pending requests always survive; decided rows are subject to
// the cursor like any other audit-style item.
if bounds.unreadOnly && r.Status != RequestStatusPending {
if bounds.inboxSeenAt != nil && !eventDate.After(*bounds.inboxSeenAt) {
continue
}
}
title := approvalRowTitle(r)
subtitle := approvalRowSubtitle(r)
detail, _ := json.Marshal(r) // request view already carries everything the UI needs
@@ -646,15 +684,46 @@ func allowedAppointmentTypes(spec FilterSpec) map[string]bool {
// allowedProjectEventKinds returns the slice of project_event.event_type
// values the spec narrows to, or nil for "all known kinds".
//
// Inbox de-dup (t-paliad-249): when the spec also fans out
// SourceApprovalRequest, every `*_approval_*` audit event is dropped
// — the approval_request row itself is the canonical signal, and we
// don't want both rows showing up side-by-side. The drop applies to
// both the explicit caller list and the implicit "all kinds" path.
func allowedProjectEventKinds(spec FilterSpec) []string {
preds, ok := spec.Predicates[SourceProjectEvent]
if !ok || preds.ProjectEvent == nil {
dedupApprovals := slices.Contains(spec.Sources, SourceApprovalRequest)
var requested []string
switch {
case ok && preds.ProjectEvent != nil && len(preds.ProjectEvent.EventTypes) > 0:
requested = preds.ProjectEvent.EventTypes
case dedupApprovals:
// No explicit narrowing, but ApprovalRequest is in sources —
// rebuild the implicit "all" list so we can subtract approvals.
requested = KnownProjectEventKinds
default:
return nil
}
if len(preds.ProjectEvent.EventTypes) == 0 {
return nil
if !dedupApprovals {
return requested
}
return preds.ProjectEvent.EventTypes
filtered := make([]string, 0, len(requested))
for _, k := range requested {
if isApprovalAuditKind(k) {
continue
}
filtered = append(filtered, k)
}
return filtered
}
// isApprovalAuditKind matches the `*_approval_*` audit event_types that
// every approval mutation emits alongside the approval_request row.
// Dropped from inbox project_event reads (see allowedProjectEventKinds).
func isApprovalAuditKind(kind string) bool {
return strings.Contains(kind, "_approval_") || kind == "approval_decided"
}
// allowedRequestStatuses returns nil for "no narrowing" (or "single value

View File

@@ -0,0 +1,111 @@
package services
import (
"slices"
"testing"
)
// t-paliad-249 — Slice A. Ensures the *_approval_* audit kinds are
// dropped from the project_event read path when ApprovalRequest is
// also fanning out, so the same fact isn't shown twice in /inbox.
func TestAllowedProjectEventKinds_DedupsApprovalAudits(t *testing.T) {
spec := FilterSpec{
Version: SpecVersion,
Sources: []DataSource{SourceApprovalRequest, SourceProjectEvent},
Predicates: map[DataSource]Predicates{
SourceProjectEvent: {ProjectEvent: &ProjectEventPredicates{
EventTypes: []string{
"deadline_created",
"deadline_approval_requested",
"appointment_approval_approved",
"approval_decided",
"note_created",
},
}},
},
}
got := allowedProjectEventKinds(spec)
if slices.Contains(got, "deadline_approval_requested") {
t.Errorf("must drop deadline_approval_requested, got %v", got)
}
if slices.Contains(got, "appointment_approval_approved") {
t.Errorf("must drop appointment_approval_approved, got %v", got)
}
if slices.Contains(got, "approval_decided") {
t.Errorf("must drop approval_decided, got %v", got)
}
if !slices.Contains(got, "deadline_created") {
t.Errorf("must keep deadline_created, got %v", got)
}
if !slices.Contains(got, "note_created") {
t.Errorf("must keep note_created, got %v", got)
}
}
func TestAllowedProjectEventKinds_NoDedupWhenApprovalsAbsent(t *testing.T) {
spec := FilterSpec{
Version: SpecVersion,
Sources: []DataSource{SourceProjectEvent},
Predicates: map[DataSource]Predicates{
SourceProjectEvent: {ProjectEvent: &ProjectEventPredicates{
EventTypes: []string{
"deadline_created",
"deadline_approval_requested",
},
}},
},
}
got := allowedProjectEventKinds(spec)
if !slices.Contains(got, "deadline_approval_requested") {
t.Errorf("Verlauf-style spec without approvals source should keep audit kinds, got %v", got)
}
}
func TestAllowedProjectEventKinds_NilWhenNoPredicateAndNoDedup(t *testing.T) {
spec := FilterSpec{
Version: SpecVersion,
Sources: []DataSource{SourceProjectEvent},
}
if got := allowedProjectEventKinds(spec); got != nil {
t.Errorf("expected nil (all kinds), got %v", got)
}
}
func TestAllowedProjectEventKinds_FillsImplicitListWhenDedup(t *testing.T) {
// When approvals are in sources but the caller didn't explicitly
// narrow EventTypes, the helper must materialise the curated set
// minus audit duplicates so the WHERE clause filters them out.
spec := FilterSpec{
Version: SpecVersion,
Sources: []DataSource{SourceApprovalRequest, SourceProjectEvent},
}
got := allowedProjectEventKinds(spec)
if got == nil {
t.Fatal("expected explicit kind list, got nil")
}
for _, k := range got {
if isApprovalAuditKind(k) {
t.Errorf("audit kind %q leaked through dedup", k)
}
}
}
func TestIsApprovalAuditKind(t *testing.T) {
cases := map[string]bool{
"deadline_approval_requested": true,
"appointment_approval_approved": true,
"appointment_approval_rejected": true,
"deadline_approval_changes_suggested": true,
"approval_decided": true,
"deadline_created": false,
"note_created": false,
"our_side_changed": false,
"project_archived": false,
}
for kind, want := range cases {
if got := isApprovalAuditKind(kind); got != want {
t.Errorf("isApprovalAuditKind(%q) = %v, want %v", kind, got, want)
}
}
}

View File

@@ -0,0 +1,303 @@
// Universal-skeleton submission template generator (t-paliad-259).
//
// One-shot authoring tool that emits a minimal but Word-compatible
// .docx file exercising every placeholder SubmissionVarsService
// resolves — without baking in any submission_code-specific prose.
//
// Drop the output into m/mWorkRepo at
//
// 6 - material/Templates/Word/Paliad/HLC/_skeleton.docx
//
// so paliad's submission generator picks it up via the fallback chain
// slotted between the per-submission_code template and the bare
// universal HL Patents Style .dotm. Any submission_code that has no
// per-firm template still gets a draft populated with variables
// instead of the macro-only letterhead.
//
// Why a separate file from de.inf.lg.erwidg.docx: that one is a
// Klageerwiderung skeleton (DE LG, "I. Anträge / II. Sachverhalt /
// III. Rechtsausführungen"). For a UPC SoC, an EPO opposition, a DPMA
// appeal, that body structure is wrong. The universal skeleton drops
// the structure and leaves a single neutral body block the lawyer
// replaces — every variable still resolves regardless of code.
//
// Run:
//
// go run ./scripts/gen-skeleton-submission-template -out /tmp/_skeleton.docx
//
// Output is byte-reproducible (zip mtimes pinned to a fixed UTC
// timestamp).
package main
import (
"archive/zip"
"bytes"
"flag"
"fmt"
"os"
"strings"
"time"
)
func main() {
out := flag.String("out", "_skeleton.docx", "output .docx path")
flag.Parse()
docx, err := buildDocx()
if err != nil {
fmt.Fprintln(os.Stderr, "gen-skeleton-submission-template:", err)
os.Exit(1)
}
if err := os.WriteFile(*out, docx, 0o644); err != nil {
fmt.Fprintln(os.Stderr, "gen-skeleton-submission-template: write:", err)
os.Exit(1)
}
fmt.Printf("wrote %s (%d bytes)\n", *out, len(docx))
}
var fixedTime = time.Date(2026, 5, 25, 0, 0, 0, 0, time.UTC)
func buildDocx() ([]byte, error) {
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
add := func(name, body string) error {
hdr := &zip.FileHeader{
Name: name,
Method: zip.Deflate,
Modified: fixedTime,
}
w, err := zw.CreateHeader(hdr)
if err != nil {
return fmt.Errorf("create %s: %w", name, err)
}
if _, err := w.Write([]byte(body)); err != nil {
return fmt.Errorf("write %s: %w", name, err)
}
return nil
}
if err := add("[Content_Types].xml", contentTypesXML); err != nil {
return nil, err
}
if err := add("_rels/.rels", rootRelsXML); err != nil {
return nil, err
}
if err := add("word/_rels/document.xml.rels", documentRelsXML); err != nil {
return nil, err
}
if err := add("word/styles.xml", stylesXML); err != nil {
return nil, err
}
if err := add("word/document.xml", buildDocumentXML()); err != nil {
return nil, err
}
if err := zw.Close(); err != nil {
return nil, fmt.Errorf("finalise zip: %w", err)
}
return buf.Bytes(), nil
}
const contentTypesXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="xml" ContentType="application/xml"/>
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
<Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>
</Types>`
const rootRelsXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
</Relationships>`
const documentRelsXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
</Relationships>`
const stylesXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:style w:type="paragraph" w:styleId="Heading1">
<w:name w:val="heading 1"/>
<w:basedOn w:val="Normal"/>
<w:pPr><w:spacing w:before="360" w:after="120"/></w:pPr>
<w:rPr><w:b/><w:sz w:val="28"/></w:rPr>
</w:style>
<w:style w:type="paragraph" w:styleId="Heading2">
<w:name w:val="heading 2"/>
<w:basedOn w:val="Normal"/>
<w:pPr><w:spacing w:before="240" w:after="80"/></w:pPr>
<w:rPr><w:b/><w:sz w:val="24"/></w:rPr>
</w:style>
<w:style w:type="paragraph" w:default="1" w:styleId="Normal">
<w:name w:val="Normal"/>
</w:style>
</w:styles>`
// Document body — a code-agnostic Schriftsatz skeleton: firm letterhead +
// case caption + parties + submission heading + deadline + a single
// neutral body block. Mirrors the variable bag from SubmissionVarsService
// (48 keys across firm.* / today.* / user.* / project.* / parties.* /
// rule.* / deadline.*) without baking in DE-LG-Klageerwiderung-specific
// structure. A lawyer customising this template for a UPC SoC, EPO
// opposition, or DPMA appeal replaces the [Schriftsatztext] block and
// renames the party labels — every placeholder still resolves regardless
// of the submission_code chosen.
//
// Every placeholder occupies its own <w:r> run so the renderer's pass-1
// (format-preserving, single-run) substitution catches it. The
// DEMO/SKELETON banner makes it obvious this is a starter template and
// not approved firm content.
func buildDocumentXML() string {
var b strings.Builder
b.WriteString(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`)
b.WriteString(`<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">`)
b.WriteString(`<w:body>`)
skeletonBanner(&b)
heading1(&b, "{{firm.name}}")
plain(&b, "Bearbeiter: {{user.display_name}}")
plain(&b, "E-Mail: {{user.email}} · Büro: {{user.office}}")
plain(&b, "Datum: {{today.long_de}} ({{today.iso}})")
plainOptional(&b, "{{firm.signature_block}}")
heading1(&b, "{{project.court}}")
plain(&b, "Aktenzeichen: {{project.case_number}}")
plain(&b, "Verfahrensart: {{project.proceeding.name}} ({{project.proceeding.code}})")
plain(&b, "Instanz: {{project.instance_level}}")
heading2(&b, "In der Sache")
plain(&b, "{{parties.claimant.name}}")
plain(&b, "vertreten durch {{parties.claimant.representative}}")
bold(&b, "— Klägerin / Patentinhaberin / Anmelderin —")
plain(&b, "")
plain(&b, "gegen")
plain(&b, "")
plain(&b, "{{parties.defendant.name}}")
plain(&b, "vertreten durch {{parties.defendant.representative}}")
bold(&b, "— Beklagte / Einsprechende / Beschwerdegegnerin —")
plainOptional(&b, "Weitere Beteiligte: {{parties.other.name}}, vertreten durch {{parties.other.representative}}")
heading2(&b, "Betreff")
plain(&b, "Streitpatent: {{project.patent_number}} (UPC: {{project.patent_number_upc}})")
plain(&b, "Anmeldung: {{project.filing_date}} · Erteilung: {{project.grant_date}}")
plain(&b, "Projekttitel: {{project.title}}")
plain(&b, "Unsere Seite: {{project.our_side_de}} ({{project.our_side}})")
plain(&b, "Mandant: {{project.client_number}} · Matter: {{project.matter_number}}")
plain(&b, "Internes Aktenzeichen: {{project.reference}}")
heading1(&b, "{{rule.name}}")
plain(&b, "(Schriftsatz-Code: {{rule.submission_code}})")
plain(&b, "Rechtsgrundlage: {{rule.legal_source_pretty}} ({{rule.legal_source}})")
plain(&b, "Typische Partei: {{rule.primary_party}} · Schriftsatz-Typ: {{rule.event_type}}")
heading2(&b, "Frist")
plain(&b, "Diese Frist wurde berechnet aus: {{deadline.computed_from}}")
plain(&b, "Fälligkeit: {{deadline.due_date_long_de}} ({{deadline.due_date}})")
plainOptional(&b, "Ursprüngliche Frist: {{deadline.original_due_date}}")
plain(&b, "Frist-Bezeichnung: {{deadline.title}} · Quelle: {{deadline.source}}")
heading2(&b, "Schriftsatztext")
plain(&b, "[Hier folgt der eigentliche Schriftsatztext. Diese Skelett-Vorlage enthält keine vorgefertigte Struktur — bitte gemäß Schriftsatz-Typ ({{rule.name}}) ergänzen.]")
plain(&b, "")
plain(&b, "[Body of the submission goes here. This skeleton template carries no pre-baked structure — fill in according to submission type ({{rule.name_en}}).]")
heading2(&b, "Schlussformel")
plain(&b, "{{today.long_de}}")
plain(&b, "")
plain(&b, "{{user.display_name}}")
plain(&b, "{{firm.name}}")
// Locale-aware verification block — exercises every EN/DE alias the
// variable bag carries (today.long_en, deadline.due_date_long_en,
// project.our_side_en, project.proceeding.name_en, rule.name_en) and
// the bare {{today}} alias. A lawyer customising the template can
// delete this block; the renderer round-trips it cleanly today.
heading2(&b, "Locale-aware variants (SKELETON)")
plain(&b, "EN long date: {{today.long_en}} · Deadline EN: {{deadline.due_date_long_en}}")
plain(&b, "Project our side (EN): {{project.our_side_en}} · Proceeding (EN): {{project.proceeding.name_en}}")
plain(&b, "Rule name (EN): {{rule.name_en}} · Project our side (DE): {{project.our_side_de}}")
plain(&b, "Proceeding (DE): {{project.proceeding.name_de}} · Rule name (DE): {{rule.name_de}}")
plain(&b, "Today (bare alias): {{today}}")
b.WriteString(`</w:body></w:document>`)
return b.String()
}
func skeletonBanner(b *strings.Builder) {
b.WriteString(`<w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr><w:r><w:rPr><w:b/><w:color w:val="C00000"/></w:rPr><w:t xml:space="preserve">SKELETON — universelle Vorlage (Schriftsatz-Typ-unabhängig, nicht freigegeben)</w:t></w:r></w:p>`)
}
func heading1(b *strings.Builder, text string) { paragraph(b, "Heading1", text, false) }
func heading2(b *strings.Builder, text string) { paragraph(b, "Heading2", text, false) }
func plain(b *strings.Builder, text string) { paragraph(b, "", text, false) }
func plainOptional(b *strings.Builder, text string) { paragraph(b, "", text, true) }
func bold(b *strings.Builder, text string) {
b.WriteString(`<w:p>`)
b.WriteString(`<w:r><w:rPr><w:b/></w:rPr><w:t xml:space="preserve">`)
b.WriteString(xmlEscape(text))
b.WriteString(`</w:t></w:r></w:p>`)
}
func paragraph(b *strings.Builder, style, text string, italic bool) {
b.WriteString(`<w:p>`)
if style != "" {
b.WriteString(`<w:pPr><w:pStyle w:val="`)
b.WriteString(style)
b.WriteString(`"/></w:pPr>`)
}
for _, seg := range splitOnPlaceholders(text) {
b.WriteString(`<w:r>`)
if italic {
b.WriteString(`<w:rPr><w:i/></w:rPr>`)
}
b.WriteString(`<w:t xml:space="preserve">`)
b.WriteString(xmlEscape(seg))
b.WriteString(`</w:t></w:r>`)
}
b.WriteString(`</w:p>`)
}
func splitOnPlaceholders(s string) []string {
if s == "" {
return []string{""}
}
var out []string
for {
open := strings.Index(s, "{{")
if open < 0 {
out = append(out, s)
return out
}
close := strings.Index(s[open:], "}}")
if close < 0 {
out = append(out, s)
return out
}
end := open + close + 2
if open > 0 {
out = append(out, s[:open])
}
out = append(out, s[open:end])
s = s[end:]
if s == "" {
return out
}
}
}
func xmlEscape(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
s = strings.ReplaceAll(s, `"`, "&quot;")
s = strings.ReplaceAll(s, "'", "&apos;")
return s
}

View File

@@ -0,0 +1,568 @@
// Seed Example Projects (t-paliad-256 / m/paliad#87).
//
// Re-runnable test-data reset:
//
// 1. Wipes every row in paliad.projects (FK CASCADE handles the
// dependent rows: deadlines, appointments, parties, notes,
// project_events, project_teams, submission_drafts, approval_*,
// project_partner_units, user_pinned_projects, documents,
// user_calendar_bindings).
//
// 2. Inserts a small but realistic example tree (3 clients, 4
// litigations, 4 patents, 8 cases — 19 projects total) that
// exercises the auto-derived chain code: Client.Litigation.Patent.Case
// → e.g. SIEMENS.HUAW.789.INF.CFI.
//
// 3. Re-reads the projects and prints each row's chain code so the
// operator can eyeball the result without bouncing to SQL.
//
// Reference tables (proceeding_types, deadline_rules, event_types,
// gerichte, checklists templates, firms, profiles) are untouched.
//
// Run:
//
// DATABASE_URL='postgres://...' go run ./scripts/seed-example-projects
//
// One transaction wraps both wipe and seed so the DB is never in a
// half-wiped state. Re-running drops the previous example tree and
// reseeds fresh UUIDs — handy when project-code semantics change.
//
// Owner: m (matthias.siebels@hoganlovells.com). The script looks the
// auth user up by email so it works on any environment where that
// account exists; on a brand-new DB it falls back to NULL created_by.
package main
import (
"context"
"database/sql"
"errors"
"flag"
"fmt"
"os"
"strings"
"text/tabwriter"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
"mgit.msbls.de/m/paliad/internal/services"
)
// ownerEmail is the auth.users email the seed assigns as created_by.
// Living in code (not a flag) because the example tree is m-owned by
// convention; flip if the example data ever needs a service-account
// owner.
const ownerEmail = "matthias.siebels@hoganlovells.com"
// Proceeding-type IDs used by the seed. Resolved by code (not pinned
// to integer IDs in source) to survive DB renumbering. Loaded once at
// startup; missing codes fail fast with a clear message.
var proceedingCodes = []string{
"upc.inf.cfi",
"upc.ccr.cfi",
"upc.apl.merits",
"de.inf.lg",
"epa.opp.opd",
"de.null.bpatg",
"dpma.opp.dpma",
}
func main() {
dsn := flag.String("dsn", os.Getenv("DATABASE_URL"), "Postgres DSN (defaults to $DATABASE_URL)")
dryRun := flag.Bool("dry-run", false, "print intended actions, roll back transaction")
flag.Parse()
if *dsn == "" {
fmt.Fprintln(os.Stderr, "seed-example-projects: DATABASE_URL not set and -dsn empty")
os.Exit(1)
}
db, err := sqlx.Connect("postgres", *dsn)
if err != nil {
fmt.Fprintln(os.Stderr, "connect:", err)
os.Exit(1)
}
defer db.Close()
ctx := context.Background()
if err := run(ctx, db, *dryRun); err != nil {
fmt.Fprintln(os.Stderr, "seed-example-projects:", err)
os.Exit(1)
}
}
func run(ctx context.Context, db *sqlx.DB, dryRun bool) error {
ownerID, err := lookupOwner(ctx, db, ownerEmail)
if err != nil {
return fmt.Errorf("lookup owner: %w", err)
}
if ownerID == uuid.Nil {
fmt.Printf("note: %s not found in auth.users — created_by will be NULL\n", ownerEmail)
} else {
fmt.Printf("owner resolved: %s = %s\n", ownerEmail, ownerID)
}
procIDs, err := lookupProceedingTypes(ctx, db, proceedingCodes)
if err != nil {
return fmt.Errorf("lookup proceeding_types: %w", err)
}
tx, err := db.BeginTxx(ctx, nil)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer func() { _ = tx.Rollback() }() // no-op if Commit ran first
if err := wipe(ctx, tx); err != nil {
return fmt.Errorf("wipe: %w", err)
}
tree, err := seed(ctx, tx, ownerID, procIDs)
if err != nil {
return fmt.Errorf("seed: %w", err)
}
if dryRun {
fmt.Println("\n--- DRY RUN — rolling back ---")
return nil
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit: %w", err)
}
fmt.Println("seed committed.")
if err := report(ctx, db, tree); err != nil {
return fmt.Errorf("report: %w", err)
}
return nil
}
func lookupOwner(ctx context.Context, db *sqlx.DB, email string) (uuid.UUID, error) {
var id uuid.UUID
err := db.GetContext(ctx, &id, `SELECT id FROM auth.users WHERE email = $1`, email)
if errors.Is(err, sql.ErrNoRows) {
return uuid.Nil, nil
}
if err != nil {
return uuid.Nil, err
}
return id, nil
}
func lookupProceedingTypes(ctx context.Context, db *sqlx.DB, codes []string) (map[string]int, error) {
rows, err := db.QueryxContext(ctx,
`SELECT id, code FROM paliad.proceeding_types WHERE code = ANY($1)`,
pgTextArray(codes))
if err != nil {
return nil, err
}
defer rows.Close()
out := make(map[string]int, len(codes))
for rows.Next() {
var id int
var code string
if err := rows.Scan(&id, &code); err != nil {
return nil, err
}
out[code] = id
}
for _, c := range codes {
if _, ok := out[c]; !ok {
return nil, fmt.Errorf("proceeding_types row missing for code=%q", c)
}
}
return out, nil
}
// pgTextArray is the lib/pq array adapter, repackaged inline so the
// script doesn't need a separate util import.
func pgTextArray(xs []string) any {
type arr = []string
return arr(xs)
}
// wipe deletes every paliad.projects row. FK CASCADE handles the
// dependent tables (verified live 2026-05-25 against information_schema:
// appointments, approval_requests, approval_policies, deadlines,
// documents, notes, parties, project_events, project_partner_units,
// project_teams, submission_drafts, user_pinned_projects,
// user_calendar_bindings, checklist_shares all cascade; projects.
// counterclaim_of and checklist_instances SET NULL; policy_audit_log
// SET NULL).
//
// Reference tables (proceeding_types, deadline_rules, event_types,
// gerichte, checklists, firms, partner_units, profiles) are not
// referenced from this delete.
func wipe(ctx context.Context, tx *sqlx.Tx) error {
res, err := tx.ExecContext(ctx, `DELETE FROM paliad.projects`)
if err != nil {
return err
}
n, _ := res.RowsAffected()
fmt.Printf("wiped: %d project rows (FK CASCADE handled dependents)\n", n)
return nil
}
// seededNode is one row of the seed result, kept so we can print the
// chain code after commit without re-querying for IDs.
type seededNode struct {
id uuid.UUID
title string
}
// seed inserts the example tree. Order matters because parent_id FKs
// must already exist — clients first, then litigations under them, then
// patents, then cases (with the CCR case referencing its sibling
// Klage case via counterclaim_of).
func seed(ctx context.Context, tx *sqlx.Tx, ownerID uuid.UUID, procIDs map[string]int) ([]seededNode, error) {
var nodes []seededNode
insertProject := func(p projectInsert) (uuid.UUID, error) {
id := uuid.New()
var createdBy any
if ownerID != uuid.Nil {
createdBy = ownerID
}
_, err := tx.ExecContext(ctx, `
INSERT INTO paliad.projects (
id, type, parent_id, title, reference, description, status,
created_by, industry, country, client_number, matter_number,
patent_number, filing_date, grant_date,
court, case_number, proceeding_type_id,
our_side, opponent_code, instance_level, counterclaim_of
) VALUES (
$1, $2, $3, $4, $5, $6, 'active',
$7, $8, $9, $10, $11,
$12, $13, $14,
$15, $16, $17,
$18, $19, $20, $21
)`,
id, p.Type, nullUUID(p.ParentID), p.Title, nullStr(p.Reference), nullStr(p.Description),
createdBy, nullStr(p.Industry), nullStr(p.Country), nullStr(p.ClientNumber), nullStr(p.MatterNumber),
nullStr(p.PatentNumber), nullDate(p.FilingDate), nullDate(p.GrantDate),
nullStr(p.Court), nullStr(p.CaseNumber), nullInt(p.ProceedingTypeID),
nullStr(p.OurSide), nullStr(p.OpponentCode), nullStr(p.InstanceLevel), nullUUID(p.CounterclaimOf),
)
if err != nil {
return uuid.Nil, fmt.Errorf("insert %s %q: %w", p.Type, p.Title, err)
}
nodes = append(nodes, seededNode{id: id, title: p.Title})
return id, nil
}
// --- Client 1: Siemens AG ----------------------------------------
siemens, err := insertProject(projectInsert{
Type: "client", Title: "Siemens AG", Reference: "SIEMENS",
Industry: "Telekommunikation / Industrieelektronik", Country: "DE",
Description: "Beispiel-Mandant — Telekommunikation & Halbleiter.",
})
if err != nil {
return nil, err
}
siemensHuawei, err := insertProject(projectInsert{
Type: "litigation", ParentID: siemens,
Title: "Siemens ./. Huawei Technologies", OpponentCode: "HUAW",
Description: "Patentstreit Mobilfunk-Standardpatent.", OurSide: "claimant",
})
if err != nil {
return nil, err
}
siemensHuaweiPatent, err := insertProject(projectInsert{
Type: "patent", ParentID: siemensHuawei,
Title: "EP3456789 — Funkkommunikationssystem mit Mehrfachantenne",
PatentNumber: "EP3456789",
FilingDate: "2018-03-12", GrantDate: "2022-11-09",
})
if err != nil {
return nil, err
}
upcInfCFI, err := insertProject(projectInsert{
Type: "case", ParentID: siemensHuaweiPatent,
Title: "UPC CFI München — Klage Siemens ./. Huawei (EP3456789)",
Court: "UPC Lokalkammer München",
CaseNumber: "UPC_CFI_123/2026",
ProceedingTypeID: procIDs["upc.inf.cfi"],
OurSide: "claimant",
InstanceLevel: "first",
})
if err != nil {
return nil, err
}
_, err = insertProject(projectInsert{
Type: "case", ParentID: siemensHuaweiPatent,
Title: "UPC CFI München — Widerklage Huawei ./. Siemens (EP3456789)",
Court: "UPC Lokalkammer München",
CaseNumber: "UPC_CFI_123/2026 (CCR)",
ProceedingTypeID: procIDs["upc.ccr.cfi"],
OurSide: "defendant", // we're respondent on the CCR
InstanceLevel: "first",
CounterclaimOf: upcInfCFI,
})
if err != nil {
return nil, err
}
_, err = insertProject(projectInsert{
Type: "case", ParentID: siemensHuaweiPatent,
Title: "UPC Berufungsgericht — Berufung Huawei (EP3456789)",
Court: "UPC Court of Appeal",
CaseNumber: "UPC_CoA_45/2027",
ProceedingTypeID: procIDs["upc.apl.merits"],
OurSide: "respondent",
InstanceLevel: "appeal",
})
if err != nil {
return nil, err
}
siemensBosch, err := insertProject(projectInsert{
Type: "litigation", ParentID: siemens,
Title: "Siemens ./. Robert Bosch GmbH", OpponentCode: "BOSCH",
Description: "Sensorik / autonomes Fahren.", OurSide: "claimant",
})
if err != nil {
return nil, err
}
siemensBoschPatent, err := insertProject(projectInsert{
Type: "patent", ParentID: siemensBosch,
Title: "EP1111222 — Sensoreinrichtung für autonomes Fahren",
PatentNumber: "EP1111222",
FilingDate: "2017-06-21", GrantDate: "2021-08-04",
})
if err != nil {
return nil, err
}
_, err = insertProject(projectInsert{
Type: "case", ParentID: siemensBoschPatent,
Title: "LG München I — Klage Siemens ./. Bosch (EP1111222)",
Court: "Landgericht München I",
CaseNumber: "7 O 12345/26",
ProceedingTypeID: procIDs["de.inf.lg"],
OurSide: "claimant",
InstanceLevel: "first",
})
if err != nil {
return nil, err
}
// --- Client 2: Bayer AG ------------------------------------------
bayer, err := insertProject(projectInsert{
Type: "client", Title: "Bayer AG", Reference: "BAYER",
Industry: "Pharma / Life Sciences", Country: "DE",
Description: "Beispiel-Mandant — pharmazeutische Wirkstoffe.",
})
if err != nil {
return nil, err
}
bayerNova, err := insertProject(projectInsert{
Type: "litigation", ParentID: bayer,
Title: "Bayer ./. Novartis Pharma", OpponentCode: "NOVA",
Description: "Wirkstoffverbindung X — Einspruch + Nichtigkeit.", OurSide: "claimant",
})
if err != nil {
return nil, err
}
bayerNovaPatent, err := insertProject(projectInsert{
Type: "patent", ParentID: bayerNova,
Title: "EP2222333 — Wirkstoffverbindung X",
PatentNumber: "EP2222333",
FilingDate: "2015-09-30", GrantDate: "2020-04-22",
})
if err != nil {
return nil, err
}
_, err = insertProject(projectInsert{
Type: "case", ParentID: bayerNovaPatent,
Title: "EPA Einspruch — Novartis ./. EP2222333",
Court: "Europäisches Patentamt — Einspruchsabteilung",
CaseNumber: "OPP-2026-0042",
ProceedingTypeID: procIDs["epa.opp.opd"],
OurSide: "respondent", // Bayer is patent owner defending the patent
InstanceLevel: "first",
})
if err != nil {
return nil, err
}
_, err = insertProject(projectInsert{
Type: "case", ParentID: bayerNovaPatent,
Title: "BPatG — Nichtigkeitsklage Novartis ./. EP2222333",
Court: "Bundespatentgericht",
CaseNumber: "5 Ni 12/26",
ProceedingTypeID: procIDs["de.null.bpatg"],
OurSide: "respondent",
InstanceLevel: "first",
})
if err != nil {
return nil, err
}
// --- Client 3: Beispiel AG (intentionally sparse) ----------------
// Demonstrates the empty-segment skip in BuildProjectCode — the
// case row has a proceeding_type set so the tail is present, but
// no instance_level / our_side, and the patent's number is national
// (DE) so the last-3-digits segment shows DE-style behaviour.
beispiel, err := insertProject(projectInsert{
Type: "client", Title: "Beispiel AG", Reference: "BEISPL",
Industry: "Unspezifiziert", Country: "DE",
Description: "Sparse-Beispiel — zeigt, wie fehlende Segmente übersprungen werden.",
})
if err != nil {
return nil, err
}
beispielWtb, err := insertProject(projectInsert{
Type: "litigation", ParentID: beispiel,
Title: "Beispiel ./. Wettbewerber GmbH", OpponentCode: "WTB",
Description: "Demo-Litigation ohne große Detailtiefe.",
})
if err != nil {
return nil, err
}
beispielWtbPatent, err := insertProject(projectInsert{
Type: "patent", ParentID: beispielWtb,
Title: "DE10987654 — Demo-Erfindung",
PatentNumber: "DE10987654",
})
if err != nil {
return nil, err
}
_, err = insertProject(projectInsert{
Type: "case", ParentID: beispielWtbPatent,
Title: "DPMA Einspruch — Wettbewerber ./. DE10987654",
Court: "Deutsches Patent- und Markenamt",
CaseNumber: "DPMA-EIN-987/26",
ProceedingTypeID: procIDs["dpma.opp.dpma"],
OurSide: "respondent",
InstanceLevel: "first",
})
if err != nil {
return nil, err
}
fmt.Printf("seeded: %d projects\n", len(nodes))
return nodes, nil
}
// projectInsert is the typed input for one insertProject call. Pointer
// fields are kept as plain strings here and converted via nullStr at
// bind time; keeps the call sites readable.
type projectInsert struct {
Type string
ParentID uuid.UUID
Title string
Reference string
Description string
Industry string
Country string
ClientNumber string
MatterNumber string
PatentNumber string
FilingDate string // YYYY-MM-DD
GrantDate string
Court string
CaseNumber string
ProceedingTypeID int
OurSide string
OpponentCode string
InstanceLevel string
CounterclaimOf uuid.UUID
}
func nullStr(s string) any {
if s == "" {
return nil
}
return s
}
func nullInt(i int) any {
if i == 0 {
return nil
}
return i
}
func nullUUID(u uuid.UUID) any {
if u == uuid.Nil {
return nil
}
return u
}
func nullDate(s string) any {
if s == "" {
return nil
}
t, err := time.Parse("2006-01-02", s)
if err != nil {
return nil
}
return t
}
// reportRow is one row of the post-seed report — only the fields the
// printout needs.
type reportRow struct {
ID uuid.UUID `db:"id"`
Type string `db:"type"`
Title string `db:"title"`
Path string `db:"path"`
}
// report prints the seeded tree with the auto-derived chain code for
// each row. Uses services.BuildProjectCode so the script verifies the
// same helper the live app uses (catches drift if the algorithm
// changes).
func report(ctx context.Context, db *sqlx.DB, _ []seededNode) error {
var rows []reportRow
err := db.SelectContext(ctx, &rows, `
SELECT id, type, title, path
FROM paliad.projects
ORDER BY path
`)
if err != nil {
return err
}
fmt.Println("\nresulting chain codes:")
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "TYPE\tTITLE\tCODE")
for _, r := range rows {
code, err := services.BuildProjectCode(ctx, db, r.ID)
if err != nil {
return fmt.Errorf("build code for %s: %w", r.ID, err)
}
indent := strings.Repeat(" ", pathDepth(r.Path)-1)
fmt.Fprintf(tw, "%s\t%s%s\t%s\n", r.Type, indent, r.Title, code)
}
return tw.Flush()
}
func pathDepth(p string) int {
if p == "" {
return 1
}
d := 1
for _, c := range p {
if c == '.' {
d++
}
}
return d
}